mirror of https://github.com/actions/checkout
Compare commits
5 Commits
4a9081b71c
...
3fd9ff6069
| Author | SHA1 | Date |
|---|---|---|
|
|
3fd9ff6069 | |
|
|
9caa1a022a | |
|
|
0dbe61b437 | |
|
|
7484b46911 | |
|
|
8879f31369 |
|
|
@ -22,10 +22,10 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set Node.js 24.x
|
- name: Set Node.js 24.x
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
|
|
||||||
|
|
@ -44,7 +44,7 @@ jobs:
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# If dist/ was different than expected, upload the expected version as an artifact
|
# If dist/ was different than expected, upload the expected version as an artifact
|
||||||
- uses: actions/upload-artifact@v7
|
- uses: actions/upload-artifact@v4
|
||||||
if: ${{ failure() && steps.diff.conclusion == 'failure' }}
|
if: ${{ failure() && steps.diff.conclusion == 'failure' }}
|
||||||
with:
|
with:
|
||||||
name: dist
|
name: dist
|
||||||
|
|
|
||||||
|
|
@ -39,10 +39,10 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v4
|
uses: github/codeql-action/init@v3
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
|
|
@ -55,4 +55,4 @@ jobs:
|
||||||
- run: rm -rf dist # We want code scanning to analyze lib instead (individual .js files)
|
- run: rm -rf dist # We want code scanning to analyze lib instead (individual .js files)
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v4
|
uses: github/codeql-action/analyze@v3
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,6 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Check licenses
|
name: Check licenses
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run licensed-check
|
- run: npm run licensed-check
|
||||||
|
|
@ -14,7 +14,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checking out
|
- name: Checking out
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
- name: Publish
|
- name: Publish
|
||||||
id: publish
|
id: publish
|
||||||
uses: actions/publish-immutable-action@v0.0.4
|
uses: actions/publish-immutable-action@v0.0.4
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,10 @@ jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
- run: npm run format-check
|
- run: npm run format-check
|
||||||
|
|
@ -37,7 +37,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
# Clone this repo
|
# Clone this repo
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
# Basic checkout
|
# Basic checkout
|
||||||
- name: Checkout basic
|
- name: Checkout basic
|
||||||
|
|
@ -229,7 +229,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
# Clone this repo
|
# Clone this repo
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
# Basic checkout using git
|
# Basic checkout using git
|
||||||
- name: Checkout basic
|
- name: Checkout basic
|
||||||
|
|
@ -261,7 +261,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
# Clone this repo
|
# Clone this repo
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
# Basic checkout using git
|
# Basic checkout using git
|
||||||
- name: Checkout basic
|
- name: Checkout basic
|
||||||
|
|
@ -291,7 +291,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
# Clone this repo
|
# Clone this repo
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
path: localClone
|
path: localClone
|
||||||
|
|
||||||
|
|
@ -319,7 +319,7 @@ jobs:
|
||||||
|
|
||||||
# needed to make checkout post cleanup succeed
|
# needed to make checkout post cleanup succeed
|
||||||
- name: Fix Checkout v6
|
- name: Fix Checkout v6
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
path: localClone
|
path: localClone
|
||||||
|
|
||||||
|
|
@ -328,7 +328,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
# Clone this repo
|
# Clone this repo
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
path: actions-checkout
|
path: actions-checkout
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ jobs:
|
||||||
# Note this update workflow can also be used as a rollback tool.
|
# Note this update workflow can also be used as a rollback tool.
|
||||||
# For that reason, it's best to pin `actions/checkout` to a known, stable version
|
# For that reason, it's best to pin `actions/checkout` to a known, stable version
|
||||||
# (typically, about two releases back).
|
# (typically, about two releases back).
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Git config
|
- name: Git config
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,12 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
# Use `docker/login-action` to log in to GHCR.io.
|
# Use `docker/login-action` to log in to GHCR.io.
|
||||||
# Once published, the packages are scoped to the account defined here.
|
# Once published, the packages are scoped to the account defined here.
|
||||||
- name: Log in to the ghcr.io container registry
|
- name: Log in to the ghcr.io container registry
|
||||||
uses: docker/login-action@v4.2.0
|
uses: docker/login-action@v3.3.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|
@ -48,7 +48,7 @@ jobs:
|
||||||
|
|
||||||
# Use `docker/build-push-action` to build (and optionally publish) the image.
|
# Use `docker/build-push-action` to build (and optionally publish) the image.
|
||||||
- name: Build Docker Image (with optional Push)
|
- name: Build Docker Image (with optional Push)
|
||||||
uses: docker/build-push-action@v7.2.0
|
uses: docker/build-push-action@v6.5.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: images/test-ubuntu-git.Dockerfile
|
file: images/test-ubuntu-git.Dockerfile
|
||||||
|
|
|
||||||
12
README.md
12
README.md
|
|
@ -1,12 +1,14 @@
|
||||||
# WarpBuild Checkout
|
# WarpBuild Checkout
|
||||||
|
|
||||||
This is [WarpBuild's](https://warpbuild.com) fork of `actions/checkout`, a drop-in
|
This is [WarpBuild's](https://warpbuild.com) fork of `actions/checkout`, a drop-in
|
||||||
replacement that adds a **git mirror cache**: on WarpBuild runners, a tar of the repo's
|
replacement that adds a **checkout snapshot cache**: on WarpBuild runners, the `.git`
|
||||||
bare mirror is restored from S3 into `.git/wb-mirror.git` and wired up via git
|
objects a checkout produces are tarred and stored in S3, keyed by the exact commit SHA.
|
||||||
alternates, so the fetch from GitHub downloads only the delta instead of the whole
|
A later job checking out the same commit restores that snapshot and skips the fetch from
|
||||||
repository. On a cache miss, the run hydrates the mirror once (full clone + upload);
|
GitHub entirely — cutting request load (the main source of GitHub rate-limiting on
|
||||||
mirrors expire on a server-configured TTL and re-hydrate.
|
matrix builds and busy repos). SHA keys are immutable, so there is no TTL or refresh.
|
||||||
|
|
||||||
|
- Only the default checkout shape is cached (`fetch-depth: 1`, no tags/filter/sparse/LFS);
|
||||||
|
everything else runs exactly like upstream.
|
||||||
- Zero new inputs — behavior is identical to upstream everywhere except WarpBuild runners.
|
- Zero new inputs — behavior is identical to upstream everywhere except WarpBuild runners.
|
||||||
- Fail-open — any cache error degrades to stock `actions/checkout` behavior.
|
- Fail-open — any cache error degrades to stock `actions/checkout` behavior.
|
||||||
- All fork code lives in `src/warpbuild/`
|
- All fork code lives in `src/warpbuild/`
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,13 @@ import {
|
||||||
afterEach,
|
afterEach,
|
||||||
afterAll
|
afterAll
|
||||||
} from '@jest/globals'
|
} from '@jest/globals'
|
||||||
|
import * as exec from '@actions/exec'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import * as os from 'os'
|
||||||
|
import * as path from 'path'
|
||||||
import {
|
import {
|
||||||
SKIP_NOT_WARPBUILD,
|
SKIP_NOT_WARPBUILD,
|
||||||
|
assertSafeTarMembers,
|
||||||
computeDestinationRef,
|
computeDestinationRef,
|
||||||
getMirrorCacheSkipReason
|
getMirrorCacheSkipReason
|
||||||
} from '../src/warpbuild/mirror-cache.js'
|
} from '../src/warpbuild/mirror-cache.js'
|
||||||
|
|
@ -131,6 +136,69 @@ describe('warpbuild snapshot cache', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('assertSafeTarMembers', () => {
|
||||||
|
async function makeTar(
|
||||||
|
dir: string,
|
||||||
|
members: string[],
|
||||||
|
writeFiles = true
|
||||||
|
): Promise<string> {
|
||||||
|
if (writeFiles) {
|
||||||
|
for (const m of members) {
|
||||||
|
const abs = path.join(dir, m)
|
||||||
|
await fs.promises.mkdir(path.dirname(abs), {recursive: true})
|
||||||
|
await fs.promises.writeFile(abs, 'x')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tar = path.join(dir, 'out.tar')
|
||||||
|
await exec.exec('tar', ['-cf', tar, '-C', dir, ...members])
|
||||||
|
return tar
|
||||||
|
}
|
||||||
|
|
||||||
|
it('accepts objects/ and shallow members', async () => {
|
||||||
|
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'wb-tar-'))
|
||||||
|
try {
|
||||||
|
const tar = await makeTar(dir, ['objects/pack/p.pack', 'shallow'])
|
||||||
|
await expect(assertSafeTarMembers(tar)).resolves.toBeUndefined()
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(dir, {recursive: true, force: true})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects members outside objects/ and shallow (e.g. hooks)', async () => {
|
||||||
|
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'wb-tar-'))
|
||||||
|
try {
|
||||||
|
const tar = await makeTar(dir, ['objects/x', 'hooks/pre-commit'])
|
||||||
|
await expect(assertSafeTarMembers(tar)).rejects.toThrow(
|
||||||
|
/unexpected snapshot tar member/
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(dir, {recursive: true, force: true})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects path traversal members', async () => {
|
||||||
|
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'wb-tar-'))
|
||||||
|
try {
|
||||||
|
await fs.promises.mkdir(path.join(dir, 'sub'))
|
||||||
|
const tar = path.join(dir, 'evil.tar')
|
||||||
|
// -P preserves the ../ member instead of stripping it.
|
||||||
|
await fs.promises.writeFile(path.join(dir, 'evil'), 'x')
|
||||||
|
await exec.exec('tar', [
|
||||||
|
'-cPf',
|
||||||
|
tar,
|
||||||
|
'-C',
|
||||||
|
path.join(dir, 'sub'),
|
||||||
|
'../evil'
|
||||||
|
])
|
||||||
|
await expect(assertSafeTarMembers(tar)).rejects.toThrow(
|
||||||
|
/unexpected snapshot tar member/
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
await fs.promises.rm(dir, {recursive: true, force: true})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('computeDestinationRef', () => {
|
describe('computeDestinationRef', () => {
|
||||||
it('maps the refs the fetch would have created', () => {
|
it('maps the refs the fetch would have created', () => {
|
||||||
expect(computeDestinationRef('refs/heads/main')).toBe(
|
expect(computeDestinationRef('refs/heads/main')).toBe(
|
||||||
|
|
@ -172,6 +240,11 @@ describe('warpbuild snapshot cache', () => {
|
||||||
expect((await lookupSnapshot('123', SHA)).kind).toBe('miss')
|
expect((await lookupSnapshot('123', SHA)).kind).toBe('miss')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('maps 409 upload responses to locked (another job is uploading)', async () => {
|
||||||
|
stubFetch(409, {sub_code: 'FVE_GITMIRROR_LOCKED'})
|
||||||
|
expect((await requestUploadURL('123', SHA)).kind).toBe('locked')
|
||||||
|
})
|
||||||
|
|
||||||
it('maps 403 to disabled (backend kill switch)', async () => {
|
it('maps 403 to disabled (backend kill switch)', async () => {
|
||||||
stubFetch(403, {sub_code: 'PDE_GITMIRROR_DISABLED'})
|
stubFetch(403, {sub_code: 'PDE_GITMIRROR_DISABLED'})
|
||||||
expect((await lookupSnapshot('123', SHA)).kind).toBe('disabled')
|
expect((await lookupSnapshot('123', SHA)).kind).toBe('disabled')
|
||||||
|
|
|
||||||
|
|
@ -41699,7 +41699,8 @@ const promises_namespaceObject = __WEBPACK_EXTERNAL_createRequire(import.meta.ur
|
||||||
|
|
||||||
// Client for backend-core's /api/v1/git-mirrors endpoints, authed by the runner
|
// Client for backend-core's /api/v1/git-mirrors endpoints, authed by the runner
|
||||||
// verification token. Contract: 200 = presigned URL; 404 = miss (upload after the
|
// verification token. Contract: 200 = presigned URL; 404 = miss (upload after the
|
||||||
// stock fetch); 403 = unservable org (skip cache + upload); else = fall back.
|
// stock fetch); 403 = unservable org (skip cache + upload); 409 = another job is
|
||||||
|
// uploading this snapshot (skip upload); else = fall back.
|
||||||
const API_TIMEOUT_MS = 10_000;
|
const API_TIMEOUT_MS = 10_000;
|
||||||
function backend_api_baseUrl() {
|
function backend_api_baseUrl() {
|
||||||
return (process.env['WARPBUILD_HOST_URL'] || '').replace(/\/+$/, '');
|
return (process.env['WARPBUILD_HOST_URL'] || '').replace(/\/+$/, '');
|
||||||
|
|
@ -41753,6 +41754,10 @@ async function requestUploadURL(repoKey, sha) {
|
||||||
core_debug(`[wb-cache] upload-url answered 403 (disabled)`);
|
core_debug(`[wb-cache] upload-url answered 403 (disabled)`);
|
||||||
return { kind: 'disabled' };
|
return { kind: 'disabled' };
|
||||||
}
|
}
|
||||||
|
if (res.status === 409) {
|
||||||
|
core_debug(`[wb-cache] upload-url answered 409 (locked)`);
|
||||||
|
return { kind: 'locked' };
|
||||||
|
}
|
||||||
core_debug(`[wb-cache] upload-url answered ${res.status}`);
|
core_debug(`[wb-cache] upload-url answered ${res.status}`);
|
||||||
return { kind: 'error' };
|
return { kind: 'error' };
|
||||||
}
|
}
|
||||||
|
|
@ -41772,6 +41777,7 @@ async function requestUploadURL(repoKey, sha) {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// WarpBuild checkout snapshot cache: SHA-keyed tars of what the stock shallow fetch
|
// WarpBuild checkout snapshot cache: SHA-keyed tars of what the stock shallow fetch
|
||||||
// produces. Hit = restore + skip the fetch; miss = upload after checkout. Keys are
|
// produces. Hit = restore + skip the fetch; miss = upload after checkout. Keys are
|
||||||
// immutable (no expiry). Fail-open: any error degrades to stock behavior.
|
// immutable (no expiry). Fail-open: any error degrades to stock behavior.
|
||||||
|
|
@ -41790,9 +41796,6 @@ function getMirrorCacheSkipReason(settings) {
|
||||||
!process.env['WARPBUILD_HOST_URL']) {
|
!process.env['WARPBUILD_HOST_URL']) {
|
||||||
return SKIP_NOT_WARPBUILD;
|
return SKIP_NOT_WARPBUILD;
|
||||||
}
|
}
|
||||||
if (process.platform === 'win32') {
|
|
||||||
return 'Windows is not supported by the snapshot cache yet';
|
|
||||||
}
|
|
||||||
const repoKey = process.env['GITHUB_REPOSITORY_ID'] || '';
|
const repoKey = process.env['GITHUB_REPOSITORY_ID'] || '';
|
||||||
if (!repoKey) {
|
if (!repoKey) {
|
||||||
return 'GITHUB_REPOSITORY_ID is not set';
|
return 'GITHUB_REPOSITORY_ID is not set';
|
||||||
|
|
@ -41873,6 +41876,11 @@ async function setup(settings) {
|
||||||
async function setupInner(settings) {
|
async function setupInner(settings) {
|
||||||
const repoKey = process.env['GITHUB_REPOSITORY_ID'];
|
const repoKey = process.env['GITHUB_REPOSITORY_ID'];
|
||||||
const sha = settings.commit;
|
const sha = settings.commit;
|
||||||
|
// The restore/upload paths shell out to `tar`; without it, fall back cleanly.
|
||||||
|
if (!(await which('tar', false))) {
|
||||||
|
info('tar not found on PATH; using standard checkout');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const lookup = await lookupSnapshot(repoKey, sha);
|
const lookup = await lookupSnapshot(repoKey, sha);
|
||||||
if (lookup.kind === 'disabled') {
|
if (lookup.kind === 'disabled') {
|
||||||
info('Snapshot cache is disabled by the backend for this organization');
|
info('Snapshot cache is disabled by the backend for this organization');
|
||||||
|
|
@ -41910,6 +41918,28 @@ async function contribute(settings) {
|
||||||
endGroup();
|
endGroup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Refuse any tar member outside objects/ or shallow, absolute, or with a `..`
|
||||||
|
// component — the tar is remote and extracted into .git, so a crafted member could
|
||||||
|
// escape (e.g. hooks/, ../) and run code during checkout.
|
||||||
|
async function assertSafeTarMembers(tar) {
|
||||||
|
let listing = '';
|
||||||
|
await exec_exec('tar', ['-tf', tar], {
|
||||||
|
silent: true,
|
||||||
|
listeners: { stdout: (d) => (listing += d.toString()) }
|
||||||
|
});
|
||||||
|
for (const raw of listing.split('\n')) {
|
||||||
|
const member = raw.trim();
|
||||||
|
if (!member) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const top = member.replace(/^\.\//, '').split('/')[0];
|
||||||
|
if (member.startsWith('/') ||
|
||||||
|
member.split('/').includes('..') ||
|
||||||
|
(top !== 'objects' && top !== 'shallow')) {
|
||||||
|
throw new Error(`unexpected snapshot tar member: ${member}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
async function restoreSnapshot(settings, url, sha) {
|
async function restoreSnapshot(settings, url, sha) {
|
||||||
const gitDir = external_path_namespaceObject.join(settings.repositoryPath, '.git');
|
const gitDir = external_path_namespaceObject.join(settings.repositoryPath, '.git');
|
||||||
const tmpTar = external_path_namespaceObject.join(external_os_namespaceObject.tmpdir(), `wb-snapshot-${process.pid}.tar`);
|
const tmpTar = external_path_namespaceObject.join(external_os_namespaceObject.tmpdir(), `wb-snapshot-${process.pid}.tar`);
|
||||||
|
|
@ -41921,6 +41951,7 @@ async function restoreSnapshot(settings, url, sha) {
|
||||||
throw new Error(`snapshot download answered ${res.status}`);
|
throw new Error(`snapshot download answered ${res.status}`);
|
||||||
}
|
}
|
||||||
await (0,promises_namespaceObject.pipeline)(external_stream_namespaceObject.Readable.fromWeb(res.body), external_fs_namespaceObject.createWriteStream(tmpTar));
|
await (0,promises_namespaceObject.pipeline)(external_stream_namespaceObject.Readable.fromWeb(res.body), external_fs_namespaceObject.createWriteStream(tmpTar));
|
||||||
|
await assertSafeTarMembers(tmpTar);
|
||||||
await exec_exec('tar', ['-xf', tmpTar, '-C', gitDir]);
|
await exec_exec('tar', ['-xf', tmpTar, '-C', gitDir]);
|
||||||
const check = await exec_exec('git', ['-C', settings.repositoryPath, 'cat-file', '-e', `${sha}^{commit}`], { ignoreReturnCode: true });
|
const check = await exec_exec('git', ['-C', settings.repositoryPath, 'cat-file', '-e', `${sha}^{commit}`], { ignoreReturnCode: true });
|
||||||
if (check !== 0) {
|
if (check !== 0) {
|
||||||
|
|
@ -41974,7 +42005,9 @@ async function uploadSnapshot(settings) {
|
||||||
const size = (await external_fs_namespaceObject.promises.stat(tmpTar)).size;
|
const size = (await external_fs_namespaceObject.promises.stat(tmpTar)).size;
|
||||||
const upload = await requestUploadURL(repoKey, sha);
|
const upload = await requestUploadURL(repoKey, sha);
|
||||||
if (upload.kind !== 'ok') {
|
if (upload.kind !== 'ok') {
|
||||||
info(upload.kind === 'disabled'
|
info(upload.kind === 'locked'
|
||||||
|
? 'Another job is already uploading this snapshot; skipping'
|
||||||
|
: upload.kind === 'disabled'
|
||||||
? 'Snapshot cache is disabled by the backend; not uploading'
|
? 'Snapshot cache is disabled by the backend; not uploading'
|
||||||
: 'Snapshot cache backend unavailable; not uploading');
|
: 'Snapshot cache backend unavailable; not uploading');
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ import * as core from '@actions/core'
|
||||||
|
|
||||||
// Client for backend-core's /api/v1/git-mirrors endpoints, authed by the runner
|
// Client for backend-core's /api/v1/git-mirrors endpoints, authed by the runner
|
||||||
// verification token. Contract: 200 = presigned URL; 404 = miss (upload after the
|
// verification token. Contract: 200 = presigned URL; 404 = miss (upload after the
|
||||||
// stock fetch); 403 = unservable org (skip cache + upload); else = fall back.
|
// stock fetch); 403 = unservable org (skip cache + upload); 409 = another job is
|
||||||
|
// uploading this snapshot (skip upload); else = fall back.
|
||||||
|
|
||||||
const API_TIMEOUT_MS = 10_000
|
const API_TIMEOUT_MS = 10_000
|
||||||
|
|
||||||
|
|
@ -22,6 +23,7 @@ export type SnapshotLookup =
|
||||||
export type UploadURLResult =
|
export type UploadURLResult =
|
||||||
| {kind: 'ok'; url: string}
|
| {kind: 'ok'; url: string}
|
||||||
| {kind: 'disabled'}
|
| {kind: 'disabled'}
|
||||||
|
| {kind: 'locked'}
|
||||||
| {kind: 'error'}
|
| {kind: 'error'}
|
||||||
|
|
||||||
function baseUrl(): string {
|
function baseUrl(): string {
|
||||||
|
|
@ -89,6 +91,10 @@ export async function requestUploadURL(
|
||||||
core.debug(`[wb-cache] upload-url answered 403 (disabled)`)
|
core.debug(`[wb-cache] upload-url answered 403 (disabled)`)
|
||||||
return {kind: 'disabled'}
|
return {kind: 'disabled'}
|
||||||
}
|
}
|
||||||
|
if (res.status === 409) {
|
||||||
|
core.debug(`[wb-cache] upload-url answered 409 (locked)`)
|
||||||
|
return {kind: 'locked'}
|
||||||
|
}
|
||||||
core.debug(`[wb-cache] upload-url answered ${res.status}`)
|
core.debug(`[wb-cache] upload-url answered ${res.status}`)
|
||||||
return {kind: 'error'}
|
return {kind: 'error'}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
/* eslint-disable i18n-text/no-en, import/no-unresolved -- upstream conventions; no TS import resolver configured */
|
/* eslint-disable i18n-text/no-en, import/no-unresolved -- upstream conventions; no TS import resolver configured */
|
||||||
import * as core from '@actions/core'
|
import * as core from '@actions/core'
|
||||||
import * as exec from '@actions/exec'
|
import * as exec from '@actions/exec'
|
||||||
|
import * as io from '@actions/io'
|
||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
import * as os from 'os'
|
import * as os from 'os'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
|
|
@ -38,9 +39,6 @@ export function getMirrorCacheSkipReason(
|
||||||
) {
|
) {
|
||||||
return SKIP_NOT_WARPBUILD
|
return SKIP_NOT_WARPBUILD
|
||||||
}
|
}
|
||||||
if (process.platform === 'win32') {
|
|
||||||
return 'Windows is not supported by the snapshot cache yet'
|
|
||||||
}
|
|
||||||
const repoKey = process.env['GITHUB_REPOSITORY_ID'] || ''
|
const repoKey = process.env['GITHUB_REPOSITORY_ID'] || ''
|
||||||
if (!repoKey) {
|
if (!repoKey) {
|
||||||
return 'GITHUB_REPOSITORY_ID is not set'
|
return 'GITHUB_REPOSITORY_ID is not set'
|
||||||
|
|
@ -127,6 +125,12 @@ async function setupInner(settings: IGitSourceSettings): Promise<boolean> {
|
||||||
const repoKey = process.env['GITHUB_REPOSITORY_ID'] as string
|
const repoKey = process.env['GITHUB_REPOSITORY_ID'] as string
|
||||||
const sha = settings.commit
|
const sha = settings.commit
|
||||||
|
|
||||||
|
// The restore/upload paths shell out to `tar`; without it, fall back cleanly.
|
||||||
|
if (!(await io.which('tar', false))) {
|
||||||
|
core.info('tar not found on PATH; using standard checkout')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const lookup = await api.lookupSnapshot(repoKey, sha)
|
const lookup = await api.lookupSnapshot(repoKey, sha)
|
||||||
|
|
||||||
if (lookup.kind === 'disabled') {
|
if (lookup.kind === 'disabled') {
|
||||||
|
|
@ -171,6 +175,31 @@ export async function contribute(settings: IGitSourceSettings): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refuse any tar member outside objects/ or shallow, absolute, or with a `..`
|
||||||
|
// component — the tar is remote and extracted into .git, so a crafted member could
|
||||||
|
// escape (e.g. hooks/, ../) and run code during checkout.
|
||||||
|
export async function assertSafeTarMembers(tar: string): Promise<void> {
|
||||||
|
let listing = ''
|
||||||
|
await exec.exec('tar', ['-tf', tar], {
|
||||||
|
silent: true,
|
||||||
|
listeners: {stdout: (d: Buffer) => (listing += d.toString())}
|
||||||
|
})
|
||||||
|
for (const raw of listing.split('\n')) {
|
||||||
|
const member = raw.trim()
|
||||||
|
if (!member) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const top = member.replace(/^\.\//, '').split('/')[0]
|
||||||
|
if (
|
||||||
|
member.startsWith('/') ||
|
||||||
|
member.split('/').includes('..') ||
|
||||||
|
(top !== 'objects' && top !== 'shallow')
|
||||||
|
) {
|
||||||
|
throw new Error(`unexpected snapshot tar member: ${member}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function restoreSnapshot(
|
async function restoreSnapshot(
|
||||||
settings: IGitSourceSettings,
|
settings: IGitSourceSettings,
|
||||||
url: string,
|
url: string,
|
||||||
|
|
@ -189,6 +218,7 @@ async function restoreSnapshot(
|
||||||
Readable.fromWeb(res.body as import('stream/web').ReadableStream),
|
Readable.fromWeb(res.body as import('stream/web').ReadableStream),
|
||||||
fs.createWriteStream(tmpTar)
|
fs.createWriteStream(tmpTar)
|
||||||
)
|
)
|
||||||
|
await assertSafeTarMembers(tmpTar)
|
||||||
await exec.exec('tar', ['-xf', tmpTar, '-C', gitDir])
|
await exec.exec('tar', ['-xf', tmpTar, '-C', gitDir])
|
||||||
|
|
||||||
const check = await exec.exec(
|
const check = await exec.exec(
|
||||||
|
|
@ -253,7 +283,9 @@ async function uploadSnapshot(settings: IGitSourceSettings): Promise<void> {
|
||||||
const upload = await api.requestUploadURL(repoKey, sha)
|
const upload = await api.requestUploadURL(repoKey, sha)
|
||||||
if (upload.kind !== 'ok') {
|
if (upload.kind !== 'ok') {
|
||||||
core.info(
|
core.info(
|
||||||
upload.kind === 'disabled'
|
upload.kind === 'locked'
|
||||||
|
? 'Another job is already uploading this snapshot; skipping'
|
||||||
|
: upload.kind === 'disabled'
|
||||||
? 'Snapshot cache is disabled by the backend; not uploading'
|
? 'Snapshot cache is disabled by the backend; not uploading'
|
||||||
: 'Snapshot cache backend unavailable; not uploading'
|
: 'Snapshot cache backend unavailable; not uploading'
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue