generate-manifest.mjs
162 lines 6.3 KB
Raw
sha256:bd1eb24434d281fe65142038c1bf217493421f5a86880c22dff7e6037d2d4700 feat(companion): add production macOS distribution path Human minor ⚠ breaking 24 days ago
1 #!/usr/bin/env node
2 import crypto from 'node:crypto';
3 import fs from 'node:fs';
4 import path from 'node:path';
5
6 function parseArgs(argv) {
7 const result = {};
8 for (let i = 0; i < argv.length; i += 2) {
9 if (!argv[i].startsWith('--') || argv[i + 1] === undefined) throw new Error('invalid arguments');
10 result[argv[i].slice(2)] = argv[i + 1];
11 }
12 return result;
13 }
14
15 function stable(value) {
16 if (Array.isArray(value)) return value.map(stable);
17 if (value && typeof value === 'object') {
18 return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
19 }
20 return value;
21 }
22
23 function writeStable(file, value) {
24 fs.mkdirSync(path.dirname(file), { recursive: true });
25 fs.writeFileSync(file, `${JSON.stringify(stable(value), null, 2)}\n`, { mode: 0o644 });
26 }
27
28 function sha256(file) {
29 const hash = crypto.createHash('sha256');
30 hash.update(fs.readFileSync(file));
31 return hash.digest('hex');
32 }
33
34 function sourceRevision({ allowUncommitted }) {
35 const muse = process.env.KNOWTATION_SOURCE_MUSE_REVISION;
36 const github = process.env.KNOWTATION_SOURCE_GIT_REVISION;
37 const validMuse = /^sha256:[0-9a-f]{64}$/.test(muse ?? '');
38 const validGit = /^[0-9a-f]{40}$/.test(github ?? '');
39 if (validMuse && validGit) return { status: 'committed', muse, github };
40 if (allowUncommitted && process.env.KNOWTATION_ALLOW_UNCOMMITTED_BUILD === '1') {
41 return {
42 status: 'uncommitted',
43 branch: 'feat/companion-phase-7-distribution',
44 baseline: release.sourceBaseline,
45 };
46 }
47 throw new Error(
48 'exact KNOWTATION_SOURCE_MUSE_REVISION and KNOWTATION_SOURCE_GIT_REVISION are required'
49 );
50 }
51
52 function walk(root, relative = '') {
53 const entries = [];
54 const directory = path.join(root, relative);
55 for (const name of fs.readdirSync(directory).sort()) {
56 const rel = relative ? `${relative}/${name}` : name;
57 const absolute = path.join(root, rel);
58 const stat = fs.lstatSync(absolute);
59 if (stat.isSymbolicLink()) throw new Error(`symbolic links are forbidden in release payload: ${rel}`);
60 if (stat.isDirectory()) entries.push(...walk(root, rel));
61 else if (stat.isFile()) entries.push({
62 path: rel,
63 sha256: sha256(absolute),
64 bytes: stat.size,
65 executable: (stat.mode & 0o111) !== 0,
66 });
67 else throw new Error(`unsupported payload entry: ${rel}`);
68 }
69 return entries;
70 }
71
72 function requireSafeOutput(output, root) {
73 const rel = path.relative(root, output);
74 if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
75 return rel.split(path.sep).join('/');
76 }
77
78 const args = parseArgs(process.argv.slice(2));
79 const stage = args.stage;
80 const output = path.resolve(args.output ?? '');
81 const repoRoot = path.resolve(new URL('../../../', import.meta.url).pathname);
82 const release = JSON.parse(fs.readFileSync(path.join(repoRoot, 'release/macos/release.json'), 'utf8'));
83 const lock = JSON.parse(fs.readFileSync(path.join(repoRoot, 'release/macos/release-lock.json'), 'utf8'));
84
85 if (stage === 'unsigned') {
86 const app = path.resolve(args.app ?? '');
87 const resources = path.join(app, 'Contents/Resources');
88 const excluded = requireSafeOutput(output, resources);
89 const files = walk(resources).filter((entry) => entry.path !== excluded);
90 writeStable(output, {
91 schema: 1,
92 releaseVersion: release.version,
93 releaseBuild: release.build,
94 architecture: release.architectures[0],
95 sourceRevision: sourceRevision({ allowUncommitted: true }),
96 sourceBaseline: release.sourceBaseline,
97 releaseLockSHA256: sha256(path.join(repoRoot, 'release/macos/release-lock.json')),
98 w3BundleDigest: lock.model.w3BundleDigest,
99 files,
100 });
101 process.exit(0);
102 }
103
104 if (stage === 'final') {
105 const dist = path.resolve(args.dist ?? '');
106 const packageName = `Knowtation-${release.version}-${release.build}-macos-${release.architectures[0]}.pkg`;
107 const packagePath = path.join(dist, packageName);
108 if (!fs.existsSync(packagePath)) throw new Error(`missing final package: ${packagePath}`);
109 const artifactNames = [
110 `Knowtation-${release.version}-${release.build}-macos-${release.architectures[0]}.zip`,
111 packageName,
112 'sbom.spdx.json',
113 'LICENSE',
114 'THIRD-PARTY-NOTICES.txt',
115 ];
116 const artifacts = artifactNames.map((name) => {
117 const file = path.join(dist, name);
118 if (!fs.existsSync(file)) throw new Error(`missing final artifact: ${file}`);
119 const stat = fs.statSync(file);
120 if (!stat.isFile()) throw new Error(`final artifact is not a file: ${file}`);
121 return { path: name, sha256: sha256(file), bytes: stat.size, executable: false };
122 });
123 const issuedAt = Number(process.env.KNOWTATION_UPDATE_ISSUED_AT);
124 const expiresAt = Number(process.env.KNOWTATION_UPDATE_EXPIRES_AT);
125 const keyID = process.env.KNOWTATION_UPDATE_KEY_ID;
126 if (!Number.isSafeInteger(issuedAt) || !Number.isSafeInteger(expiresAt) || expiresAt <= issuedAt) {
127 throw new Error('valid KNOWTATION_UPDATE_ISSUED_AT and KNOWTATION_UPDATE_EXPIRES_AT are required');
128 }
129 if (!keyID || keyID.includes('<')) throw new Error('KNOWTATION_UPDATE_KEY_ID is required');
130 const sourceMin = Number(process.env.KNOWTATION_UPDATE_SOURCE_MIN_BUILD);
131 const sourceMax = Number(process.env.KNOWTATION_UPDATE_SOURCE_MAX_BUILD);
132 if (!Number.isSafeInteger(sourceMin) || !Number.isSafeInteger(sourceMax) ||
133 sourceMin < 0 || sourceMax < sourceMin || sourceMax >= release.build) {
134 throw new Error('valid update source build range below the target build is required');
135 }
136 writeStable(output, {
137 schema: 1,
138 channel: release.channel,
139 platform: release.platform,
140 architecture: release.architectures[0],
141 keyID,
142 issuedAt,
143 expiresAt,
144 source: { minimumBuild: sourceMin, maximumBuild: sourceMax },
145 target: {
146 version: release.version,
147 build: release.build,
148 packageURL: `https://releases.knowtation.store/stable/${packageName}`,
149 packageSHA256: sha256(packagePath),
150 packageBytes: fs.statSync(packagePath).size,
151 },
152 minimumAcceptedBuild: release.update.minimumAcceptedBuild,
153 sourceRevision: sourceRevision({ allowUncommitted: false }),
154 sourceBaseline: release.sourceBaseline,
155 releaseLockSHA256: sha256(path.join(repoRoot, 'release/macos/release-lock.json')),
156 w3BundleDigest: lock.model.w3BundleDigest,
157 artifacts,
158 });
159 process.exit(0);
160 }
161
162 throw new Error('stage must be unsigned or final');
File History 1 commit
sha256:bd1eb24434d281fe65142038c1bf217493421f5a86880c22dff7e6037d2d4700 feat(companion): add production macOS distribution path Human minor ⚠ 24 days ago