Skip to content

Commit 3a6e606

Browse files
committed
feat: Refactor release metadata structure to support nested artifacts by type and architecture, and implement external minisig file handling
1 parent cf994b2 commit 3a6e606

6 files changed

Lines changed: 197 additions & 61 deletions

File tree

README.md

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -123,38 +123,39 @@ async function createRelease() {
123123

124124
## Manifest Format
125125

126-
The manifest is output as JSON with embedded minisign signatures:
126+
The manifest is output as JSON with a nested artifacts structure, organized by artifact type (zip/dmg) and architecture:
127127

128128
```json
129129
{
130130
"version": "1.2.3",
131131
"schemaVersion": 1,
132132
"releaseDate": "2024-03-20T10:00:00.000Z",
133133
"expires": "2099-12-31T23:59:59Z",
134-
"files": [
135-
{
136-
"path": "MyApp-1.2.3-arm64.zip",
137-
"sha512": "abcdef1234567890...",
138-
"size": 123456789,
139-
"arch": "arm64",
140-
"minisig": "untrusted comment: signature from minisign secret key\n..."
141-
},
142-
{
143-
"path": "MyApp-1.2.3-x64.zip",
144-
"sha512": "0987654321fedcba...",
145-
"size": 123456789,
146-
"arch": "x64",
147-
"minisig": "untrusted comment: signature from minisign secret key\n..."
134+
"artifacts": {
135+
"zip": {
136+
"arm64": {
137+
"path": "MyApp-1.2.3-arm64.zip",
138+
"sha512": "abcdef1234567890...",
139+
"size": 123456789
140+
},
141+
"x64": {
142+
"path": "MyApp-1.2.3-x64.zip",
143+
"sha512": "0987654321fedcba...",
144+
"size": 123456789
145+
}
148146
}
149-
],
147+
},
150148
"releaseNotes": "What's new in this release:\n- Feature A\n- Bug fix B"
151149
}
152150
```
153151

152+
Signatures are stored as external `.minisig` files alongside each artifact (e.g., `MyApp-1.2.3-arm64.zip.minisig`).
153+
154154
**Important:**
155155

156156
- **Version** is automatically extracted from the filename (e.g., `MyApp-1.2.3-arm64.zip``1.2.3`). This includes prerelease tags like `1.2.3-beta.1`. Use `--app-version` only if you need to override the detected version.
157157
- **Architecture** must be detectable from the filename. Include one of: `arm64`, `aarch64`, `x64`, `x86_64`, `amd64`, `x86`, `ia32`, `i386`, or `universal`.
158+
- **Artifact type** is determined by the file extension. Supported types: `.zip`, `.dmg`.
158159

159160
## Options
160161

src/file-utils.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,18 @@ export function extractArchFromFilename(filename: string): string | undefined {
6060
return undefined;
6161
}
6262

63+
/**
64+
* Extract artifact type from filename extension
65+
*/
66+
export function extractArtifactTypeFromFilename(
67+
filename: string,
68+
): "zip" | "dmg" | undefined {
69+
const lowerFilename = filename.toLowerCase();
70+
if (lowerFilename.endsWith(".zip")) return "zip";
71+
if (lowerFilename.endsWith(".dmg")) return "dmg";
72+
return undefined;
73+
}
74+
6375
/**
6476
* Read release notes from a file if path is provided
6577
*/

src/integration.test.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ function createTestServer(
4040
const urlPath = req.url ?? "/";
4141
const filePath = urlPath.startsWith("/") ? urlPath.slice(1) : urlPath;
4242

43+
// Debug: log all requests
44+
process.stderr.write(
45+
`[Server] Request: ${urlPath} -> ${filePath in files ? "200" : "404"}\n`,
46+
);
47+
4348
if (filePath in files) {
4449
const content = files[filePath];
4550
res.writeHead(200, {
@@ -245,20 +250,31 @@ describe("Integration Tests", () => {
245250
"utf-8",
246251
);
247252

248-
// Verify the manifest is valid JSON
253+
// Read the zip's signature (external minisig file)
254+
const zipSigContent = await fs.readFile(`${zipPath}.minisig`, "utf-8");
255+
256+
// Verify the manifest is valid JSON with nested artifacts structure
249257
const manifest = JSON.parse(manifestContent) as ReleaseManifest;
250258
expect(manifest.version).toBe("1.0.0");
251-
expect(manifest.files).toHaveLength(1);
252-
expect(manifest.files[0].arch).toBe(arch);
253-
expect(manifest.files[0].minisig).toBeDefined();
259+
expect(manifest.artifacts).toBeDefined();
260+
expect(manifest.artifacts.zip).toBeDefined();
261+
const archKey = arch as "arm64" | "x64";
262+
expect(manifest.artifacts.zip?.[archKey]).toBeDefined();
263+
expect(manifest.artifacts.zip?.[archKey]?.sha512).toBeDefined();
254264

255265
// Step 3: Start the test server
256266
const serverFiles: ServerFiles = {
257267
"manifest.json": manifestContent,
258268
"manifest.json.minisig": manifestSigContent,
259269
[zipFilename]: zipContent,
270+
[`${zipFilename}.minisig`]: zipSigContent,
260271
};
261272

273+
// Debug: log server files
274+
process.stderr.write(
275+
`[Server] Available files: ${Object.keys(serverFiles).join(", ")}\n`,
276+
);
277+
262278
server = await createTestServer(serverFiles, PORT);
263279

264280
// Step 4: Modify the installer's Info.plist
@@ -281,8 +297,10 @@ describe("Integration Tests", () => {
281297

282298
// Step 6: Verify the installation
283299
if (exitCode !== 0) {
284-
console.error("Installer stdout:", stdout);
285-
console.error("Installer stderr:", stderr);
300+
// Use process.stderr to bypass mocked console
301+
process.stderr.write(`Installer stdout: ${stdout}\n`);
302+
process.stderr.write(`Installer stderr: ${stderr}\n`);
303+
process.stderr.write(`Manifest content: ${manifestContent}\n`);
286304
}
287305
expect(exitCode).toBe(0);
288306

@@ -358,15 +376,19 @@ describe("Integration Tests", () => {
358376
const manifestPath = path.join(tempDir, "manifest.json");
359377
const manifestContent = await fs.readFile(manifestPath, "utf-8");
360378

379+
// Read the valid zip signature
380+
const zipSigContent = await fs.readFile(`${zipPath}.minisig`, "utf-8");
381+
361382
// Corrupt the manifest signature
362383
const corruptedSig =
363384
"untrusted comment: corrupted signature\nRUQNnHQoKEDznXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX=\ntrusted comment: corrupted\nXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX==";
364385

365-
// Start the test server with corrupted signature
386+
// Start the test server with corrupted manifest signature but valid zip signature
366387
const serverFiles: ServerFiles = {
367388
"manifest.json": manifestContent,
368389
"manifest.json.minisig": corruptedSig,
369390
[zipFilename]: zipContent,
391+
[`${zipFilename}.minisig`]: zipSigContent,
370392
};
371393

372394
server = await createTestServer(serverFiles, PORT);

src/manifest.test.ts

Lines changed: 80 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,10 @@ describe("createReleaseMetadata", () => {
4848
size: 12345,
4949
});
5050
vi.mocked(fileUtils.extractArchFromFilename).mockReturnValue("x64");
51+
vi.mocked(fileUtils.extractArtifactTypeFromFilename).mockReturnValue("zip");
5152
vi.mocked(fileUtils.readReleaseNotes).mockResolvedValue(
5253
"Mocked release notes",
5354
);
54-
vi.mocked(fileUtils.readMinisigFile).mockResolvedValue(
55-
"untrusted comment: signature from minisign secret key\nmocked-signature-content",
56-
);
5755
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
5856
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
5957

@@ -89,7 +87,7 @@ describe("createReleaseMetadata", () => {
8987
expect(result).toBe(path.join(".", "manifest-latest-mac.json"));
9088
});
9189

92-
it("should output valid JSON", async () => {
90+
it("should output valid JSON with nested artifacts structure", async () => {
9391
const writeFileMock = vi.mocked(fs.writeFile);
9492

9593
await createReleaseMetadata({
@@ -101,31 +99,33 @@ describe("createReleaseMetadata", () => {
10199
// Get the written content
102100
const writtenContent = writeFileMock.mock.calls[0][1] as string;
103101

104-
// Verify it's valid JSON
102+
// Verify it's valid JSON with nested structure
105103
const parsed = JSON.parse(writtenContent) as ReleaseManifest;
106104
expect(parsed.version).toBe("1.0.0");
107105
expect(parsed.schemaVersion).toBe(1);
108-
expect(parsed.files).toHaveLength(1);
109-
expect(parsed.files[0].path).toBe("app-x64-1.0.0.zip");
110-
expect(parsed.files[0].arch).toBe("x64");
111-
expect(parsed.files[0].minisig).toContain("mocked-signature-content");
106+
expect(parsed.artifacts).toBeDefined();
107+
expect(parsed.artifacts.zip).toBeDefined();
108+
expect(parsed.artifacts.zip?.x64).toBeDefined();
109+
expect(parsed.artifacts.zip?.x64?.path).toBe("app-x64-1.0.0.zip");
110+
expect(parsed.artifacts.zip?.x64?.sha512).toBe("mocked-hash");
111+
expect(parsed.artifacts.zip?.x64?.size).toBe(12345);
112112
});
113113

114-
it("should embed minisig content in file entries", async () => {
115-
const writeFileMock = vi.mocked(fs.writeFile);
114+
it("should create external minisig files for distributables", async () => {
115+
const childProcess = await import("node:child_process");
116+
const spawnMock = vi.mocked(childProcess.spawn);
116117

117118
await createReleaseMetadata({
118119
distributables: ["app-x64-1.0.0.zip"],
119120
secretKeyPath: "secret.key",
120121
appVersion: "1.0.0",
121122
});
122123

123-
const writtenContent = writeFileMock.mock.calls[0][1] as string;
124-
const parsed = JSON.parse(writtenContent) as ReleaseManifest;
125-
126-
// Verify minisig is embedded
127-
expect(parsed.files[0].minisig).toBe(
128-
"untrusted comment: signature from minisign secret key\nmocked-signature-content",
124+
// Verify minisign was called to sign the distributable (creates external .minisig file)
125+
expect(spawnMock).toHaveBeenCalledWith(
126+
"minisign",
127+
expect.arrayContaining(["-S", "-m", "app-x64-1.0.0.zip"]),
128+
expect.any(Object),
129129
);
130130
});
131131

@@ -160,6 +160,21 @@ describe("createReleaseMetadata", () => {
160160
).rejects.toThrow("Could not detect architecture from filename");
161161
});
162162

163+
it("should throw an error when artifact type cannot be detected", async () => {
164+
// Mock extractArtifactTypeFromFilename to return undefined
165+
vi.mocked(fileUtils.extractArtifactTypeFromFilename).mockReturnValue(
166+
undefined,
167+
);
168+
169+
await expect(
170+
createReleaseMetadata({
171+
distributables: ["app-x64-1.0.0.tar.gz"],
172+
secretKeyPath: "secret.key",
173+
appVersion: "1.0.0",
174+
}),
175+
).rejects.toThrow("Could not detect artifact type from filename");
176+
});
177+
163178
it("should include expires field when provided", async () => {
164179
const writeFileMock = vi.mocked(fs.writeFile);
165180
const expiresDate = "2099-12-31T23:59:59Z";
@@ -320,4 +335,52 @@ describe("createReleaseMetadata", () => {
320335
// Second spawn call should be signing the manifest
321336
expect(spawnMock).toHaveBeenCalledTimes(2);
322337
});
338+
339+
it("should support multiple architectures in the same artifact type", async () => {
340+
const writeFileMock = vi.mocked(fs.writeFile);
341+
342+
// Mock different architectures for different files
343+
vi.mocked(fileUtils.extractArchFromFilename)
344+
.mockReturnValueOnce("arm64")
345+
.mockReturnValueOnce("x64");
346+
347+
await createReleaseMetadata({
348+
distributables: ["app-arm64-1.0.0.zip", "app-x64-1.0.0.zip"],
349+
secretKeyPath: "secret.key",
350+
appVersion: "1.0.0",
351+
});
352+
353+
const writtenContent = writeFileMock.mock.calls[0][1] as string;
354+
const parsed = JSON.parse(writtenContent) as ReleaseManifest;
355+
356+
// Verify both architectures are present
357+
expect(parsed.artifacts.zip?.arm64).toBeDefined();
358+
expect(parsed.artifacts.zip?.x64).toBeDefined();
359+
expect(parsed.artifacts.zip?.arm64?.path).toBe("app-arm64-1.0.0.zip");
360+
expect(parsed.artifacts.zip?.x64?.path).toBe("app-x64-1.0.0.zip");
361+
});
362+
363+
it("should support both zip and dmg artifact types", async () => {
364+
const writeFileMock = vi.mocked(fs.writeFile);
365+
366+
// Mock different artifact types
367+
vi.mocked(fileUtils.extractArtifactTypeFromFilename)
368+
.mockReturnValueOnce("zip")
369+
.mockReturnValueOnce("dmg");
370+
371+
await createReleaseMetadata({
372+
distributables: ["app-x64-1.0.0.zip", "app-x64-1.0.0.dmg"],
373+
secretKeyPath: "secret.key",
374+
appVersion: "1.0.0",
375+
});
376+
377+
const writtenContent = writeFileMock.mock.calls[0][1] as string;
378+
const parsed = JSON.parse(writtenContent) as ReleaseManifest;
379+
380+
// Verify both artifact types are present
381+
expect(parsed.artifacts.zip).toBeDefined();
382+
expect(parsed.artifacts.dmg).toBeDefined();
383+
expect(parsed.artifacts.zip?.x64?.path).toBe("app-x64-1.0.0.zip");
384+
expect(parsed.artifacts.dmg?.x64?.path).toBe("app-x64-1.0.0.dmg");
385+
});
323386
});

0 commit comments

Comments
 (0)