Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Fixed

- GitHub Actions caching now uses `http` or `https` based on the scheme of
`ACTIONS_RESULTS_URL`. Always calling `https.request` broke `http://` cache
proxies used by some third-party runners.

## [0.14.13] - 2026-06-23

### Added
Expand Down
7 changes: 5 additions & 2 deletions src/caching/github-actions-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import * as pathlib from 'path';
import * as unbudgetedFs from 'fs/promises';
import * as fs from '../util/fs.js';
import * as http from 'http';
import * as https from 'https';
import {createHash} from 'crypto';
import {scriptReferenceToString} from '../config.js';
Expand All @@ -17,7 +18,6 @@ import {execFile} from 'child_process';
import '../util/dispose.js';
import {inspect} from 'util';

import type * as http from 'http';
import type {Cache, CacheHit} from './cache.js';
import type {ScriptReference} from '../config.js';
import type {Fingerprint} from '../fingerprint.js';
Expand Down Expand Up @@ -817,7 +817,10 @@ function request(
let req!: http.ClientRequest;
const resPromise = new Promise<Result<http.IncomingMessage, Error>>(
(resolve) => {
req = https.request(url, opts, (value) => {
const parsed = typeof url === 'string' ? new URL(url) : url;
// https.request() throws ERR_INVALID_PROTOCOL for http: URLs.
const transport = parsed.protocol === 'http:' ? http : https;
req = transport.request(url, opts, (value) => {
resolve({ok: true, value});
});
req.on('error', (error) => {
Expand Down
81 changes: 81 additions & 0 deletions src/test/cache-github-fake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,3 +561,84 @@ void test(
}
},
);

void test('caches over HTTP when ACTIONS_RESULTS_URL is http://', async () => {
// The suite fixtures always speak HTTPS. Stand up an HTTP server for this
// case, which is what some third-party runner cache proxies expose.
const authToken = String(Math.random()).slice(2);
const httpServer = new FakeGitHubActionsCacheServer(authToken);
const actionsCacheUrl = await httpServer.listen();
assert.equal(new URL(actionsCacheUrl).protocol, 'http:');

const httpRig = new WireitTestRig();
httpRig.env = {
...httpRig.env,
WIREIT_CACHE: 'github',
ACTIONS_RESULTS_URL: actionsCacheUrl,
ACTIONS_RUNTIME_TOKEN: authToken,
RUNNER_TEMP: pathlib.join(httpRig.temp, 'github-cache-temp'),
};
await httpRig.setup();

try {
const cmdA = await httpRig.newCommand();
await httpRig.write({
'package.json': {
scripts: {
a: 'wireit',
},
wireit: {
a: {
command: cmdA.command,
files: ['input'],
output: ['output'],
},
},
},
input: 'v0',
});

{
const exec = httpRig.exec('npm run a');
const inv = await cmdA.nextInvocation();
await httpRig.write({output: 'v0'});
inv.exit(0);
const res = await exec.exit;
assert.equal(res.code, 0);
assert.equal(cmdA.numInvocations, 1);
assert.equal(await httpRig.read('output'), 'v0');
assert.deepEqual(httpServer.metrics, {
getCacheEntry: 1,
createCacheEntry: 1,
putBlobBlock: 1,
putBlobBlockList: 1,
finalizeCacheEntry: 1,
getBlob: 0,
} satisfies FakeGitHubActionsCacheServerMetrics);
}

// Delete the ".wireit" folder so that the next run won't be considered
// fresh, and the "output" file so that we can be sure it gets restored from
// cache.
await httpRig.delete('.wireit');
await httpRig.delete('output');

{
const exec = httpRig.exec('npm run a');
const res = await exec.exit;
assert.equal(res.code, 0);
assert.equal(cmdA.numInvocations, 1);
assert.equal(await httpRig.read('output'), 'v0');
assert.deepEqual(httpServer.metrics, {
getCacheEntry: 2,
createCacheEntry: 1,
putBlobBlock: 1,
putBlobBlockList: 1,
finalizeCacheEntry: 1,
getBlob: 1,
} satisfies FakeGitHubActionsCacheServerMetrics);
}
} finally {
await Promise.all([httpServer.close(), httpRig.cleanup()]);
}
});
15 changes: 11 additions & 4 deletions src/test/util/fake-github-actions-cache-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/

import * as http from 'http';
import * as https from 'https';
import type * as http from 'http';

/**
* Numeric ID for a cache entry.
Expand Down Expand Up @@ -95,6 +95,7 @@ export type FakeGitHubActionsCacheServerMetrics = {
*/
export class FakeGitHubActionsCacheServer {
readonly #server: http.Server;
readonly #protocol: 'http:' | 'https:';
#url!: URL;

/**
Expand All @@ -115,9 +116,13 @@ export class FakeGitHubActionsCacheServer {
readonly #keyAndVersionToEntryId = new Map<KeyAndVersion, EntryId>();
readonly #blobIdToEntryId = new Map<BlobId, EntryId>();

constructor(authToken: string, tlsCert: {cert: string; key: string}) {
constructor(authToken: string, tlsCert?: {cert: string; key: string}) {
this.#authToken = authToken;
this.#server = https.createServer(tlsCert, this.#route);
this.#protocol = tlsCert === undefined ? 'http:' : 'https:';
this.#server =
tlsCert === undefined
? http.createServer(this.#route)
: https.createServer(tlsCert, this.#route);
this.resetMetrics();
}

Expand Down Expand Up @@ -148,7 +153,9 @@ export class FakeGitHubActionsCacheServer {
// include this in the fake because it ensures the client is preserving the
// base path and not just using the origin.
const randomBasePath = Math.random().toString().slice(2);
this.#url = new URL(`https://${host}:${address.port}/${randomBasePath}/`);
this.#url = new URL(
`${this.#protocol}//${host}:${address.port}/${randomBasePath}/`,
);
return this.#url.href;
}

Expand Down