Skip to content

Commit 659c01a

Browse files
dgieselaardelannimistickibanamachine
authored
[CI] Cache TypeScript build artifacts (#241414)
Implements a GCS-backed cache of TypeScript builds, which ensures only invalidated projects need to be re-checked, which can speed up CI checks from 25-30m to 3m in many cases. When restoring an archive, we use the most recent commit for which there is an archive. Archives are stored either by commit sha (for the on-merge pipeline) or PR number (for the pull-request pipeline). Storing & restoring takes about 20s each. A fully-cached type check is about 50s. Based on #240981 from @delanni. --------- Co-authored-by: Alex Szabo <[email protected]> Co-authored-by: Tiago Costa <[email protected]> Co-authored-by: kibanamachine <[email protected]>
1 parent 8698ff3 commit 659c01a

18 files changed

+1179
-13
lines changed

.buildkite/pipelines/build_api_docs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
env:
22
PUBLISH_API_DOCS_CHANGES: 'true'
33
steps:
4-
- command: .buildkite/scripts/steps/check_types.sh
4+
- command: .buildkite/scripts/steps/typecheck/check_types.sh
55
label: 'Check types'
66
key: check_types
77
agents:

.buildkite/pipelines/on_merge.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ steps:
106106
- exit_status: '-1'
107107
limit: 3
108108

109-
- command: .buildkite/scripts/steps/check_types.sh
109+
- command: .buildkite/scripts/steps/typecheck/check_types.sh
110110
label: 'Check Types'
111111
agents:
112112
image: family/kibana-ubuntu-2404

.buildkite/pipelines/pull_request/base.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ steps:
9898
- exit_status: '-1'
9999
limit: 3
100100

101-
- command: .buildkite/scripts/steps/check_types.sh
101+
- command: .buildkite/scripts/steps/typecheck/check_types.sh
102102
label: 'Check Types'
103103
agents:
104104
machineType: c4-standard-4

.buildkite/scripts/common/activate_service_account.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ if [[ -z "$EMAIL" ]]; then
7878
"ci-artifacts.kibana.dev")
7979
EMAIL="kibana-ci-access-artifacts@$GCLOUD_EMAIL_POSTFIX"
8080
;;
81+
"ci-typescript-archives")
82+
EMAIL="kibana-ci-access-ts-archives@$GCLOUD_EMAIL_POSTFIX"
83+
;;
8184
"kibana-ci-access-chromium-blds")
8285
EMAIL="kibana-ci-access-chromium-blds@$GCLOUD_EMAIL_POSTFIX"
8386
;;

.buildkite/scripts/steps/check_types.sh renamed to .buildkite/scripts/steps/typecheck/check_types.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ source .buildkite/scripts/common/util.sh
77
.buildkite/scripts/bootstrap.sh
88

99
echo --- Check Types
10-
node scripts/type_check
10+
set +e
11+
node scripts/type_check --with-archive

packages/kbn-ts-type-check-cli/run_type_check_cli.ts

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,22 @@
99

1010
import Path from 'path';
1111
import Fsp from 'fs/promises';
12-
1312
import { run } from '@kbn/dev-cli-runner';
1413
import { createFailError } from '@kbn/dev-cli-errors';
1514
import { REPO_ROOT } from '@kbn/repo-info';
1615
import { asyncForEachWithLimit, asyncMapWithLimit } from '@kbn/std';
1716
import type { SomeDevLog } from '@kbn/some-dev-log';
1817
import { type TsProject, TS_PROJECTS } from '@kbn/ts-projects';
18+
import execa from 'execa';
1919

2020
import {
2121
updateRootRefsConfig,
2222
cleanupRootRefsConfig,
2323
ROOT_REFS_CONFIG_PATH,
2424
} from './root_refs_config';
25+
import { archiveTSBuildArtifacts } from './src/archive/archive_ts_build_artifacts';
26+
import { restoreTSBuildArtifacts } from './src/archive/restore_ts_build_artifacts';
27+
import { LOCAL_CACHE_ROOT } from './src/archive/constants';
2528

2629
const rel = (from: string, to: string) => {
2730
const path = Path.relative(from, to);
@@ -84,22 +87,46 @@ async function createTypeCheckConfigs(log: SomeDevLog, projects: TsProject[]) {
8487
);
8588
}
8689

