diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 762de81..005f50f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,7 +24,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version-file: package.json - cache: 'npm' + cache: "npm" - run: npm ci - run: npm test @@ -39,11 +39,13 @@ jobs: - uses: actions/setup-node@v4 with: node-version-file: package.json - cache: 'npm' + cache: "npm" - run: npm ci - run: npm run build - uses: ./ # Uses the action in the root directory id: test + env: + https_proxy: https://example.com with: app-id: ${{ vars.TEST_APP_ID }} private-key: ${{ secrets.TEST_APP_PRIVATE_KEY }} @@ -54,3 +56,30 @@ jobs: with: route: GET /installation/repositories - run: echo '${{ steps.get-repository.outputs.data }}' + + end-to-end-proxy: + name: End-to-End test using proxy + runs-on: ubuntu-latest + # do not run from forks, as forks don’t have access to repository secrets + if: github.event.pull_request.head.repo.owner.login == github.event.pull_request.base.repo.owner.login + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + with: + node-version-file: package.json + cache: "npm" + - run: npm ci + - run: npm run build + - uses: ./ # Uses the action in the root directory + id: test + env: + https_proxy: https://example.com + with: + app-id: ${{ vars.TEST_APP_ID }} + private-key: ${{ secrets.TEST_APP_PRIVATE_KEY }} + + # fail CI if prior step succeeds and vice versa + - if: ${{ success() }} + run: exit 1 + - if: ${{ failure() }} + run: echo "action failed as expected" diff --git a/main.js b/main.js index 7670378..502288f 100644 --- a/main.js +++ b/main.js @@ -15,32 +15,67 @@ if (!process.env.GITHUB_REPOSITORY_OWNER) { throw new Error("GITHUB_REPOSITORY_OWNER missing, must be set to ''"); } -const appId = core.getInput("app-id"); -const privateKey = core.getInput("private-key"); -const owner = core.getInput("owner"); -const repositories = core - .getInput("repositories") - .split(/[\n,]+/) - .map((s) => s.trim()) - .filter((x) => x !== ""); - -const skipTokenRevoke = core.getBooleanInput("skip-token-revoke"); - -const permissions = getPermissionsFromInputs(process.env); - -// Export promise for testing -export default main( - appId, - privateKey, - owner, - repositories, - permissions, - core, - createAppAuth, - request, - skipTokenRevoke, -).catch((error) => { - /* c8 ignore next 3 */ - console.error(error); - core.setFailed(error.message); -}); +async function run() { + // spawn a child process if proxy is set + const httpProxyEnvVars = [ + "https_proxy", + "HTTPS_PROXY", + "http_proxy", + "HTTP_PROXY", + ]; + const nodeHasProxySupportEnabled = process.env.NODE_USE_ENV_PROXY === "1"; + const shouldUseProxy = httpProxyEnvVars.some((v) => process.env[v]); + + // There is no other way to enable proxy support in Node.js as of 2025-09-19 + // https://github.com/nodejs/node/blob/4612c793cb9007a91cb3fd82afe518440473826e/lib/internal/process/pre_execution.js#L168-L187 + if (!nodeHasProxySupportEnabled && shouldUseProxy) { + return new Promise(async (resolve, reject) => { + const { spawn } = await import("node:child_process"); + // spawn itself with NODE_USE_ENV_PROXY=1 + const child = spawn(process.execPath, process.argv.slice(1), { + env: { ...process.env, NODE_USE_ENV_PROXY: "1" }, + stdio: "inherit", + }); + child.on("exit", (code) => { + process.exitCode = code; + if (code !== 0) { + reject(new Error(`Child process exited with code ${code}`)); + } else { + resolve(); + } + }); + }); + } + + const appId = core.getInput("app-id"); + const privateKey = core.getInput("private-key"); + const owner = core.getInput("owner"); + const repositories = core + .getInput("repositories") + .split(/[\n,]+/) + .map((s) => s.trim()) + .filter((x) => x !== ""); + + const skipTokenRevoke = core.getBooleanInput("skip-token-revoke"); + + const permissions = getPermissionsFromInputs(process.env); + + // Export promise for testing + return main( + appId, + privateKey, + owner, + repositories, + permissions, + core, + createAppAuth, + request, + skipTokenRevoke, + ).catch((error) => { + /* c8 ignore next 3 */ + console.error(error); + core.setFailed(error.message); + }); +} + +export default run(); diff --git a/package-lock.json b/package-lock.json index fe32d4b..d73f82e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "devDependencies": { "@octokit/openapi": "^19.1.0", "@sinonjs/fake-timers": "^14.0.0", + "@types/node": "^24.5.2", "ava": "^6.4.1", "c8": "^10.1.3", "dotenv": "^17.2.1", @@ -943,6 +944,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz", + "integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.12.0" + } + }, "node_modules/@types/retry": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", @@ -3823,6 +3834,13 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", + "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/unicorn-magic": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", diff --git a/package.json b/package.json index a2e5ffa..5a73edb 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "devDependencies": { "@octokit/openapi": "^19.1.0", "@sinonjs/fake-timers": "^14.0.0", + "@types/node": "^24.5.2", "ava": "^6.4.1", "c8": "^10.1.3", "dotenv": "^17.2.1", diff --git a/tests/index.js b/tests/index.js index f300270..ed9f3d1 100644 --- a/tests/index.js +++ b/tests/index.js @@ -22,7 +22,15 @@ for (const file of testFiles) { GITHUB_OUTPUT: undefined, GITHUB_STATE: undefined, }; - const { stderr, stdout } = await execa("node", [`tests/${file}`], { env }); + const { stderr, stdout } = await execa( + "node", + [ + "--experimental-test-module-mocks", + "--disable-warning=ExperimentalWarning", + `tests/${file}`, + ], + { env }, + ); t.snapshot(stderr, "stderr"); t.snapshot(stdout, "stdout"); }); diff --git a/tests/main-spawns-sub-process-for-proxy.test.js b/tests/main-spawns-sub-process-for-proxy.test.js new file mode 100644 index 0000000..8335e4e --- /dev/null +++ b/tests/main-spawns-sub-process-for-proxy.test.js @@ -0,0 +1,88 @@ +import test from "node:test"; +import assert from "node:assert"; + +test("spawns a child process if proxy is set and NODE_USE_ENV_PROXY is not set", async (t) => { + let spawnCalled = false; + + // https://nodejs.org/api/test.html#class-mocktracker + t.mock.module("node:child_process", { + namedExports: { + spawn() { + spawnCalled = true; + return { + on(event, callback) { + callback(0); + }, + }; + }, + }, + }); + + process.env.GITHUB_REPOSITORY = "foo/bar"; + process.env.GITHUB_REPOSITORY_OWNER = "foo"; + process.env.https_proxy = "http://example.com"; + + const { default: runPromise } = await import("../main.js?" + Math.random()); + await runPromise; + + assert(spawnCalled, "spawn was called"); + assert.equal(process.exitCode, 0, "process exit code is 0"); +}); + +test("child process throws error", async (t) => { + let spawnCalled = false; + + // https://nodejs.org/api/test.html#class-mocktracker + t.mock.module("node:child_process", { + namedExports: { + spawn() { + spawnCalled = true; + return { + on(event, callback) { + callback(1); + }, + }; + }, + }, + }); + + process.env.GITHUB_REPOSITORY = "foo/bar"; + process.env.GITHUB_REPOSITORY_OWNER = "foo"; + process.env.https_proxy = "http://example.com"; + + const { default: runPromise } = await import("../main.js?" + Math.random()); + await runPromise.catch((error) => { + assert.equal( + error.message, + "Child process exited with code 1", + "error message is correct", + ); + }); + + assert(spawnCalled, "spawn was called"); + assert.equal(process.exitCode, 1, "process exit code is 0"); + process.exitCode = 0; // reset for other tests +}); + +test("does not spawn a child process if proxy is set and NODE_USE_ENV_PROXY is set", async (t) => { + let mainCalled = false; + + t.mock.module("../lib/main.js", { + namedExports: { + async main() { + mainCalled = true; + }, + }, + }); + + process.env.GITHUB_REPOSITORY = "foo/bar"; + process.env.GITHUB_REPOSITORY_OWNER = "foo"; + process.env.https_proxy = "http://example.com"; + process.env.NODE_USE_ENV_PROXY = "1"; + process.env["INPUT_SKIP-TOKEN-REVOKE"] = "false"; + + const { default: runPromise } = await import("../main.js?" + Math.random()); + await runPromise; + + assert(mainCalled, "main was called"); +}); diff --git a/tests/snapshots/index.js.md b/tests/snapshots/index.js.md index e419536..88aa2ee 100644 --- a/tests/snapshots/index.js.md +++ b/tests/snapshots/index.js.md @@ -109,6 +109,26 @@ Generated by [AVA](https://avajs.dev). POST /app/installations/123456/access_tokens␊ {"repositories":["failed-repo"]}` +## main-spawns-sub-process-for-proxy.test.js + +> stderr + + '' + +> stdout + + `✔ spawns a child process if proxy is set and NODE_USE_ENV_PROXY is not set (87.402375ms)␊ + ✔ child process throws error (4.003417ms)␊ + ✔ does not spawn a child process if proxy is set and NODE_USE_ENV_PROXY is set (3.248625ms)␊ + ℹ tests 3␊ + ℹ suites 0␊ + ℹ pass 3␊ + ℹ fail 0␊ + ℹ cancelled 0␊ + ℹ skipped 0␊ + ℹ todo 0␊ + ℹ duration_ms 97.942333` + ## main-token-get-owner-set-fail-response.test.js > stderr diff --git a/tests/snapshots/index.js.snap b/tests/snapshots/index.js.snap index e66c3d5..c372f52 100644 Binary files a/tests/snapshots/index.js.snap and b/tests/snapshots/index.js.snap differ