Skip to content
C.W.K.
Lesson 02 of 04 · published

Build, Install, Doctor, Smoke

~15 min · build scripts, installation, rollback, smoke test, isolation

Level 0New Tab
0 XP0/44 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

A green build is not an installed browser

Voyager's house sign repeats one line in several places: verify from /Applications/Voyager.app, never from swift build. A passing compile says the code type-checks. What Dad uses is the bundle in Applications, signed, with its icon, its version and his session. So scripts/build-app.sh treats build, bundle, sign, verify and install as one transaction. Either all of it happens, or Dad's installed copy is left as it was.

Stage, sign, verify

  1. Refuse early. The version in cwk-product.json must parse (Voyager is pre-1.0), the signing identity must be installed, and the icon must exist, all before a compile starts.
  2. Build and number. A release build, and a build number equal to the repository's commit count, so every bundle says how much history it was built from.
  3. Stage. The bundle is assembled in a temporary folder under .build: the binary, Info.plist, the icon, a copy of cwk-product.json, and Pippa's mark rendered from the kit at build time, because the repository keeps no copy of it.
  4. Sign and read back. Sign with the identity from the last lesson, verify deeply and strictly, then check the authority, the runtime and the designated requirement. Only a bundle that passes is copied to build/.

Interrupts are routed through the exit trap, so a build stopped with Control-C still removes its staging folder, and every delete the script performs is checked against the few paths it owns.

Installing over a running browser

Dad's copy is usually open. The install copies the verified bundle into /Applications under a hidden name stamped with the script's process id and verifies that copy too. Then it asks the running Voyager to quit with SIGTERM (first block). Voyager saves its session on SIGTERM exactly as it does when Dad quits from the menu, so his tabs come back. The script waits up to ten seconds. If Voyager is still running, perhaps because a page holds a dialog open, the script stops and says so. Nothing is killed.

Once Voyager has quit, the old bundle is moved aside, the new one is moved into place and verified where it stands. Until that verification passes, an exit trap stands ready to put the previous bundle back. Telling Launch Services comes after it and is best effort: a failed registration is ignored, the install counts as done, and the previous bundle is deleted. An install killed so hard that its trap never ran leaves pid-stamped leftovers, and the next run removes the ones whose process is dead.

The doctor reads, never writes

scripts/doctor.sh answers what is built, what is installed and what Voyager holds, without changing any of it. It reports the product version and each bundle's own numbers apart, because they answer different questions. It names the signing identity, then the system WebKit version: the engine is the system's, and Voyager reports it and never pins it. For the built and the installed app it prints version, build, signature state, authority, runtime, requirement and the executable's hash. For Voyager's folder it prints only counts: tabs in the saved session, identities, departures recorded and how many have no reason. Any problem ends in VOYAGER_DOCTOR_FAIL with the count.

Checks that cannot touch Dad's state

A smoke test that runs the real app writes into the real app's folder, Keychain and Pippa. Voyager closes that door by identity, with the kit's isolation gate (second block). build-app.sh --verification builds a separate bundle, com.cwk.voyager.verify, with no URL or document types, so macOS never offers it as a browser. Only that bundle may honour --isolated. The shipping app asked to run isolated prints the refusal and exits, because a check that quietly fell back to the real folder is the exact accident the gate exists to prevent. The overrides a check sets, such as a stand-in Pippa's address, read as empty outside isolation, and inside it Pippa's address must be loopback.

An isolated run lives in a throwaway container, starts from settings that reach nothing of the family's (no catalog, a blank new tab, page zoom 1), and keeps saved logins in a memory vault instead of the Keychain.

smoke.sh walks the app twice

scripts/smoke.sh builds the verification app and starts a stand-in Pippa on a loopback port: a small server whose panel asks which page is in front and writes down what it is told, plus a test page with a password field and a sign-in form. It launches the app twice (third block), once to use it and once to see the session come back, each run limited to 60 seconds. Its 58 check lines cover the window and tabs, find, zoom, bookmarks, pinning and drags, Incognito, Home, the mouse's side buttons, keys after a tab switch, the settings window, the panel told about the page without the password's value, a login saved and filled back, a companion source shared and cleared, Pippa herself as a pinned tab, and a restore with only the front tab awake. Then it deletes the container, the verification build's WebKit data and its preferences.

