├── .eslintignore ├── .eslintrc.json ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── build.yml │ ├── examples.yml │ └── update-tags.yaml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── __tests__ └── main.test.ts ├── action.yml ├── dist └── index.js ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── checks.ts ├── inputs.ts ├── main.ts ├── mocks.ts └── namespaces │ ├── GitHub.ts │ └── Inputs.ts ├── tsconfig.eslint.json └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["jest", "@typescript-eslint"], 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:@typescript-eslint/eslint-recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 8 | "plugin:prettier/recommended" 9 | ], 10 | "parser": "@typescript-eslint/parser", 11 | "parserOptions": { 12 | "ecmaVersion": 9, 13 | "sourceType": "module", 14 | "project": "./tsconfig.eslint.json" 15 | }, 16 | "rules": { 17 | "@typescript-eslint/camelcase": ["off"] 18 | }, 19 | "env": { 20 | "node": true, 21 | "es6": true, 22 | "jest/globals": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [LouisBrunner] 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: "build-test" 2 | on: # rebuild any PRs and main branch changes 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | - 'feature/*' 8 | - 'releases/*' 9 | 10 | jobs: 11 | # make sure build/ci work properly 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - run: | 17 | npm install 18 | npm run all 19 | -------------------------------------------------------------------------------- /.github/workflows/examples.yml: -------------------------------------------------------------------------------- 1 | name: "examples" 2 | on: [push] 3 | 4 | jobs: 5 | # make sure the action works on a clean machines without building 6 | 7 | ## Basic 8 | test_basic_success: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: ./ 13 | with: 14 | token: ${{ secrets.GITHUB_TOKEN }} 15 | name: Test Basic Success (passes) 16 | conclusion: success 17 | 18 | test_basic_success_with_output: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: ./ 23 | with: 24 | token: ${{ secrets.GITHUB_TOKEN }} 25 | name: Test Basic Success With Output (passes) 26 | conclusion: success 27 | output: | 28 | {"summary":"Test was a success","text_description":"This is a text description of the annotations and images\nWith more stuff\nAnd more"} 29 | 30 | test_basic_failure: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | - uses: ./ 35 | with: 36 | token: ${{ secrets.GITHUB_TOKEN }} 37 | name: Test Basic Failure (fails) 38 | conclusion: failure 39 | 40 | # Other codes 41 | test_basic_neutral: 42 | runs-on: ubuntu-latest 43 | steps: 44 | - uses: actions/checkout@v4 45 | - uses: ./ 46 | with: 47 | token: ${{ secrets.GITHUB_TOKEN }} 48 | name: Test Basic Neutral (neutral) 49 | conclusion: neutral 50 | 51 | test_basic_cancelled: 52 | runs-on: ubuntu-latest 53 | steps: 54 | - uses: actions/checkout@v4 55 | - uses: ./ 56 | with: 57 | token: ${{ secrets.GITHUB_TOKEN }} 58 | name: Test Basic Cancelled (cancelled) 59 | conclusion: cancelled 60 | 61 | test_basic_timed_out: 62 | runs-on: ubuntu-latest 63 | steps: 64 | - uses: actions/checkout@v4 65 | - uses: ./ 66 | with: 67 | token: ${{ secrets.GITHUB_TOKEN }} 68 | name: Test Basic Timed Out (fails) 69 | conclusion: timed_out 70 | 71 | test_basic_action_required: 72 | runs-on: ubuntu-latest 73 | steps: 74 | - uses: actions/checkout@v4 75 | - uses: ./ 76 | with: 77 | token: ${{ secrets.GITHUB_TOKEN }} 78 | name: Test Basic Action Required (action required) 79 | conclusion: action_required 80 | action_url: https://example.com/action 81 | details_url: https://example.com/details 82 | 83 | test_basic_skipped: 84 | runs-on: ubuntu-latest 85 | steps: 86 | - uses: actions/checkout@v4 87 | - uses: ./ 88 | with: 89 | token: ${{ secrets.GITHUB_TOKEN }} 90 | name: Test Basic Skipped (skipped) 91 | conclusion: skipped 92 | 93 | # With details 94 | test_with_details: 95 | runs-on: ubuntu-latest 96 | steps: 97 | - uses: actions/checkout@v4 98 | - uses: ./ 99 | with: 100 | token: ${{ secrets.GITHUB_TOKEN }} 101 | name: Test With Details (passes) 102 | conclusion: success 103 | action_url: https://example.com/action 104 | details_url: https://example.com/details 105 | 106 | ## With annotations 107 | test_with_annotations: 108 | runs-on: ubuntu-latest 109 | steps: 110 | - uses: actions/checkout@v4 111 | - uses: ./ 112 | with: 113 | token: ${{ secrets.GITHUB_TOKEN }} 114 | name: Test With Annotations (passes) 115 | conclusion: success 116 | # output.summary is required with actions! 117 | output: | 118 | {"summary":"Some warnings in README.md"} 119 | annotations: | 120 | [{"path":"README.md","annotation_level":"warning","title":"Spell Checker","message":"Check your spelling for 'banaas'.","raw_details":"Do you mean 'bananas' or 'banana'?","start_line":1,"end_line":2}] 121 | 122 | test_with_annotations_from_run: 123 | runs-on: ubuntu-latest 124 | steps: 125 | - uses: actions/checkout@v4 126 | - id: annotations 127 | run: | 128 | echo "value=$ANNOTATIONS" >> $GITHUB_OUTPUT 129 | env: 130 | ANNOTATIONS: | 131 | [{"path":"README.md","start_line":1,"end_line":2,"message":"Check your spelling for 'banaas'.","annotation_level":"warning"}] 132 | - uses: ./ 133 | with: 134 | token: ${{ secrets.GITHUB_TOKEN }} 135 | name: Test With Annotations From Run (passes) 136 | conclusion: success 137 | # output.summary is required with actions! 138 | output: | 139 | {"summary":"Some warnings in README.md"} 140 | annotations: ${{ steps.annotations.outputs.value }} 141 | 142 | ## With images 143 | test_with_images: 144 | runs-on: ubuntu-latest 145 | steps: 146 | - uses: actions/checkout@v4 147 | - uses: ./ 148 | with: 149 | token: ${{ secrets.GITHUB_TOKEN }} 150 | name: Test With Images (passes) 151 | conclusion: success 152 | # output.summary is required with actions! 153 | output: | 154 | {"summary":"Some cool pics"} 155 | images: | 156 | [{"alt":"Cool pic","image_url":"https://via.placeholder.com/150","caption":"Cool description"}] 157 | 158 | test_with_images_from_run: 159 | runs-on: ubuntu-latest 160 | steps: 161 | - uses: actions/checkout@v4 162 | - id: images 163 | run: | 164 | echo "value=$IMAGES" >> $GITHUB_OUTPUT 165 | env: 166 | IMAGES: | 167 | [{"alt":"Cool pic","image_url":"https://via.placeholder.com/150","caption":"Cool description"}] 168 | - uses: ./ 169 | with: 170 | token: ${{ secrets.GITHUB_TOKEN }} 171 | name: Test With Images From Run (passes) 172 | conclusion: success 173 | # output.summary is required with actions! 174 | output: | 175 | {"summary":"Some warnings in README.md"} 176 | images: ${{ steps.images.outputs.value }} 177 | 178 | ## With actions 179 | test_with_actions: 180 | runs-on: ubuntu-latest 181 | steps: 182 | - uses: actions/checkout@v4 183 | - uses: ./ 184 | with: 185 | token: ${{ secrets.GITHUB_TOKEN }} 186 | name: Test With Actions (passes) 187 | conclusion: success 188 | action_url: https://example.com/action 189 | details_url: https://example.com/details 190 | actions: | 191 | [{"label":"Click Me","description":"Click me to get free RAM","identifier":"sent_to_webhook"}] 192 | 193 | test_with_actions_from_run: 194 | runs-on: ubuntu-latest 195 | steps: 196 | - uses: actions/checkout@v4 197 | - id: actions 198 | run: | 199 | echo "value=$ACTIONS" >> $GITHUB_OUTPUT 200 | env: 201 | ACTIONS: | 202 | [{"label":"Click Me","description":"Click me to get free RAM","identifier":"sent_to_webhook"}] 203 | - uses: ./ 204 | with: 205 | token: ${{ secrets.GITHUB_TOKEN }} 206 | name: Test With Actions From Run (passes) 207 | conclusion: success 208 | action_url: https://example.com/action 209 | # output.summary is required with actions! 210 | output: | 211 | {"summary":"Some warnings in README.md"} 212 | actions: ${{ steps.actions.outputs.value }} 213 | 214 | ## With init 215 | test_with_init: 216 | runs-on: ubuntu-latest 217 | steps: 218 | - uses: actions/checkout@v4 219 | - uses: ./ 220 | id: init 221 | with: 222 | token: ${{ secrets.GITHUB_TOKEN }} 223 | name: Test With Init (fails) 224 | status: in_progress 225 | - run: sleep 30 226 | - uses: ./ 227 | with: 228 | token: ${{ secrets.GITHUB_TOKEN }} 229 | check_id: ${{ steps.init.outputs.check_id }} 230 | status: completed 231 | output: | 232 | {"summary":"Some warnings in README.md"} 233 | conclusion: failure 234 | 235 | test_with_init_implicit: 236 | runs-on: ubuntu-latest 237 | steps: 238 | - uses: actions/checkout@v4 239 | - uses: ./ 240 | id: init 241 | with: 242 | token: ${{ secrets.GITHUB_TOKEN }} 243 | name: Test With Init Implicit (passes) 244 | status: in_progress 245 | - run: sleep 30 246 | - uses: ./ 247 | with: 248 | token: ${{ secrets.GITHUB_TOKEN }} 249 | check_id: ${{ steps.init.outputs.check_id }} 250 | conclusion: success 251 | 252 | ## Based on job 253 | test_based_job_success: 254 | name: "Test Job Success (passes)" 255 | runs-on: ubuntu-latest 256 | steps: 257 | - uses: actions/checkout@v4 258 | - uses: ./ 259 | with: 260 | token: ${{ secrets.GITHUB_TOKEN }} 261 | name: Test Based On Job (passes) 262 | conclusion: ${{ job.status }} 263 | 264 | test_based_job_failure: 265 | name: "Test Job Failure (fails)" 266 | runs-on: ubuntu-latest 267 | steps: 268 | - uses: actions/checkout@v4 269 | - run: false 270 | - uses: ./ 271 | if: always() 272 | with: 273 | token: ${{ secrets.GITHUB_TOKEN }} 274 | name: Test Based On Job (fails) 275 | conclusion: ${{ job.status }} 276 | -------------------------------------------------------------------------------- /.github/workflows/update-tags.yaml: -------------------------------------------------------------------------------- 1 | name: 'Update aggregate tags' 2 | on: 3 | push: 4 | tags: 5 | - 'v*.*' 6 | 7 | jobs: 8 | retag: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | fetch-depth: 0 14 | - name: Calculate short tag name 15 | id: calculate_short_tag 16 | run: | 17 | TRUNC_VER=$(echo ${{ github.ref_name }} | cut -d '.' -f 1) 18 | echo "Short tag: $TRUNC_VER" 19 | echo "tag=$TRUNC_VER" >> $GITHUB_OUTPUT 20 | - uses: rickstaa/action-create-tag@v1 21 | with: 22 | force_push_tag: true 23 | tag: '${{ steps.calculate_short_tag.outputs.tag }}' 24 | message: 'Points to ${{ github.ref_name }}' 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directory 2 | node_modules 3 | 4 | # Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Optional REPL history 60 | .node_repl_history 61 | 62 | # Output of 'npm pack' 63 | *.tgz 64 | 65 | # Yarn Integrity file 66 | .yarn-integrity 67 | 68 | # dotenv environment variables file 69 | .env 70 | .env.test 71 | 72 | # parcel-bundler cache (https://parceljs.org/) 73 | .cache 74 | 75 | # next.js build output 76 | .next 77 | 78 | # nuxt.js build output 79 | .nuxt 80 | 81 | # vuepress build output 82 | .vuepress/dist 83 | 84 | # Serverless directories 85 | .serverless/ 86 | 87 | # FuseBox cache 88 | .fusebox/ 89 | 90 | # DynamoDB Local files 91 | .dynamodb/ 92 | 93 | # OS metadata 94 | .DS_Store 95 | Thumbs.db 96 | 97 | # Ignore built ts files 98 | __tests__/runner/* 99 | lib 100 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "all", 8 | "bracketSpacing": false, 9 | "arrowParens": "avoid", 10 | "parser": "typescript" 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2018 GitHub, Inc. and contributors 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Actions: `checks-action` ![build-test](https://github.com/LouisBrunner/checks-action/workflows/build-test/badge.svg) 2 | 3 | This GitHub Action allows you to create [Check Runs](https://developer.github.com/v3/checks/runs/#create-a-check-run) directly from your GitHub Action workflow. While each job of a workflow already creates a Check Run, this Action allows to include `annotations`, `images`, `actions` or any other parameters supported by the [Check Runs API](https://developer.github.com/v3/checks/runs/#parameters). 4 | 5 | ## Usage 6 | 7 | The following shows how to publish a Check Run which will have the same status as your job and contains the output of another action. This will be shown predominantly in a Pull Request or on the workflow run. 8 | 9 | ```yaml 10 | name: "build-test" 11 | on: [push] 12 | 13 | jobs: 14 | test_something: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v1 18 | - uses: actions/create-outputs@v0.0.0-fake 19 | id: test 20 | - uses: LouisBrunner/checks-action@v2.0.0 21 | if: always() 22 | with: 23 | token: ${{ secrets.GITHUB_TOKEN }} 24 | name: Test XYZ 25 | conclusion: ${{ job.status }} 26 | output: | 27 | {"summary":"${{ steps.test.outputs.summary }}"} 28 | ``` 29 | 30 | See the [examples workflow](.github/workflows/examples.yml) for more details and examples (and see the [associated runs](https://github.com/LouisBrunner/checks-action/actions?query=workflow%3Aexamples) to see how it will look like). 31 | 32 | ### Permissions 33 | 34 | When the action is run as part of a Pull Request, your workflow might fail with the following error: `Error: Resource not accessible by integration`. 35 | 36 | You can solve this in multiple ways: 37 | 38 | * Increase the permissions given to `GITHUB_TOKEN` (see https://github.com/actions/first-interaction/issues/10#issuecomment-1232740076), please note that you should understand the security implications of this change 39 | * Use a Github App token instead of a `GITHUB_TOKEN` (see https://github.com/LouisBrunner/checks-action/issues/26#issuecomment-1232948025) 40 | 41 | Most of the time, it means setting up your workflow this way: 42 | 43 | ```yaml 44 | name: "build-test" 45 | on: [push] 46 | 47 | jobs: 48 | test_something: 49 | runs-on: ubuntu-latest 50 | permissions: 51 | checks: write 52 | contents: read 53 | steps: 54 | - uses: actions/checkout@v1 55 | - uses: actions/create-outputs@v0.0.0-fake 56 | id: test 57 | - uses: LouisBrunner/checks-action@v2.0.0 58 | if: always() 59 | with: 60 | token: ${{ secrets.GITHUB_TOKEN }} 61 | name: Test XYZ 62 | conclusion: ${{ job.status }} 63 | output: | 64 | {"summary":"${{ steps.test.outputs.summary }}"} 65 | ``` 66 | 67 | Notice the extra `permissions` section. 68 | 69 | ## Inputs 70 | 71 | ### `repo` 72 | 73 | _Optional_ The target repository (`owner/repo`) on which to manage the check run. Defaults to the current repository. 74 | 75 | ### `sha` 76 | 77 | _Optional_ The SHA of the target commit. Defaults to the current commit. 78 | 79 | ### `token` 80 | 81 | **Required** Your `GITHUB_TOKEN` 82 | 83 | ### `name` 84 | 85 | **Required** for creation, the name of the check to create (mutually exclusive with `check_id`) 86 | 87 | ### `check_id` 88 | 89 | **Required** for update, ID of the check to update (mutually exclusive with `name`) 90 | 91 | ### `conclusion` 92 | 93 | _Optional_ (**Required** if `status` is `completed`, the default) The conclusion of your check, can be either `success`, `failure`, `neutral`, `cancelled`, `timed_out`, `action_required` or `skipped` 94 | 95 | ### `status` 96 | 97 | _Optional_ The status of your check, defaults to `completed`, can be either `queued`, `in_progress`, `completed` 98 | 99 | ### `action_url` 100 | 101 | _Optional_ The URL to call back to when using `action_required` as a `conclusion` of your check or when including `actions` 102 | 103 | See [Check Runs API (`action_required`)](https://developer.github.com/v3/checks/runs/#parameters) or [Check Runs API (`actions`)](https://developer.github.com/v3/checks/runs/#actions-object) for more information 104 | 105 | Note that this will override `details_url` (see next) when `conclusion` is `action_required` or when `actions` is provided (the two inputs set the same check attribute, `details_url`) 106 | 107 | ### `details_url` 108 | 109 | _Optional_ A URL with more details about your check, can be an third-party website, a preview of the changes to your Github Pages, etc 110 | 111 | Note that this will be overridden by `action_url` (see previous) when `conclusion` is `action_required` or when `actions` is provided (the two inputs set the same check attribute, `details_url`) 112 | 113 | ### `output` 114 | 115 | _Optional_ A JSON object (as a string) containing the output of your check, required when using `annotations` or `images`. 116 | 117 | Supports the following properties: 118 | 119 | - `title`: _Optional_, title of your check, defaults to `name` 120 | - `summary`: **Required**, summary of your check 121 | - `text_description`: _Optional_, a text description of your annotation (if any) 122 | 123 | See [Check Runs API](https://developer.github.com/v3/checks/runs/#output-object) for more information 124 | 125 | ### `output_text_description_file` 126 | 127 | _Optional_ Path to a file containing text which should be set as the `text_description` property of `output`'. Can contain plain text or markdown. 128 | 129 | Note that this will be ignored if `output` is not provided. When `output` is provided with a text_description, this input will take precedence and override it. 130 | 131 | ### `annotations` 132 | 133 | _Optional_ A JSON array (as a string) containing the annotations of your check, requires `output` to be included. 134 | 135 | Supports the same properties with the same types and names as the [Check Runs API](https://developer.github.com/v3/checks/runs/#annotations-object) 136 | 137 | ### `images` 138 | 139 | _Optional_ A JSON array (as a string) containing the images of your check, requires `output` to be included. 140 | 141 | Supports the same properties with the same types and names as the [Check Runs API](https://developer.github.com/v3/checks/runs/#images-object) 142 | 143 | ### `actions` 144 | 145 | _Optional_ A JSON array (as a string) containing the actions of your check. 146 | 147 | Supports the same properties with the same types and names as the [Check Runs API](https://developer.github.com/v3/checks/runs/#actions-object) 148 | 149 | Note that this will override `details_url` as it relies on `action_url` (the two inputs set the same check attribute, `details_url`) 150 | 151 | ## Outputs 152 | 153 | ### `check_id` 154 | 155 | The ID of the created check, useful to update it in another action (e.g. non-`completed` `status`) 156 | 157 | ## Issues 158 | 159 | - Action Required conclusion: button doesn't work? 160 | - Action elements: button doesn't work? 161 | - Non-completed status: too many arguments required 162 | -------------------------------------------------------------------------------- /__tests__/main.test.ts: -------------------------------------------------------------------------------- 1 | import * as cp from 'child_process'; 2 | import * as path from 'path'; 3 | import * as fs from 'fs'; 4 | import * as os from 'os'; 5 | import * as http from 'http'; 6 | 7 | export enum Conclusion { 8 | Success = 'success', 9 | Failure = 'failure', 10 | Neutral = 'neutral', 11 | Cancelled = 'cancelled', 12 | TimedOut = 'timed_out', 13 | ActionRequired = 'action_required', 14 | Skipped = 'skipped', 15 | } 16 | 17 | export enum Status { 18 | Queued = 'queued', 19 | InProgress = 'in_progress', 20 | Completed = 'completed', 21 | } 22 | 23 | type ErrorWithStdout = Error & {stdout: Buffer | string}; 24 | 25 | // A spawnSync which is actually usable 26 | const actualSpawnSync = async ( 27 | command: string, 28 | args: string[], 29 | options: cp.ExecSyncOptions, 30 | ): Promise => { 31 | return new Promise((resolve, reject) => { 32 | let replied = false; 33 | 34 | const node = cp.spawn(command, args, options); 35 | 36 | if (node.stdout === null) { 37 | reject(new Error('stdout is null')); 38 | return; 39 | } 40 | 41 | let stdout = ''; 42 | 43 | node.on('error', (err: ErrorWithStdout) => { 44 | if (stdout !== '') { 45 | err.stdout = stdout; 46 | } 47 | reject(err); 48 | replied = true; 49 | }); 50 | 51 | node.on('exit', (code, signal) => { 52 | if (replied) { 53 | return; 54 | } 55 | 56 | let err: ErrorWithStdout | undefined; 57 | if (signal !== null) { 58 | err = new Error(`Action failed with signal: ${signal}`) as ErrorWithStdout; 59 | } else if (code !== 0) { 60 | err = new Error(`Action failed with code: ${code}`) as ErrorWithStdout; 61 | } 62 | 63 | if (err !== undefined) { 64 | if (stdout !== '') { 65 | err.stdout = stdout; 66 | } 67 | reject(err); 68 | replied = true; 69 | } 70 | }); 71 | 72 | node.stdout.on('data', data => { 73 | stdout += data; 74 | }); 75 | 76 | node.on('close', () => { 77 | if (replied) { 78 | return; 79 | } 80 | 81 | resolve(stdout); 82 | replied = true; 83 | }); 84 | }); 85 | }; 86 | 87 | describe('run action', () => { 88 | const mockEventFile = async ( 89 | event: Record, 90 | scope: (filename: string) => Promise, 91 | ): Promise => { 92 | const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'checks-actions-')); 93 | const filename = path.join(directory, 'github_event.json'); 94 | fs.writeFileSync(filename, JSON.stringify(event)); 95 | try { 96 | await scope(filename); 97 | } finally { 98 | fs.unlinkSync(filename); 99 | fs.rmdirSync(directory); 100 | } 101 | }; 102 | 103 | type RequestHandler = ( 104 | method: string | undefined, 105 | url: string | undefined, 106 | headers: http.IncomingHttpHeaders, 107 | body: Record | undefined, 108 | ) => {status: number; headers: Record; reply: Record}; 109 | 110 | const mockHTTPServer = async ( 111 | handler: RequestHandler, 112 | scope: (port: string) => Promise, 113 | ): Promise => { 114 | const server = http.createServer((req, res) => { 115 | let body = ''; 116 | req.on('data', chunk => { 117 | body += chunk; 118 | }); 119 | req.on('end', () => { 120 | console.debug('request', req.method, req.url, req.headers, body); 121 | const {status, headers, reply} = handler( 122 | req.method, 123 | req.url, 124 | req.headers, 125 | body !== '' ? (JSON.parse(body) as Record) : undefined, 126 | ); 127 | for (const [key, value] of Object.entries(headers)) { 128 | res.setHeader(key, value); 129 | } 130 | res.statusCode = status; 131 | res.end(JSON.stringify(reply)); 132 | }); 133 | }); 134 | 135 | const portPromise = new Promise((resolve, reject) => { 136 | const handle = setTimeout(() => { 137 | reject(new Error('Timeout while starting mock HTTP server')); 138 | }, 1000); 139 | 140 | server.listen(0, 'localhost', () => { 141 | clearTimeout(handle); 142 | 143 | let port = 'INVALID'; 144 | const address = server.address(); 145 | if (address !== null) { 146 | if (typeof address === 'string') { 147 | port = address; 148 | } else { 149 | port = address.port.toString(); 150 | } 151 | } 152 | 153 | resolve(port); 154 | }); 155 | }); 156 | 157 | try { 158 | await scope(await portPromise); 159 | } finally { 160 | server.close(); 161 | } 162 | }; 163 | 164 | const parseOutput = ( 165 | output: string, 166 | ): {error: string | undefined; checkID: number | undefined; output: string} => { 167 | let error; 168 | let checkID; 169 | for (const line of output.split('\n')) { 170 | if (line.startsWith('::error::')) { 171 | error = line.split('::error::')[1]; 172 | } 173 | if (line.startsWith('::set-output name=check_id::')) { 174 | checkID = parseInt(line.split('::set-output name=check_id::')[1]); 175 | } 176 | } 177 | return {error, checkID, output}; 178 | }; 179 | 180 | const runAction = async ({ 181 | repo, 182 | sha, 183 | token = 'ABC', 184 | name, 185 | id, 186 | eventName, 187 | eventPath, 188 | status, 189 | conclusion, 190 | testPort, 191 | }: { 192 | repo: string | undefined; 193 | sha: string | undefined; 194 | token: string | undefined; 195 | id: string | undefined; 196 | name: string | undefined; 197 | eventName: string | undefined; 198 | eventPath: string | undefined; 199 | status: string; 200 | conclusion: string; 201 | testPort: string; 202 | }): Promise<{error: string | undefined; checkID: number | undefined; output: string}> => { 203 | const entry = path.join(__dirname, '..', 'lib', 'main.js'); 204 | const optional: Record = {}; 205 | if (repo !== undefined) { 206 | optional['INPUT_REPO'] = repo; 207 | } 208 | if (sha !== undefined) { 209 | optional['INPUT_SHA'] = sha; 210 | } 211 | if (name !== undefined) { 212 | optional['INPUT_NAME'] = name; 213 | } 214 | if (id !== undefined) { 215 | optional['INPUT_CHECK_ID'] = id; 216 | } 217 | if (eventName !== undefined) { 218 | optional['GITHUB_EVENT_NAME'] = eventName; 219 | } 220 | if (eventPath !== undefined) { 221 | optional['GITHUB_EVENT_PATH'] = eventPath; 222 | } 223 | const options: cp.ExecSyncOptions = { 224 | env: { 225 | PATH: process.env.PATH, 226 | GITHUB_REPOSITORY: 'LB/ABC', 227 | GITHUB_SHA: 'SHA1', 228 | INPUT_TOKEN: token, 229 | INPUT_STATUS: status, 230 | INPUT_CONCLUSION: conclusion, 231 | ...optional, 232 | GITHUB_OUTPUT: '', 233 | INTERNAL_TESTING_MODE_HTTP_LOCAL_PORT: testPort, 234 | }, 235 | timeout: 1500, 236 | }; 237 | try { 238 | const actionOutput = await actualSpawnSync('node', [entry], options); 239 | return parseOutput(actionOutput); 240 | } catch (e) { 241 | const error = e as ErrorWithStdout; 242 | if (error.stdout === undefined) { 243 | throw error; 244 | } 245 | try { 246 | return parseOutput(error.stdout.toString()); 247 | } catch { 248 | throw new Error( 249 | `Action failed with error: ${error.message} and output: ${error.stdout.toString()}`, 250 | ); 251 | } 252 | } 253 | }; 254 | 255 | type LoggedRequest = { 256 | method: string | undefined; 257 | url: string | undefined; 258 | body?: Record; 259 | }; 260 | 261 | type Case = { 262 | name: string; 263 | checkName?: string; 264 | checkID?: string; 265 | eventName?: string; 266 | eventRecord?: Record; 267 | repo?: string; 268 | sha?: string; 269 | token?: string; 270 | status: Status; 271 | conclusion: Conclusion; 272 | expectedError?: string; 273 | expectedRequests?: Array; 274 | expectedCheckID?: number; 275 | }; 276 | 277 | const cases = ((): Case[] => { 278 | return [ 279 | { 280 | name: 'creation', 281 | checkName: 'testo', 282 | status: Status.Completed, 283 | conclusion: Conclusion.Success, 284 | expectedRequests: [ 285 | { 286 | method: 'POST', 287 | url: '/repos/LB/ABC/check-runs', 288 | body: { 289 | conclusion: 'success', 290 | head_sha: 'SHA1', 291 | name: 'testo', 292 | status: 'completed', 293 | }, 294 | }, 295 | ], 296 | expectedCheckID: 456, 297 | }, 298 | { 299 | name: 'update', 300 | checkID: '123', 301 | status: Status.Completed, 302 | conclusion: Conclusion.Success, 303 | expectedRequests: [ 304 | { 305 | method: 'GET', 306 | url: '/repos/LB/ABC/check-runs/123', 307 | body: undefined, 308 | }, 309 | { 310 | method: 'PATCH', 311 | url: '/repos/LB/ABC/check-runs/123', 312 | body: { 313 | status: 'completed', 314 | conclusion: 'success', 315 | }, 316 | }, 317 | ], 318 | }, 319 | { 320 | name: 'creation on remote repository', 321 | checkName: 'testo', 322 | status: Status.Completed, 323 | conclusion: Conclusion.Success, 324 | repo: 'remote/repo', 325 | sha: 'DEF', 326 | expectedRequests: [ 327 | { 328 | method: 'POST', 329 | url: '/repos/remote/repo/check-runs', 330 | body: { 331 | conclusion: 'success', 332 | head_sha: 'DEF', 333 | name: 'testo', 334 | status: 'completed', 335 | }, 336 | }, 337 | ], 338 | expectedCheckID: 456, 339 | }, 340 | { 341 | name: 'update on remote repository', 342 | checkID: '123', 343 | status: Status.Completed, 344 | conclusion: Conclusion.Success, 345 | repo: 'remote/repo', 346 | sha: 'DEF', 347 | expectedRequests: [ 348 | { 349 | method: 'GET', 350 | url: '/repos/remote/repo/check-runs/123', 351 | body: undefined, 352 | }, 353 | { 354 | method: 'PATCH', 355 | url: '/repos/remote/repo/check-runs/123', 356 | body: { 357 | status: 'completed', 358 | conclusion: 'success', 359 | }, 360 | }, 361 | ], 362 | }, 363 | { 364 | name: 'fails with invalid repo', 365 | checkID: '123', 366 | status: Status.Completed, 367 | conclusion: Conclusion.Success, 368 | repo: 'invalid', 369 | sha: 'DEF', 370 | expectedError: 'repo needs to be in the {owner}/{repository} format', 371 | }, 372 | { 373 | name: 'creation from pull_request', 374 | checkName: 'testo', 375 | eventName: 'pull_request', 376 | eventRecord: { 377 | pull_request: { 378 | head: { 379 | sha: '123', 380 | }, 381 | }, 382 | }, 383 | status: Status.Completed, 384 | conclusion: Conclusion.Success, 385 | expectedRequests: [ 386 | { 387 | method: 'POST', 388 | url: '/repos/LB/ABC/check-runs', 389 | body: { 390 | conclusion: 'success', 391 | head_sha: '123', 392 | name: 'testo', 393 | status: 'completed', 394 | }, 395 | }, 396 | ], 397 | expectedCheckID: 456, 398 | }, 399 | // TODO: add more 400 | ]; 401 | })(); 402 | 403 | test.each(cases)( 404 | 'with $name', 405 | async ({expectedError, expectedRequests, expectedCheckID, ...rest}: Case) => { 406 | const requests: Array = []; 407 | 408 | await mockHTTPServer( 409 | (reqMethod, reqURL, reqHeaders, reqBody) => { 410 | if (reqBody !== undefined) { 411 | delete reqBody['completed_at']; 412 | delete reqBody['started_at']; 413 | } 414 | requests.push({method: reqMethod, url: reqURL, body: reqBody}); 415 | let reply = {}; 416 | if (expectedCheckID !== undefined) { 417 | reply = {id: expectedCheckID}; 418 | } 419 | return { 420 | status: 200, 421 | headers: { 422 | 'content-type': 'application/json', 423 | }, 424 | reply, 425 | }; 426 | }, 427 | async port => { 428 | await mockEventFile(rest.eventRecord || {}, async filename => { 429 | const props = { 430 | name: rest.checkName, 431 | id: rest.checkID, 432 | status: rest.status.toString(), 433 | conclusion: rest.conclusion.toString(), 434 | repo: rest.repo, 435 | sha: rest.sha, 436 | token: rest.token, 437 | eventName: rest.eventName, 438 | eventPath: rest.eventRecord ? filename : undefined, 439 | testPort: port, 440 | }; 441 | 442 | const {error, checkID} = await runAction(props); 443 | 444 | expect(error).toBe(expectedError); 445 | expect(checkID).toBe(expectedCheckID); 446 | if (expectedRequests !== undefined) { 447 | expect(requests).toEqual(expectedRequests); 448 | } else { 449 | expect(requests).toEqual([]); 450 | } 451 | }); 452 | }, 453 | ); 454 | }, 455 | ); 456 | }); 457 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'GitHub Checks' 2 | description: 'Wrapper around the GitHub Checks API' 3 | author: 'Louis Brunner' 4 | branding: 5 | icon: 'check-circle' 6 | color: 'green' 7 | inputs: 8 | repo: 9 | description: 'the target `owner/repo` to manage the check run on (defaults to the current repository' 10 | required: false 11 | sha: 12 | description: 'the target commit''s SHA (defaults to the current commit)' 13 | required: false 14 | token: 15 | description: 'your GITHUB_TOKEN' 16 | required: true 17 | name: 18 | description: 'the name of the check to create (incompatible with `check_id`)' 19 | required: false 20 | check_id: 21 | description: 'ID of the check to update (incompatible with `name`)' 22 | required: false 23 | conclusion: 24 | description: 'the conclusion of your check' 25 | required: false 26 | status: 27 | description: 'the status of your check' 28 | required: false 29 | default: completed 30 | action_url: 31 | description: 'the url to call back to when using `action_required` as conclusion or with `actions`' 32 | required: false 33 | details_url: 34 | description: 'a URL with more details about your check, will be overriden by `action_url` depending on context' 35 | required: false 36 | output: 37 | description: 'the output of your check' 38 | required: false 39 | output_text_description_file: 40 | description: 'path to a file containing text which should be set as the `text_description` of `output`' 41 | required: false 42 | annotations: 43 | description: 'the annotations of your check' 44 | required: false 45 | images: 46 | description: 'the images of your check' 47 | required: false 48 | actions: 49 | description: 'the actions of your check' 50 | required: false 51 | outputs: 52 | check_id: 53 | description: 'the ID of the created check, useful to update it in another action' 54 | runs: 55 | using: 'node20' 56 | main: 'dist/index.js' 57 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | moduleFileExtensions: ['js', 'ts'], 4 | testEnvironment: 'node', 5 | testMatch: ['**/*.test.ts'], 6 | testRunner: 'jest-circus/runner', 7 | transform: { 8 | '^.+\\.ts$': 'ts-jest', 9 | }, 10 | verbose: true, 11 | }; 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "checks-action", 3 | "version": "2.0.0", 4 | "private": true, 5 | "description": "GitHub Action which wraps calls to GitHub Checks API", 6 | "main": "lib/main.js", 7 | "scripts": { 8 | "build": "tsc", 9 | "lint": "eslint '**/*.js' '**/*.ts'", 10 | "pack": "ncc build -m", 11 | "test": "jest", 12 | "format": "prettier --write '**/*.js' '**/*.ts'", 13 | "all": "npm run build && npm run lint && npm run pack && npm test" 14 | }, 15 | "funding": { 16 | "type": "individual", 17 | "url": "https://github.com/sponsors/LouisBrunner" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/LouisBrunner/checks-action.git" 22 | }, 23 | "keywords": [ 24 | "github", 25 | "actions", 26 | "checks" 27 | ], 28 | "author": "Louis Brunner", 29 | "license": "MIT", 30 | "dependencies": { 31 | "@actions/core": "^1.11.1", 32 | "@actions/github": "^6.0.0", 33 | "@octokit/rest": "^21.1.0" 34 | }, 35 | "devDependencies": { 36 | "@types/jest": "^29.5.14", 37 | "@types/node": "^22.13.0", 38 | "@typescript-eslint/eslint-plugin": "^7.14.1", 39 | "@typescript-eslint/parser": "^7.18.0", 40 | "@vercel/ncc": "^0.38.3", 41 | "eslint": "^8.57.1", 42 | "eslint-config-prettier": "^10.1.5", 43 | "eslint-plugin-jest": "^28.11.0", 44 | "eslint-plugin-prettier": "^5.4.1", 45 | "jest": "^29.7.0", 46 | "jest-circus": "^29.7.0", 47 | "prettier": "^3.4.2", 48 | "ts-jest": "^29.2.5", 49 | "typescript": "^5.8.3" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/checks.ts: -------------------------------------------------------------------------------- 1 | import {GitHub} from '@actions/github/lib/utils'; 2 | import * as core from '@actions/core'; 3 | import * as Inputs from './namespaces/Inputs'; 4 | 5 | type Ownership = { 6 | owner: string; 7 | repo: string; 8 | }; 9 | 10 | const unpackInputs = (title: string, inputs: Inputs.Args): Record => { 11 | let output; 12 | if (inputs.output) { 13 | output = { 14 | title: inputs.output.title ?? title, 15 | summary: inputs.output.summary, 16 | text: inputs.output.text_description, 17 | actions: inputs.actions, 18 | annotations: inputs.annotations, 19 | images: inputs.images, 20 | }; 21 | } 22 | 23 | let details_url; 24 | 25 | if (inputs.conclusion === Inputs.Conclusion.ActionRequired || inputs.actions) { 26 | if (inputs.detailsURL) { 27 | const reasonList = []; 28 | if (inputs.conclusion === Inputs.Conclusion.ActionRequired) { 29 | reasonList.push(`'conclusion' is 'action_required'`); 30 | } 31 | if (inputs.actions) { 32 | reasonList.push(`'actions' was provided`); 33 | } 34 | const reasons = reasonList.join(' and '); 35 | core.info( 36 | `'details_url' was ignored in favor of 'action_url' because ${reasons} (see documentation for details)`, 37 | ); 38 | } 39 | details_url = inputs.actionURL; 40 | } else if (inputs.detailsURL) { 41 | details_url = inputs.detailsURL; 42 | } 43 | 44 | return { 45 | status: inputs.status.toString(), 46 | output, 47 | actions: inputs.actions, 48 | conclusion: inputs.conclusion ? inputs.conclusion.toString() : undefined, 49 | completed_at: inputs.status === Inputs.Status.Completed ? formatDate() : undefined, 50 | details_url, 51 | }; 52 | }; 53 | 54 | const formatDate = (): string => { 55 | return new Date().toISOString(); 56 | }; 57 | 58 | export const createRun = async ( 59 | octokit: InstanceType, 60 | name: string, 61 | sha: string, 62 | ownership: Ownership, 63 | inputs: Inputs.Args, 64 | ): Promise => { 65 | const {data} = await octokit.rest.checks.create({ 66 | ...ownership, 67 | head_sha: sha, 68 | name: name, 69 | started_at: formatDate(), 70 | ...unpackInputs(name, inputs), 71 | }); 72 | return data.id; 73 | }; 74 | 75 | export const updateRun = async ( 76 | octokit: InstanceType, 77 | id: number, 78 | ownership: Ownership, 79 | inputs: Inputs.Args, 80 | ): Promise => { 81 | const previous = await octokit.rest.checks.get({ 82 | ...ownership, 83 | check_run_id: id, 84 | }); 85 | await octokit.rest.checks.update({ 86 | ...ownership, 87 | check_run_id: id, 88 | ...unpackInputs(previous.data.name, inputs), 89 | }); 90 | }; 91 | -------------------------------------------------------------------------------- /src/inputs.ts: -------------------------------------------------------------------------------- 1 | import {InputOptions} from '@actions/core'; 2 | import * as GitHub from './namespaces/GitHub'; 3 | import * as Inputs from './namespaces/Inputs'; 4 | import fs from 'fs'; 5 | 6 | type GetInput = (name: string, options?: InputOptions | undefined) => string; 7 | 8 | const parseJSON = (getInput: GetInput, property: string): T | undefined => { 9 | const value = getInput(property); 10 | if (!value) { 11 | return; 12 | } 13 | try { 14 | return JSON.parse(value) as T; 15 | } catch (e) { 16 | const error = e as Error; 17 | throw new Error(`invalid format for '${property}: ${error.toString()}`); 18 | } 19 | }; 20 | 21 | export const parseInputs = (getInput: GetInput): Inputs.Args => { 22 | const repo = getInput('repo'); 23 | const sha = getInput('sha'); 24 | const token = getInput('token', {required: true}); 25 | const output_text_description_file = getInput('output_text_description_file'); 26 | 27 | const name = getInput('name'); 28 | const checkIDStr = getInput('check_id'); 29 | 30 | const status = getInput('status', {required: true}) as Inputs.Status; 31 | let conclusion = getInput('conclusion') as Inputs.Conclusion; 32 | 33 | const actionURL = getInput('action_url'); 34 | const detailsURL = getInput('details_url'); 35 | 36 | if (repo && repo.split('/').length != 2) { 37 | throw new Error('repo needs to be in the {owner}/{repository} format'); 38 | } 39 | 40 | if (name && checkIDStr) { 41 | throw new Error(`can only provide 'name' or 'check_id'`); 42 | } 43 | 44 | if (!name && !checkIDStr) { 45 | throw new Error(`must provide 'name' or 'check_id'`); 46 | } 47 | 48 | const checkID = checkIDStr ? parseInt(checkIDStr) : undefined; 49 | 50 | if (!Object.values(Inputs.Status).includes(status)) { 51 | throw new Error(`invalid value for 'status': '${status}'`); 52 | } 53 | 54 | if (conclusion) { 55 | conclusion = conclusion.toLowerCase() as Inputs.Conclusion; 56 | if (!Object.values(Inputs.Conclusion).includes(conclusion)) { 57 | if (conclusion.toString() === 'stale') { 58 | throw new Error(`'stale' is a conclusion reserved for GitHub and cannot be set manually`); 59 | } 60 | throw new Error(`invalid value for 'conclusion': '${conclusion}'`); 61 | } 62 | } 63 | 64 | if (status === Inputs.Status.Completed) { 65 | if (!conclusion) { 66 | throw new Error(`'conclusion' is required when 'status' is 'completed'`); 67 | } 68 | } else { 69 | if (conclusion) { 70 | throw new Error(`can't provide a 'conclusion' with a non-'completed' 'status'`); 71 | } 72 | } 73 | 74 | const output = parseJSON(getInput, 'output'); 75 | const annotations = parseJSON(getInput, 'annotations'); 76 | const images = parseJSON(getInput, 'images'); 77 | const actions = parseJSON(getInput, 'actions'); 78 | 79 | if (!actionURL && (conclusion === Inputs.Conclusion.ActionRequired || actions)) { 80 | throw new Error(`missing value for 'action_url'`); 81 | } 82 | 83 | if (output && output_text_description_file) { 84 | output.text_description = fs.readFileSync(output_text_description_file, 'utf8'); 85 | } 86 | 87 | if ((!output || !output.summary) && (annotations || images)) { 88 | throw new Error(`missing value for 'output.summary'`); 89 | } 90 | 91 | return { 92 | repo, 93 | sha, 94 | name, 95 | token, 96 | status, 97 | conclusion, 98 | 99 | checkID, 100 | 101 | actionURL, 102 | detailsURL, 103 | 104 | output, 105 | annotations, 106 | images, 107 | actions, 108 | }; 109 | }; 110 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core'; 2 | import * as github from '@actions/github'; 3 | import * as Inputs from './namespaces/Inputs'; 4 | import * as GitHub from './namespaces/GitHub'; 5 | import {parseInputs} from './inputs'; 6 | import {createRun, updateRun} from './checks'; 7 | import {useLocalFetcher, localFetcher} from './mocks'; 8 | 9 | const isCreation = (inputs: Inputs.Args): inputs is Inputs.ArgsCreate => { 10 | return !!(inputs as Inputs.ArgsCreate).name; 11 | }; 12 | 13 | // prettier-ignore 14 | const prEvents = [ 15 | 'pull_request', 16 | 'pull_request_review', 17 | 'pull_request_review_comment', 18 | 'pull_request_target', 19 | ]; 20 | 21 | const options: GitHub.OctokitOptions = useLocalFetcher 22 | ? { 23 | request: { 24 | fetch: localFetcher, 25 | }, 26 | } 27 | : {}; 28 | 29 | const getSHA = (inputSHA: string | undefined): string => { 30 | let sha = github.context.sha; 31 | if (prEvents.includes(github.context.eventName)) { 32 | const pull = github.context.payload.pull_request as GitHub.PullRequest; 33 | if (pull?.head.sha) { 34 | sha = pull?.head.sha; 35 | } 36 | } 37 | if (inputSHA) { 38 | sha = inputSHA; 39 | } 40 | return sha; 41 | }; 42 | 43 | async function run(): Promise { 44 | try { 45 | core.debug(`Parsing inputs`); 46 | const inputs = parseInputs(core.getInput); 47 | 48 | core.debug(`Setting up OctoKit`); 49 | const octokit = github.getOctokit(inputs.token, options); 50 | 51 | const ownership = { 52 | owner: github.context.repo.owner, 53 | repo: github.context.repo.repo, 54 | }; 55 | const sha = getSHA(inputs.sha); 56 | 57 | if (inputs.repo) { 58 | const repo = inputs.repo.split('/'); 59 | ownership.owner = repo[0]; 60 | ownership.repo = repo[1]; 61 | } 62 | 63 | if (isCreation(inputs)) { 64 | core.debug(`Creating a new Run on ${ownership.owner}/${ownership.repo}@${sha}`); 65 | const id = await createRun(octokit, inputs.name, sha, ownership, inputs); 66 | core.setOutput('check_id', id); 67 | } else { 68 | const id = inputs.checkID; 69 | core.debug(`Updating a Run on ${ownership.owner}/${ownership.repo}@${sha} (${id})`); 70 | await updateRun(octokit, id, ownership, inputs); 71 | } 72 | core.debug(`Done`); 73 | } catch (e) { 74 | const error = e as Error; 75 | core.debug(error.toString()); 76 | core.setFailed(error.message); 77 | } 78 | } 79 | 80 | void run(); 81 | -------------------------------------------------------------------------------- /src/mocks.ts: -------------------------------------------------------------------------------- 1 | const replaceURL = (url: URL, port: string): URL => { 2 | url.host = 'localhost'; 3 | url.port = port; 4 | url.protocol = 'http'; 5 | return url; 6 | }; 7 | 8 | const localPort = process.env.INTERNAL_TESTING_MODE_HTTP_LOCAL_PORT; 9 | 10 | export const useLocalFetcher = localPort !== undefined; 11 | 12 | export const localFetcher = (input: RequestInfo | URL, init?: RequestInit): Promise => { 13 | if (!localPort) { 14 | throw new Error('INTERNAL_TESTING_MODE_HTTP_LOCAL_PORT is not defined'); 15 | } 16 | 17 | console.debug('localFetcher::before', input); 18 | if (typeof input === 'string') { 19 | input = replaceURL(new URL(input), localPort).toString(); 20 | } else if (input instanceof URL) { 21 | input = replaceURL(input, localPort); 22 | } else { 23 | input = { 24 | ...input, 25 | url: replaceURL(new URL(input.url), localPort).toString(), 26 | }; 27 | } 28 | console.debug('localFetcher::after', input); 29 | 30 | return fetch(input, init); 31 | }; 32 | -------------------------------------------------------------------------------- /src/namespaces/GitHub.ts: -------------------------------------------------------------------------------- 1 | import {RestEndpointMethodTypes} from '@octokit/plugin-rest-endpoint-methods'; 2 | import {operations} from '@octokit/openapi-types'; 3 | export {OctokitOptions} from '@octokit/core/dist-types/types'; 4 | 5 | export type PullRequest = RestEndpointMethodTypes['pulls']['get']['response']['data']; 6 | 7 | type ChecksCreate = operations['checks/create']['requestBody']['content']['application/json']; 8 | 9 | type Output = NonNullable; 10 | 11 | export type Annotations = NonNullable; 12 | 13 | export type Images = NonNullable; 14 | 15 | export type Actions = NonNullable; 16 | -------------------------------------------------------------------------------- /src/namespaces/Inputs.ts: -------------------------------------------------------------------------------- 1 | import {Actions, Annotations, Images} from './GitHub'; 2 | 3 | interface ArgsBase { 4 | repo?: string; 5 | sha?: string; 6 | token: string; 7 | conclusion?: Conclusion; 8 | status: Status; 9 | 10 | actionURL?: string; 11 | detailsURL?: string; 12 | 13 | output?: Output; 14 | annotations?: Annotations; 15 | images?: Images; 16 | actions?: Actions; 17 | } 18 | 19 | export interface ArgsCreate extends ArgsBase { 20 | name: string; 21 | } 22 | 23 | export interface ArgsUpdate extends ArgsBase { 24 | checkID: number; 25 | } 26 | 27 | export type Args = ArgsCreate | ArgsUpdate; 28 | 29 | export type Output = { 30 | title?: string; 31 | summary: string; 32 | text_description?: string; 33 | }; 34 | 35 | export enum Conclusion { 36 | Success = 'success', 37 | Failure = 'failure', 38 | Neutral = 'neutral', 39 | Cancelled = 'cancelled', 40 | TimedOut = 'timed_out', 41 | ActionRequired = 'action_required', 42 | Skipped = 'skipped', 43 | } 44 | 45 | export enum Status { 46 | Queued = 'queued', 47 | InProgress = 'in_progress', 48 | Completed = 'completed', 49 | } 50 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "outDir": "./lib", 6 | "rootDir": "./src", 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "esModuleInterop": true 10 | }, 11 | "exclude": [ 12 | "node_modules", 13 | "**/*.test.ts" 14 | ] 15 | } 16 | --------------------------------------------------------------------------------