├── .gitattributes ├── .github └── workflows │ ├── codeql-analysis.yml │ ├── compile.yml │ ├── test.yml │ └── validate-action-typings.yml ├── .gitignore ├── LICENSE ├── README.md ├── __tests__ ├── exec.test.ts ├── main.test.ts └── releases.test.ts ├── action-types.yml ├── action.yml ├── babel.config.js ├── dist └── index.js ├── package-lock.json ├── package.json ├── src ├── const.ts ├── exec.ts ├── git.ts ├── index.ts ├── main.ts └── releases.ts └── tsconfig.json /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto -------------------------------------------------------------------------------- /.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 | 21 | jobs: 22 | analyze: 23 | name: Analyze 24 | runs-on: ubuntu-latest 25 | 26 | # strategy: 27 | # fail-fast: false 28 | # matrix: 29 | # language: [ 'javascript' ] 30 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 31 | # Learn more: 32 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 33 | 34 | steps: 35 | - name: Checkout repository 36 | uses: actions/checkout@v4 37 | 38 | # Initializes the CodeQL tools for scanning. 39 | - name: Initialize CodeQL 40 | uses: github/codeql-action/init@v3 41 | with: 42 | languages: javascript 43 | # If you wish to specify custom queries, you can do so here or in a config file. 44 | # By default, queries listed here will override any specified in a config file. 45 | # Prefix the list here with "+" to use these queries and those in the config file. 46 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 47 | 48 | - name: Perform CodeQL Analysis 49 | uses: github/codeql-action/analyze@v3 50 | -------------------------------------------------------------------------------- /.github/workflows/compile.yml: -------------------------------------------------------------------------------- 1 | name: Compile and push dist file 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | compile-and-push: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - if: github.event_name != 'pull_request' 10 | uses: actions/checkout@v4 11 | - if: github.event_name == 'pull_request' 12 | uses: actions/checkout@v4 13 | with: 14 | repository: ${{ github.event.pull_request.head.repo.full_name }} 15 | ref: ${{ github.event.pull_request.head.ref }} 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 20 19 | - name: Compile with ncc 20 | run: | 21 | npm install 22 | npx ncc build src/index.ts -o dist 23 | - name: Push 24 | env: 25 | DIR: dist 26 | run: | 27 | if git diff --exit-code $DIR 28 | then 29 | exit 0 30 | fi 31 | git config user.name github-actions 32 | git config user.email github-actions@github.com 33 | git add $DIR/* 34 | git commit -m "Update dist/index.js" 35 | git push 36 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | unit-tests: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-node@v4 11 | with: 12 | node-version: 20 13 | - name: Test 14 | run: | 15 | npm install 16 | npm test 17 | 18 | e2e-tests-with-token: 19 | if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.owner.login == 'axel-op' 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | os: [ubuntu-latest, windows-latest, macos-latest] 24 | java-version: [8, 11, 17] 25 | runs-on: ${{ matrix.os }} 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: actions/setup-java@v1 29 | with: 30 | java-version: ${{ matrix.java-version }} 31 | - uses: actions/setup-node@v4 32 | with: 33 | node-version: 20 34 | - name: Compile 35 | run: | 36 | npm install 37 | npx tsc 38 | - name: Create dummy Java files 39 | run: touch Main.java 40 | - uses: ./ 41 | with: 42 | skip-commit: true 43 | github-token: ${{ secrets.GITHUB_TOKEN }} 44 | 45 | e2e-tests-without-token: 46 | strategy: 47 | fail-fast: false 48 | matrix: 49 | os: [ubuntu-latest, windows-latest, macos-latest] 50 | java-version: [8, 11, 17] 51 | runs-on: ${{ matrix.os }} 52 | steps: 53 | - uses: actions/checkout@v4 54 | - uses: actions/setup-java@v1 55 | with: 56 | java-version: ${{ matrix.java-version }} 57 | - uses: actions/setup-node@v4 58 | with: 59 | node-version: 20 60 | - name: Compile 61 | run: | 62 | npm install 63 | npx tsc 64 | - name: Create dummy Java files 65 | run: touch Main.java 66 | - uses: ./ 67 | continue-on-error: ${{ matrix.os == 'macos-latest' }} 68 | with: 69 | skip-commit: true 70 | -------------------------------------------------------------------------------- /.github/workflows/validate-action-typings.yml: -------------------------------------------------------------------------------- 1 | name: Validate action typings 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | validate-typings: 7 | runs-on: "ubuntu-latest" 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: krzema12/github-actions-typing@v2 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2018-2022 Axel Ogereau-Peltier 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Google Java Format Action 2 | 3 | Automatically format your Java files using [Google Java Style guidelines](https://google.github.io/styleguide/javaguide.html). 4 | 5 | This action automatically downloads the latest release of the [Google Java Format](https://github.com/google/google-java-format) program. 6 | 7 | This action can format your files and push the changes, or just check the formatting without committing anything. 8 | 9 | You must checkout your repository with `actions/checkout` before calling this action (see the example). 10 | 11 | ## Examples 12 | 13 | Format all Java files in the repository and commit the changes: 14 | 15 | ```yml 16 | name: Format 17 | 18 | on: 19 | push: 20 | branches: [ master ] 21 | 22 | jobs: 23 | 24 | formatting: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v4 # v2 minimum required 28 | - uses: axel-op/googlejavaformat-action@v4 29 | with: 30 | args: "--skip-sorting-imports --replace" 31 | # Recommended if you use MacOS: 32 | # github-token: ${{ secrets.GITHUB_TOKEN }} 33 | ``` 34 | 35 | Check if the formatting is correct without pushing anything: 36 | 37 | ```yml 38 | name: Format 39 | 40 | on: [ push, pull_request ] 41 | 42 | jobs: 43 | 44 | formatting: 45 | runs-on: ubuntu-latest 46 | steps: 47 | - uses: actions/checkout@v4 # v2 minimum required 48 | - uses: axel-op/googlejavaformat-action@4 49 | with: 50 | args: "--set-exit-if-changed" 51 | ``` 52 | 53 | Print the diff of every incorrectly formatted file, and fail if there is any: 54 | 55 | ```yml 56 | name: Format 57 | 58 | on: [ push, pull_request ] 59 | 60 | jobs: 61 | 62 | formatting: 63 | runs-on: ubuntu-latest 64 | steps: 65 | - uses: actions/checkout@v4 # v2 minimum required 66 | - uses: axel-op/googlejavaformat-action@v4 67 | with: 68 | args: "--replace" 69 | skip-commit: true 70 | - name: Print diffs 71 | run: git --no-pager diff --exit-code 72 | ``` 73 | 74 | ## Inputs 75 | 76 | None of these inputs is required, but you can add them to change the behavior of this action. 77 | 78 | ### `github-token` 79 | 80 | **Recommended if you execute this action from GitHub-hosted MacOS runners or from [self-hosted runners](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners)**. Because of [IP-address based rate limiting](https://github.com/actions/virtual-environments/issues/602), calling the GitHub API from any small pool of IPs, [including the GitHub-hosted MacOS runners](https://github.com/actions/runner-images/issues/602#issuecomment-602472951), can result in an error. To overcome this, provide the [`GITHUB_TOKEN`](https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token) to authenticate these calls. If you provide it, it will also be used to authenticate the commits made by this action. 81 | 82 | ### `release-name` 83 | 84 | Set this input to use a [specific release of Google Java Format](https://github.com/google/google-java-format/releases). For example: `1.7`, `v1.24.0`... If left empty, the latest version compatible with your JDK will be used. 85 | 86 | ### `files` 87 | 88 | A pattern to match the files to format. The default is `**/*.java`, which means that all Java files in your repository will be formatted. 89 | 90 | ### `files-excluded` 91 | 92 | A pattern to match the files to be ignored. Optional. 93 | 94 | ### `skip-commit` 95 | 96 | Set to `true` if you don't want the changes to be committed by this action. Default: `false`. 97 | 98 | ### `commit-message` 99 | 100 | You can specify a custom commit message. Default: `Google Java Format`. 101 | 102 | ### `args` 103 | 104 | The arguments to pass to the Google Java Format executable. 105 | By default, only `--replace` is used. 106 | 107 | ```console 108 | Options: 109 | -i, -r, -replace, --replace 110 | Send formatted output back to files, not stdout. 111 | --aosp, -aosp, -a 112 | Use AOSP style instead of Google Style (4-space indentation). 113 | --fix-imports-only 114 | Fix import order and remove any unused imports, but do no other formatting. 115 | --skip-sorting-imports 116 | Do not fix the import order. Unused imports will still be removed. 117 | --skip-removing-unused-imports 118 | Do not remove unused imports. Imports will still be sorted. 119 | --skip-reflowing-long-strings 120 | Do not reflow string literals that exceed the column limit. 121 | --skip-javadoc-formatting 122 | Do not reformat javadoc. 123 | --dry-run, -n 124 | Prints the paths of the files whose contents would change if the formatter were run normally. 125 | --set-exit-if-changed 126 | Return exit code 1 if there are any formatting changes. 127 | --lines, -lines, --line, -line 128 | Line range(s) to format, like 5:10 (1-based; default is all). 129 | --offset, -offset 130 | Character offset to format (0-based; default is all). 131 | --length, -length 132 | Character length to format. 133 | --help, -help, -h 134 | Print this usage statement. 135 | --version, -version, -v 136 | Print the version. 137 | @ 138 | Read options and filenames from file. 139 | 140 | The --lines, --offset, and --length flags may be given more than once. 141 | The --offset and --length flags must be given an equal number of times. 142 | If --lines, --offset, or --length are given, only one file may be given. 143 | ``` 144 | 145 | Note: 146 | 147 | - If you add `--dry-run` or `-n`, no commit will be made. 148 | - The argument `--set-exit-if-changed` will work as expected and this action will fail if some files need to be formatted. 149 | -------------------------------------------------------------------------------- /__tests__/exec.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, jest, test } from '@jest/globals'; 2 | import { ExecOptions } from '@actions/exec'; 3 | import { wrapExecutor } from '../src/exec'; 4 | 5 | function mockWrappedExec(returnCode: number, std: { stdOut: string, stdErr: string }) { 6 | return jest.fn((command: string, args?: string[], options?: ExecOptions) => { 7 | options?.listeners?.stdout?.(Buffer.from(std.stdOut)); 8 | options?.listeners?.stderr?.(Buffer.from(std.stdErr)); 9 | return Promise.resolve(returnCode); 10 | }); 11 | } 12 | 13 | test('test executing command with no error', async () => { 14 | const stdOut = 'hello world'; 15 | const stdErr = ''; 16 | const exitCode = 0; 17 | const mockedWrapped = mockWrappedExec(exitCode, { stdOut, stdErr }); 18 | const execute = wrapExecutor(mockedWrapped); 19 | const command = 'echo'; 20 | const args = ['hello world']; 21 | const result = await execute(command, args); 22 | expect(result).toEqual({ exitCode, stdOut, stdErr }); 23 | expect(mockedWrapped).toHaveBeenCalledTimes(1); 24 | }); 25 | 26 | test('test executing command with error and ignoring return code', async () => { 27 | const stdOut = 'hello world'; 28 | const stdErr = ''; 29 | const exitCode = 1; 30 | const mockedWrapped = mockWrappedExec(exitCode, { stdOut, stdErr }); 31 | const execute = wrapExecutor(mockedWrapped); 32 | const command = 'echo'; 33 | const args = ['hello world']; 34 | const result = await execute(command, args); 35 | expect(result).toEqual({ exitCode, stdOut, stdErr }); 36 | expect(mockedWrapped).toHaveBeenCalledTimes(1); 37 | }); 38 | 39 | test('test executing command with error and not ignoring return code', async () => { 40 | const stdOut = 'hello world'; 41 | const stdErr = ''; 42 | const exitCode = 1; 43 | const mockedWrapped = mockWrappedExec(exitCode, { stdOut, stdErr }); 44 | const execute = wrapExecutor(mockedWrapped); 45 | const command = 'echo'; 46 | const args = ['hello world']; 47 | const fn = async () => await execute(command, args, { ignoreReturnCode: false }); 48 | await expect(fn).rejects.toThrow(new Error("Command 'echo hello world' failed with exit code 1")); 49 | expect(mockedWrapped).toHaveBeenCalledTimes(1); 50 | }); -------------------------------------------------------------------------------- /__tests__/main.test.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals'; 2 | import { getInput, Main } from '../src/main'; 3 | import { CommandExecutor } from '../src/exec'; 4 | import { ReleaseData, Releases } from '../src/releases'; 5 | import { GitOperations } from '../src/git'; 6 | 7 | const mockGetLatestReleaseData = jest.fn['getLatestReleaseData']>(); 8 | const mockGetReleaseDataByName = jest.fn['getReleaseDataByName']>(); 9 | jest.mock('../src/releases', () => { 10 | return { 11 | Releases: jest.fn().mockImplementation(() => { 12 | return { 13 | getLatestReleaseData: mockGetLatestReleaseData, 14 | getReleaseDataByName: mockGetReleaseDataByName, 15 | } 16 | }), 17 | }; 18 | }); 19 | 20 | const mockHasChanges = jest.fn['hasChanges']>(); 21 | const mockCommitAll = jest.fn['commitAll']>(); 22 | const mockPush = jest.fn['push']>(); 23 | jest.mock('../src/git', () => { 24 | return { 25 | GitOperations: jest.fn().mockImplementation(() => { 26 | return { 27 | configureGit: () => { }, 28 | commitAll: mockCommitAll, 29 | hasChanges: mockHasChanges, 30 | push: mockPush, 31 | } 32 | }), 33 | }; 34 | }); 35 | 36 | const executor = jest.fn(); 37 | 38 | beforeEach(() => { 39 | executor.mockReset(); 40 | }); 41 | 42 | const dummyReleaseData: ReleaseData = { 43 | url: "", 44 | html_url: "", 45 | assets_url: "", 46 | upload_url: "", 47 | tarball_url: null, 48 | zipball_url: null, 49 | id: 0, 50 | node_id: "", 51 | tag_name: "", 52 | target_commitish: "", 53 | name: "dummy-release-data", 54 | draft: false, 55 | prerelease: false, 56 | created_at: "", 57 | published_at: null, 58 | author: { 59 | name: undefined, 60 | email: undefined, 61 | login: "", 62 | id: 0, 63 | node_id: "", 64 | avatar_url: "", 65 | gravatar_id: null, 66 | url: "", 67 | html_url: "", 68 | followers_url: "", 69 | following_url: "", 70 | gists_url: "", 71 | starred_url: "", 72 | subscriptions_url: "", 73 | organizations_url: "", 74 | repos_url: "", 75 | events_url: "", 76 | received_events_url: "", 77 | type: "", 78 | site_admin: false, 79 | starred_at: undefined 80 | }, 81 | assets: [] 82 | }; 83 | 84 | function defineInput(inputName: string, inputValue?: string) { 85 | process.env[`INPUT_${inputName.toUpperCase()}`] = inputValue; 86 | } 87 | 88 | describe('test getting inputs', () => { 89 | const existingInputName = "my-input"; 90 | const existingInputValue = "my-input-value"; 91 | 92 | beforeEach(() => { 93 | defineInput(existingInputName, existingInputValue); 94 | }); 95 | 96 | afterEach(() => { 97 | defineInput(existingInputName, undefined); 98 | }); 99 | 100 | test('test getting existing input value', () => { 101 | const value = getInput([existingInputName, "non-existing-input"]); 102 | expect(value).toBe(existingInputValue); 103 | }); 104 | 105 | test('test getting existing input value with fallback input name', () => { 106 | const value = getInput(["input_name_one", existingInputName]); 107 | expect(value).toBe(existingInputValue); 108 | }); 109 | 110 | test('test getting non existing input value', () => { 111 | const inputName = "my-non-existing-input"; 112 | const value = getInput([inputName]); 113 | expect(value).toBeUndefined(); 114 | }); 115 | }); 116 | 117 | describe('test getting Java version', () => { 118 | const main = new Main(executor); 119 | 120 | test('test invalid return code', () => { 121 | const error = new Error("Command 'java -version' failed with exit code 1"); 122 | executor.mockReturnValueOnce(Promise.reject(error)); 123 | expect(() => main.getJavaVersion()) 124 | .rejects 125 | .toThrow(error); 126 | expect(executor).lastCalledWith('java', ['-version'], { ignoreReturnCode: false, silent: true }); 127 | }); 128 | 129 | test('test no output', () => { 130 | executor.mockReturnValueOnce(Promise.resolve({ 131 | exitCode: 0, 132 | stdErr: '', 133 | stdOut: '', 134 | })); 135 | expect(() => main.getJavaVersion()) 136 | .rejects 137 | .toThrow(new Error("Cannot find Java version number")); 138 | expect(executor).lastCalledWith('java', ['-version'], { ignoreReturnCode: false, silent: true }); 139 | }); 140 | 141 | test('test version <= JDK 8', () => { 142 | executor.mockReturnValueOnce(Promise.resolve({ 143 | exitCode: 0, 144 | stdErr: 'java version "1.8.0_211"', 145 | stdOut: '', 146 | })); 147 | expect(main.getJavaVersion()) 148 | .resolves 149 | .toBe(8); 150 | expect(executor).lastCalledWith('java', ['-version'], { ignoreReturnCode: false, silent: true }); 151 | }); 152 | 153 | test('test version > JDK 8', () => { 154 | executor.mockReturnValueOnce(Promise.resolve({ 155 | exitCode: 0, 156 | stdErr: 'openjdk version "21.0.6" 2025-01-21', 157 | stdOut: '', 158 | })); 159 | expect(main.getJavaVersion()) 160 | .resolves 161 | .toBe(21); 162 | expect(executor).lastCalledWith('java', ['-version'], { ignoreReturnCode: false, silent: true }); 163 | }); 164 | }); 165 | 166 | describe('test getting release data', () => { 167 | test('if no release name, then return latest release', async () => { 168 | const main = new Main(executor); 169 | mockGetLatestReleaseData.mockReturnValueOnce(Promise.resolve(dummyReleaseData)); 170 | const releaseData = await main.getReleaseData(21, undefined); 171 | expect(releaseData).toEqual(dummyReleaseData); 172 | }); 173 | 174 | test('if release name, then return it', async () => { 175 | const releaseName = 'my-release'; 176 | const main = new Main(executor); 177 | mockGetReleaseDataByName.mockReturnValueOnce(Promise.resolve(dummyReleaseData)); 178 | const releaseData = await main.getReleaseData(11, releaseName); 179 | expect(releaseData).toEqual(dummyReleaseData); 180 | expect(mockGetReleaseDataByName).lastCalledWith(releaseName); 181 | }); 182 | 183 | test('if release not found, then throw', async () => { 184 | const releaseName = 'my-release'; 185 | defineInput('version', releaseName); 186 | const main = new Main(executor); 187 | mockGetReleaseDataByName.mockReturnValueOnce(Promise.resolve(undefined)); 188 | expect(() => main.getReleaseData(11, releaseName)) 189 | .rejects 190 | .toThrow(new Error(`Cannot find release id of Google Java Format ${releaseName}`)); 191 | expect(mockGetReleaseDataByName).lastCalledWith(releaseName); 192 | }); 193 | }); 194 | 195 | describe('test getting args', () => { 196 | const main = new Main(executor); 197 | 198 | test('if input has args, then output has them too', async () => { 199 | const inputs = { args: ['--replace'], files: '**/*.java', filesExcluded: undefined }; 200 | const output = await main.getGJFArgs(inputs); 201 | expect(output).toEqual(['--replace']); 202 | }); 203 | 204 | test('files matching glob are appended to output', async () => { 205 | const inputs = { args: ['--replace'], files: '*.md', filesExcluded: undefined }; 206 | const output = await main.getGJFArgs(inputs); 207 | expect(output).toHaveLength(2); 208 | expect(output[0]).toEqual('--replace'); 209 | expect(output[1]).toMatch(/^.*README\.md$/); 210 | }); 211 | 212 | test('if input has exclusion glob, then output excludes matching files', async () => { 213 | const inputs = { args: ['--replace'], files: '*.md', filesExcluded: '*.md' }; 214 | const output = await main.getGJFArgs(inputs); 215 | expect(output).toEqual(['--replace']); 216 | }) 217 | }); 218 | 219 | describe('commit changes', () => { 220 | const githubToken = '***'; 221 | defineInput('github-token', githubToken); 222 | const main = new Main(executor); 223 | 224 | test('if there is no change, then skip commit', async () => { 225 | mockHasChanges.mockReturnValueOnce(Promise.resolve(false)); 226 | await main.commitChanges({ githubActor: '', repository: '', commitMessage: undefined, }); 227 | expect(mockCommitAll).not.toBeCalled(); 228 | expect(mockPush).not.toBeCalled(); 229 | }); 230 | 231 | test('if there are changes, but no commit message, then commit with default message', async () => { 232 | mockHasChanges.mockReturnValueOnce(Promise.resolve(true)); 233 | const githubActor = "actor"; 234 | const repository = "actor/repo"; 235 | await main.commitChanges({ githubActor, repository, commitMessage: undefined }); 236 | expect(mockCommitAll).toHaveBeenCalledWith('Google Java Format'); 237 | expect(mockPush).toBeCalledWith({ githubActor, repository, githubToken }); 238 | }); 239 | 240 | test('if there are changes, and a commit message, then commit with message', async () => { 241 | mockHasChanges.mockReturnValueOnce(Promise.resolve(true)); 242 | const githubActor = "actor"; 243 | const repository = "actor/repo"; 244 | const commitMessage = "my message"; 245 | await main.commitChanges({ githubActor, repository, commitMessage }); 246 | expect(mockCommitAll).toHaveBeenCalledWith(commitMessage); 247 | expect(mockPush).toBeCalledWith({ githubActor, repository, githubToken }); 248 | }); 249 | }); 250 | 251 | describe('execute Google Java Format', () => { 252 | const executablePath = 'google-java-format.jar'; 253 | const main = new Main(executor, executablePath); 254 | 255 | test('when running GJF, then pass user args to command', async () => { 256 | const mockedResult = { exitCode: 0, stdErr: '', stdOut: '' }; 257 | executor.mockReturnValueOnce(Promise.resolve(mockedResult)); 258 | const args = ['a', 'b', 'c']; 259 | const result = await main.executeGJF(8, args); 260 | expect(result).toEqual(mockedResult); 261 | expect(executor).lastCalledWith('java', ['-jar', executablePath, 'a', 'b', 'c'], { ignoreReturnCode: false }); 262 | }); 263 | 264 | test('when running GJF with java version >= 11, then exports required jdk modules', async () => { 265 | const mockedResult = { exitCode: 0, stdErr: '', stdOut: '' }; 266 | executor.mockReturnValueOnce(Promise.resolve(mockedResult)); 267 | const args = ['a', 'b', 'c']; 268 | const result = await main.executeGJF(11, args); 269 | expect(result).toEqual(mockedResult); 270 | expect(executor).lastCalledWith( 271 | 'java', 272 | [ 273 | '--add-exports', 274 | 'jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED', 275 | '--add-exports', 276 | 'jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED', 277 | '--add-exports', 278 | 'jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED', 279 | '--add-exports', 280 | 'jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED', 281 | '--add-exports', 282 | 'jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED', 283 | '-jar', 284 | executablePath, 285 | 'a', 286 | 'b', 287 | 'c', 288 | ], 289 | { ignoreReturnCode: false } 290 | ); 291 | }); 292 | }); -------------------------------------------------------------------------------- /__tests__/releases.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, jest, test } from "@jest/globals"; 2 | import { Octokit, ReleaseData, Releases } from "../src/releases"; 3 | import { CommandExecutor } from "../src/exec"; 4 | 5 | const dummyReleaseData: ReleaseData = { 6 | url: "", 7 | html_url: "", 8 | assets_url: "", 9 | upload_url: "", 10 | tarball_url: null, 11 | zipball_url: null, 12 | id: 0, 13 | node_id: "", 14 | tag_name: "", 15 | target_commitish: "", 16 | name: "dummy-release-data", 17 | draft: false, 18 | prerelease: false, 19 | created_at: "", 20 | published_at: null, 21 | author: { 22 | name: undefined, 23 | email: undefined, 24 | login: "", 25 | id: 0, 26 | node_id: "", 27 | avatar_url: "", 28 | gravatar_id: null, 29 | url: "", 30 | html_url: "", 31 | followers_url: "", 32 | following_url: "", 33 | gists_url: "", 34 | starred_url: "", 35 | subscriptions_url: "", 36 | organizations_url: "", 37 | repos_url: "", 38 | events_url: "", 39 | received_events_url: "", 40 | type: "", 41 | site_admin: false, 42 | starred_at: undefined 43 | }, 44 | assets: [] 45 | }; 46 | 47 | const dummyReleaseData_1_7 = { ...dummyReleaseData, name: '1.7' }; 48 | const dummyReleaseData_v1_24_0 = { ...dummyReleaseData, name: 'v1.24.0' }; 49 | const allReleases = [dummyReleaseData, dummyReleaseData_1_7, dummyReleaseData_v1_24_0]; 50 | 51 | const executor = jest.fn(); 52 | type ListReleases = Octokit['rest']['repos']['listReleases']; 53 | type GetLatestRelease = Octokit['rest']['repos']['getLatestRelease']; 54 | const mockListReleases = jest.fn(); 55 | const mockGetLatestRelease = jest.fn(); 56 | const octokit = { 57 | rest: { 58 | repos: { 59 | listReleases: mockListReleases, 60 | getLatestRelease: mockGetLatestRelease, 61 | } 62 | } 63 | } as unknown as Octokit; 64 | 65 | beforeEach(() => { 66 | jest.clearAllMocks(); 67 | }) 68 | 69 | function expectLastCurlCallForUrl(url: string) { 70 | expect(executor).lastCalledWith('curl', ['-sL', url], { ignoreReturnCode: false }); 71 | } 72 | 73 | function mockApiReturn(stdOut: string) { 74 | executor.mockReturnValueOnce(Promise.resolve({ 75 | exitCode: 0, 76 | stdOut, 77 | stdErr: '', 78 | })); 79 | } 80 | 81 | function mockApiReturnReleases() { 82 | mockApiReturn(JSON.stringify(allReleases)); 83 | } 84 | 85 | function mockApiReturnRelease(releaseData: ReleaseData) { 86 | mockApiReturn(JSON.stringify(releaseData)); 87 | } 88 | 89 | function mockOctokitReturnReleases() { 90 | mockListReleases.mockReturnValueOnce(Promise.resolve({ 91 | headers: undefined as any, 92 | status: 200, 93 | url: '', 94 | data: allReleases 95 | })); 96 | } 97 | 98 | function mockOctokitReturnRelease(releaseData: ReleaseData) { 99 | mockGetLatestRelease.mockReturnValueOnce(Promise.resolve({ 100 | headers: undefined as any, 101 | status: 200, 102 | url: '', 103 | data: releaseData 104 | })); 105 | } 106 | 107 | const URL_BASE = "https://api.github.com/repos/google/google-java-format/releases"; 108 | 109 | describe('get all release data', () => { 110 | test('get all release data with API', async () => { 111 | mockApiReturnReleases(); 112 | const releases = new Releases(executor); 113 | const results = await releases.getAllReleaseData(); 114 | expect(results).toEqual(allReleases); 115 | // IMPORTANT: should not have a trailing slash 116 | expectLastCurlCallForUrl(URL_BASE); 117 | }); 118 | 119 | test('get all release data with API and call to API fails', async () => { 120 | const error = new Error("Command 'curl ...' failed with exit code 1"); 121 | executor.mockReturnValueOnce(Promise.reject(error)); 122 | const releases = new Releases(executor); 123 | expect(() => releases.getAllReleaseData()) 124 | .rejects 125 | .toThrow(error) 126 | // IMPORTANT: should not have a trailing slash 127 | expectLastCurlCallForUrl(URL_BASE); 128 | }); 129 | 130 | test('get all release data with octokit', async () => { 131 | mockOctokitReturnReleases(); 132 | const releases = new Releases(executor, octokit); 133 | const results = await releases.getAllReleaseData(); 134 | expect(results).toEqual(allReleases); 135 | expect(mockGetLatestRelease).not.toBeCalled(); 136 | expect(executor).not.toBeCalled(); 137 | }); 138 | }); 139 | 140 | describe('get latest release data', () => { 141 | const casesJavaVersions: [number, ReleaseData][] = [[8, dummyReleaseData_1_7], [11, dummyReleaseData_v1_24_0]]; 142 | 143 | describe('get latest release data with API', () => { 144 | const releases = new Releases(executor); 145 | 146 | test.each(casesJavaVersions)('when java version is %i, then return release %o', async (javaVersion, expectedRelease) => { 147 | mockApiReturnReleases(); 148 | const result = await releases.getLatestReleaseData(javaVersion); 149 | expect(result).toEqual(expectedRelease); 150 | // IMPORTANT: should not have a trailing slash 151 | expectLastCurlCallForUrl(URL_BASE); 152 | }); 153 | 154 | test('when java version is 21, then return release latest', async () => { 155 | mockApiReturnRelease(dummyReleaseData); 156 | const result = await releases.getLatestReleaseData(21); 157 | expect(result).toEqual(dummyReleaseData); 158 | expectLastCurlCallForUrl(URL_BASE + "/latest"); 159 | }); 160 | }); 161 | 162 | test('get latest release data with API and call to API fails', async () => { 163 | const error = new Error("Command 'curl ...' failed with exit code 1"); 164 | executor.mockReturnValueOnce(Promise.reject(error)); 165 | const releases = new Releases(executor); 166 | expect(() => releases.getLatestReleaseData(21)) 167 | .rejects 168 | .toThrow(error); 169 | expectLastCurlCallForUrl(URL_BASE + "/latest"); 170 | }); 171 | 172 | describe('get latest release data with octokit', () => { 173 | const releases = new Releases(executor, octokit); 174 | 175 | test.each(casesJavaVersions)('when java version is %i, then return release %o', async (javaVersion, expectedRelease) => { 176 | mockOctokitReturnReleases(); 177 | const result = await releases.getLatestReleaseData(javaVersion); 178 | expect(result).toEqual(expectedRelease); 179 | expect(executor).not.toBeCalled(); 180 | }); 181 | 182 | test('when java version is 21, then return latest release', async () => { 183 | mockOctokitReturnRelease(dummyReleaseData); 184 | const result = await releases.getLatestReleaseData(21); 185 | expect(result).toEqual(dummyReleaseData); 186 | expect(executor).not.toBeCalled(); 187 | }); 188 | }); 189 | }); 190 | 191 | describe('get release by name', () => { 192 | test('get release by name (existing)', async () => { 193 | mockApiReturnReleases(); 194 | const releases = new Releases(executor); 195 | const result = await releases.getReleaseDataByName('dummy-release-data'); 196 | expect(result).toEqual(dummyReleaseData); 197 | // IMPORTANT: should not have a trailing slash 198 | expectLastCurlCallForUrl(URL_BASE); 199 | }); 200 | 201 | test('get release by name (non-existing)', async () => { 202 | mockApiReturnReleases(); 203 | const releases = new Releases(executor); 204 | const result = await releases.getReleaseDataByName('non-existing-data'); 205 | expect(result).toBeUndefined(); 206 | // IMPORTANT: should not have a trailing slash 207 | expectLastCurlCallForUrl(URL_BASE); 208 | }); 209 | }); -------------------------------------------------------------------------------- /action-types.yml: -------------------------------------------------------------------------------- 1 | # See https://github.com/krzema12/github-actions-typing 2 | inputs: 3 | args: 4 | type: string 5 | files: 6 | type: string 7 | files-excluded: 8 | type: string 9 | skip-commit: 10 | type: boolean 11 | release-name: 12 | type: string 13 | github-token: 14 | type: string 15 | commit-message: 16 | type: string 17 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: "Google Java Format" 2 | description: "Automatically format Java files using Google Java Style" 3 | author: "axel-op" 4 | branding: 5 | color: "red" 6 | icon: "align-right" 7 | inputs: 8 | args: 9 | description: "Arguments for the Google Java Format executable" 10 | required: false 11 | default: "--replace" 12 | files: 13 | description: "Pattern to match the files to be formatted" 14 | required: false 15 | default: "**/*.java" 16 | files-excluded: 17 | description: "Pattern to match the files to be ignored by this action" 18 | required: false 19 | skip-commit: 20 | description: "By default, this action commits any change made to the files. Set to \"true\" to skip this commit." 21 | required: false 22 | default: "false" 23 | release-name: 24 | description: "Release name of Google Java Format to use. Examples: 1.7, v1.24.0... Leave empty to use the latest release compatible with your JDK." 25 | required: false 26 | github-token: 27 | description: "If provided, will be used to authenticate the calls to the GitHub API." 28 | required: false 29 | commit-message: 30 | description: "This message will be used for commits made by this action" 31 | required: false 32 | runs: 33 | using: "node20" 34 | main: "dist/index.js" 35 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { node: 'current' } }], 4 | '@babel/preset-typescript' 5 | ], 6 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "googlejavaformat-action", 3 | "version": "3.0.0", 4 | "description": "Automatically format your Java files using Google Java Style guidelines.", 5 | "main": "index.js", 6 | "scripts": { 7 | "prepare": "ncc build src/index.ts -o dist", 8 | "test": "jest" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/axel-op/googlejavaformat-action.git" 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "ISC", 17 | "bugs": { 18 | "url": "https://github.com/axel-op/googlejavaformat-action/issues" 19 | }, 20 | "homepage": "https://github.com/axel-op/googlejavaformat-action#readme", 21 | "dependencies": { 22 | "@actions/core": "^1.11.1", 23 | "@actions/exec": "^1.1.0", 24 | "@actions/github": "^6.0.0", 25 | "@actions/glob": "^0.2.0", 26 | "typescript": "^5.7.3" 27 | }, 28 | "devDependencies": { 29 | "@babel/core": "^7.26.8", 30 | "@babel/preset-env": "^7.26.8", 31 | "@babel/preset-typescript": "^7.26.0", 32 | "@jest/globals": "^29.7.0", 33 | "@types/node": "^22.13.1", 34 | "@vercel/ncc": "^0.38.1", 35 | "babel-jest": "^29.7.0", 36 | "jest": "^29.7.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/const.ts: -------------------------------------------------------------------------------- 1 | export const repositoryOwner = 'google'; 2 | export const repositoryName = 'google-java-format'; -------------------------------------------------------------------------------- /src/exec.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core'; 2 | import * as exec from '@actions/exec'; 3 | 4 | export interface CommandExecutorResult { 5 | exitCode: number; 6 | stdOut: string; 7 | stdErr: string; 8 | } 9 | 10 | export interface CommandExecutorOptions { 11 | silent?: boolean; 12 | ignoreReturnCode?: boolean; 13 | workingDirectory?: string; 14 | } 15 | 16 | export type CommandExecutor = (command: string, args?: string[], options?: CommandExecutorOptions) => Promise; 17 | 18 | export function wrapExecutor(wrapped: typeof exec.exec): CommandExecutor { 19 | return async function execute(command: string, args?: string[], options?: CommandExecutorOptions) { 20 | let stdErr = ''; 21 | let stdOut = ''; 22 | const opts: exec.ExecOptions = { 23 | cwd: options?.workingDirectory, 24 | silent: options?.silent ?? true, 25 | ignoreReturnCode: true, 26 | listeners: { 27 | stdout: (data) => stdOut += data.toString(), 28 | stderr: (data) => stdErr += data.toString(), 29 | } 30 | }; 31 | const commandStr = `${command} ${args?.join(' ')}`.trim(); 32 | core.debug(`Executing: ${commandStr}`); 33 | const exitCode = await wrapped(command, args, opts); 34 | core.debug(`Command '${commandStr}' terminated with exit code ${exitCode}`); 35 | const result = { exitCode, stdOut, stdErr }; 36 | core.debug(JSON.stringify(result)); 37 | if (!(options?.ignoreReturnCode ?? true) && exitCode !== 0) { 38 | throw new Error(`Command '${commandStr}' failed with exit code ${exitCode}`); 39 | } 40 | return result; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/git.ts: -------------------------------------------------------------------------------- 1 | import { CommandExecutor, CommandExecutorOptions } from "./exec"; 2 | 3 | export class GitOperations { 4 | private readonly execute: CommandExecutor; 5 | 6 | constructor(executor: CommandExecutor) { 7 | this.execute = executor; 8 | } 9 | 10 | async configureGit() { 11 | const options: CommandExecutorOptions = { silent: true }; 12 | await this.execute('git', ['config', 'user.name', 'github-actions'], options); 13 | await this.execute('git', ['config', 'user.email', ''], options); 14 | } 15 | 16 | async hasChanges(): Promise { 17 | const commandResult = await this.execute('git', ['diff-index', '--quiet', 'HEAD'], { 18 | ignoreReturnCode: true, 19 | silent: true, 20 | }); 21 | return commandResult.exitCode !== 0; 22 | } 23 | 24 | async commitAll(commitMessage: string) { 25 | await this.execute('git', ['commit', '--all', '-m', commitMessage]); 26 | } 27 | 28 | async push(options: { repository: string, githubActor: string, githubToken?: string }) { 29 | if (!options.githubToken) { 30 | await this.execute('git', ['push']); 31 | } else { 32 | const remote = `https://${options.githubActor}:${options.githubToken}@github.com/${options.repository}.git`; 33 | await this.execute('git', ['push', remote]); 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as exec from '@actions/exec'; 2 | import { Main } from './main' 3 | import { wrapExecutor } from './exec'; 4 | 5 | const main = new Main(wrapExecutor(exec.exec)); 6 | main.run() -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core'; 2 | import * as github from '@actions/github'; 3 | import * as glob from '@actions/glob'; 4 | import * as path from 'path'; 5 | import { CommandExecutor, CommandExecutorOptions, CommandExecutorResult } from './exec'; 6 | import { GitOperations } from './git'; 7 | import { ReleaseData, Releases } from './releases'; 8 | 9 | export function getInput(inputAlternativeNames: string[]) { 10 | if (inputAlternativeNames.length === 0) throw new Error("inputAlternativeNames is empty"); 11 | let val: string | undefined; 12 | for (const inputName of inputAlternativeNames) { 13 | val = core.getInput(inputName) || undefined; 14 | core.debug(`${val ? "Value" : "No value"} provided for input "${inputName}"`); 15 | if (val) break; 16 | } 17 | return val; 18 | } 19 | 20 | export class Main { 21 | readonly execute: CommandExecutor; 22 | readonly releases: Releases; 23 | readonly gitOperations: GitOperations; 24 | readonly githubToken: string | undefined; 25 | readonly executablePath: string; 26 | 27 | constructor(executor: CommandExecutor, executablePath: string = path.join((process.env.HOME || process.env.USERPROFILE)!, 'google-java-format.jar')) { 28 | this.execute = executor; 29 | this.gitOperations = new GitOperations(executor); 30 | this.githubToken = getInput(['githubToken', 'github-token']); 31 | const octokit = this.githubToken ? github.getOctokit(this.githubToken) : undefined; 32 | this.releases = new Releases(executor, octokit); 33 | this.executablePath = executablePath; 34 | } 35 | 36 | async getJavaVersion(): Promise { 37 | const javaVersion = await this.execute('java', ['-version'], { silent: true, ignoreReturnCode: false }); 38 | core.debug(javaVersion.stdErr); 39 | let versionNumber = javaVersion.stdErr 40 | .split('\n')[0] 41 | .match(RegExp(/[0-9\.]+/)) 42 | ?.[0]; 43 | if (!versionNumber) throw new Error("Cannot find Java version number"); 44 | core.debug(`Extracted version number: ${versionNumber}`); 45 | if (versionNumber.startsWith('1.')) versionNumber = versionNumber.replace(RegExp(/^1\./), ''); 46 | versionNumber = versionNumber.split('\.')[0]; 47 | return parseInt(versionNumber); 48 | } 49 | 50 | async executeGJF(javaVersion: number, userArgs: string[] = []): Promise { 51 | const args = new Array(); 52 | // see https://github.com/google/google-java-format#jdk-16 53 | if (javaVersion >= 11) { 54 | const modules = ['api', 'file', 'parser', 'tree', 'util']; 55 | const exports = modules.flatMap(l => ['--add-exports', `jdk.compiler/com.sun.tools.javac.${l}=ALL-UNNAMED`]); 56 | args.push(...exports); 57 | } 58 | args.push('-jar', this.executablePath, ...userArgs); 59 | const options: CommandExecutorOptions = { ignoreReturnCode: false }; 60 | return await this.execute('java', args, options); 61 | } 62 | 63 | async getReleaseData(javaVersion: number, releaseName: string | undefined): Promise { 64 | if (!releaseName) { 65 | return this.releases.getLatestReleaseData(javaVersion); 66 | } 67 | const releaseData = await this.releases.getReleaseDataByName(releaseName); 68 | if (!releaseData) { 69 | throw new Error(`Cannot find release id of Google Java Format ${releaseName}`); 70 | } 71 | return releaseData; 72 | } 73 | 74 | getDownloadUrl(releaseData: ReleaseData) { 75 | const downloadUrl = releaseData.assets.find(asset => asset.name.endsWith('all-deps.jar'))?.browser_download_url; 76 | if (!downloadUrl) { 77 | throw new Error("Cannot find URL to Google Java Format executable"); 78 | } 79 | return downloadUrl; 80 | } 81 | 82 | async getGJFArgs(inputs: { args: string[], filesExcluded: string | undefined, files: string }): Promise { 83 | const args = new Array(...inputs.args); 84 | const includePattern = inputs.files; 85 | const excludePattern = inputs.filesExcluded; 86 | const includeFiles = await (await glob.create(includePattern)).glob(); 87 | const excludeFiles = new Set(excludePattern ? await (await glob.create(excludePattern)).glob() : []); 88 | return args.concat(includeFiles.filter(f => !excludeFiles.has(f))); 89 | } 90 | 91 | async downloadExecutable(downloadUrl: string) { 92 | core.info(`Downloading executable to ${this.executablePath}`); 93 | await this.execute('curl', ['-sL', downloadUrl, '-o', this.executablePath], { ignoreReturnCode: false }); 94 | } 95 | 96 | async commitChanges(inputs: { commitMessage: string | undefined, githubActor: string, repository: string }) { 97 | await this.gitOperations.configureGit(); 98 | const hasChanges = await this.gitOperations.hasChanges(); 99 | if (hasChanges) { 100 | await this.gitOperations.commitAll(inputs.commitMessage || 'Google Java Format'); 101 | await this.gitOperations.push({ 102 | ...inputs, 103 | githubToken: this.githubToken, 104 | }); 105 | } else { 106 | core.info('Nothing to commit!'); 107 | } 108 | } 109 | 110 | async run(): Promise { 111 | try { 112 | const javaVersion = await this.getJavaVersion(); 113 | // Get Google Java Format executable and save it to [executable] 114 | await core.group('Download Google Java Format', async () => { 115 | const releaseName = getInput(['release-name', 'version']); 116 | const release = await this.getReleaseData(javaVersion, releaseName); 117 | const downloadUrl = this.getDownloadUrl(release); 118 | await this.downloadExecutable(downloadUrl); 119 | await this.executeGJF(javaVersion, ['--version']); 120 | }); 121 | 122 | // Execute Google Java Format with provided arguments 123 | const args = await this.getGJFArgs({ 124 | args: core.getInput('args').split(' '), 125 | files: core.getInput('files', { required: true }), 126 | filesExcluded: core.getInput('files-excluded'), 127 | }); 128 | await this.executeGJF(javaVersion, args); 129 | 130 | // Commit changed files if there are any and if skipCommit != true 131 | const skipCommit = getInput(['skipCommit', 'skip-commit'])?.toLowerCase() === 'true'; 132 | if (!skipCommit) { 133 | await core.group('Commit changes', async () => { 134 | await this.commitChanges({ 135 | commitMessage: getInput(['commitMessage', 'commit-message']), 136 | githubActor: process.env.GITHUB_ACTOR!, 137 | repository: process.env.GITHUB_REPOSITORY!, 138 | }); 139 | }); 140 | } 141 | } catch (message) { 142 | core.setFailed(message); 143 | } 144 | } 145 | } -------------------------------------------------------------------------------- /src/releases.ts: -------------------------------------------------------------------------------- 1 | import * as github from '@actions/github'; 2 | import { CommandExecutor } from './exec'; 3 | import { repositoryName as GJF_REPO_NAME, repositoryOwner as GJF_REPO_OWNER } from './const'; 4 | import { RestEndpointMethodTypes } from '@octokit/plugin-rest-endpoint-methods' 5 | 6 | type ReleaseDataArray = RestEndpointMethodTypes['repos']['listReleases']['response']['data']; 7 | export type ReleaseData = ReleaseDataArray[0]; 8 | export type Octokit = ReturnType; 9 | 10 | export class Releases { 11 | private static readonly apiReleases = `https://api.github.com/repos/${GJF_REPO_OWNER}/${GJF_REPO_NAME}/releases`; 12 | private readonly execute: CommandExecutor; 13 | private readonly octokit: Octokit | undefined; 14 | 15 | constructor(execute: CommandExecutor, octokit?: Octokit) { 16 | this.execute = execute; 17 | this.octokit = octokit; 18 | } 19 | 20 | private async callReleasesApi(): Promise; 21 | private async callReleasesApi(pathParameter: string | number): Promise; 22 | private async callReleasesApi(pathParameter?: number): Promise; 23 | private async callReleasesApi(pathParameter?: string | number): Promise { 24 | const url = `${Releases.apiReleases}${pathParameter || ''}`; 25 | const response = await this.execute('curl', ['-sL', url], { ignoreReturnCode: false }); 26 | return JSON.parse(response.stdOut); 27 | } 28 | 29 | async getAllReleaseData(): Promise { 30 | if (!this.octokit) { 31 | return this.callReleasesApi(); 32 | } 33 | const params = { owner: GJF_REPO_OWNER, repo: GJF_REPO_NAME }; 34 | const response = await this.octokit.rest.repos.listReleases(params); 35 | return response.data; 36 | } 37 | 38 | async getLatestReleaseData(javaVersion: number): Promise { 39 | if (javaVersion < 11) { 40 | // Versions after 1.7 require JDK 11+ 41 | return (await this.getReleaseDataByName('1.7'))!; 42 | } 43 | if (javaVersion < 17) { 44 | // Versions after v1.24.0 require JDK 17+ 45 | return (await this.getReleaseDataByName('v1.24.0'))!; 46 | } 47 | if (!this.octokit) { 48 | return this.callReleasesApi('/latest'); 49 | } 50 | const params = { owner: GJF_REPO_OWNER, repo: GJF_REPO_NAME }; 51 | const response = await this.octokit.rest.repos.getLatestRelease(params); 52 | return response.data; 53 | } 54 | 55 | async getReleaseDataByName(releaseName: string): Promise { 56 | const allReleaseData = await this.getAllReleaseData(); 57 | return allReleaseData.find(r => r.name === releaseName); 58 | } 59 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./built", 4 | "allowJs": true, 5 | "module": "NodeNext", 6 | "moduleResolution": "nodenext", 7 | "noEmit": true, 8 | "noImplicitAny": true, 9 | "noImplicitThis": true, 10 | "strictNullChecks": true, 11 | "target": "ES2022" 12 | }, 13 | "files": ["src/main.ts"], 14 | } --------------------------------------------------------------------------------