@quenty/nevermore-cli 4.51.0 → 4.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/commands/batch-command/batch-bundle-command.d.ts +3 -0
- package/dist/commands/batch-command/batch-bundle-command.d.ts.map +1 -1
- package/dist/commands/batch-command/batch-bundle-command.js +84 -12
- package/dist/commands/batch-command/batch-bundle-command.js.map +1 -1
- package/dist/commands/batch-command/batch-deploy-command.d.ts +2 -0
- package/dist/commands/batch-command/batch-deploy-command.d.ts.map +1 -1
- package/dist/commands/batch-command/batch-deploy-command.js +115 -16
- package/dist/commands/batch-command/batch-deploy-command.js.map +1 -1
- package/dist/commands/batch-command/batch-test-command.d.ts.map +1 -1
- package/dist/commands/batch-command/batch-test-command.js +7 -3
- package/dist/commands/batch-command/batch-test-command.js.map +1 -1
- package/dist/commands/deploy-command/index.d.ts.map +1 -1
- package/dist/commands/deploy-command/index.js +7 -0
- package/dist/commands/deploy-command/index.js.map +1 -1
- package/dist/commands/test-command/test-command.d.ts +1 -0
- package/dist/commands/test-command/test-command.d.ts.map +1 -1
- package/dist/commands/test-command/test-command.js +19 -3
- package/dist/commands/test-command/test-command.js.map +1 -1
- package/dist/commands/tools-command/ci-post-deploy-results.d.ts.map +1 -1
- package/dist/commands/tools-command/ci-post-deploy-results.js +6 -2
- package/dist/commands/tools-command/ci-post-deploy-results.js.map +1 -1
- package/dist/commands/tools-command/ci-post-test-results.d.ts.map +1 -1
- package/dist/commands/tools-command/ci-post-test-results.js +6 -2
- package/dist/commands/tools-command/ci-post-test-results.js.map +1 -1
- package/dist/utils/batch/changed-packages-utils.d.ts +11 -0
- package/dist/utils/batch/changed-packages-utils.d.ts.map +1 -1
- package/dist/utils/batch/changed-packages-utils.js +14 -0
- package/dist/utils/batch/changed-packages-utils.js.map +1 -1
- package/dist/utils/build/build.d.ts +7 -0
- package/dist/utils/build/build.d.ts.map +1 -1
- package/dist/utils/build/build.js.map +1 -1
- package/dist/utils/bundle/untrusted-tar.d.ts +28 -0
- package/dist/utils/bundle/untrusted-tar.d.ts.map +1 -0
- package/dist/utils/bundle/untrusted-tar.js +116 -0
- package/dist/utils/bundle/untrusted-tar.js.map +1 -0
- package/dist/utils/deploy/deploy-github-columns.d.ts +8 -1
- package/dist/utils/deploy/deploy-github-columns.d.ts.map +1 -1
- package/dist/utils/deploy/deploy-github-columns.js.map +1 -1
- package/dist/utils/deploy/deploy-metadata.d.ts +5 -0
- package/dist/utils/deploy/deploy-metadata.d.ts.map +1 -1
- package/dist/utils/deploy/deploy-metadata.js +36 -0
- package/dist/utils/deploy/deploy-metadata.js.map +1 -1
- package/dist/utils/deploy/deploy-metadata.test.js +35 -2
- package/dist/utils/deploy/deploy-metadata.test.js.map +1 -1
- package/dist/utils/deploy/prebuilt-deploy-bundle.d.ts +38 -0
- package/dist/utils/deploy/prebuilt-deploy-bundle.d.ts.map +1 -0
- package/dist/utils/deploy/prebuilt-deploy-bundle.js +209 -0
- package/dist/utils/deploy/prebuilt-deploy-bundle.js.map +1 -0
- package/dist/utils/deploy/prebuilt-deploy-bundle.test.d.ts +2 -0
- package/dist/utils/deploy/prebuilt-deploy-bundle.test.d.ts.map +1 -0
- package/dist/utils/deploy/prebuilt-deploy-bundle.test.js +194 -0
- package/dist/utils/deploy/prebuilt-deploy-bundle.test.js.map +1 -0
- package/dist/utils/job-context/base-job-context.d.ts.map +1 -1
- package/dist/utils/job-context/base-job-context.js +3 -2
- package/dist/utils/job-context/base-job-context.js.map +1 -1
- package/dist/utils/job-context/batch-script-job-context.d.ts +1 -1
- package/dist/utils/open-cloud/open-cloud-client.d.ts +1 -0
- package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.js +5 -0
- package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.test.js +11 -0
- package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -1
- package/dist/utils/testing/runner/prebuilt-batch-bundle.d.ts +1 -1
- package/dist/utils/testing/runner/prebuilt-batch-bundle.d.ts.map +1 -1
- package/dist/utils/testing/runner/prebuilt-batch-bundle.js +13 -68
- package/dist/utils/testing/runner/prebuilt-batch-bundle.js.map +1 -1
- package/dist/utils/testing/runner/prebuilt-batch-bundle.test.js +16 -0
- package/dist/utils/testing/runner/prebuilt-batch-bundle.test.js.map +1 -1
- package/package.json +3 -3
- package/src/commands/batch-command/batch-bundle-command.ts +112 -16
- package/src/commands/batch-command/batch-deploy-command.ts +160 -16
- package/src/commands/batch-command/batch-test-command.ts +7 -3
- package/src/commands/deploy-command/index.ts +7 -0
- package/src/commands/test-command/test-command.ts +20 -3
- package/src/commands/tools-command/ci-post-deploy-results.ts +9 -2
- package/src/commands/tools-command/ci-post-test-results.ts +9 -2
- package/src/utils/batch/changed-packages-utils.ts +23 -0
- package/src/utils/build/build.ts +7 -0
- package/src/utils/bundle/untrusted-tar.ts +160 -0
- package/src/utils/deploy/deploy-github-columns.ts +8 -1
- package/src/utils/deploy/deploy-metadata.test.ts +50 -1
- package/src/utils/deploy/deploy-metadata.ts +39 -0
- package/src/utils/deploy/prebuilt-deploy-bundle.test.ts +265 -0
- package/src/utils/deploy/prebuilt-deploy-bundle.ts +297 -0
- package/src/utils/job-context/base-job-context.ts +4 -2
- package/src/utils/job-context/batch-script-job-context.ts +1 -1
- package/src/utils/open-cloud/open-cloud-client.test.ts +15 -0
- package/src/utils/open-cloud/open-cloud-client.ts +11 -0
- package/src/utils/testing/runner/prebuilt-batch-bundle.test.ts +20 -0
- package/src/utils/testing/runner/prebuilt-batch-bundle.ts +20 -83
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
1
|
+
import { afterEach, describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import * as fs from 'fs/promises';
|
|
3
3
|
import * as os from 'os';
|
|
4
4
|
import * as path from 'path';
|
|
5
5
|
import {
|
|
6
6
|
buildDeployMetadataAttributes,
|
|
7
|
+
gatherGitDeployInfo,
|
|
7
8
|
packageUsesManifestAsync,
|
|
8
9
|
type GitDeployInfo,
|
|
9
10
|
type DeployPlaceInfo,
|
|
@@ -207,3 +208,51 @@ describe('packageUsesManifestAsync', () => {
|
|
|
207
208
|
});
|
|
208
209
|
});
|
|
209
210
|
});
|
|
211
|
+
|
|
212
|
+
describe('gatherGitDeployInfo', () => {
|
|
213
|
+
afterEach(() => {
|
|
214
|
+
vi.unstubAllEnvs();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
async function withEventAsync(
|
|
218
|
+
eventName: string,
|
|
219
|
+
event: unknown,
|
|
220
|
+
fn: () => void
|
|
221
|
+
): Promise<void> {
|
|
222
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'git-info-'));
|
|
223
|
+
try {
|
|
224
|
+
const eventPath = path.join(dir, 'event.json');
|
|
225
|
+
await fs.writeFile(eventPath, JSON.stringify(event));
|
|
226
|
+
vi.stubEnv('GITHUB_EVENT_NAME', eventName);
|
|
227
|
+
vi.stubEnv('GITHUB_EVENT_PATH', eventPath);
|
|
228
|
+
fn();
|
|
229
|
+
} finally {
|
|
230
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
it("stamps a pull_request run with the PR's head, not the merge checkout", async () => {
|
|
235
|
+
const sha = 'b'.repeat(40);
|
|
236
|
+
await withEventAsync(
|
|
237
|
+
'pull_request',
|
|
238
|
+
{ pull_request: { head: { sha, ref: 'feature/x' } } },
|
|
239
|
+
() => {
|
|
240
|
+
expect(gatherGitDeployInfo()).toEqual({
|
|
241
|
+
commit: 'bbbbbbbbbb',
|
|
242
|
+
version: sha,
|
|
243
|
+
branch: 'feature/x',
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('reads the checkout for any other event', async () => {
|
|
250
|
+
await withEventAsync(
|
|
251
|
+
'push',
|
|
252
|
+
{ pull_request: { head: { sha: 'b'.repeat(40), ref: 'feature/x' } } },
|
|
253
|
+
() => {
|
|
254
|
+
expect(gatherGitDeployInfo().version).not.toBe('b'.repeat(40));
|
|
255
|
+
}
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execSync } from 'child_process';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
2
3
|
import * as fs from 'fs/promises';
|
|
3
4
|
import * as path from 'path';
|
|
4
5
|
import {
|
|
@@ -53,8 +54,22 @@ export interface GitDeployInfo {
|
|
|
53
54
|
/**
|
|
54
55
|
* Collect git metadata once per deploy. Every field is best-effort — a missing
|
|
55
56
|
* git binary or a non-repo directory yields an empty object rather than an error.
|
|
57
|
+
*
|
|
58
|
+
* A `pull_request` run checks out `refs/pull/N/merge`, a synthetic commit that
|
|
59
|
+
* exists in neither repo, on a detached HEAD. There the PR's head commit and
|
|
60
|
+
* branch are read from the event payload instead, so a deployed build traces
|
|
61
|
+
* back to the commit its checks are reported on.
|
|
56
62
|
*/
|
|
57
63
|
export function gatherGitDeployInfo(): GitDeployInfo {
|
|
64
|
+
const pullRequestHead = _readPullRequestHead();
|
|
65
|
+
if (pullRequestHead) {
|
|
66
|
+
return {
|
|
67
|
+
commit: pullRequestHead.sha.slice(0, 10),
|
|
68
|
+
version: pullRequestHead.sha,
|
|
69
|
+
branch: pullRequestHead.ref,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
58
73
|
const info: GitDeployInfo = {};
|
|
59
74
|
const commit = _git(['rev-parse', '--short', 'HEAD']);
|
|
60
75
|
if (commit) info.commit = commit;
|
|
@@ -65,6 +80,30 @@ export function gatherGitDeployInfo(): GitDeployInfo {
|
|
|
65
80
|
return info;
|
|
66
81
|
}
|
|
67
82
|
|
|
83
|
+
function _readPullRequestHead(): { sha: string; ref?: string } | undefined {
|
|
84
|
+
const eventName = process.env.GITHUB_EVENT_NAME;
|
|
85
|
+
const eventPath = process.env.GITHUB_EVENT_PATH;
|
|
86
|
+
if (
|
|
87
|
+
!eventPath ||
|
|
88
|
+
(eventName !== 'pull_request' && eventName !== 'pull_request_target')
|
|
89
|
+
) {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const event = JSON.parse(readFileSync(eventPath, 'utf-8'));
|
|
94
|
+
const head = event?.pull_request?.head;
|
|
95
|
+
if (typeof head?.sha !== 'string' || !/^[0-9a-f]{40}$/.test(head.sha)) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
sha: head.sha,
|
|
100
|
+
ref: typeof head.ref === 'string' ? head.ref : undefined,
|
|
101
|
+
};
|
|
102
|
+
} catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
68
107
|
/** Per-place facts known at deploy time. */
|
|
69
108
|
export interface DeployPlaceInfo {
|
|
70
109
|
/** Deploy target name from deploy.nevermore.json (e.g. "test", "integration"). */
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { Header } from 'tar';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
type PrebuiltDeployBundle,
|
|
8
|
+
readPrebuiltDeployBundleAsync,
|
|
9
|
+
writePrebuiltDeployBundleAsync,
|
|
10
|
+
} from './prebuilt-deploy-bundle.js';
|
|
11
|
+
|
|
12
|
+
interface RawEntry {
|
|
13
|
+
path: string;
|
|
14
|
+
body?: string;
|
|
15
|
+
type?: 'File' | 'SymbolicLink';
|
|
16
|
+
linkpath?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const PROJECT_TEXT = JSON.stringify({ name: 'game', tree: {} });
|
|
20
|
+
const SHA = 'a'.repeat(40);
|
|
21
|
+
|
|
22
|
+
function rawTar(entries: RawEntry[]): Buffer {
|
|
23
|
+
const blocks: Buffer[] = [];
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
const body = Buffer.from(entry.body ?? '');
|
|
26
|
+
const header = new Header({
|
|
27
|
+
path: entry.path,
|
|
28
|
+
type: entry.type ?? 'File',
|
|
29
|
+
linkpath: entry.linkpath,
|
|
30
|
+
size: entry.type && entry.type !== 'File' ? 0 : body.length,
|
|
31
|
+
mode: 0o644,
|
|
32
|
+
mtime: new Date(0),
|
|
33
|
+
});
|
|
34
|
+
header.encode();
|
|
35
|
+
blocks.push(header.block!);
|
|
36
|
+
if (!entry.type || entry.type === 'File') {
|
|
37
|
+
const padded = Buffer.alloc(Math.ceil(body.length / 512) * 512);
|
|
38
|
+
body.copy(padded);
|
|
39
|
+
blocks.push(padded);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
blocks.push(Buffer.alloc(1024));
|
|
43
|
+
return Buffer.concat(blocks);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validManifest(): any {
|
|
47
|
+
return {
|
|
48
|
+
formatVersion: 1,
|
|
49
|
+
target: 'integration',
|
|
50
|
+
git: { commit: 'aaaaaaa', version: SHA },
|
|
51
|
+
places: [
|
|
52
|
+
{
|
|
53
|
+
name: '@quenty/integration',
|
|
54
|
+
file: 'place-1.rbxl',
|
|
55
|
+
project: 'project-1.json',
|
|
56
|
+
packageVersion: '1.2.3',
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('prebuilt deploy bundle', () => {
|
|
63
|
+
let workDir: string;
|
|
64
|
+
const opened: PrebuiltDeployBundle[] = [];
|
|
65
|
+
|
|
66
|
+
beforeEach(async () => {
|
|
67
|
+
workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'deploy-bundle-'));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterEach(async () => {
|
|
71
|
+
for (const bundle of opened.splice(0)) {
|
|
72
|
+
await bundle.buildContext.cleanupAsync();
|
|
73
|
+
}
|
|
74
|
+
await fs.rm(workDir, { recursive: true, force: true });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
async function readAsync(file: string) {
|
|
78
|
+
const bundle = await readPrebuiltDeployBundleAsync(file);
|
|
79
|
+
opened.push(bundle);
|
|
80
|
+
return bundle;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function writeRawAsync(entries: RawEntry[]): Promise<string> {
|
|
84
|
+
const file = path.join(workDir, 'raw.nevermore.deploy.tar');
|
|
85
|
+
await fs.writeFile(file, rawTar(entries));
|
|
86
|
+
return file;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function writeManifestAsync(
|
|
90
|
+
manifest: any,
|
|
91
|
+
projectText = PROJECT_TEXT
|
|
92
|
+
): Promise<string> {
|
|
93
|
+
return writeRawAsync([
|
|
94
|
+
{ path: 'manifest.json', body: JSON.stringify(manifest) },
|
|
95
|
+
{ path: 'place-1.rbxl', body: 'place bytes' },
|
|
96
|
+
{ path: 'project-1.json', body: projectText },
|
|
97
|
+
]);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
it('round-trips places, projects, versions and git info through one file', async () => {
|
|
101
|
+
const place = path.join(workDir, 'source.rbxl');
|
|
102
|
+
const project = path.join(workDir, 'default.project.json');
|
|
103
|
+
await fs.writeFile(place, 'place bytes');
|
|
104
|
+
await fs.writeFile(project, PROJECT_TEXT);
|
|
105
|
+
const file = path.join(workDir, 'out', 'pr-1.nevermore.deploy.tar');
|
|
106
|
+
|
|
107
|
+
await writePrebuiltDeployBundleAsync(
|
|
108
|
+
file,
|
|
109
|
+
'integration',
|
|
110
|
+
{ commit: 'abc1234', version: SHA, branch: 'feature/x' },
|
|
111
|
+
[
|
|
112
|
+
{
|
|
113
|
+
name: '@quenty/integration',
|
|
114
|
+
rbxlPath: place,
|
|
115
|
+
projectPath: project,
|
|
116
|
+
packageVersion: '1.0.0',
|
|
117
|
+
},
|
|
118
|
+
]
|
|
119
|
+
);
|
|
120
|
+
const bundle = await readAsync(file);
|
|
121
|
+
|
|
122
|
+
expect(bundle.target).toBe('integration');
|
|
123
|
+
expect(bundle.git).toEqual({
|
|
124
|
+
commit: 'abc1234',
|
|
125
|
+
version: SHA,
|
|
126
|
+
branch: 'feature/x',
|
|
127
|
+
});
|
|
128
|
+
expect(bundle.places).toHaveLength(1);
|
|
129
|
+
expect(bundle.places[0]!.name).toBe('@quenty/integration');
|
|
130
|
+
expect(bundle.places[0]!.packageVersion).toBe('1.0.0');
|
|
131
|
+
expect(await fs.readFile(bundle.places[0]!.rbxlPath, 'utf-8')).toBe(
|
|
132
|
+
'place bytes'
|
|
133
|
+
);
|
|
134
|
+
expect(await fs.readFile(bundle.places[0]!.projectPath, 'utf-8')).toBe(
|
|
135
|
+
PROJECT_TEXT
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('reads an empty bundle as no places', async () => {
|
|
140
|
+
const file = path.join(workDir, 'empty.nevermore.deploy.tar');
|
|
141
|
+
await writePrebuiltDeployBundleAsync(file, 'integration', {}, []);
|
|
142
|
+
|
|
143
|
+
const bundle = await readAsync(file);
|
|
144
|
+
|
|
145
|
+
expect(bundle.places).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('never writes outside its own directory for an entry that escapes', async () => {
|
|
149
|
+
const file = await writeRawAsync([
|
|
150
|
+
{ path: 'manifest.json', body: JSON.stringify(validManifest()) },
|
|
151
|
+
{ path: 'place-1.rbxl', body: 'place bytes' },
|
|
152
|
+
{ path: 'project-1.json', body: PROJECT_TEXT },
|
|
153
|
+
{ path: '../escaped.json', body: 'evil' },
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
await expect(readAsync(file)).rejects.toThrow('unexpected entry');
|
|
157
|
+
await expect(
|
|
158
|
+
fs.stat(path.join(os.tmpdir(), 'escaped.json'))
|
|
159
|
+
).rejects.toThrow();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('rejects a symlink entry', async () => {
|
|
163
|
+
const file = await writeRawAsync([
|
|
164
|
+
{ path: 'manifest.json', body: JSON.stringify(validManifest()) },
|
|
165
|
+
{ path: 'place-1.rbxl', body: 'place bytes' },
|
|
166
|
+
{ path: 'project-1.json', type: 'SymbolicLink', linkpath: '/etc/passwd' },
|
|
167
|
+
]);
|
|
168
|
+
|
|
169
|
+
await expect(readAsync(file)).rejects.toThrow('not a regular file');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('rejects a file the manifest does not list', async () => {
|
|
173
|
+
const file = await writeRawAsync([
|
|
174
|
+
{ path: 'manifest.json', body: JSON.stringify(validManifest()) },
|
|
175
|
+
{ path: 'place-1.rbxl', body: 'place bytes' },
|
|
176
|
+
{ path: 'project-1.json', body: PROJECT_TEXT },
|
|
177
|
+
{ path: 'place-2.rbxl', body: 'extra' },
|
|
178
|
+
]);
|
|
179
|
+
|
|
180
|
+
await expect(readAsync(file)).rejects.toThrow('every file it holds');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('rejects a place pointing at a project file as its place', async () => {
|
|
184
|
+
const manifest = validManifest();
|
|
185
|
+
manifest.places[0].file = 'project-1.json';
|
|
186
|
+
|
|
187
|
+
await expect(readAsync(await writeManifestAsync(manifest))).rejects.toThrow(
|
|
188
|
+
'invalid file'
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('rejects a project without a tree', async () => {
|
|
193
|
+
await expect(
|
|
194
|
+
readAsync(await writeManifestAsync(validManifest(), '{"name":"x"}'))
|
|
195
|
+
).rejects.toThrow('has no "tree"');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it.each([
|
|
199
|
+
['target', (m: any) => (m.target = 'integration; rm -rf /')],
|
|
200
|
+
[
|
|
201
|
+
'package name',
|
|
202
|
+
(m: any) => (m.places[0].name = '@quenty/x [a](http://x)'),
|
|
203
|
+
],
|
|
204
|
+
['git version', (m: any) => (m.git.version = 'not-a-sha')],
|
|
205
|
+
['package version', (m: any) => (m.places[0].packageVersion = '1.0 <b>')],
|
|
206
|
+
])('rejects an invalid %s', async (label, mutate) => {
|
|
207
|
+
const manifest = validManifest();
|
|
208
|
+
mutate(manifest);
|
|
209
|
+
|
|
210
|
+
await expect(readAsync(await writeManifestAsync(manifest))).rejects.toThrow(
|
|
211
|
+
'invalid'
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('drops a branch name it will not stamp instead of failing', async () => {
|
|
216
|
+
const manifest = validManifest();
|
|
217
|
+
manifest.git.branch = 'main\n<img>';
|
|
218
|
+
|
|
219
|
+
const bundle = await readAsync(await writeManifestAsync(manifest));
|
|
220
|
+
|
|
221
|
+
expect(bundle.git.branch).toBeUndefined();
|
|
222
|
+
expect(bundle.git.version).toBe(SHA);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('rejects an unknown format version', async () => {
|
|
226
|
+
const manifest = validManifest();
|
|
227
|
+
manifest.formatVersion = 2;
|
|
228
|
+
|
|
229
|
+
await expect(readAsync(await writeManifestAsync(manifest))).rejects.toThrow(
|
|
230
|
+
'Unsupported bundle format'
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('names the right command for a tests bundle', async () => {
|
|
235
|
+
const file = await writeRawAsync([
|
|
236
|
+
{
|
|
237
|
+
path: 'manifest.json',
|
|
238
|
+
body: JSON.stringify({
|
|
239
|
+
formatVersion: 1,
|
|
240
|
+
kind: 'tests',
|
|
241
|
+
chunks: [
|
|
242
|
+
{
|
|
243
|
+
file: 'place-1.rbxl',
|
|
244
|
+
packages: [{ name: '@quenty/maid', slug: 'maid' }],
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
}),
|
|
248
|
+
},
|
|
249
|
+
{ path: 'place-1.rbxl', body: 'place bytes' },
|
|
250
|
+
]);
|
|
251
|
+
|
|
252
|
+
await expect(readAsync(file)).rejects.toThrow(
|
|
253
|
+
'This is a tests bundle, not a deploy bundle. Run it with "nevermore batch test --bundle <file>".'
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('rejects a bundle of an unknown kind', async () => {
|
|
258
|
+
const manifest = validManifest();
|
|
259
|
+
manifest.kind = 'mystery';
|
|
260
|
+
|
|
261
|
+
await expect(readAsync(await writeManifestAsync(manifest))).rejects.toThrow(
|
|
262
|
+
'unknown kind'
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as tar from 'tar';
|
|
4
|
+
import { BuildContext } from '@quenty/nevermore-template-helpers';
|
|
5
|
+
import { PLACE_UPLOAD_MAX_BYTES } from '../open-cloud/open-cloud-client.js';
|
|
6
|
+
import {
|
|
7
|
+
PACKAGE_NAME_PATTERN,
|
|
8
|
+
readUntrustedTarAsync,
|
|
9
|
+
} from '../bundle/untrusted-tar.js';
|
|
10
|
+
import { type GitDeployInfo } from './deploy-metadata.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Places built by `nevermore batch bundle --deploy`, unpacked from a
|
|
14
|
+
* `*.nevermore.deploy.tar` and ready for `batch deploy --bundle` to deploy.
|
|
15
|
+
*
|
|
16
|
+
* Each place is the plain rojo build. Its base place is merged at deploy time,
|
|
17
|
+
* because downloading one needs the Open Cloud key the building run lacks.
|
|
18
|
+
*/
|
|
19
|
+
export interface PrebuiltDeployBundle {
|
|
20
|
+
/** Deploy target name the places were built for, e.g. "integration". */
|
|
21
|
+
target: string;
|
|
22
|
+
/** The commit the places were built from, not the deploying checkout's. */
|
|
23
|
+
git: GitDeployInfo;
|
|
24
|
+
places: PrebuiltDeployPlace[];
|
|
25
|
+
/** Holds the unpacked files; whoever deploys them owns its cleanup. */
|
|
26
|
+
buildContext: BuildContext;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PrebuiltDeployPlace {
|
|
30
|
+
/** Batch target display name, e.g. "@quenty/game" or "@quenty/game - hub". */
|
|
31
|
+
name: string;
|
|
32
|
+
rbxlPath: string;
|
|
33
|
+
/** The building checkout's rojo project, which says what to graft onto the base place. */
|
|
34
|
+
projectPath: string;
|
|
35
|
+
packageVersion?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ManifestFile {
|
|
39
|
+
formatVersion: number;
|
|
40
|
+
kind?: 'deploy';
|
|
41
|
+
target: string;
|
|
42
|
+
git: GitDeployInfo;
|
|
43
|
+
places: Array<{
|
|
44
|
+
name: string;
|
|
45
|
+
file: string;
|
|
46
|
+
project: string;
|
|
47
|
+
packageVersion?: string;
|
|
48
|
+
}>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Suggested file suffix, following `deploy.nevermore.json`. */
|
|
52
|
+
export const DEPLOY_BUNDLE_FILE_SUFFIX = '.nevermore.deploy.tar';
|
|
53
|
+
|
|
54
|
+
const MANIFEST_FILE_NAME = 'manifest.json';
|
|
55
|
+
const FORMAT_VERSION = 1;
|
|
56
|
+
const MAX_PLACES = 64;
|
|
57
|
+
const MAX_MANIFEST_BYTES = 1024 * 1024;
|
|
58
|
+
const MAX_PROJECT_BYTES = 1024 * 1024;
|
|
59
|
+
const PLACE_FILE_PATTERN = /^place-\d{1,3}\.rbxl$/;
|
|
60
|
+
const PROJECT_FILE_PATTERN = /^project-\d{1,3}\.json$/;
|
|
61
|
+
const TARGET_NAME_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
62
|
+
const SHORT_SHA_PATTERN = /^[0-9a-f]{4,40}$/;
|
|
63
|
+
const SHA_PATTERN = /^[0-9a-f]{40}$/;
|
|
64
|
+
const BRANCH_PATTERN = /^[A-Za-z0-9._/-]{1,200}$/;
|
|
65
|
+
const PACKAGE_VERSION_PATTERN = /^[0-9A-Za-z.+-]{1,64}$/;
|
|
66
|
+
|
|
67
|
+
export async function writePrebuiltDeployBundleAsync(
|
|
68
|
+
file: string,
|
|
69
|
+
target: string,
|
|
70
|
+
git: GitDeployInfo,
|
|
71
|
+
places: PrebuiltDeployPlace[]
|
|
72
|
+
): Promise<void> {
|
|
73
|
+
const stage = await BuildContext.createAsync({ prefix: 'deploy-bundle-' });
|
|
74
|
+
const stageDir = stage.buildDir;
|
|
75
|
+
try {
|
|
76
|
+
const manifest: ManifestFile = {
|
|
77
|
+
formatVersion: FORMAT_VERSION,
|
|
78
|
+
kind: 'deploy',
|
|
79
|
+
target,
|
|
80
|
+
git,
|
|
81
|
+
places: [],
|
|
82
|
+
};
|
|
83
|
+
for (const [index, place] of places.entries()) {
|
|
84
|
+
const placeFile = `place-${index + 1}.rbxl`;
|
|
85
|
+
const projectFile = `project-${index + 1}.json`;
|
|
86
|
+
await fs.copyFile(place.rbxlPath, path.join(stageDir, placeFile));
|
|
87
|
+
await fs.copyFile(place.projectPath, path.join(stageDir, projectFile));
|
|
88
|
+
manifest.places.push({
|
|
89
|
+
name: place.name,
|
|
90
|
+
file: placeFile,
|
|
91
|
+
project: projectFile,
|
|
92
|
+
packageVersion: place.packageVersion,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
await fs.writeFile(
|
|
96
|
+
path.join(stageDir, MANIFEST_FILE_NAME),
|
|
97
|
+
JSON.stringify(manifest, null, 2)
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
await fs.mkdir(path.dirname(path.resolve(file)), { recursive: true });
|
|
101
|
+
await tar.c({ file, cwd: stageDir, portable: true }, [
|
|
102
|
+
MANIFEST_FILE_NAME,
|
|
103
|
+
...manifest.places.flatMap((p) => [p.file, p.project]),
|
|
104
|
+
]);
|
|
105
|
+
} finally {
|
|
106
|
+
await stage.cleanupAsync();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Read a deploy bundle file. It is untrusted: it comes from a fork's CI run.
|
|
112
|
+
* Entries are read rather than extracted, every manifest string is checked
|
|
113
|
+
* before it can reach a place attribute or the PR comment, and the bundle
|
|
114
|
+
* names places only by target name — where each one deploys, and which base
|
|
115
|
+
* place it merges onto, come from the deploying checkout's config.
|
|
116
|
+
*/
|
|
117
|
+
export async function readPrebuiltDeployBundleAsync(
|
|
118
|
+
file: string
|
|
119
|
+
): Promise<PrebuiltDeployBundle> {
|
|
120
|
+
let stat;
|
|
121
|
+
try {
|
|
122
|
+
stat = await fs.lstat(file);
|
|
123
|
+
} catch {
|
|
124
|
+
throw new Error(`Bundle file not found: ${file}`);
|
|
125
|
+
}
|
|
126
|
+
if (!stat.isFile()) {
|
|
127
|
+
throw new Error(`Bundle ${file} is not a regular file`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const buildContext = await BuildContext.createAsync({
|
|
131
|
+
prefix: 'deploy-bundle-',
|
|
132
|
+
});
|
|
133
|
+
const dir = buildContext.buildDir;
|
|
134
|
+
try {
|
|
135
|
+
const { manifestText, entrySizes } = await readUntrustedTarAsync(
|
|
136
|
+
file,
|
|
137
|
+
dir,
|
|
138
|
+
{
|
|
139
|
+
kind: 'deploy',
|
|
140
|
+
manifestName: MANIFEST_FILE_NAME,
|
|
141
|
+
maxManifestBytes: MAX_MANIFEST_BYTES,
|
|
142
|
+
isEntryAllowed: (name) =>
|
|
143
|
+
PLACE_FILE_PATTERN.test(name) || PROJECT_FILE_PATTERN.test(name),
|
|
144
|
+
maxEntries: MAX_PLACES * 2,
|
|
145
|
+
maxEntriesMessage: `Bundle must hold at most ${MAX_PLACES} places`,
|
|
146
|
+
checkEntrySize: (name, size) => {
|
|
147
|
+
if (PLACE_FILE_PATTERN.test(name) && size > PLACE_UPLOAD_MAX_BYTES) {
|
|
148
|
+
return `${name} is over the place upload limit`;
|
|
149
|
+
}
|
|
150
|
+
if (PROJECT_FILE_PATTERN.test(name) && size > MAX_PROJECT_BYTES) {
|
|
151
|
+
return `${name} is over the project file size limit`;
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
);
|
|
157
|
+
const parsed = _parseManifest(manifestText, dir, entrySizes);
|
|
158
|
+
for (const place of parsed.places) {
|
|
159
|
+
await _validateProjectFileAsync(place.projectPath);
|
|
160
|
+
}
|
|
161
|
+
return { ...parsed, buildContext };
|
|
162
|
+
} catch (err) {
|
|
163
|
+
await buildContext.cleanupAsync();
|
|
164
|
+
throw err;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function _parseManifest(
|
|
169
|
+
manifestText: string,
|
|
170
|
+
dir: string,
|
|
171
|
+
entrySizes: Map<string, number>
|
|
172
|
+
): Omit<PrebuiltDeployBundle, 'buildContext'> {
|
|
173
|
+
let parsed: unknown;
|
|
174
|
+
try {
|
|
175
|
+
parsed = JSON.parse(manifestText);
|
|
176
|
+
} catch {
|
|
177
|
+
throw new Error('Bundle manifest is not valid JSON');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const manifest = parsed as Partial<ManifestFile>;
|
|
181
|
+
if (manifest?.formatVersion !== FORMAT_VERSION) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`Unsupported bundle format ${String(manifest?.formatVersion)} ` +
|
|
184
|
+
`(expected ${FORMAT_VERSION})`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
if (
|
|
188
|
+
typeof manifest.target !== 'string' ||
|
|
189
|
+
!TARGET_NAME_PATTERN.test(manifest.target)
|
|
190
|
+
) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`Bundle lists an invalid target: ${String(manifest.target)}`
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
if (!Array.isArray(manifest.places) || manifest.places.length > MAX_PLACES) {
|
|
196
|
+
throw new Error(`Bundle must list at most ${MAX_PLACES} places`);
|
|
197
|
+
}
|
|
198
|
+
if (manifest.places.length * 2 !== entrySizes.size) {
|
|
199
|
+
throw new Error('Bundle manifest does not list every file it holds');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const git = _parseGit(manifest.git);
|
|
203
|
+
const seenNames = new Set<string>();
|
|
204
|
+
const seenFiles = new Set<string>();
|
|
205
|
+
const places: PrebuiltDeployPlace[] = [];
|
|
206
|
+
|
|
207
|
+
for (const entry of manifest.places) {
|
|
208
|
+
const { name, file, project, packageVersion } = entry ?? {};
|
|
209
|
+
if (typeof name !== 'string' || !PACKAGE_NAME_PATTERN.test(name)) {
|
|
210
|
+
throw new Error(`Bundle lists an invalid package name: ${String(name)}`);
|
|
211
|
+
}
|
|
212
|
+
if (seenNames.has(name)) {
|
|
213
|
+
throw new Error(`Bundle lists ${name} more than once`);
|
|
214
|
+
}
|
|
215
|
+
seenNames.add(name);
|
|
216
|
+
|
|
217
|
+
for (const [listed, pattern] of [
|
|
218
|
+
[file, PLACE_FILE_PATTERN],
|
|
219
|
+
[project, PROJECT_FILE_PATTERN],
|
|
220
|
+
] as const) {
|
|
221
|
+
if (
|
|
222
|
+
typeof listed !== 'string' ||
|
|
223
|
+
!pattern.test(listed) ||
|
|
224
|
+
!entrySizes.has(listed) ||
|
|
225
|
+
seenFiles.has(listed)
|
|
226
|
+
) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
`Bundle lists an invalid file for ${name}: ${String(listed)}`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
seenFiles.add(listed);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (
|
|
235
|
+
packageVersion !== undefined &&
|
|
236
|
+
(typeof packageVersion !== 'string' ||
|
|
237
|
+
!PACKAGE_VERSION_PATTERN.test(packageVersion))
|
|
238
|
+
) {
|
|
239
|
+
throw new Error(`Bundle lists an invalid package version for ${name}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
places.push({
|
|
243
|
+
name,
|
|
244
|
+
rbxlPath: path.join(dir, file!),
|
|
245
|
+
projectPath: path.join(dir, project!),
|
|
246
|
+
packageVersion,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return { target: manifest.target, git, places };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function _parseGit(git: unknown): GitDeployInfo {
|
|
254
|
+
if (git === undefined) {
|
|
255
|
+
return {};
|
|
256
|
+
}
|
|
257
|
+
if (typeof git !== 'object' || git === null) {
|
|
258
|
+
throw new Error('Bundle lists invalid git info');
|
|
259
|
+
}
|
|
260
|
+
const { commit, version, branch } = git as Record<string, unknown>;
|
|
261
|
+
const result: GitDeployInfo = {};
|
|
262
|
+
for (const [key, value, pattern] of [
|
|
263
|
+
['commit', commit, SHORT_SHA_PATTERN],
|
|
264
|
+
['version', version, SHA_PATTERN],
|
|
265
|
+
] as const) {
|
|
266
|
+
if (value === undefined) {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (typeof value !== 'string' || !pattern.test(value)) {
|
|
270
|
+
throw new Error(`Bundle lists an invalid git ${key}`);
|
|
271
|
+
}
|
|
272
|
+
result[key] = value;
|
|
273
|
+
}
|
|
274
|
+
// Git allows branch names this pattern does not, and a branch is only a
|
|
275
|
+
// label, so one that fails it is dropped rather than failing the deploy.
|
|
276
|
+
if (typeof branch === 'string' && BRANCH_PATTERN.test(branch)) {
|
|
277
|
+
result.branch = branch;
|
|
278
|
+
}
|
|
279
|
+
return result;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function _validateProjectFileAsync(projectPath: string): Promise<void> {
|
|
283
|
+
let project: unknown;
|
|
284
|
+
try {
|
|
285
|
+
project = JSON.parse(await fs.readFile(projectPath, 'utf-8'));
|
|
286
|
+
} catch {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`Bundle project ${path.basename(projectPath)} is not valid JSON`
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
const tree = (project as { tree?: unknown })?.tree;
|
|
292
|
+
if (typeof tree !== 'object' || tree === null) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
`Bundle project ${path.basename(projectPath)} has no "tree"`
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
@@ -111,7 +111,7 @@ export abstract class BaseJobContext implements JobContext {
|
|
|
111
111
|
);
|
|
112
112
|
this._builtPlaces.add(tracked);
|
|
113
113
|
|
|
114
|
-
if (target.basePlace) {
|
|
114
|
+
if (target.basePlace && !options.deferBasePlaceMerge) {
|
|
115
115
|
await this._mergeBasePlaceAsync(tracked, options);
|
|
116
116
|
}
|
|
117
117
|
|
|
@@ -248,7 +248,9 @@ export abstract class BaseJobContext implements JobContext {
|
|
|
248
248
|
|
|
249
249
|
this._reporter.onPackagePhaseChange(resolvedName, 'merging');
|
|
250
250
|
const packagePath = options.packagePath;
|
|
251
|
-
const projectPath =
|
|
251
|
+
const projectPath =
|
|
252
|
+
options.overrides?.projectFile ??
|
|
253
|
+
path.resolve(packagePath, tracked.target.project);
|
|
252
254
|
const mergedPath = mergeContext.resolvePath('merged.rbxl');
|
|
253
255
|
|
|
254
256
|
await mergeContext.executeLuneTransformScriptAsync(
|