Ben (2026-09-05): "ok do all and deploy" on the six updater recommendations. Backlog MMDB-2681.
Signature verification of the staged update (UpdateChecker.verifySignature): Security framework strict validation (kSecCSStrictValidate | kSecCSCheckAllArchitectures | kSecCSCheckNestedCode), bundle id must be ai.flowmaster.shotdeck, team id must be PWMCBMX5M8 or L3N9S54CN3 (env REDLINE_ALLOWED_TEAMS for the self-test only). Previously only the SHA256 from the same host was checked.
Atomic install + rollback: copy into itemReplacementDirectory, replaceItemAt with Redline.app.previous kept as the one rollback copy, installed target re-verified. New revertToPrevious.
Update visibility: badge on the menu-bar icon while an update is staged; rows "Check for updates" / "Checking…", "Revert to "; footer "Redline " with the status line.
Timeouts: 30 s request / 600 s resource (was 15 s both).
Relaunch handoff: detached shell waits for the old pid to exit before open -n; no overlapping instances.
publish-update.sh: resolves a real signing identity (REDLINE_SIGN_IDENTITY → first Developer ID Application → Apple Development with a loud warning), --options runtime --timestamp, optional notarytool submit + staple + DMG rebuild from the stapled app, spctl gate, notarized + teamIdentifier written into appcast.json.
Gates run (real output)
Signed build via scripts/build-app.sh, then the full self-test chain, run by the builder and re-run independently by the coordinator:
swift build -c release clean; swift test 99/99 ShotdeckCoreTests pass; bash -n scripts/publish-update.sh OK; nm -u on the binary shows no UNUserNotificationCenter / NSAppleScript / AXUIElement symbols (no permission prompts).
Runtime-verified
Self-test installs into a temp target only; /Applications/Redline.app confirmed untouched (still 0.2.0) after the run.
Notarization path (notarytool submit / staple / re-DMG) not executed end to end: no Developer ID certificate exists yet (Apple API refused to issue one: Account Holder only). Release 0.3.0 ships Apple Development signed, NOT NOTARIZED printed.
Menu rows, icon badge and the real relaunch handoff not driven in the GUI by an agent (macOS menu-bar UI, no headless lane). Ben-GUI-only.
Conflicts: open PR #23 (OneDrive transport) touches AppModel.swift, MenuBarView.swift, PickerSelfTest.swift, main.swift and will need a rebase after this merges.
## What changed and why
Ben (2026-09-05): "ok do all and deploy" on the six updater recommendations. Backlog MMDB-2681.
1. **Signature verification of the staged update** (`UpdateChecker.verifySignature`): Security framework strict validation (`kSecCSStrictValidate | kSecCSCheckAllArchitectures | kSecCSCheckNestedCode`), bundle id must be `ai.flowmaster.shotdeck`, team id must be `PWMCBMX5M8` or `L3N9S54CN3` (env `REDLINE_ALLOWED_TEAMS` for the self-test only). Previously only the SHA256 from the same host was checked.
2. **Atomic install + rollback**: copy into `itemReplacementDirectory`, `replaceItemAt` with `Redline.app.previous` kept as the one rollback copy, installed target re-verified. New `revertToPrevious`.
3. **Update visibility**: badge on the menu-bar icon while an update is staged; rows "Check for updates" / "Checking…", "Revert to <previous>"; footer "Redline <version>" with the status line.
4. **Timeouts**: 30 s request / 600 s resource (was 15 s both).
5. **Relaunch handoff**: detached shell waits for the old pid to exit before `open -n`; no overlapping instances.
6. **publish-update.sh**: resolves a real signing identity (`REDLINE_SIGN_IDENTITY` → first Developer ID Application → Apple Development with a loud warning), `--options runtime --timestamp`, optional notarytool submit + staple + DMG rebuild from the stapled app, spctl gate, `notarized` + `teamIdentifier` written into appcast.json.
## Gates run (real output)
Signed build via `scripts/build-app.sh`, then the full self-test chain, run by the builder and re-run independently by the coordinator:
```
REDLINE_ALLOWED_TEAMS=PWMCBMX5M8 SHOTDECK_PICKER_SELFTEST=<tmp>/picker SHOTDECK_UPDATE_SELFTEST=<tmp>/update .build/Redline.app/Contents/MacOS/Shotdeck
PICKER-SELFTEST PASS rect=(200.0, 729.0, 400.0, 300.0)
REGION-PERSIST PASS
SEND-TRUTH PASS
UPDATE-SELFTEST reject-unsigned PASS
UPDATE-SELFTEST staged-signed PASS
UPDATE-SELFTEST atomic-install PASS
UPDATE-SELFTEST revert PASS
UPDATE-SELFTEST PASS version=99.0.0
exit=0
```
`swift build -c release` clean; `swift test` 99/99 ShotdeckCoreTests pass; `bash -n scripts/publish-update.sh` OK; `nm -u` on the binary shows no UNUserNotificationCenter / NSAppleScript / AXUIElement symbols (no permission prompts).
## Runtime-verified
- Self-test installs into a temp target only; `/Applications/Redline.app` confirmed untouched (still 0.2.0) after the run.
- Unsigned (plist-edited) bundle rejected; re-signed bundle staged; atomic install leaves `Redline.app.previous`; revert swaps back.
## NOT verified
- Notarization path (notarytool submit / staple / re-DMG) not executed end to end: no Developer ID certificate exists yet (Apple API refused to issue one: Account Holder only). Release 0.3.0 ships Apple Development signed, `NOT NOTARIZED` printed.
- Menu rows, icon badge and the real relaunch handoff not driven in the GUI by an agent (macOS menu-bar UI, no headless lane). Ben-GUI-only.
- Conflicts: open PR #23 (OneDrive transport) touches AppModel.swift, MenuBarView.swift, PickerSelfTest.swift, main.swift and will need a rebase after this merges.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
30s/600s URLSession timeouts on the update session (was 15s/15s, too tight for
a real zip download). Every staged and installed payload is now verified with
the Security framework (SecStaticCodeCheckValidityWithErrors, strict + all
architectures + nested code) against bundle id ai.flowmaster.shotdeck and an
allowed-team set (PWMCBMX5M8, L3N9S54CN3; overridable via REDLINE_ALLOWED_TEAMS
for the self-test only) — an unsigned or wrongly-signed update is discarded
before checkNow ever offers it, and installStaged re-verifies what actually
landed on disk as defense in depth.
installStaged is now atomic: ditto into an itemReplacementDirectory, then
FileManager.replaceItemAt swaps it into place, keeping exactly one
Redline.app.previous rollback copy (older ones are dropped first). Added
revertToPrevious(target:) to swap that copy back in (itself reversible — the
replaced version becomes the new .previous), and previousVersion(target:) to
read its CFBundleShortVersionString. Relaunch is now a detached
"wait for this PID to exit, then open -n" shell handoff instead of a
synchronous open+terminate, so there is never a moment with two instances
running. checkNow(manual:) now says "Redline X is up to date." when the user
asked directly, and exposes isCheckingNow/lastCheckedAt for the UI.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
AppModel exposes appVersion, previousVersion, isCheckingForUpdates and
updateStatusMessage (an alias for the existing statusLine plumbing — one
status channel, not a new one), plus checkForUpdates() and
revertToPreviousVersion() wired to the hardened UpdateChecker.
Menu gains, in order: "Update to X" (unchanged, staged-only), "Check for
updates" (labelled "Checking…" and disabled mid-check), "Revert to <version>"
(only when a rollback copy exists), then the existing rows unchanged, then a
non-interactive footer "Redline <version>" with the status line under it —
same caption/secondary styles already used elsewhere in the file, no new
tokens.
Menu-bar icon gets a small badge while an update is staged: uses the SF
Symbol's own ".badge" variant when one exists for the current icon, otherwise
overlays a small dot on the plain symbol. Reads live model state, so the
badge disappears on its own once the offer clears.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Same fake-99.0.0-bundle setup as before, but now drives it through the
hardened UpdateChecker end to end, in-process:
(a) reject-unsigned — the fake bundle, copied then plist-edited without
re-signing (editing Info.plist after copy invalidates the inherited
signature on its own — nothing stripped by hand), must be rejected by
checkNow(): updateAvailable stays nil and statusMessage is the exact
"Update is not signed by MMD" text.
(b) staged-signed — codesign --force --deep --sign the same bundle
(SHOTDECK_SELFTEST_SIGN_IDENTITY or the default Apple Development
identity), re-zip, re-serve the same appcast path; must now stage.
(c) atomic-install — installStaged into a throwaway <tmp>/Applications
(never real /Applications) pre-populated with a copy of the actually
running app; asserts the target lands on 99.0.0, Redline.app.previous
holds the original version, and no replacement-directory cruft is left
beside them.
(d) revert — revertToPrevious swaps the rollback copy back in; asserts the
target is back to the original version and .previous now holds 99.0.0.
Caught a real bug while wiring (d): replaceItemAt(target, withItemAt:
previousURL, backupItemName: "Redline.app.previous") self-clobbers, because
the backup name and the withItemAt source resolve to the same path — the
backup write lands before the swap ever reads it, so target ends up
unchanged. Fixed in UpdateChecker by staging previousURL through a throwaway
ditto copy first (same pattern installStaged already used).
Every existing phase (PICKER-SELFTEST, REGION-PERSIST, SEND-TRUTH, and the
final "UPDATE-SELFTEST PASS version=99.0.0") is unchanged and still prints.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
SIGN_IDENTITY now comes from REDLINE_SIGN_IDENTITY, else the first
"Developer ID Application" identity in the keychain (REDLINE_KEYCHAIN adds
--keychain everywhere it's searched/used), else the existing Apple
Development identity with a loud WARNING that Gatekeeper will block first
install elsewhere. build-app.sh still has to sign first with its own
hardcoded Apple Development identity (that pair is what keeps the Screen
Recording grant alive) — this script now re-signs the resulting bundle with
the resolved identity, --options runtime --timestamp, before zipping.
Notarization is optional: set REDLINE_NOTARY_PROFILE (a notarytool
keychain profile) or all three of REDLINE_NOTARY_KEY_ID/_ISSUER/_KEY_PATH,
and after the zip is built the script submits it, waits, and on Accepted
staples Redline.app, rebuilds the zip and DMG from the stapled app (a new
build_dmg() that ditto-copies whatever is already at .build/Redline.app
rather than re-invoking make-dmg.sh, which would rebuild from source and
strip both the resolved signature and the staple), staples the DMG, and
requires `spctl -a -vv -t exec` to say "accepted" or the script aborts.
Absent notary config: prints NOT NOTARIZED and continues exactly as before.
appcast.json gains "notarized" and "teamIdentifier" (from codesign -dv);
confirmed UpdateChecker's Appcast Decodable already ignores unknown JSON
keys (verified with a standalone decode), so no app-side change was needed
for old appcasts to keep working. Ends with a summary block: identity used,
notarized yes/no, spctl verdict, team, sha256. --test dry-run behaviour
(upload to .../test/, skip the git commit) is unchanged.
Known gap, out of scope here: build-app.sh's own pre-sign step still hard-
requires its hardcoded Apple Development identity in the keychain even when
a Developer ID identity is what will actually ship — untouched per the task
boundary (this file only).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Un-notarized fallback builds (Apple Development identity) are rejected by
spctl by design; the script must still publish them with a warning, otherwise
the fallback path can never release.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What changed and why
Ben (2026-09-05): "ok do all and deploy" on the six updater recommendations. Backlog MMDB-2681.
UpdateChecker.verifySignature): Security framework strict validation (kSecCSStrictValidate | kSecCSCheckAllArchitectures | kSecCSCheckNestedCode), bundle id must beai.flowmaster.shotdeck, team id must bePWMCBMX5M8orL3N9S54CN3(envREDLINE_ALLOWED_TEAMSfor the self-test only). Previously only the SHA256 from the same host was checked.itemReplacementDirectory,replaceItemAtwithRedline.app.previouskept as the one rollback copy, installed target re-verified. NewrevertToPrevious.open -n; no overlapping instances.REDLINE_SIGN_IDENTITY→ first Developer ID Application → Apple Development with a loud warning),--options runtime --timestamp, optional notarytool submit + staple + DMG rebuild from the stapled app, spctl gate,notarized+teamIdentifierwritten into appcast.json.Gates run (real output)
Signed build via
scripts/build-app.sh, then the full self-test chain, run by the builder and re-run independently by the coordinator:swift build -c releaseclean;swift test99/99 ShotdeckCoreTests pass;bash -n scripts/publish-update.shOK;nm -uon the binary shows no UNUserNotificationCenter / NSAppleScript / AXUIElement symbols (no permission prompts).Runtime-verified
/Applications/Redline.appconfirmed untouched (still 0.2.0) after the run.Redline.app.previous; revert swaps back.NOT verified
NOT NOTARIZEDprinted.🤖 Generated with Claude Code
Same fake-99.0.0-bundle setup as before, but now drives it through the hardened UpdateChecker end to end, in-process: (a) reject-unsigned — the fake bundle, copied then plist-edited without re-signing (editing Info.plist after copy invalidates the inherited signature on its own — nothing stripped by hand), must be rejected by checkNow(): updateAvailable stays nil and statusMessage is the exact "Update is not signed by MMD" text. (b) staged-signed — codesign --force --deep --sign the same bundle (SHOTDECK_SELFTEST_SIGN_IDENTITY or the default Apple Development identity), re-zip, re-serve the same appcast path; must now stage. (c) atomic-install — installStaged into a throwaway <tmp>/Applications (never real /Applications) pre-populated with a copy of the actually running app; asserts the target lands on 99.0.0, Redline.app.previous holds the original version, and no replacement-directory cruft is left beside them. (d) revert — revertToPrevious swaps the rollback copy back in; asserts the target is back to the original version and .previous now holds 99.0.0. Caught a real bug while wiring (d): replaceItemAt(target, withItemAt: previousURL, backupItemName: "Redline.app.previous") self-clobbers, because the backup name and the withItemAt source resolve to the same path — the backup write lands before the swap ever reads it, so target ends up unchanged. Fixed in UpdateChecker by staging previousURL through a throwaway ditto copy first (same pattern installStaged already used). Every existing phase (PICKER-SELFTEST, REGION-PERSIST, SEND-TRUTH, and the final "UPDATE-SELFTEST PASS version=99.0.0") is unchanged and still prints. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>