Principle: A check that can touch real state eventually will. Make the boundary a property of the build, not a flag someone has to remember.

Code

cwkVoyager, scripts/build-app.sh: ask the running copy to quit, never kill it (verbatim, lines 221-234)·shell
# Voyager saves its session when it is asked to quit, and on SIGTERM. It is
# asked first; nothing is killed.
running_pid() { /usr/bin/pgrep -f '^/Applications/Voyager\.app/Contents/MacOS/Voyager' 2>/dev/null | /usr/bin/head -n 1 }
if [[ -n "$(running_pid)" ]]; then
  /bin/kill -TERM "$(running_pid)" 2>/dev/null || true
  for _ in {1..40}; do
    [[ -z "$(running_pid)" ]] && break
    /bin/sleep 0.25
  done
fi
if [[ -n "$(running_pid)" ]]; then
  print -u2 "HARD_FAIL Voyager is still running (a page may be holding a dialog open); quit it, then run this again"
  exit 1
fi
cwkVoyager, Sources/VoyagerApp/VoyagerRuntime.swift: isolation only in the verification build, and Pippa only on loopback there (verbatim, lines 11-40)·swift
    /// Honoured only in the verification build (the kit's rule): the shipping
    /// app cannot be pointed at a throwaway container.
    static let isolation: IsolationGate = {
        let gate = IsolationGate(requiredBundleIdentifier: VoyagerPaths.verificationBundleIdentifier)
        // Two launches of one verification run share a container, so the
        // second can restore what the first saved.
        if let run = gate.override("VOYAGER_ISOLATED_RUN"),
           !run.isEmpty, run.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "-" }) {
            return IsolationGate(requiredBundleIdentifier: VoyagerPaths.verificationBundleIdentifier, runIdentifier: run)
        }
        return gate
    }()
    static let paths = VoyagerPaths(isolation: isolation)

    /// The product version, from `cwk-product.json`. Nil when the manifest
    /// cannot be read; it is never guessed.
    static let productVersion: String? = {
        try? ProductManifest.load(using: BundledResourceLocator(), expecting: productID).version
    }()

    /// Where Pippa is. The installed app reaches her on this Mac. A
    /// verification run reaches only the stand-in its script names, never
    /// the real one: a check must not write into her store.
    static let pippaLocation: PippaLocation? = {
        guard isolation.isPermitted else { return .thisMac }
        guard let base = isolation.override("VOYAGER_PIPPA_BASE").flatMap(URL.init(string:)),
              ["127.0.0.1", "localhost"].contains(base.host() ?? "")
        else { return nil }
        return PippaLocation(base: base)
    }()
cwkVoyager, scripts/smoke.sh: two launches of one isolated run, each with a deadline (verbatim, lines 96-111)·shell
for phase in first restore; do
  VOYAGER_ISOLATED_RUN="$run" VOYAGER_SMOKE_PHASE="$phase" VOYAGER_SMOKE_REPORT="$work/$phase.json" \
    VOYAGER_PIPPA_BASE="$pippa_base" "$binary" --isolated &
  pid=$!
  # A run that hangs is a failure, not a wait.
  for _ in {1..240}; do
    /bin/kill -0 "$pid" 2>/dev/null || break
    /bin/sleep 0.25
  done
  if /bin/kill -0 "$pid" 2>/dev/null; then
    /bin/kill -TERM "$pid" 2>/dev/null || true
    print -u2 "VOYAGER_SMOKE_FAIL the $phase run did not finish within 60 s"
    exit 1
  fi
  [[ -f "$work/$phase.json" ]] || { print -u2 "VOYAGER_SMOKE_FAIL the $phase run wrote no report"; exit 1 }
done

External links

Exercise

Write an install script for a Mac app you ship. Stage the new bundle beside the installed one and verify its signature. Ask the running copy to quit with SIGTERM and wait a bounded time. Swap the bundles with the previous one kept aside, and restore it from an exit trap if any later step fails. Then add a verification build with its own bundle identifier, the only build your app allows to run against a temporary folder, and make the shipping build exit if it is asked to.
Hint
Test the rollback on purpose: make the final verification fail after the swap and confirm that /Applications holds the old bundle afterwards. For the isolation switch, check the bundle identifier inside the app, not in the script, so that no way of launching the shipping binary can turn it on.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.