├── .eslintignore ├── .eslintrc.json ├── .github ├── dependabot.yml └── workflows │ ├── operational-test.yml │ └── test.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── __tests__ └── main.test.ts ├── action.yml ├── dist ├── 37.index.js └── index.js ├── doc_assets ├── hello-world-comment.png ├── lgtm-in.gif ├── merge-preview.png ├── saved-replies.gif ├── ssh-over-piping-server-terminal.jpg ├── ssh-over-piping-server.png └── update-all-npm-packages.png ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── async-function.ts ├── main.ts └── wait.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["jest", "@typescript-eslint"], 3 | // "extends": ["plugin:github/es6"], 4 | "parser": "@typescript-eslint/parser", 5 | "parserOptions": { 6 | "ecmaVersion": 9, 7 | "sourceType": "module", 8 | "project": "./tsconfig.json" 9 | }, 10 | "rules": { 11 | // "eslint-comments/no-use": "off", 12 | // "import/no-namespace": "off", 13 | // "no-unused-vars": "off", 14 | // "@typescript-eslint/no-unused-vars": "error", 15 | // "@typescript-eslint/explicit-member-accessibility": ["error", {"accessibility": "no-public"}], 16 | // "@typescript-eslint/no-require-imports": "error", 17 | // "@typescript-eslint/array-type": "error", 18 | // "@typescript-eslint/await-thenable": "error", 19 | // "@typescript-eslint/ban-ts-ignore": "error", 20 | // "camelcase": "off", 21 | // "@typescript-eslint/camelcase": "error", 22 | // "@typescript-eslint/class-name-casing": "error", 23 | // "@typescript-eslint/explicit-function-return-type": ["error", {"allowExpressions": true}], 24 | // "@typescript-eslint/func-call-spacing": ["error", "never"], 25 | // "@typescript-eslint/generic-type-naming": ["error", "^[A-Z][A-Za-z]*$"], 26 | // "@typescript-eslint/no-array-constructor": "error", 27 | // "@typescript-eslint/no-empty-interface": "error", 28 | // "@typescript-eslint/no-explicit-any": "error", 29 | // "@typescript-eslint/no-extraneous-class": "error", 30 | // "@typescript-eslint/no-for-in-array": "error", 31 | // "@typescript-eslint/no-inferrable-types": "error", 32 | // "@typescript-eslint/no-misused-new": "error", 33 | // "@typescript-eslint/no-namespace": "error", 34 | // "@typescript-eslint/no-non-null-assertion": "warn", 35 | // "@typescript-eslint/no-object-literal-type-assertion": "error", 36 | // "@typescript-eslint/no-unnecessary-qualifier": "error", 37 | // "@typescript-eslint/no-unnecessary-type-assertion": "error", 38 | // "@typescript-eslint/no-useless-constructor": "error", 39 | // "@typescript-eslint/no-var-requires": "error", 40 | // "@typescript-eslint/prefer-for-of": "warn", 41 | // "@typescript-eslint/prefer-function-type": "warn", 42 | // "@typescript-eslint/prefer-includes": "error", 43 | // "@typescript-eslint/prefer-interface": "error", 44 | // "@typescript-eslint/prefer-string-starts-ends-with": "error", 45 | // "@typescript-eslint/promise-function-async": "error", 46 | // "@typescript-eslint/require-array-sort-compare": "error", 47 | // "@typescript-eslint/restrict-plus-operands": "error", 48 | // "semi": "off", 49 | // "@typescript-eslint/semi": ["error", "never"], 50 | // "@typescript-eslint/type-annotation-spacing": "error", 51 | // "@typescript-eslint/unbound-method": "error" 52 | }, 53 | "env": { 54 | "node": true, 55 | "es6": true, 56 | "jest/globals": true 57 | } 58 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | timezone: Asia/Tokyo 8 | open-pull-requests-limit: 99 9 | reviewers: 10 | - nwtgck 11 | assignees: 12 | - nwtgck 13 | - package-ecosystem: github-actions 14 | directory: "/" 15 | schedule: 16 | interval: daily 17 | timezone: Asia/Tokyo 18 | open-pull-requests-limit: 99 19 | reviewers: 20 | - nwtgck 21 | assignees: 22 | - nwtgck 23 | -------------------------------------------------------------------------------- /.github/workflows/operational-test.yml: -------------------------------------------------------------------------------- 1 | name: "operational-test" 2 | on: 3 | # (from: https://help.github.com/en/actions/reference/events-that-trigger-workflows#issue-comment-event-issue_comment) 4 | issue_comment: 5 | types: [created, edited] 6 | 7 | jobs: 8 | test: # make sure the action works on a clean machine without building 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | # 0 indicates all history 14 | fetch-depth: 0 15 | - uses: ./ 16 | with: 17 | github-token: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: "build-test" 2 | on: [push] 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-20.04 7 | steps: 8 | - uses: actions/checkout@v4 9 | - uses: actions/setup-node@v4 10 | with: 11 | node-version: 20.x 12 | - run: npm ci 13 | - run: npm run all 14 | -------------------------------------------------------------------------------- /.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/**/* -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true, 7 | "trailingComma": "none", 8 | "bracketSpacing": false, 9 | "arrowParens": "avoid", 10 | "parser": "typescript" 11 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | 6 | ## [Unreleased] 7 | 8 | ## [3.0.0] - 2024-03-10 9 | ### Changed 10 | * Updates the default runtime to node20 11 | * Use @actions/core 1.10.1 12 | * Use @actions/github 6.0.0 13 | * Use node-fetch 3.3.2 14 | * Update dependencies 15 | 16 | ## [2.0.0] - 2023-05-05 17 | ### Changed 18 | * Updates the default runtime to node16 19 | * Use @actions/core 1.10.0 20 | * Use @actions/exec 1.1.1 21 | * Use @actions/github 5.1.1 22 | * Use node-fetch 3.3.1 23 | * Update dependencies 24 | 25 | ## [1.1.3] - 2020-04-07 26 | ### Changed 27 | * Update dependencies 28 | 29 | ## [1.1.2] - 2020-04-03 30 | ### Changed 31 | * Update dependencies 32 | 33 | ## [1.1.1] - 2020-03-23 34 | ### Fixed 35 | * Add missing `require()` 36 | 37 | ## [1.1.0] - 2020-03-23 38 | ### Changed 39 | * Update dependencies 40 | 41 | ### Added 42 | * Support top-level await 43 | 44 | ## [1.0.3] - 2020-03-10 45 | ### Changed 46 | * Add eyes reaction when started and after finished successfully add +1 reaction and remove the eyes 47 | 48 | ## [1.0.2] - 2020-03-10 49 | ### Changed 50 | * Accept only comment authors who have admin/write permission 51 | 52 | ### Added 53 | * Add +1 reaction to comment 54 | 55 | ## [1.0.1] - 2020-03-09 56 | ### Changed 57 | * Update documents 58 | 59 | ## 1.0.0 - 2020-03-09 60 | ### Added 61 | * Execute comment 62 | 63 | [Unreleased]: https://github.com/nwtgck/actions-comment-run/compare/v3.0.0...HEAD 64 | [3.0.0]: https://github.com/nwtgck/actions-comment-run/compare/v2.0.0...v3.0.0 65 | [2.0.0]: https://github.com/nwtgck/actions-comment-run/compare/v1.1.3...v2.0.0 66 | [1.1.3]: https://github.com/nwtgck/actions-comment-run/compare/v1.1.2...v1.1.3 67 | [1.1.2]: https://github.com/nwtgck/actions-comment-run/compare/v1.1.1...v1.1.2 68 | [1.1.1]: https://github.com/nwtgck/actions-comment-run/compare/v1.1.0...v1.1.1 69 | [1.1.0]: https://github.com/nwtgck/actions-comment-run/compare/v1.0.3...v1.1.0 70 | [1.0.3]: https://github.com/nwtgck/actions-comment-run/compare/v1.0.2...v1.0.3 71 | [1.0.2]: https://github.com/nwtgck/actions-comment-run/compare/v1.0.1...v1.0.2 72 | [1.0.1]: https://github.com/nwtgck/actions-comment-run/compare/v1.0.0...v1.0.1 73 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2018 Ryo Ota, 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 | # comment-run 2 | Execute any script in a GitHub issue comment 3 | 4 | ## Say "hello, world" 5 | 6 | You can make GitHub Actions Bot to say "hello, world". 7 | 8 | Post comment below on your issue or pull request. 9 | 10 | 11 | 12 | For shorter, you can use as follows. 13 | 14 | ````md 15 | @github-actions run 16 | 17 | ```js 18 | await postComment("hello, world"); 19 | ``` 20 | ```` 21 | 22 | ## Introduce this action 23 | Put `.github/workflows/comment-run.yml` to introduce comment-run. 24 | 25 | ```yaml 26 | # .github/workflows/comment-run.yml 27 | name: "Comment run" 28 | on: 29 | issue_comment: 30 | types: [created, edited] 31 | 32 | jobs: 33 | comment-run: 34 | runs-on: ubuntu-22.04 35 | steps: 36 | - uses: actions/checkout@v4 37 | - uses: nwtgck/actions-comment-run@v3 38 | with: 39 | github-token: ${{ secrets.GITHUB_TOKEN }} 40 | allowed-associations: '["OWNER"]' 41 | ``` 42 | 43 | You can introduce comment-run with the following command. 44 | ```bash 45 | mkdir -p .github/workflows && cd .github/workflows && wget https://gist.githubusercontent.com/nwtgck/a9b291f6869db42ecc3d9e30d0a0494c/raw/comment-run.yml && cd - 46 | ``` 47 | After introducing this, create new issue or pull request and post `@github-actions run` comment. 48 | 49 | ## Comment author who can run scripts 50 | 51 | Only accounts who have admin or write permission can execute the comment on your repository. (ref: [Collaborators | GitHub Developer Guide](https://developer.github.com/v3/repos/collaborators/#review-a-users-permission-level)) 52 | 53 | By default, only owner can execute the scripts. You can change `allowed-associations: '["OWNER"]'` in the yaml above. 54 | 55 | Here are examples. 56 | - `allowed-associations: '["OWNER"]'` 57 | - `allowed-associations: '["OWNER", "MEMBER"]'` 58 | - `allowed-associations: '["OWNER", "COLLABORATOR"]'` 59 | 60 | Learn more: [CommentAuthorAssociation | GitHub Developer Guide](https://developer.github.com/v4/enum/commentauthorassociation/) 61 | 62 | ## Available variables in the js-comment-context 63 | 64 | Here are available variables and functions in the ```` ```js ```` code block. 65 | 66 | | variable | examples | type or reference | 67 | |-----------------------------|----------------------------------------------------------------------------|------------------------------------------------------------------------------------| 68 | | `context` | `context.repo.owner`, `context.payload.comment` | [toolkit/context.ts at @actions/github@1.1.0 · actions/toolkit] | 69 | | `githubToken` | `require('@actions/github').getOctokit(githubToken)` | | 70 | | `octokit` | `await octokit.rest.pulls.create(...)`, `await octokit.graphql(...)` | [toolkit/packages/github at master · actions/toolkit] | 71 | | `execSync` | `execSync("ls -l")` | [child_process.execSync()] | 72 | | `postComment` | `await postComment("**hey!**")` | `(markdown: string) => Promise`, post GitHub issue/pull request comment | 73 | | `fetch` | `await fetch("https://example.com")` | [node-fetch/node-fetch: A light-weight module that brings window.fetch to Node.js] | 74 | | `core` | `core.debug('my message')` | [toolkit/packages/core at master · actions/toolkit] | 75 | | `exec` | `await exec.exec("git status")` | [toolkit/packages/exec at master · actions/toolkit] | 76 | | (deprecated) `githubClient` | `await githubClient.pulls.create(...)`, `await githubClient.graphql(...)` | [toolkit/packages/github at master · actions/toolkit] | 77 | 78 | Other built-in variables and functions in Node.js such as `process` and `require(...)` are also available. This means you can use `process.env` for environment variables and `require('fs')` for file access. 79 | 80 | Although other variables not in the list can be used on the comment, comment-run guarantees use of the variables list above and non-listed variables are not guaranteed to use. 81 | 82 | [toolkit/context.ts at @actions/github@1.1.0 · actions/toolkit]: https://github.com/actions/toolkit/blob/a2ab4bcf78e4f7080f0d45856e6eeba16f0bbc52/packages/github/src/context.ts 83 | [toolkit/packages/github at master · actions/toolkit]: https://github.com/actions/toolkit/tree/master/packages/github#usage 84 | [child_process.execSync()]: https://nodejs.org/api/child_process.html#child_process_child_process_execsync_command_options 85 | [node-fetch/node-fetch: A light-weight module that brings window.fetch to Node.js]: https://github.com/node-fetch/node-fetch#common-usage 86 | [toolkit/packages/core at master · actions/toolkit]: https://github.com/actions/toolkit/tree/master/packages/core 87 | [toolkit/packages/exec at master · actions/toolkit]: https://github.com/actions/toolkit/tree/master/packages/exec 88 | 89 | ## Useful examples 90 | 91 | ### LGTM Image 92 | 93 | Post random LGTM image with [LGTM.in/g](https://lgtm.in/). 94 | 95 | ![LGTM.in](doc_assets/lgtm-in.gif) 96 | 97 | ````md 98 | @github-actions run 99 | 100 |
101 | LGTM 👍 102 | 103 | ```js 104 | const res = await fetch("https://lgtm.in/g", { 105 | redirect: 'manual' 106 | }); 107 | const webSiteUrl = res.headers.get('location'); 108 | const picUrl = new URL(webSiteUrl); 109 | picUrl.pathname = picUrl.pathname.replace("/i/", "/p/"); 110 | postComment(`![LGTM](${picUrl.href})`); 111 | ``` 112 |
113 | ```` 114 | 115 | 116 | ### Update all npm packages 117 | 118 | Although Dependabot is useful, sometimes you might want to bump all packages up. This comment allows you to do this. 119 | 120 | 121 | 122 | ````md 123 | @github-actions run 124 | 125 | ```js 126 | function exec(cmd) { 127 | console.log(execSync(cmd).toString()); 128 | } 129 | 130 | // Config 131 | const gitUserEmail = "github-actions[bot]@users.noreply.github.com"; 132 | const gitUserName = "github-actions[bot]"; 133 | const prBranchName = "comment-run/npm-update"; 134 | 135 | const baseBranchName = context.payload.repository.default_branch"; 136 | exec(`git config --global user.email "${gitUserEmail}"`); 137 | exec(`git config --global user.name "${gitUserName}"`); 138 | exec(`git fetch --all`); 139 | exec(`git checkout ${baseBranchName}`); 140 | exec(`git checkout -b ${prBranchName}`); 141 | 142 | const packageJson = JSON.parse(require('fs').readFileSync('package.json')); 143 | const depStr = Object.keys(packageJson.dependencies || {}).join(' '); 144 | const devDepStr = Object.keys(packageJson.devDependencies || {}).join(' '); 145 | exec(`npm i ${depStr} ${devDepStr}`); 146 | 147 | exec("git status"); 148 | exec("git add package*json"); 149 | exec(`git commit -m "chore(deps): update npm dependencies"`); 150 | exec(`git push -fu origin ${prBranchName}`); 151 | 152 | await githubClient.pulls.create({ 153 | base: baseBranchName, 154 | head: prBranchName, 155 | owner: context.repo.owner, 156 | repo: context.repo.repo, 157 | title: "chore(deps): update npm dependencies", 158 | body: "update npm dependencies", 159 | }); 160 | ``` 161 | ```` 162 | 163 | 164 | ### PR merge preview 165 | 166 | GitHub Actions do not pass `secrets` to pull request from forked repositories. This security feature may restricts GitHub Actions usages. This comment is created to resolve the problem 167 | 168 | 169 | 170 | 171 | ````md 172 | @github-actions run 173 | 174 |
175 | 🚀 Merge preview 176 | 177 | ```js 178 | // Get pull-req URL like "https://api.github.com/repos/nwtgck/actions-merge-preview/pulls/4" 179 | const pullReqUrl = context.payload.issue.pull_request.url; 180 | const githubUser = context.payload.repository.owner.login; 181 | const res = await fetch(pullReqUrl, { 182 | headers: [ 183 | ['Authorization', `Basic ${Buffer.from(`${githubUser}:${githubToken}`).toString('base64')}`] 184 | ] 185 | }); 186 | const resJson = await res.json(); 187 | const prUserName = resJson.head.user.login; 188 | const baseBranchName = resJson.base.ref; 189 | const branchName = resJson.head.ref; 190 | const fullRepoName = resJson.head.repo.full_name; 191 | const previewBranchName = `actions-merge-preview/${prUserName}-${branchName}`; 192 | execSync(`git config --global user.email "github-actions[bot]@users.noreply.github.com"`); 193 | execSync(`git config --global user.name "github-actions[bot]"`); 194 | // (from: https://stackoverflow.com/a/23987039/2885946) 195 | execSync(`git fetch --all`); 196 | console.log(execSync(`git checkout ${baseBranchName}`).toString()); 197 | console.log(execSync(`git checkout -b ${previewBranchName} ${baseBranchName}`).toString()); 198 | console.log(execSync(`git pull https://github.com/${fullRepoName}.git ${branchName}`).toString()); 199 | // Push preview branch 200 | // NOTE: Force push (should be safe because preview branch always start with "actions-merge-preview/") 201 | execSync(`git push -fu origin ${previewBranchName}`); 202 | const baseRepoFullName = context.payload.repository.full_name; 203 | // Comment body 204 | const commentBody = `🚀 Preview branch: \n`; 205 | // Comment the deploy URL 206 | await postComment(commentBody); 207 | ``` 208 |
209 | ```` 210 | 211 | ### SSH in GitHub Actions over Piping Server 212 | 213 | This comment allows you to go inside of GitHub Actions environment. 214 | 215 | SSH over Piping Server 216 | SSH over Piping Server terminal 217 | 218 | ````md 219 | @github-actions run 220 | 221 |
222 | 🌐 SSH debug over Piping Server 223 | 224 | ```js 225 | const crypto = require('crypto'); 226 | const pathLen = 64; 227 | const aPath = randomString(pathLen); 228 | const bPath = randomString(pathLen); 229 | const commentUserId = context.payload.comment.user.login; 230 | const clientHostPort = Math.floor(Math.random() * 55536) + 10000; 231 | 232 | console.log(execSync(` 233 | chmod 755 "$HOME" 234 | ls -lA /home 235 | authorized_keys_file="$(sshd -T 2>/dev/null | grep -E '^authorizedkeysfile ' | cut -d ' ' -f 2)" 236 | authorized_keys_file="$(cd && realpath -m "$authorized_keys_file")" 237 | sshd_config_dir="$(dirname "$authorized_keys_file")" 238 | (umask 0077 && mkdir "$sshd_config_dir") 239 | echo $authorized_keys_file; 240 | 241 | # (from: https://qiita.com/zackey2/items/429c77e5780ba8bc1bf9#authorized_keys%E3%81%AB%E8%A8%AD%E5%AE%9A%E3%81%99%E3%82%8B%E6%96%B9%E6%B3%95) 242 | (echo; curl https://github.com/${commentUserId}.keys; echo) >> ~/.ssh/authorized_keys 243 | 244 | sudo apt install -y socat; 245 | `).toString()); 246 | 247 | // Comment new session 248 | const commentBody = `\ 249 | ## 🌐 New SSH session 250 | Run the command below. 251 | 252 | \`\`\`bash 253 | socat TCP-LISTEN:${clientHostPort} 'EXEC:curl -NsS https\\://ppng.io/${bPath}!!EXEC:curl -NsST - https\\://ppng.io/${aPath}' 254 | \`\`\` 255 | 256 | Run the command below in another terminal. 257 | 258 | \`\`\`bash 259 | ssh -p ${clientHostPort} runner@localhost 260 | \`\`\` 261 | 262 | `; 263 | await githubClient.issues.createComment({ 264 | issue_number: context.issue.number, 265 | owner: context.repo.owner, 266 | repo: context.repo.repo, 267 | body: commentBody 268 | }); 269 | 270 | execSync(`socat 'EXEC:curl -NsS https\\://ppng.io/${aPath}!!EXEC:curl -NsST - https\\://ppng.io/${bPath}' TCP:127.0.0.1:22`); 271 | 272 | 273 | function randomString(len){ 274 | const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 275 | const randomArr = new Uint32Array(new Uint8Array(crypto.randomBytes(len * 4)).buffer); 276 | return [...randomArr].map(n => chars.charAt(n % chars.length)).join(''); 277 | } 278 | ``` 279 | 280 | ## References 281 | * ) 282 | * 283 | 284 | Thanks Cryolite! 285 | 286 |
287 | ```` 288 | 289 | ## TIPS: Saved replies 290 | 291 | "Saved replies" fits this action very much. 292 | ![Saved replies](doc_assets/saved-replies.gif) 293 | 294 | You can save "Saved replies" as follows. 295 | Avatar icon > Settings > Saved replies 296 | 297 | ## TIPS: Reactions 298 | 299 | Reactions on comments represent the Action is working. Here is a list of the reactions and descriptions. 300 | 301 | | reaction | reason | 302 | |----------|-------------------------------------------------| 303 | | 👀 | The Action has started looking at your comment. | 304 | | 👍 | The Action has completed. | 305 | 306 | 307 | ## TIPS: Run other languages 308 | 309 | This action supports shebang (`#!`), so you can run shell and Python as follows. 310 | 311 | ````md 312 | @github-actions run 313 | 314 | ```sh 315 | #! /bin/sh 316 | pip install numpy 317 | ``` 318 | 319 | ```py 320 | #! /usr/bin/python 321 | import numpy as np 322 | 323 | print(np.array([1, 2, 3])) 324 | ``` 325 | ```` 326 | 327 | Here are examples. 328 | - Deno: 329 | - Go: 330 | - Haskell: 331 | - Scala: 332 | 333 | ## TIPS: Use existing package, TypeScript and manage on GitHub 334 | 335 | When your comment-run scripts are matured, you might want to use TypeScript for maintainability. 336 | The following repository uses existing npm packages and TypeScript. 337 | 338 | 339 | 340 | Built bundle .js files are hosted on GitHub Pages. So, your comment will be as follows. 341 | 342 | ````md 343 | @github-actions run 344 | 345 | ```js 346 | const url = "https://nwtgck.github.io/comment-run-scripts/hello-world-comment.js"; 347 | const js = await (await fetch(url)).text(); 348 | eval(js); 349 | ``` 350 | ```` 351 | -------------------------------------------------------------------------------- /__tests__/main.test.ts: -------------------------------------------------------------------------------- 1 | import {wait} from '../src/wait' 2 | import * as process from 'process' 3 | import * as cp from 'child_process' 4 | import * as path from 'path' 5 | 6 | test('throws invalid number', async () => { 7 | const input = parseInt('foo', 10) 8 | await expect(wait(input)).rejects.toThrow('milliseconds not a number') 9 | }) 10 | 11 | test('wait 500 ms', async () => { 12 | const start = new Date() 13 | await wait(500) 14 | const end = new Date() 15 | var delta = Math.abs(end.getTime() - start.getTime()) 16 | expect(delta).toBeGreaterThan(450) 17 | }) 18 | 19 | // shows how the runner will run a javascript action with env / stdout protocol 20 | test('test runs', () => { 21 | process.env['INPUT_MILLISECONDS'] = '500' 22 | const ip = path.join(__dirname, '..', 'lib', 'main.js') 23 | const options: cp.ExecSyncOptions = { 24 | env: process.env 25 | } 26 | // console.log(cp.execSync(`node ${ip}`, options).toString()) 27 | }) 28 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Comment Run Actions' 2 | description: 'Execute any script in a GitHub issue comment' 3 | author: 'Ryo Ota ' 4 | inputs: 5 | github-token: 6 | description: 'GitHub token' 7 | required: true 8 | allowed-associations: 9 | description: Comment author associations allowed to execute scripts (e.g. '["OWNER", "COLLABORATOR"]') 10 | default: '["OWNER"]' 11 | runs: 12 | using: 'node20' 13 | main: 'dist/index.js' 14 | -------------------------------------------------------------------------------- /dist/37.index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | exports.id = 37; 3 | exports.ids = [37]; 4 | exports.modules = { 5 | 6 | /***/ 4037: 7 | /***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => { 8 | 9 | __webpack_require__.r(__webpack_exports__); 10 | /* harmony export */ __webpack_require__.d(__webpack_exports__, { 11 | /* harmony export */ "toFormData": () => (/* binding */ toFormData) 12 | /* harmony export */ }); 13 | /* harmony import */ var fetch_blob_from_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(2777); 14 | /* harmony import */ var formdata_polyfill_esm_min_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(8010); 15 | 16 | 17 | 18 | let s = 0; 19 | const S = { 20 | START_BOUNDARY: s++, 21 | HEADER_FIELD_START: s++, 22 | HEADER_FIELD: s++, 23 | HEADER_VALUE_START: s++, 24 | HEADER_VALUE: s++, 25 | HEADER_VALUE_ALMOST_DONE: s++, 26 | HEADERS_ALMOST_DONE: s++, 27 | PART_DATA_START: s++, 28 | PART_DATA: s++, 29 | END: s++ 30 | }; 31 | 32 | let f = 1; 33 | const F = { 34 | PART_BOUNDARY: f, 35 | LAST_BOUNDARY: f *= 2 36 | }; 37 | 38 | const LF = 10; 39 | const CR = 13; 40 | const SPACE = 32; 41 | const HYPHEN = 45; 42 | const COLON = 58; 43 | const A = 97; 44 | const Z = 122; 45 | 46 | const lower = c => c | 0x20; 47 | 48 | const noop = () => {}; 49 | 50 | class MultipartParser { 51 | /** 52 | * @param {string} boundary 53 | */ 54 | constructor(boundary) { 55 | this.index = 0; 56 | this.flags = 0; 57 | 58 | this.onHeaderEnd = noop; 59 | this.onHeaderField = noop; 60 | this.onHeadersEnd = noop; 61 | this.onHeaderValue = noop; 62 | this.onPartBegin = noop; 63 | this.onPartData = noop; 64 | this.onPartEnd = noop; 65 | 66 | this.boundaryChars = {}; 67 | 68 | boundary = '\r\n--' + boundary; 69 | const ui8a = new Uint8Array(boundary.length); 70 | for (let i = 0; i < boundary.length; i++) { 71 | ui8a[i] = boundary.charCodeAt(i); 72 | this.boundaryChars[ui8a[i]] = true; 73 | } 74 | 75 | this.boundary = ui8a; 76 | this.lookbehind = new Uint8Array(this.boundary.length + 8); 77 | this.state = S.START_BOUNDARY; 78 | } 79 | 80 | /** 81 | * @param {Uint8Array} data 82 | */ 83 | write(data) { 84 | let i = 0; 85 | const length_ = data.length; 86 | let previousIndex = this.index; 87 | let {lookbehind, boundary, boundaryChars, index, state, flags} = this; 88 | const boundaryLength = this.boundary.length; 89 | const boundaryEnd = boundaryLength - 1; 90 | const bufferLength = data.length; 91 | let c; 92 | let cl; 93 | 94 | const mark = name => { 95 | this[name + 'Mark'] = i; 96 | }; 97 | 98 | const clear = name => { 99 | delete this[name + 'Mark']; 100 | }; 101 | 102 | const callback = (callbackSymbol, start, end, ui8a) => { 103 | if (start === undefined || start !== end) { 104 | this[callbackSymbol](ui8a && ui8a.subarray(start, end)); 105 | } 106 | }; 107 | 108 | const dataCallback = (name, clear) => { 109 | const markSymbol = name + 'Mark'; 110 | if (!(markSymbol in this)) { 111 | return; 112 | } 113 | 114 | if (clear) { 115 | callback(name, this[markSymbol], i, data); 116 | delete this[markSymbol]; 117 | } else { 118 | callback(name, this[markSymbol], data.length, data); 119 | this[markSymbol] = 0; 120 | } 121 | }; 122 | 123 | for (i = 0; i < length_; i++) { 124 | c = data[i]; 125 | 126 | switch (state) { 127 | case S.START_BOUNDARY: 128 | if (index === boundary.length - 2) { 129 | if (c === HYPHEN) { 130 | flags |= F.LAST_BOUNDARY; 131 | } else if (c !== CR) { 132 | return; 133 | } 134 | 135 | index++; 136 | break; 137 | } else if (index - 1 === boundary.length - 2) { 138 | if (flags & F.LAST_BOUNDARY && c === HYPHEN) { 139 | state = S.END; 140 | flags = 0; 141 | } else if (!(flags & F.LAST_BOUNDARY) && c === LF) { 142 | index = 0; 143 | callback('onPartBegin'); 144 | state = S.HEADER_FIELD_START; 145 | } else { 146 | return; 147 | } 148 | 149 | break; 150 | } 151 | 152 | if (c !== boundary[index + 2]) { 153 | index = -2; 154 | } 155 | 156 | if (c === boundary[index + 2]) { 157 | index++; 158 | } 159 | 160 | break; 161 | case S.HEADER_FIELD_START: 162 | state = S.HEADER_FIELD; 163 | mark('onHeaderField'); 164 | index = 0; 165 | // falls through 166 | case S.HEADER_FIELD: 167 | if (c === CR) { 168 | clear('onHeaderField'); 169 | state = S.HEADERS_ALMOST_DONE; 170 | break; 171 | } 172 | 173 | index++; 174 | if (c === HYPHEN) { 175 | break; 176 | } 177 | 178 | if (c === COLON) { 179 | if (index === 1) { 180 | // empty header field 181 | return; 182 | } 183 | 184 | dataCallback('onHeaderField', true); 185 | state = S.HEADER_VALUE_START; 186 | break; 187 | } 188 | 189 | cl = lower(c); 190 | if (cl < A || cl > Z) { 191 | return; 192 | } 193 | 194 | break; 195 | case S.HEADER_VALUE_START: 196 | if (c === SPACE) { 197 | break; 198 | } 199 | 200 | mark('onHeaderValue'); 201 | state = S.HEADER_VALUE; 202 | // falls through 203 | case S.HEADER_VALUE: 204 | if (c === CR) { 205 | dataCallback('onHeaderValue', true); 206 | callback('onHeaderEnd'); 207 | state = S.HEADER_VALUE_ALMOST_DONE; 208 | } 209 | 210 | break; 211 | case S.HEADER_VALUE_ALMOST_DONE: 212 | if (c !== LF) { 213 | return; 214 | } 215 | 216 | state = S.HEADER_FIELD_START; 217 | break; 218 | case S.HEADERS_ALMOST_DONE: 219 | if (c !== LF) { 220 | return; 221 | } 222 | 223 | callback('onHeadersEnd'); 224 | state = S.PART_DATA_START; 225 | break; 226 | case S.PART_DATA_START: 227 | state = S.PART_DATA; 228 | mark('onPartData'); 229 | // falls through 230 | case S.PART_DATA: 231 | previousIndex = index; 232 | 233 | if (index === 0) { 234 | // boyer-moore derrived algorithm to safely skip non-boundary data 235 | i += boundaryEnd; 236 | while (i < bufferLength && !(data[i] in boundaryChars)) { 237 | i += boundaryLength; 238 | } 239 | 240 | i -= boundaryEnd; 241 | c = data[i]; 242 | } 243 | 244 | if (index < boundary.length) { 245 | if (boundary[index] === c) { 246 | if (index === 0) { 247 | dataCallback('onPartData', true); 248 | } 249 | 250 | index++; 251 | } else { 252 | index = 0; 253 | } 254 | } else if (index === boundary.length) { 255 | index++; 256 | if (c === CR) { 257 | // CR = part boundary 258 | flags |= F.PART_BOUNDARY; 259 | } else if (c === HYPHEN) { 260 | // HYPHEN = end boundary 261 | flags |= F.LAST_BOUNDARY; 262 | } else { 263 | index = 0; 264 | } 265 | } else if (index - 1 === boundary.length) { 266 | if (flags & F.PART_BOUNDARY) { 267 | index = 0; 268 | if (c === LF) { 269 | // unset the PART_BOUNDARY flag 270 | flags &= ~F.PART_BOUNDARY; 271 | callback('onPartEnd'); 272 | callback('onPartBegin'); 273 | state = S.HEADER_FIELD_START; 274 | break; 275 | } 276 | } else if (flags & F.LAST_BOUNDARY) { 277 | if (c === HYPHEN) { 278 | callback('onPartEnd'); 279 | state = S.END; 280 | flags = 0; 281 | } else { 282 | index = 0; 283 | } 284 | } else { 285 | index = 0; 286 | } 287 | } 288 | 289 | if (index > 0) { 290 | // when matching a possible boundary, keep a lookbehind reference 291 | // in case it turns out to be a false lead 292 | lookbehind[index - 1] = c; 293 | } else if (previousIndex > 0) { 294 | // if our boundary turned out to be rubbish, the captured lookbehind 295 | // belongs to partData 296 | const _lookbehind = new Uint8Array(lookbehind.buffer, lookbehind.byteOffset, lookbehind.byteLength); 297 | callback('onPartData', 0, previousIndex, _lookbehind); 298 | previousIndex = 0; 299 | mark('onPartData'); 300 | 301 | // reconsider the current character even so it interrupted the sequence 302 | // it could be the beginning of a new sequence 303 | i--; 304 | } 305 | 306 | break; 307 | case S.END: 308 | break; 309 | default: 310 | throw new Error(`Unexpected state entered: ${state}`); 311 | } 312 | } 313 | 314 | dataCallback('onHeaderField'); 315 | dataCallback('onHeaderValue'); 316 | dataCallback('onPartData'); 317 | 318 | // Update properties for the next call 319 | this.index = index; 320 | this.state = state; 321 | this.flags = flags; 322 | } 323 | 324 | end() { 325 | if ((this.state === S.HEADER_FIELD_START && this.index === 0) || 326 | (this.state === S.PART_DATA && this.index === this.boundary.length)) { 327 | this.onPartEnd(); 328 | } else if (this.state !== S.END) { 329 | throw new Error('MultipartParser.end(): stream ended unexpectedly'); 330 | } 331 | } 332 | } 333 | 334 | function _fileName(headerValue) { 335 | // matches either a quoted-string or a token (RFC 2616 section 19.5.1) 336 | const m = headerValue.match(/\bfilename=("(.*?)"|([^()<>@,;:\\"/[\]?={}\s\t]+))($|;\s)/i); 337 | if (!m) { 338 | return; 339 | } 340 | 341 | const match = m[2] || m[3] || ''; 342 | let filename = match.slice(match.lastIndexOf('\\') + 1); 343 | filename = filename.replace(/%22/g, '"'); 344 | filename = filename.replace(/&#(\d{4});/g, (m, code) => { 345 | return String.fromCharCode(code); 346 | }); 347 | return filename; 348 | } 349 | 350 | async function toFormData(Body, ct) { 351 | if (!/multipart/i.test(ct)) { 352 | throw new TypeError('Failed to fetch'); 353 | } 354 | 355 | const m = ct.match(/boundary=(?:"([^"]+)"|([^;]+))/i); 356 | 357 | if (!m) { 358 | throw new TypeError('no or bad content-type header, no multipart boundary'); 359 | } 360 | 361 | const parser = new MultipartParser(m[1] || m[2]); 362 | 363 | let headerField; 364 | let headerValue; 365 | let entryValue; 366 | let entryName; 367 | let contentType; 368 | let filename; 369 | const entryChunks = []; 370 | const formData = new formdata_polyfill_esm_min_js__WEBPACK_IMPORTED_MODULE_1__/* .FormData */ .Ct(); 371 | 372 | const onPartData = ui8a => { 373 | entryValue += decoder.decode(ui8a, {stream: true}); 374 | }; 375 | 376 | const appendToFile = ui8a => { 377 | entryChunks.push(ui8a); 378 | }; 379 | 380 | const appendFileToFormData = () => { 381 | const file = new fetch_blob_from_js__WEBPACK_IMPORTED_MODULE_0__/* .File */ .$B(entryChunks, filename, {type: contentType}); 382 | formData.append(entryName, file); 383 | }; 384 | 385 | const appendEntryToFormData = () => { 386 | formData.append(entryName, entryValue); 387 | }; 388 | 389 | const decoder = new TextDecoder('utf-8'); 390 | decoder.decode(); 391 | 392 | parser.onPartBegin = function () { 393 | parser.onPartData = onPartData; 394 | parser.onPartEnd = appendEntryToFormData; 395 | 396 | headerField = ''; 397 | headerValue = ''; 398 | entryValue = ''; 399 | entryName = ''; 400 | contentType = ''; 401 | filename = null; 402 | entryChunks.length = 0; 403 | }; 404 | 405 | parser.onHeaderField = function (ui8a) { 406 | headerField += decoder.decode(ui8a, {stream: true}); 407 | }; 408 | 409 | parser.onHeaderValue = function (ui8a) { 410 | headerValue += decoder.decode(ui8a, {stream: true}); 411 | }; 412 | 413 | parser.onHeaderEnd = function () { 414 | headerValue += decoder.decode(); 415 | headerField = headerField.toLowerCase(); 416 | 417 | if (headerField === 'content-disposition') { 418 | // matches either a quoted-string or a token (RFC 2616 section 19.5.1) 419 | const m = headerValue.match(/\bname=("([^"]*)"|([^()<>@,;:\\"/[\]?={}\s\t]+))/i); 420 | 421 | if (m) { 422 | entryName = m[2] || m[3] || ''; 423 | } 424 | 425 | filename = _fileName(headerValue); 426 | 427 | if (filename) { 428 | parser.onPartData = appendToFile; 429 | parser.onPartEnd = appendFileToFormData; 430 | } 431 | } else if (headerField === 'content-type') { 432 | contentType = headerValue; 433 | } 434 | 435 | headerValue = ''; 436 | headerField = ''; 437 | }; 438 | 439 | for await (const chunk of Body) { 440 | parser.write(chunk); 441 | } 442 | 443 | parser.end(); 444 | 445 | return formData; 446 | } 447 | 448 | 449 | /***/ }) 450 | 451 | }; 452 | ; -------------------------------------------------------------------------------- /doc_assets/hello-world-comment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwtgck/actions-comment-run/8b4201b83d9fdc82bc086b46d659b69bfb05ca86/doc_assets/hello-world-comment.png -------------------------------------------------------------------------------- /doc_assets/lgtm-in.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwtgck/actions-comment-run/8b4201b83d9fdc82bc086b46d659b69bfb05ca86/doc_assets/lgtm-in.gif -------------------------------------------------------------------------------- /doc_assets/merge-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwtgck/actions-comment-run/8b4201b83d9fdc82bc086b46d659b69bfb05ca86/doc_assets/merge-preview.png -------------------------------------------------------------------------------- /doc_assets/saved-replies.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwtgck/actions-comment-run/8b4201b83d9fdc82bc086b46d659b69bfb05ca86/doc_assets/saved-replies.gif -------------------------------------------------------------------------------- /doc_assets/ssh-over-piping-server-terminal.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwtgck/actions-comment-run/8b4201b83d9fdc82bc086b46d659b69bfb05ca86/doc_assets/ssh-over-piping-server-terminal.jpg -------------------------------------------------------------------------------- /doc_assets/ssh-over-piping-server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwtgck/actions-comment-run/8b4201b83d9fdc82bc086b46d659b69bfb05ca86/doc_assets/ssh-over-piping-server.png -------------------------------------------------------------------------------- /doc_assets/update-all-npm-packages.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nwtgck/actions-comment-run/8b4201b83d9fdc82bc086b46d659b69bfb05ca86/doc_assets/update-all-npm-packages.png -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "actions-comment-run", 3 | "version": "3.0.0", 4 | "private": true, 5 | "description": "Comment run action", 6 | "main": "lib/main.js", 7 | "scripts": { 8 | "build": "tsc", 9 | "format": "exit 0 && prettier --write **/*.ts", 10 | "format-check": "exit 0 && prettier --check **/*.ts", 11 | "lint": "eslint src/**/*.ts", 12 | "pack": "ncc build", 13 | "test": "jest", 14 | "all": "npm run build && npm run lint && npm run pack && npm test" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/nwtgck/actions-comment-run.git" 19 | }, 20 | "keywords": [ 21 | "actions", 22 | "node", 23 | "setup" 24 | ], 25 | "author": "Ryo Ota (https://github.com/nwtgck)", 26 | "license": "MIT", 27 | "dependencies": { 28 | "@actions/core": "1.10.1", 29 | "@actions/exec": "1.1.1", 30 | "@actions/github": "6.0.0", 31 | "marked": "^12.0.1", 32 | "node-fetch": "3.3.2", 33 | "zod": "^3.22.4" 34 | }, 35 | "devDependencies": { 36 | "@types/jest": "^29.5.12", 37 | "@types/node": "^20.11.25", 38 | "@typescript-eslint/eslint-plugin": "^5.62.0", 39 | "@typescript-eslint/parser": "^5.62.0", 40 | "@vercel/ncc": "^0.38.1", 41 | "eslint": "^8.57.0", 42 | "eslint-plugin-github": "^4.10.2", 43 | "eslint-plugin-jest": "^27.9.0", 44 | "jest": "^29.7.0", 45 | "jest-circus": "^29.5.0", 46 | "prettier": "^3.2.5", 47 | "ts-jest": "^29.1.2", 48 | "typescript": "^5.4.2" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/async-function.ts: -------------------------------------------------------------------------------- 1 | // (from: https://github.com/actions/github-script/blob/80a5e943b446817466ff17e8b61cb80848641ed6/src/async-function.ts) 2 | 3 | const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor 4 | 5 | type AsyncFunctionArguments = {[key: string]: any} 6 | 7 | export async function callAsyncFunction( 8 | args: AsyncFunctionArguments, 9 | source: string 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | ): Promise { 12 | const fn = new AsyncFunction(...Object.keys(args), source) 13 | return fn(...Object.values(args)) 14 | } 15 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as github from '@actions/github' 3 | import * as exec from '@actions/exec' 4 | import fetch from 'node-fetch' 5 | import {execSync} from 'child_process' 6 | import * as marked from 'marked' 7 | import {z} from 'zod' 8 | import * as fs from 'fs' 9 | 10 | import {callAsyncFunction} from './async-function' 11 | 12 | const commentAuthorAssociationsSchema = z.array(z.string()) 13 | 14 | const commentPrefix = '@github-actions run' 15 | 16 | async function run(): Promise { 17 | const context = github.context 18 | try { 19 | const githubToken = core.getInput('github-token', {required: true}) 20 | if (context.eventName !== 'issue_comment') { 21 | console.warn(`event name is not 'issue_comment': ${context.eventName}`) 22 | return 23 | } 24 | // Create GitHub client which can be used in the user script 25 | const octokit = github.getOctokit(githubToken) 26 | const permissionRes = 27 | await octokit.rest.repos.getCollaboratorPermissionLevel({ 28 | owner: context.repo.owner, 29 | repo: context.repo.repo, 30 | username: context.actor 31 | }) 32 | if (permissionRes.status !== 200) { 33 | console.error( 34 | `Permission check returns non-200 status: ${permissionRes.status}` 35 | ) 36 | return 37 | } 38 | const actorPermission = permissionRes.data.permission 39 | if (!['admin', 'write'].includes(actorPermission)) { 40 | console.error( 41 | `ERROR: ${context.actor} does not have admin/write permission: ${actorPermission}` 42 | ) 43 | return 44 | } 45 | const comment: string = (context.payload as any).comment.body 46 | // If not command-run-request comment 47 | if (!comment.startsWith(commentPrefix)) { 48 | console.log( 49 | `HINT: Comment-run is triggered when your comment start with "${commentPrefix}"` 50 | ) 51 | return 52 | } 53 | // Get allowed associations 54 | const allowedAssociationsStr = core.getInput('allowed-associations') 55 | // Parse and validate 56 | const allowedAssociationsResult = commentAuthorAssociationsSchema.safeParse( 57 | JSON.parse(allowedAssociationsStr) 58 | ) 59 | if (!allowedAssociationsResult.success) { 60 | console.error( 61 | `ERROR: Invalid allowed-associations: ${allowedAssociationsStr}` 62 | ) 63 | return 64 | } 65 | const allowedAssociations: string[] = allowedAssociationsResult.data 66 | const association = (context.payload as any).comment.author_association 67 | // If commenting user is not allowed to run scripts 68 | if (!allowedAssociations.includes(association)) { 69 | console.warn( 70 | `NOTE: The allowed associations to run scripts are ${allowedAssociationsStr}, but you are ${association}.` 71 | ) 72 | return 73 | } 74 | // Add :eyes: reaction 75 | const reactionRes = await octokit.rest.reactions 76 | .createForIssueComment({ 77 | comment_id: (context.payload as any).comment.id, 78 | content: 'eyes', 79 | owner: context.repo.owner, 80 | repo: context.repo.repo 81 | }) 82 | .catch(err => { 83 | console.error('Add-eyes-reaction failed') 84 | }) 85 | // Post GitHub issue comment 86 | const postComment = async (body: string): Promise => { 87 | await octokit.rest.issues.createComment({ 88 | issue_number: context.issue.number, 89 | owner: context.repo.owner, 90 | repo: context.repo.repo, 91 | body 92 | }) 93 | } 94 | // for @actions/github@2 compat 95 | const githubClient = new Proxy(octokit, { 96 | get(target, prop, receiver) { 97 | if (prop === 'graphql') { 98 | core.warning(`Use octokit.graphql instead of githubClient.graphql`) 99 | return octokit.graphql 100 | } 101 | core.warning(`Use octokit.rest.${String(prop)} instead of githubClient.${String(prop)}`) 102 | return Reflect.get(octokit.rest, prop, receiver) 103 | } 104 | }) 105 | // Parse the comment 106 | const tokens = marked.Lexer.lex(comment) 107 | for (const token of tokens) { 108 | if (token.type === 'code') { 109 | if (token.lang === 'js' || token.lang === 'javascript') { 110 | // Eval JavaScript 111 | await callAsyncFunction( 112 | { 113 | require, 114 | core, 115 | exec, 116 | fetch, 117 | context, 118 | githubToken, 119 | octokit, 120 | githubClient, // deprecated (users should use octokit) 121 | execSync, 122 | postComment 123 | }, 124 | token.text 125 | ) 126 | } else if (token.text.startsWith('#!')) { 127 | // Execute script with shebang 128 | await executeShebangScript(token.text) 129 | } 130 | } 131 | } 132 | if (reactionRes !== undefined) { 133 | // Add +1 reaction 134 | await octokit.rest.reactions 135 | .createForIssueComment({ 136 | comment_id: (context.payload as any).comment.id, 137 | content: '+1', 138 | owner: context.repo.owner, 139 | repo: context.repo.repo 140 | }) 141 | .catch(err => { 142 | console.error('Add-+1-reaction failed') 143 | }) 144 | // Delete eyes reaction 145 | await octokit.rest.reactions 146 | .deleteForIssueComment({ 147 | comment_id: (context.payload as any).comment.id, 148 | reaction_id: reactionRes.data.id, 149 | owner: context.repo.owner, 150 | repo: context.repo.repo 151 | }) 152 | .catch(err => { 153 | console.error('Delete-reaction failed', err) 154 | }) 155 | } 156 | } catch (error: any) { 157 | core.setFailed(error.message) 158 | } 159 | } 160 | 161 | function createTmpFileName(): string { 162 | const prefix = 'tmp_' 163 | const len = 32 164 | while (true) { 165 | const fileName = `${prefix}${randomString(len)}` 166 | if (!fs.existsSync(fileName)) return fileName 167 | } 168 | } 169 | 170 | // (base: https://stackoverflow.com/a/1349426/2885946) 171 | function randomString(length: number): string { 172 | let result = '' 173 | const characters = 174 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' 175 | const charactersLength = characters.length 176 | for (let i = 0; i < length; i++) { 177 | result += characters.charAt(Math.floor(Math.random() * charactersLength)) 178 | } 179 | return result 180 | } 181 | 182 | async function executeShebangScript(script: string): Promise { 183 | // NOTE: Executing file in /tmp cause the error "UnhandledPromiseRejectionWarning: Error: There was an error when attempting to execute the process '/tmp/tmp-26373utihbUOauHW'. This may indicate the process failed to start. Error: spawn /tmp/tmp-26373utihbUOauHW ENOENT" 184 | const fpath = createTmpFileName() 185 | try { 186 | fs.writeFileSync(fpath, script) 187 | fs.chmodSync(fpath, 0o777) 188 | await exec.exec(`./${fpath}`, [], { 189 | outStream: process.stdout, 190 | errStream: process.stderr 191 | }) 192 | } finally { 193 | // Remove file 194 | fs.unlinkSync(fpath) 195 | } 196 | } 197 | 198 | run() 199 | -------------------------------------------------------------------------------- /src/wait.ts: -------------------------------------------------------------------------------- 1 | export async function wait(milliseconds: number): Promise { 2 | return new Promise(resolve => { 3 | if (isNaN(milliseconds)) { 4 | throw new Error('milliseconds not a number') 5 | } 6 | 7 | setTimeout(() => resolve('done!'), milliseconds) 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // NOTE: "es6" causes "[error]await is only valid in async function" when using new AsyncFunction() 4 | "target": "es2018", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 5 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 6 | "outDir": "./lib", /* Redirect output structure to the directory. */ 7 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 8 | "strict": true, /* Enable all strict type-checking options. */ 9 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 10 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 11 | }, 12 | "exclude": ["node_modules", "**/*.test.ts"] 13 | } 14 | --------------------------------------------------------------------------------