Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
## Unreleased

- Honor proxy and bypass environment variables for GitHub API calls, including authenticated CONNECT proxies and artifact redirects.

- Preserve nested IDs during merge/grouping and renew derived report IDs; retain existing name-based historical test matching.
- Update CTRF to 0.5.0 and refresh AI, JUnit, Teams and Handlebars integrations, dependencies and tooling.
- Bundle all CTRF schemas and retain compatibility with legacy report inputs.
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,27 @@ GITHUB_TOKEN:
if: always()
```

## Proxy configuration

GitHub API requests support `HTTPS_PROXY`, `HTTP_PROXY`, and `NO_PROXY`, including
their lowercase equivalents. Lowercase variables take precedence when both forms
are set. HTTPS requests use `HTTPS_PROXY`, falling back to `HTTP_PROXY` when it is
unset. Bypass rules are evaluated for each destination, including artifact redirects.

For a self-hosted runner that reaches GitHub Enterprise Server through an HTTP proxy:

```yaml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HTTPS_PROXY: http://proxy.example.com:8080
NO_PROXY: localhost,127.0.0.1,.internal.example.com
```

HTTP CONNECT and proxy credentials in the proxy URL are supported. Store a proxy
URL containing credentials in a GitHub secret. These variables work without
`NODE_USE_ENV_PROXY` or additional reporter inputs. The runner-provided
`GITHUB_API_URL` continues to select the enterprise API endpoint.

## Storing Artifacts

Some reports require you to store CTRF reports as artifacts, use the
Expand Down
4 changes: 4 additions & 0 deletions __tests__/fixtures/proxy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
These certificate and private-key files are public test fixtures, used only by
the local HTTPS server in `__tests__/github/proxy.test.ts`. They contain no
production credentials. The test child process trusts this certificate through
`NODE_EXTRA_CA_CERTS`; TLS certificate verification remains enabled.
18 changes: 18 additions & 0 deletions __tests__/fixtures/proxy/cert.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-----BEGIN CERTIFICATE-----
MIIC7jCCAdagAwIBAgIJAJXVvIwGjG/lMA0GCSqGSIb3DQEBCwUAMBwxGjAYBgNV
BAMMEXJlcG9ydGVyLWFwaS50ZXN0MCAXDTI2MTAwOTA2NDQzMloYDzIxMjYwOTE1
MDY0NDMyWjAcMRowGAYDVQQDDBFyZXBvcnRlci1hcGkudGVzdDCCASIwDQYJKoZI
hvcNAQEBBQADggEPADCCAQoCggEBANBV07FC6ZMCaEyKkITGnGu07VmERRSUk8Ia
ZSWiwv73sngXMv4S/Igq2xI19+eNx7ygIMb1tBKhrCT+QCM3+D4CGicrBoJMyPNM
jygdcY2fs7t54JEw2eXIJVqUo76wt1zH+pM7s42Ht0fOBYlhr9XVYTCrcNuCxyqj
BMAzZvLwMPUBLf2RthYb13DPV4Tgu2zbF6kZ0O4FNd6jVJJHf8C5YmOsuhgU0Kdu
rAZ3iXDRdhXHbipsUmuEsMT0wsFcqzD+wjiEaYnj2A560FfJAtzOEELAMXmHg5QM
ZQ/wP5uctFh25+GryIAbDhwaEceZndbdzxkReEyWyNDLbkcEc1ECAwEAAaMxMC8w
LQYDVR0RBCYwJIIRcmVwb3J0ZXItYXBpLnRlc3SCCWxvY2FsaG9zdIcEfwAAATAN
BgkqhkiG9w0BAQsFAAOCAQEASbCKUpA9UxOyJtg88JMrSys9CfTlIWFH6+nqEzjT
JuNaCZfmN4TCvQEd7h16qZaa4lu1XKbYt/D7sXTjW2o8lyzzHbauqTphM8xDKSGh
VVN0W/5xegsm5AbKomLnW2FEQ3JAukWtFdM8xP2E3wkYji3r74i8BWIURBL74duc
YjRtkkCB1KGZde7a8mZwTmQZ0xYh5PnPiQ+0OuK819R48OJpV8nONM5uUCR537iJ
w9hG25muppFjunUPK+NNEZcILCABcHmpG/62nXJdqQP7pzhokIn2qGcrfwJ0XpqX
1fRgobHt/aq2MEJ8NIiOu7fhtedztyj7+sPZ9fpzPCcDpA==
-----END CERTIFICATE-----
28 changes: 28 additions & 0 deletions __tests__/fixtures/proxy/key.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDQVdOxQumTAmhM
ipCExpxrtO1ZhEUUlJPCGmUlosL+97J4FzL+EvyIKtsSNffnjce8oCDG9bQSoawk
/kAjN/g+AhonKwaCTMjzTI8oHXGNn7O7eeCRMNnlyCValKO+sLdcx/qTO7ONh7dH
zgWJYa/V1WEwq3DbgscqowTAM2by8DD1AS39kbYWG9dwz1eE4Lts2xepGdDuBTXe
o1SSR3/AuWJjrLoYFNCnbqwGd4lw0XYVx24qbFJrhLDE9MLBXKsw/sI4hGmJ49gO
etBXyQLczhBCwDF5h4OUDGUP8D+bnLRYdufhq8iAGw4cGhHHmZ3W3c8ZEXhMlsjQ
y25HBHNRAgMBAAECggEAZz9RtVSi6y9TPzOgoZzIYnx88FMj5Soo/BYWsIs0NiXN
dcSRiecg7d7HSQQkAsZ6CVoWRGP6RQ2IgaDbaljPlvN1wJmLFUI2iJUVkVzeLMDL
0wp4p5vdMQp6sKRfbh6oELwuy47X0jlDNLu+dLJL8R5Zl0Qvnyn1yISX0yTmTVik
s8y4e42TCd+ibPBNsg1dW/5gICVkuVO2yx5NQCRpifmuu7eW+cecFSBI6nvxNGDf
aUscA5qIzOlPpp8lch+LLH3nS+1xIh+DyA6H5ha9vHDxIxJLbGiKVFFepuUgItvT
uLAVTGCkPYuPiBh687YRg7n+M/I6FFyVyNKbqJD7zQKBgQD0fbCQq482YJIHrM8c
wJaiN/dnxTo01vXhKxZyHmgUC6R986eG+gHbPb+LMts1scbbaMa3Q+e/z4WaUSeT
teRdcTIWHAnpOCHd7T42Z2JOd/y4I3dOPSvczPVTMylzh1fqSAoMz7xwNMtr2kRK
5Zt2PZ5NfqZmHbudw/Pbc1BpSwKBgQDaJG6iiT0Uyz97WD98UZT9eY9DCQ6GRl1C
aJVvBFpNysNnCBPe2xaLybtAHLWnjZ7d2FN9LMIQo6gpD/Qm5IZk+CfCMKqazm4r
GKaXVUcvlJXWnNmG0Ee0Q9NM6ZIhGkRoChLoAjn/nLXQz6Hnq6NkOIgfs37Uvvc2
EuqAmjTwUwKBgCOKjftJ5US7Xx8cgPMmD/++8Qk1w5Ng2FeGQvoN74VSd/QTUnwt
pc1wOYMLvpAcqtLEdXa4uVzJ1xsqxv0TCQKIbQaHxu0PSuUPEucnukO775H5HePS
wqiTZupUWqc/uokFNejM3hDVq+iezySwIhxbMsMDMT9Q/TAfBzkUl1DnAoGBAMX7
Os4ZcG5m6P6xYS0bffVvVi6pEZ2cTxfug3F2qd7gn+fq1N32ScglFuVhKR0g04bU
xzFcZeVkI0LhT93cmn0ZHDyKnsxRfnturAO9ShZM7JETWfmZHvrkc+niMtYIWP1z
5yTaoHj1aa5SQ8dXZ7uV8zWSWxILH6LcGCkPy+wxAoGBAKggFQ13q+uR0z04LRWP
g6mr2qNTnlr/KOXkMJK4Al1O8slg9Z2h0PgU3ZsQk/fGNrEWcgv+wl4bjGaFT2iZ
+mAFzmaT8rm7rSC3Ri6yuvzvyOHCybB3uMKgrRh4Wn1gdMW6iBwx1z6+IEClDbFG
92SCw4Woiygx0LvUflIsnuPh
-----END PRIVATE KEY-----
212 changes: 212 additions & 0 deletions __tests__/github/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { execFile } from "node:child_process";
import { readFileSync } from "node:fs";
import http from "node:http";
import https from "node:https";
import net from "node:net";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

const run = promisify(execFile);
const fixtures = path.resolve("__tests__/fixtures/proxy");
let api: https.Server;
let proxy: http.Server;
let apiPort: number;
let proxyPort: number;
let tunnels: string[];
let credentials: (string | undefined)[];
let requests: { url: string; method: string; auth?: string }[];
let sockets: Set<net.Socket>;

async function listen(server: http.Server | https.Server): Promise<number> {
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
return (server.address() as net.AddressInfo).port;
}

beforeEach(async () => {
tunnels = [];
credentials = [];
requests = [];
sockets = new Set();
api = https.createServer(
{
key: readFileSync(path.join(fixtures, "key.pem")),
cert: readFileSync(path.join(fixtures, "cert.pem")),
},
(req, res) => {
requests.push({
url: req.url || "",
method: req.method || "",
auth: req.headers.authorization,
});
if (req.url === "/api/v3/artifact") {
res.writeHead(302, {
location: `https://localhost:${apiPort}/archive.zip`,
});
res.end();
} else if (req.url === "/archive.zip") {
res.writeHead(200, { "content-type": "application/zip" });
res.end(Buffer.from([80, 75, 3, 4]));
} else {
res.writeHead(200, { "content-type": "application/json" });
res.end(req.method === "GET" ? '[{"id":42}]' : '{"id":42}');
}
},
);
apiPort = await listen(api);
proxy = http.createServer();
proxy.on("connect", (req, client, head) => {
tunnels.push(req.url || "");
credentials.push(req.headers["proxy-authorization"]);
const target = net.connect(apiPort, "127.0.0.1", () => {
client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
if (head.length) target.write(head);
client.pipe(target);
target.pipe(client);
});
sockets.add(target);
target.on("error", () => client.destroy());
client.on("error", () => target.destroy());
});
for (const server of [api, proxy]) {
server.on("connection", (socket) => sockets.add(socket));
}
proxyPort = await listen(proxy);
});

