also looking at this
fix(release): refuse to publish what has not been checked
#59
2 files
+90
-2
| @@ -47,6 +47,13 @@ iPhone <------------------------- Messages (AppleScript) <------- the reply, as | ||
| 47 | 47 | through Full Disk Access, signing in to the agent you picked, and who may text it. Then text |
| 48 | 48 | yourself `ping`. |
| 49 | 49 | |
| 50 | To 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 | |
| 54 | shasum -a 256 ~/Downloads/Uplink.dmg | |
| 55 | ``` | |
| 56 | ||
| 50 | 57 | You need one of the two CLIs and a plan for it: |
| 51 | 58 | |
| 52 | 59 | - [Claude Code](https://docs.claude.com/en/docs/claude-code), the default. The menubar takes a |
| @@ -228,6 +228,56 @@ export default defineCommand((cli) => { | ||
| 228 | 228 | process.exit(result.exitCode ?? 1) |
| 229 | 229 | } |
| 230 | 230 | |
| 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 | ||
| 231 | 281 | step(['./buddy', 'build:desktop'], { CRAFT_BIN: `${appDir}/pantry/.bin/craft` }) |
| 232 | 282 | step(['./buddy', 'build:dmg'], { |
| 233 | 283 | DESKTOP_APP_NAME: 'Uplink', |
| @@ -242,10 +292,26 @@ export default defineCommand((cli) => { | ||
| 242 | 292 | const built = `${appDir}/storage/framework/desktop-dmg/Uplink-${version}.dmg` |
| 243 | 293 | const stable = `${appDir}/storage/framework/desktop-dmg/Uplink.dmg` |
| 244 | 294 | 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 | ||
| 245 | 310 | // What the homepage's download button reads. |
| 246 | 311 | await Bun.write(`${appDir}/resources/data/release.json`, `${JSON.stringify({ |
| 247 | 312 | version, |
| 248 | 313 | bytes: Bun.file(built).size, |
| 314 | sha256, | |
| 249 | 315 | minimumMacOS: '13', |
| 250 | 316 | url: 'https://github.com/stacksjs/uplink/releases/latest/download/Uplink.dmg', |
| 251 | 317 | }, null, 2)}\n`) |
| @@ -276,9 +342,24 @@ export default defineCommand((cli) => { | ||
| 276 | 342 | // a second release for the same tag with its own generated notes, so |
| 277 | 343 | // which text a reader got came down to which of the two finished last. |
| 278 | 344 | 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')) | |
| 280 | 356 | |
| 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'] : [])]) | |
| 282 | 363 | process.exit(0) |
| 283 | 364 | }) |
| 284 | 365 | |