ReviewOS

stacks/uplink

fix(release): uplink:release publishes unverified builds from an untagged tree

#33
Closed glennmichael123 opened this 9 days ago ·
9 days ago

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. appDir is process.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, gh creates 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#L223 writes resources/data/release.json and never commits it, so by the time gh runs 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:dmg action 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 reports Accepted, 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#L221 copies the versioned DMG to Uplink.dmg and L229 uploads both, with no codesign --verify, spctl or xcrun stapler validate run against either one. The only codesign call in this repository is the ad-hoc signature for the from-source bundle at app/Uplink/service.ts#L120, and there is no spctl or stapler call 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 at resources/views/index.stx#L53, and the release notes string at app/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-releaser and writes release notes (.github/workflows/release.yml#L40).

Proposal

  1. Add a preflight block to the uplink:release action in app/Commands/Uplink.ts, ahead of any build step: refuse when git status --porcelain is non-empty, refuse when local HEAD is not equal to the tracked remote branch (git rev-parse HEAD against git rev-parse @{u} after a git fetch), and refuse when a tag named v${version} already exists locally or on the remote.
  2. Capture git rev-parse HEAD in that preflight and pass --target <sha> to gh release create at app/Commands/Uplink.ts#L229, so the tag names the commit the bytes were built from.
  3. 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, and xcrun stapler validate. Any non-zero exit aborts before gh runs.
  4. Hash the published DMG with Bun.CryptoHasher, print the SHA-256, and put it in the --notes string. Add a line to README.md beside the download link giving the digest and the shasum -a 256 Uplink.dmg command to check it.
  5. Decide where the release.json write at app/Commands/Uplink.ts#L223 sits 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 --porcelain check placed after it either always fails or has to exclude that one path, and excluding it silently is how the homepage version drifts again.
  • --target takes 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.dmg copy made at app/Commands/Uplink.ts#L221, not only the versioned build output, because the copy is what the releases/latest/download links serve.
  • --draft does not skip anything. A draft release still passes every preflight and verification check.
  • Every failure aborts before gh release create runs. A release that is created and then found to be unverifiable has to be deleted by hand, and the download links resolve against latest from 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, xcrun and shasum are already on any Mac that can notarize.

Acceptance

  • buddy uplink:release with 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 create and leaves no release behind.
  • The SHA-256 the command prints matches shasum -a 256 of the asset downloaded from the published release, and the same value is in README.md.

Sign in to comment on this issue.