afterEach(async () => {
for (const socket of sockets) socket.destroy();
await Promise.all(
[api, proxy].map(
(server) => new Promise<void>((resolve) => server.close(() => resolve())),
),
);
});

async function callClient(
env: Record<string, string>,
host = "reporter-api.test",
artifact = false,
): Promise<void> {
const childEnv = { ...process.env };
for (const key of [
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"no_proxy",
"all_proxy",
"NODE_USE_ENV_PROXY",
"NODE_TLS_REJECT_UNAUTHORIZED",
])
delete childEnv[key];
await run(
process.execPath,
[
"--import",
"tsx",
"--input-type=module",
"-e",
`
import assert from 'node:assert/strict';
import { createGitHubClient } from './src/client/github/client.ts';
const client = await createGitHubClient();
const comments = await client.paginate(client.issues.listComments, {
owner: 'o', repo: 'r', issue_number: 1
});
assert.equal(comments[0].id, 42);
await client.issues.updateComment({owner: 'o', repo: 'r', comment_id: 42, body: 'report'});
if (${artifact}) {
const archive = await client.request('GET /artifact');
assert.deepEqual([...new Uint8Array(archive.data)], [80, 75, 3, 4]);
}
process.exit(0);
`,
],
{
env: {
...childEnv,
GITHUB_TOKEN: "test-token",
GITHUB_API_URL: `https://${host}:${apiPort}/api/v3`,
NODE_EXTRA_CA_CERTS: path.join(fixtures, "cert.pem"),
...env,
},
timeout: 10000,
},
);
}

