Skip to content

Commit d0004dd

Browse files
committed
feat: add an attestations.json substitutable template
1 parent a8b3bf3 commit d0004dd

16 files changed

+1024
-344
lines changed

src/application/cli/create-entry-command.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import path from 'path';
44
import treeNodeCli from 'tree-node-cli';
55
import { ArgumentsCamelCase } from 'yargs';
66

7+
import { AttestationsTemplate } from '../../domain/attestations-template.js';
78
import {
89
CreateEntryService,
910
VersionAlreadyPublishedError,
@@ -19,10 +20,10 @@ import {
1920
import { Repository } from '../../domain/repository.js';
2021
import {
2122
SourceTemplate,
22-
SubstitutableVar,
2323
UnsubstitutedVarsError,
2424
} from '../../domain/source-template.js';
2525
import { SourceTemplateError } from '../../domain/source-template.js';
26+
import { SubstitutableVar } from '../../domain/substitution.js';
2627
import { CreateEntryArgs } from './yargs.js';
2728

2829
export interface CreateEntryCommandOutput {
@@ -47,6 +48,9 @@ export class CreateEntryCommand {
4748
path.join(args.templatesDir, 'metadata.template.json')
4849
);
4950
const sourceTemplate = new SourceTemplate(sourceTemplatePath);
51+
const attestationsTemplate = AttestationsTemplate.tryLoad(
52+
path.join(args.templatesDir, 'attestations.template.json')
53+
);
5054
const presubmitPath = path.join(args.templatesDir, 'presubmit.yml');
5155
const patchesPath = path.join(args.templatesDir, 'patches');
5256

@@ -65,7 +69,8 @@ export class CreateEntryCommand {
6569
presubmitPath,
6670
patchesPath,
6771
args.localRegistry,
68-
args.moduleVersion
72+
args.moduleVersion,
73+
attestationsTemplate
6974
);
7075

7176
console.error(

src/application/release-event-handler.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ export class ReleaseEventHandler {
8686
rulesetRepo.presubmitPath(moduleRoot),
8787
rulesetRepo.patchesPath(moduleRoot),
8888
bcr.diskPath,
89-
version
89+
version,
90+
null // TODO
9091
);
9192
moduleNames.push(moduleName);
9293
}

src/domain/BUILD.bazel

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ load("//bazel/ts:defs.bzl", "ts_project")
44
ts_project(
55
name = "domain",
66
srcs = [
7+
"artifact.ts",
8+
"attestations-template.ts",
79
"configuration.ts",
810
"create-entry.ts",
911
"error.ts",
@@ -16,6 +18,7 @@ ts_project(
1618
"repository.ts",
1719
"ruleset-repository.ts",
1820
"source-template.ts",
21+
"substitution.ts",
1922
"user.ts",
2023
"version.ts",
2124
],
@@ -41,6 +44,8 @@ ts_project(
4144
name = "domain_tests",
4245
testonly = True,
4346
srcs = [
47+
"artifact.spec.ts",
48+
"attestations-template.spec.ts",
4449
"configuration.spec.ts",
4550
"create-entry.spec.ts",
4651
"find-registry-fork.spec.ts",
@@ -52,6 +57,7 @@ ts_project(
5257
"repository.spec.ts",
5358
"ruleset-repository.spec.ts",
5459
"source-template.spec.ts",
60+
"substitution.spec.ts",
5561
"version.spec.ts",
5662
],
5763
deps = [

src/domain/artifact.spec.ts

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { randomUUID } from 'node:crypto';
2+
import fs, { WriteStream } from 'node:fs';
3+
import os from 'node:os';
4+
import path from 'node:path';
5+
6+
import axios from 'axios';
7+
import axiosRetry from 'axios-retry';
8+
import { mocked } from 'jest-mock';
9+
10+
import { expectThrownError } from '../test/util';
11+
import { Artifact, ArtifactDownloadError } from './artifact';
12+
import { computeIntegrityHash } from './integrity-hash';
13+
14+
jest.mock('node:fs');
15+
jest.mock('node:os');
16+
jest.mock('axios');
17+
jest.mock('axios-retry');
18+
jest.mock('./integrity-hash');
19+
20+
const ARTIFACT_URL = 'https://foo.bar/artifact.baz';
21+
const TEMP_DIR = '/tmp';
22+
const TEMP_FOLDER = 'artifact-1234';
23+
24+
beforeEach(() => {
25+
mocked(axios.get).mockReturnValue(
26+
Promise.resolve({
27+
data: {
28+
pipe: jest.fn(),
29+
},
30+
status: 200,
31+
})
32+
);
33+
34+
mocked(fs.createWriteStream).mockReturnValue({
35+
on: jest.fn((event: string, func: (...args: any[]) => unknown) => {
36+
if (event === 'finish') {
37+
func();
38+
}
39+
}),
40+
} as any);
41+
42+
mocked(os.tmpdir).mockReturnValue(TEMP_DIR);
43+
mocked(fs.mkdtempSync).mockReturnValue(path.join(TEMP_DIR, TEMP_FOLDER));
44+
mocked(computeIntegrityHash).mockReturnValue(`sha256-${randomUUID()}`);
45+
});
46+
47+
describe('Artifact', () => {
48+
describe('download', () => {
49+
test('downloads the artifact', async () => {
50+
const artifact = new Artifact(ARTIFACT_URL);
51+
await artifact.download();
52+
53+
expect(axios.get).toHaveBeenCalledWith(ARTIFACT_URL, {
54+
responseType: 'stream',
55+
});
56+
});
57+
58+
test('retries the request if it fails', async () => {
59+
const artifact = new Artifact(ARTIFACT_URL);
60+
61+
// Restore the original behavior of exponentialDelay.
62+
mocked(axiosRetry.exponentialDelay).mockImplementation(
63+
jest.requireActual('axios-retry').exponentialDelay
64+
);
65+
66+
await artifact.download();
67+
68+
expect(axiosRetry).toHaveBeenCalledWith(axios, {
69+
retries: 3,
70+
71+
retryCondition: expect.matchesPredicate(
72+
(retryConditionFn: Function) => {
73+
// Make sure HTTP 404 errors are retried.
74+
const notFoundError = { response: { status: 404 } };
75+
return retryConditionFn.call(this, notFoundError);
76+
}
77+
),
78+
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
79+
retryDelay: expect.matchesPredicate((retryDelayFn: Function) => {
80+
// Make sure the retry delays follow exponential backoff
81+
// and the final retry happens after at least 1 minute total
82+
// (in this case, at least 70 seconds).
83+
// Axios randomly adds an extra 0-20% of jitter to each delay.
84+
// Test upper bounds as well to ensure the workflow completes reasonably quickly
85+
// (in this case, no more than 84 seconds total).
86+
const firstRetryDelay = retryDelayFn.call(this, 0);
87+
const secondRetryDelay = retryDelayFn.call(this, 1);
88+
const thirdRetryDelay = retryDelayFn.call(this, 2);
89+
return (
90+
10000 <= firstRetryDelay &&
91+
firstRetryDelay <= 12000 &&
92+
20000 <= secondRetryDelay &&
93+
secondRetryDelay <= 24000 &&
94+
40000 <= thirdRetryDelay &&
95+
thirdRetryDelay <= 48000
96+
);
97+
}),
98+
shouldResetTimeout: true,
99+
});
100+
});
101+
102+
test('saves the artifact to disk', async () => {
103+
const artifact = new Artifact(ARTIFACT_URL);
104+
105+
await artifact.download();
106+
107+
const expectedPath = path.join(TEMP_DIR, TEMP_FOLDER, 'artifact.baz');
108+
expect(fs.createWriteStream).toHaveBeenCalledWith(expectedPath, {
109+
flags: 'w',
110+
});
111+
112+
const mockedAxiosResponse = await (mocked(axios.get).mock.results[0]
113+
.value as Promise<{ data: { pipe: Function } }>); // eslint-disable-line @typescript-eslint/no-unsafe-function-type
114+
const mockedWriteStream = mocked(fs.createWriteStream).mock.results[0]
115+
.value as WriteStream;
116+
117+
expect(mockedAxiosResponse.data.pipe).toHaveBeenCalledWith(
118+
mockedWriteStream
119+
);
120+
});
121+
122+
test('sets the diskPath', async () => {
123+
const artifact = new Artifact(ARTIFACT_URL);
124+
125+
await artifact.download();
126+
127+
const expectedPath = path.join(TEMP_DIR, TEMP_FOLDER, 'artifact.baz');
128+
expect(artifact.diskPath).toEqual(expectedPath);
129+
});
130+
131+
test('throws on a non 200 status', async () => {
132+
const artifact = new Artifact(ARTIFACT_URL);
133+
134+
mocked(axios.get).mockRejectedValue({
135+
response: {
136+
status: 401,
137+
},
138+
});
139+
140+
const thrownError = await expectThrownError(
141+
() => artifact.download(),
142+
ArtifactDownloadError
143+
);
144+
145+
expect(thrownError.message.includes(ARTIFACT_URL)).toEqual(true);
146+
expect(thrownError.message.includes('401')).toEqual(true);
147+
});
148+
});
149+
150+
describe('computeIntegrityHash', () => {
151+
test('throws when artifact has not yet been downloaded', () => {
152+
const artifact = new Artifact(ARTIFACT_URL);
153+
154+
expect(() => artifact.computeIntegrityHash()).toThrowWithMessage(
155+
Error,
156+
`The artifact ${ARTIFACT_URL} must be downloaded before an integrity hash can be calculated`
157+
);
158+
});
159+
160+
test('computes the integrity of the file', async () => {
161+
const artifact = new Artifact(ARTIFACT_URL);
162+
await artifact.download();
163+
164+
const expected = `sha256-${randomUUID()}`;
165+
mocked(computeIntegrityHash).mockReturnValue(expected);
166+
167+
const actual = await artifact.computeIntegrityHash();
168+
169+
expect(expected).toEqual(actual);
170+
expect(computeIntegrityHash).toHaveBeenCalledWith(artifact.diskPath);
171+
});
172+
});
173+
174+
describe('cleanup', () => {
175+
test('removed the stored file', async () => {
176+
const artifact = new Artifact(ARTIFACT_URL);
177+
await artifact.download();
178+
const diskPath = artifact.diskPath;
179+
artifact.cleanup();
180+
181+
expect(fs.rmSync).toHaveBeenCalledWith(diskPath, { force: true });
182+
});
183+
184+
test('removes the diskPath', async () => {
185+
const artifact = new Artifact(ARTIFACT_URL);
186+
await artifact.download();
187+
artifact.cleanup();
188+
189+
expect(() => artifact.diskPath).toThrowWithMessage(
190+
Error,
191+
`The artifact ${ARTIFACT_URL} has not been downloaded yet`
192+
);
193+
});
194+
});
195+
});

0 commit comments

Comments
 (0)