90+
async function detectLocalChanges(): Promise<boolean> {
91+
const { stdout } = await execa('git', ['status', '--porcelain'], {
92+
cwd: REPO_ROOT,
93+
});
94+
95+
return stdout.trim().length > 0;
96+
}
97+
8798
run(
8899
async ({ log, flagsReader, procRunner }) => {
89-
if (flagsReader.boolean('clean-cache')) {
100+
const shouldCleanCache = flagsReader.boolean('clean-cache');
101+
const shouldUseArchive = flagsReader.boolean('with-archive');
102+
103+
if (shouldCleanCache) {
90104
await asyncForEachWithLimit(TS_PROJECTS, 10, async (proj) => {
91105
await Fsp.rm(Path.resolve(proj.directory, 'target/types'), {
92106
force: true,
93107
recursive: true,
94108
});
95109
});
96-
log.warning('Deleted all typescript caches');
110+
await Fsp.rm(LOCAL_CACHE_ROOT, {
111+
force: true,
112+
recursive: true,
113+
});
114+
log.warning('Deleted all TypeScript caches');
115+
return;
97116
}
98117

99118
// if the tsconfig.refs.json file is not self-managed then make sure it has
100119
// a reference to every composite project in the repo
101120
await updateRootRefsConfig(log);
102121

122+
if (shouldUseArchive && !shouldCleanCache) {
123+
await restoreTSBuildArtifacts(log);
124+
} else if (shouldCleanCache && shouldUseArchive) {
125+
log.info('Skipping TypeScript cache restore because --clean-cache was provided.');
126+
} else {
127+
log.verbose('Skipping TypeScript cache restore because --with-archive was not provided.');
128+
}
129+
103130
const projectFilter = flagsReader.path('project');
104131

105132
const projects = TS_PROJECTS.filter(
@@ -108,7 +135,7 @@ run(
108135

109136
const created = await createTypeCheckConfigs(log, projects);
110137

111-
let pluginBuildResult;
138+
let didTypeCheckFail = false;
112139
try {
113140
log.info(
114141
`Building TypeScript projects to check types (For visible, though excessive, progress info you can pass --verbose)`
@@ -126,17 +153,28 @@ run(
126153
relative,
127154
'--pretty',
128155
...(flagsReader.boolean('verbose') ? ['--verbose'] : []),
156+
...(flagsReader.boolean('extended-diagnostics') ? ['--extendedDiagnostics'] : []),
129157
],
130158
env: {
131159
NODE_OPTIONS: '--max-old-space-size=10240',
132160
},
133161
cwd: REPO_ROOT,
134162
wait: true,
135163
});
136-
137-
pluginBuildResult = { failed: false };
138164
} catch (error) {
139-
pluginBuildResult = { failed: true };
165+
didTypeCheckFail = true;
166+
}
167+
168+
const hasLocalChanges = shouldUseArchive ? await detectLocalChanges() : false;
169+
170+
if (shouldUseArchive) {
171+
if (hasLocalChanges) {
172+
log.info('Skipping TypeScript cache archive because uncommitted changes were detected.');
173+
} else {
174+
await archiveTSBuildArtifacts(log);
175+
}
176+
} else {
177+
log.verbose('Skipping TypeScript cache archive because --with-archive was not provided.');
140178
}
141179

142180
// cleanup if requested
@@ -149,7 +187,7 @@ run(
149187
});
150188
}
151189

152-
if (pluginBuildResult.failed) {
190+
if (didTypeCheckFail) {
153191
throw createFailError('Unable to build TS project refs');
154192
}
155193
},
@@ -166,7 +204,7 @@ run(
166204
`,
167205
flags: {
168206
string: ['project'],
169-
boolean: ['clean-cache', 'cleanup'],
207+
boolean: ['clean-cache', 'cleanup', 'extended-diagnostics', 'with-archive'],
170208
help: `
171209
--project [path] Path to a tsconfig.json file determines the project to check
172210
--help Show this message
@@ -175,6 +213,8 @@ run(
175213
files in place makes subsequent executions faster because ts can
176214
identify that none of the imports have changed (it uses creation/update
177215
times) but cleaning them prevents leaving garbage around the repo.
216+
--extended-diagnostics Turn on extended diagnostics in the TypeScript compiler
217+
--with-archive Restore cached artifacts before running and archive results afterwards
178218
`,
179219
},
180220
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import type { SomeDevLog } from '@kbn/some-dev-log';
11+
import globby from 'globby';
12+
import { archiveTSBuildArtifacts } from './archive_ts_build_artifacts';
13+
import { LocalFileSystem } from './file_system/local_file_system';
14+
import { getPullRequestNumber, isCiEnvironment, resolveCurrentCommitSha } from './utils';
15+
16+
jest.mock('globby', () => jest.fn());
17+
18+
jest.mock('./utils', () => ({
19+
getPullRequestNumber: jest.fn(),
20+
isCiEnvironment: jest.fn(),
21+
resolveCurrentCommitSha: jest.fn(),
22+
withGcsAuth: jest.fn((_, action: () => Promise<unknown>) => action()),
23+
}));
24+
25+
jest.mock('./file_system/gcs_file_system', () => ({
26+
GcsFileSystem: jest.fn().mockImplementation(() => ({
27+
updateArchive: jest.fn(),
28+
})),
29+
}));
30+
31+
const mockedGlobby = globby as jest.MockedFunction<typeof globby>;
32+
const mockedGetPullRequestNumber = getPullRequestNumber as jest.MockedFunction<
33+
typeof getPullRequestNumber
34+
>;
35+
const mockedIsCiEnvironment = isCiEnvironment as jest.MockedFunction<typeof isCiEnvironment>;
36+
const mockedResolveCurrentCommitSha = resolveCurrentCommitSha as jest.MockedFunction<
37+
typeof resolveCurrentCommitSha
38+
>;
39+
40+
const createLog = (): SomeDevLog => {
41+
return {
42+
info: jest.fn(),
43+
warning: jest.fn(),
44+
error: jest.fn(),
45+
debug: jest.fn(),
46+
} as unknown as SomeDevLog;
47+
};
48+
49+
describe('archiveTSBuildArtifacts', () => {
50+
let updateSpy: jest.SpyInstance;
51+
52+
beforeEach(() => {
53+
jest.clearAllMocks();
54+
mockedIsCiEnvironment.mockReturnValue(false);
55+
mockedGetPullRequestNumber.mockReturnValue(undefined);
56+
mockedResolveCurrentCommitSha.mockResolvedValue('');
57+
mockedGlobby.mockResolvedValue([]);
58+
updateSpy = jest
59+
.spyOn(LocalFileSystem.prototype, 'updateArchive')
60+
.mockResolvedValue(Promise.resolve() as unknown as void);
61+
});
62+
63+
afterEach(() => {
64+
updateSpy.mockRestore();
65+
});
66+
67+
it('logs when no build artifacts are present', async () => {
68+
const log = createLog();
69+
70+
mockedGlobby.mockResolvedValueOnce([]);
71+
72+
await archiveTSBuildArtifacts(log);
73+
74+
expect(log.info).toHaveBeenCalledWith('No TypeScript build artifacts found to archive.');
75+
expect(updateSpy).not.toHaveBeenCalled();
76+
});
77+
78+
it('warns when the commit SHA cannot be determined', async () => {
79+
const log = createLog();
80+
81+
mockedGlobby.mockResolvedValueOnce(['a']);
82+
mockedResolveCurrentCommitSha.mockResolvedValueOnce(undefined);
83+
84+
await archiveTSBuildArtifacts(log);
85+
86+
expect(log.warning).toHaveBeenCalledWith(
87+
'Unable to determine commit SHA for TypeScript cache archive.'
88+
);
89+
expect(updateSpy).not.toHaveBeenCalled();
90+
});
91+
92+
it('uses the LocalFileSystem to archive matching artifacts', async () => {
93+
const log = createLog();
94+
const files = ['target/types/foo.d.ts', 'tsconfig.type_check.json'];
95+
96+
mockedGlobby.mockResolvedValueOnce(files);
97+
mockedResolveCurrentCommitSha.mockResolvedValueOnce('abc123');
98+
mockedGetPullRequestNumber.mockReturnValueOnce('789');
99+
100+
await archiveTSBuildArtifacts(log);
101+
102+
expect(updateSpy).toHaveBeenCalledTimes(1);
103+
expect(updateSpy).toHaveBeenCalledWith({
104+
files,
105+
prNumber: '789',
106+
sha: 'abc123',
107+
});
108+
});
109+
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
import { REPO_ROOT } from '@kbn/repo-info';
10+
import type { SomeDevLog } from '@kbn/some-dev-log';
11+
import globby from 'globby';
12+
import { CACHE_IGNORE_GLOBS, CACHE_MATCH_GLOBS } from './constants';
13+
import { GcsFileSystem } from './file_system/gcs_file_system';
14+
import { LocalFileSystem } from './file_system/local_file_system';
15+
import {
16+
getPullRequestNumber,
17+
isCiEnvironment,
18+
resolveCurrentCommitSha,
19+
withGcsAuth,
20+
} from './utils';
21+
22+
/**
23+
* Archives .tsbuildinfo, type_check.tsconfig.json, and declaration files
24+
* in a GCS bucket for cached type checks.
25+
*/
26+
export async function archiveTSBuildArtifacts(log: SomeDevLog) {
27+
try {
28+
const matches = await globby(CACHE_MATCH_GLOBS, {
29+
cwd: REPO_ROOT,
30+
dot: true,
31+
followSymbolicLinks: false,
32+
ignore: CACHE_IGNORE_GLOBS,
33+
});
34+
35+
if (matches.length === 0) {
36+
log.info('No TypeScript build artifacts found to archive.');
37+
return;
38+
}
39+
40+
const commitSha = await resolveCurrentCommitSha();
41+
42+
if (!commitSha) {
43+
log.warning('Unable to determine commit SHA for TypeScript cache archive.');
44+
return;
45+
}
46+
47+
const prNumber = getPullRequestNumber();
48+
49+
const options = { files: matches, sha: commitSha, prNumber };
50+
51+
if (isCiEnvironment()) {
52+
await withGcsAuth(log, () => new GcsFileSystem(log).updateArchive(options));
53+
} else {
54+
await new LocalFileSystem(log).updateArchive(options);
55+
}
56+
} catch (error) {
57+
const archiveErrorDetails = error instanceof Error ? error.message : String(error);
58+
log.warning(`Failed to archive TypeScript build artifacts: ${archiveErrorDetails}`);
59+
}
60+
}

0 commit comments

Comments
 (0)