describe("GitHub API environment proxies", () => {
it.each(["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"])(
"uses %s for HTTPS API requests through HTTP CONNECT",
async (variable) => {
await callClient({ [variable]: `http://127.0.0.1:${proxyPort}` });
expect(tunnels).toContain(`reporter-api.test:${apiPort}`);
expect(requests.map((req) => req.method)).toEqual(["GET", "PATCH"]);
expect(requests.every((req) => req.auth === "token test-token")).toBe(
true,
);
},
);
it("prefers lowercase and HTTPS-specific proxy settings", async () => {
await callClient({
HTTPS_PROXY: "http://127.0.0.1:1",
https_proxy: `http://127.0.0.1:${proxyPort}`,
HTTP_PROXY: "http://127.0.0.1:1",
});
expect(tunnels).toContain(`reporter-api.test:${apiPort}`);
expect(requests).toHaveLength(2);
});
it("prefers lowercase bypass settings", async () => {
await callClient(
{
HTTPS_PROXY: "http://127.0.0.1:1",
NO_PROXY: "other.test",
no_proxy: "localhost",
},
"localhost",
);
expect(tunnels).toHaveLength(0);
expect(requests).toHaveLength(2);
});
it.each(["NO_PROXY", "no_proxy"])("honors %s", async (variable) => {
await callClient(
{
HTTPS_PROXY: "http://127.0.0.1:1",
[variable]: `localhost:${apiPort}`,
},
"localhost",
);
expect(tunnels).toHaveLength(0);
expect(requests).toHaveLength(2);
});
it("connects directly without a proxy", async () => {
await callClient({}, "localhost");
expect(tunnels).toHaveLength(0);
expect(requests).toHaveLength(2);
});
it("supports proxy authentication and evaluates bypass on artifact redirects", async () => {
await callClient(
{
HTTPS_PROXY: `http://user:password@127.0.0.1:${proxyPort}`,
NO_PROXY: "localhost",
},
"reporter-api.test",
true,
);
expect(
credentials.every((value) => value === "Basic dXNlcjpwYXNzd29yZA=="),
).toBe(true);
expect(tunnels.length).toBeGreaterThan(0);
expect(
tunnels.every((host) => host === `reporter-api.test:${apiPort}`),
).toBe(true);
expect(requests.at(-1)?.url).toBe("/archive.zip");
expect(requests.at(-1)?.auth).toBeUndefined();
});
});
2 changes: 1 addition & 1 deletion badges/coverage.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading