generate-manifest.mjs
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