ReviewOS

also looking at this

stacks/uplink

fix(release): refuse to publish what has not been checked

#59
Merged glennmichael123 wants to merge fix/verify-before-publish into main
2 files +90 -2
README.mdmodified+7-0
Changes to README.md
@@ -47,6 +47,13 @@ iPhone <------------------------- Messages (AppleScript) <------- the reply, as
4747through Full Disk Access, signing in to the agent you picked, and who may text it. Then text
4848yourself `ping`.
4949
50To check what you downloaded, compare it against the digest on the release page, which is also in
51[`resources/data/release.json`](resources/data/release.json):
52
53```bash
54shasum -a 256 ~/Downloads/Uplink.dmg
55```
56
5057You need one of the two CLIs and a plan for it:
5158
5259- [Claude Code](https://docs.claude.com/en/docs/claude-code), the default. The menubar takes a
app/Commands/Uplink.tsmodified+83-2
Changes to app/Commands/Uplink.ts
@@ -228,6 +228,56 @@ export default defineCommand((cli) => {
228228 process.exit(result.exitCode ?? 1)
229229 }
230230
231 const git = (...args: string[]): string =>
232 Bun.spawnSync(['git', ...args], { cwd: appDir, stderr: 'pipe' }).stdout.toString().trim()
233
234 const refuse = (...lines: string[]): never => {
235 console.error(lines.join('\n'))
236 return process.exit(1)
237 }
238
239 // Everything below runs before a single byte is built. A release that
240 // turns out to be unverifiable after `gh release create` has to be
241 // deleted by hand, and the download links resolve against `latest` from
242 // the moment it exists.
243 //
244 // The build runs out of the working tree, so an uncommitted edit ends up
245 // in the shipped binary with nothing naming the source it came from.
246 const dirty = git('status', '--porcelain')
247 if (dirty) {
248 refuse(
249 'The working tree is not clean, and the build comes out of it.',
250 'Commit or stash these first, or the signed binary contains changes no tag names:',
251 dirty,
252 )
253 }
254
255 // `gh release create` cuts the tag from the remote's default branch when
256 // the tag does not exist, not from local HEAD. A stale or unpushed local
257 // tree therefore produces a binary whose source is not the tagged source.
258 step(['git', 'fetch', '--quiet', '--tags'])
259 const head = git('rev-parse', 'HEAD')
260 const upstream = git('rev-parse', '@{u}')
261 if (!upstream) {
262 refuse('This branch has no upstream, so there is nothing to publish from. Push it first.')
263 }
264 if (head !== upstream) {
265 refuse(
266 'Local HEAD and the tracked remote branch are not the same commit.',
267 ` local: ${head}`,
268 ` remote: ${upstream}`,
269 'Push or pull first. The tag would otherwise name a commit that did not build this.',
270 )
271 }
272
273 const tag = `v${version}`
274 if (git('tag', '--list', tag) || git('ls-remote', '--tags', 'origin', tag)) {
275 refuse(
276 `${tag} already exists, so this would publish under a tag that names something else.`,
277 'Bump the version in package.json, or delete the tag and its release first.',
278 )
279 }
280
231281 step(['./buddy', 'build:desktop'], { CRAFT_BIN: `${appDir}/pantry/.bin/craft` })
232282 step(['./buddy', 'build:dmg'], {
233283 DESKTOP_APP_NAME: 'Uplink',
@@ -242,10 +292,26 @@ export default defineCommand((cli) => {
242292 const built = `${appDir}/storage/framework/desktop-dmg/Uplink-${version}.dmg`
243293 const stable = `${appDir}/storage/framework/desktop-dmg/Uplink.dmg`
244294 copyFileSync(built, stable)
295
296 // Against the copy that gets uploaded, not only the build output: the
297 // stable name is what releases/latest/download serves, and it is the
298 // file a user actually opens. Three claims are made about it on the
299 // homepage and in the README, and until now nothing checked any of them.
300 step(['codesign', '--verify', '--deep', '--strict', '--verbose=2', stable])
301 step(['spctl', '-a', '-t', 'open', '--context', 'context:primary-signature', stable])
302 step(['xcrun', 'stapler', 'validate', stable])
303
304 // Published so a download can be checked against something. The file is
305 // hashed after verification and before anything is written, so the digest
306 // belongs to the bytes that were verified.
307 const sha256 = new Bun.CryptoHasher('sha256').update(await Bun.file(stable).arrayBuffer()).digest('hex')
308 console.log(`Uplink.dmg sha256 ${sha256}`)
309
245310 // What the homepage's download button reads.
246311 await Bun.write(`${appDir}/resources/data/release.json`, `${JSON.stringify({
247312 version,
248313 bytes: Bun.file(built).size,
314 sha256,
249315 minimumMacOS: '13',
250316 url: 'https://github.com/stacksjs/uplink/releases/latest/download/Uplink.dmg',
251317 }, null, 2)}\n`)
@@ -276,9 +342,24 @@ export default defineCommand((cli) => {
276342 // a second release for the same tag with its own generated notes, so
277343 // which text a reader got came down to which of the two finished last.
278344 const notesPath = `${appDir}/storage/framework/desktop-dmg/notes.md`
279 await Bun.write(notesPath, `Uplink ${version} for macOS 13 or later. Signed with a Developer ID and notarized by Apple.\n\n${generated}\n`)
345 await Bun.write(notesPath, [
346 `Uplink ${version} for macOS 13 or later. Signed with a Developer ID and notarized by Apple.`,
347 '',
348 '```',
349 `shasum -a 256 Uplink.dmg`,
350 `${sha256} Uplink.dmg`,
351 '```',
352 '',
353 generated,
354 '',
355 ].join('\n'))
280356
281 step(['gh', 'release', 'create', `v${version}`, stable, built, '--title', `Uplink ${version}`, '--notes-file', notesPath, ...(options.draft ? ['--draft'] : [])])
357 // The commit that carries this release's own metadata, pinned as a full
358 // SHA. Without `--target`, `gh` cuts the tag from the remote's default
359 // branch instead, which is not necessarily what was built.
360 const tagged = git('rev-parse', 'HEAD')
361 console.log(`${tag} will name ${tagged}`)
362 step(['gh', 'release', 'create', tag, stable, built, '--target', tagged, '--title', `Uplink ${version}`, '--notes-file', notesPath, ...(options.draft ? ['--draft'] : [])])
282363 process.exit(0)
283364 })
284365