Part of #6.
The DMG a user downloads is compiled from whatever is in the maintainer's working directory at that moment, while the tag that names it is created by gh from the remote's default branch. Those can be two different commits, and nothing in the command compares them. Nothing re-checks the file it uploads either, and no checksum is published, so a user who wants to confirm what they downloaded has nothing to compare it against.
What is there today
- The build runs out of the working tree.
appDirisprocess.cwd()(app/Commands/Uplink.ts#L190) and both build steps run there (app/Commands/Uplink.ts#L208,app/Commands/Uplink.ts#L209), so uncommitted edits end up in the shipped binary. - The publish step is
gh release create v${version}with no--target(app/Commands/Uplink.ts#L229). When that tag does not exist yet,ghcreates it from the latest commit on the remote's default branch, not from local HEAD. A stale local main or an unpushed commit therefore produces a signed binary whose source is not the tagged source. - No git command runs anywhere in the file, so a dirty tree, a HEAD that has diverged from the remote, and an existing tag of the same name all pass unnoticed.
- The command dirties the tree itself before publishing:
app/Commands/Uplink.ts#L223writesresources/data/release.jsonand never commits it, so by the timeghruns there is an uncommitted file, and the tagged commit does not contain the release metadata the homepage reads. - The only thing checked before building is that an identity name and a notarytool profile name are non-empty strings (
app/Commands/Uplink.ts#L196). Everything after that is trusted. - Signing, notarizing and stapling happen inside the
build:dmgaction shipped by the framework dependency, not in this repository (node_modules/@stacksjs/actions/dist/build/dmg.js#L31, a bundled file). That action does abort unless notarytool reportsAccepted, so the version in use is not silently skipping notarization. It is resolved by a caret range (package.json#L93), so this repository asserts nothing of its own about the artifact and an upgrade that changed that behavior would not be noticed here. - Nothing verifies the file that is actually uploaded.
app/Commands/Uplink.ts#L221copies the versioned DMG toUplink.dmgand L229 uploads both, with nocodesign --verify,spctlorxcrun stapler validaterun against either one. The onlycodesigncall in this repository is the ad-hoc signature for the from-source bundle atapp/Uplink/service.ts#L120, and there is nospctlorstaplercall in it at all. - No SHA-256 is printed or published, while three places tell users the download is signed and notarized:
README.md#L42, the install copy atresources/views/index.stx#L53, and the release notes string atapp/Commands/Uplink.ts#L229. The README carries no checksum for the DMG. - The tag workflow adds no check of its own. It runs
stacksjs/action-releaserand writes release notes (.github/workflows/release.yml#L40).
Proposal
- Add a preflight block to the
uplink:releaseaction inapp/Commands/Uplink.ts, ahead of any build step: refuse whengit status --porcelainis non-empty, refuse when local HEAD is not equal to the tracked remote branch (git rev-parse HEADagainstgit rev-parse @{u}after agit fetch), and refuse when a tag namedv${version}already exists locally or on the remote. - Capture
git rev-parse HEADin that preflight and pass--target <sha>togh release createatapp/Commands/Uplink.ts#L229, so the tag names the commit the bytes were built from. - Add a verification step between the build and the publish, run against the DMG that gets uploaded:
codesign --verify --deep --strict --verbose=2,spctl -a -t open --context context:primary-signature, andxcrun stapler validate. Any non-zero exit aborts beforeghruns. - Hash the published DMG with
Bun.CryptoHasher, print the SHA-256, and put it in the--notesstring. Add a line toREADME.mdbeside the download link giving the digest and theshasum -a 256 Uplink.dmgcommand to check it. - Decide where the
release.jsonwrite atapp/Commands/Uplink.ts#L223sits relative to the clean check, since it is what makes the tree dirty. Either write it before the preflight and require it committed, or move it after the publish. Record the choice in a comment next to it.
Requirements
- The clean check has to account for the command's own write. L223 writes an uncommitted file before L229 publishes, so a
git status --porcelaincheck placed after it either always fails or has to exclude that one path, and excluding it silently is how the homepage version drifts again. --targettakes a full SHA, not a branch name. The point of the flag here is that the tag names the exact commit that produced the bundle, which a branch name does not pin.- Verification runs against the file that is uploaded, the stable
Uplink.dmgcopy made atapp/Commands/Uplink.ts#L221, not only the versioned build output, because the copy is what thereleases/latest/downloadlinks serve. --draftdoes not skip anything. A draft release still passes every preflight and verification check.- Every failure aborts before
gh release createruns. A release that is created and then found to be unverifiable has to be deleted by hand, and the download links resolve againstlatestfrom the moment it exists. - Keep the identity and profile check at L196. The new checks go around it, not in place of it.
- No new dependency.
git,codesign,spctl,xcrunandshasumare already on any Mac that can notarize.
Acceptance
buddy uplink:releasewith any uncommitted change refuses and builds nothing.- It refuses when local HEAD is ahead of or behind the tracked remote branch, and prints both SHAs.
- It refuses when a tag named
v${version}already exists. - After a successful run,
git rev-list -n1 v${version}equals the SHA the command printed and passed as--target. - Making any one of the three verification commands fail, for example by pointing the step at a DMG that was never stapled, aborts the run before
gh release createand leaves no release behind. - The SHA-256 the command prints matches
shasum -a 256of the asset downloaded from the published release, and the same value is inREADME.md.