├── .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 | 
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(``);
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 |
216 |
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 | 
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 |
--------------------------------------------------------------------------------