diff --git a/CHANGELOG.md b/CHANGELOG.md index f8baa1ddf..eb064cd25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/caching/github-actions-cache.ts b/src/caching/github-actions-cache.ts index 3f6fc630a..58b47f127 100644 --- a/src/caching/github-actions-cache.ts +++ b/src/caching/github-actions-cache.ts @@ -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'; @@ -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'; @@ -817,7 +817,10 @@ function request( let req!: http.ClientRequest; const resPromise = new Promise>( (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) => { diff --git a/src/test/cache-github-fake.test.ts b/src/test/cache-github-fake.test.ts index 6e361ebb9..6e4ba1362 100644 --- a/src/test/cache-github-fake.test.ts +++ b/src/test/cache-github-fake.test.ts @@ -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()]); + } +}); diff --git a/src/test/util/fake-github-actions-cache-server.ts b/src/test/util/fake-github-actions-cache-server.ts index 3e465e844..9ace70d67 100644 --- a/src/test/util/fake-github-actions-cache-server.ts +++ b/src/test/util/fake-github-actions-cache-server.ts @@ -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. @@ -95,6 +95,7 @@ export type FakeGitHubActionsCacheServerMetrics = { */ export class FakeGitHubActionsCacheServer { readonly #server: http.Server; + readonly #protocol: 'http:' | 'https:'; #url!: URL; /** @@ -115,9 +116,13 @@ export class FakeGitHubActionsCacheServer { readonly #keyAndVersionToEntryId = new Map(); readonly #blobIdToEntryId = new Map(); - 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(); } @@ -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; }