Skip to content

Build a macOS installer .pkg from the staged install payload - #16

Merged
chrisuthe merged 2 commits into
mainfrom
chrisuthe/task/macos-installer-pkg-from-the-install-payload
Aug 19, 2026
Merged

Build a macOS installer .pkg from the staged install payload#16
chrisuthe merged 2 commits into
mainfrom
chrisuthe/task/macos-installer-pkg-from-the-install-payload

Conversation

@chrisuthe

Copy link
Copy Markdown
Member

Third slice of roadmap item 10, and the one it named as the untested half of the
payload: the .pkg. The payload the first slice already stages is the input
rather than something built again, so the installer ships exactly the files the
install() rules do and cannot drift from the tarball published beside it.

What is here

scripts/build_macos_pkg.sh wraps a staged DESTDIR tree. A script rather than
lines in ci.yml for two reasons: an installer has to be buildable and
installable
by a developer with no runner, and scripts/*.sh is already gated
by the shellcheck job, so it arrives linted with no workflow change.

  • pkgbuild --root <payload>/usr/local --install-location /usr/local, not a
    root one level up paired with /usr. The staged tree carries BUILD-INFO.txt
    beside usr/ for the tarball's readers, and the wider root would install that
    file at / — excluded structurally, by where the root points, rather than by
    a filter a later tidy-up can drop. --ownership recommended is named although
    it is the default, because it is what makes the installed files root:wheel.
  • productbuild over the component, for two things a component package
    cannot do. It has no architecture gate, so an arm64-only installer reports
    success on an Intel Mac and leaves a binary answering Bad CPU type in executable; <options hostArchitectures> is the declarative gate, and the
    value is read off the binary with lipo rather than passed in, so a universal
    build widens the installer by itself. And distribution panes are a
    product-archive feature, so a component package has nowhere to say the
    installer is unsigned at the moment somebody is deciding whether to run it.
  • The destination is /usr/local, not /opt — not a free choice. The
    prefix is baked in at configure time and the install rules already chose it.

Signing is absent, not stubbed

Out of scope per the brief, and gated on an enrolment that has not happened. No
placeholder --sign branch, no commented-out codesign, nothing assuming
secrets — a public repo gets none on a fork PR. The .pkg is what makes signing
possible later: xcrun stapler staples to a .app, .dmg or .pkg and
refuses a bare Mach-O, and that constraint is written at the build site.

It is shipped honestly meanwhile. The welcome pane and README.md both say it
is unsigned, and README.md states plainly that the .pkg is not the
Gatekeeper fix
, then separates the three paths that genuinely differ: an
Actions artifact arrives as a zip, so the browser quarantines the zip and the
.pkg inherits the flag only from Finder's Archive Utility and not from
unzip; installer -pkg makes no Gatekeeper assessment at all; a locally built
one is never quarantined. What the .pkg does fix is narrow and named as such —
installer does not quarantine what it writes, so the installed binary needs no
xattr -d. That is convenience, not identity.

Verification

CI installs it at / on the macOS leg and checks what came out: the receipt's
file list, version: and location:, then --version and the whole smoke
suite against /usr/local/bin/sendspin-cli rather than a build-tree binary. An
installer nobody installed is not shipped.

Green on all five jobs. installer reports success, the receipt reads
location: usr/local, and the smoke suite passes against the installed binary.
Locally: a real universal binary (clang -arch arm64 -arch x86_64) widens the
declaration to hostArchitectures="x86_64,arm64" by itself, and the empty
version and missing payload guards both refuse with the message that names the
fix. shellcheck and actionlint clean.

Roadmap item 10 records what shipped, and keeps two entries owed with owners
named: Developer ID signing and notarization, and a minimum-macOS gate the
architecture gate's asymmetry exposes.

@chrisuthe
chrisuthe marked this pull request as ready for review August 19, 2026 16:29
The payload item 10 already stages is the input rather than something built
again, so the installer ships exactly the files the install() rules do and
cannot drift from the tarball published beside it.

scripts/build_macos_pkg.sh wraps it: pkgbuild for the component, rooted at the
payload's usr/local so BUILD-INFO.txt is excluded by where the root points
rather than by a filter, then productbuild for the product archive. That layer
earns itself twice over -- a component package has no architecture gate, so an
arm64-only installer would report success on an Intel Mac and leave a binary
answering "Bad CPU type in executable", and it has nowhere to say the installer
is unsigned at the moment somebody is deciding whether to run it. The
architectures are read off the binary with lipo, so a universal build widens the
declaration by itself.

It goes in build.yml, the reusable build ci.yml and release.yml both call, next
to the Package step whose staged tree it consumes. Not in ci.yml: that file takes
no part in a tag, and an installer only a branch push produced would be exactly
the seam build.yml's no-inputs comment refuses. So release.yml attaches it, which
is the third obligation the matrix comment there already says a publishing leg
owes -- an entry in the asset set the release diffs before it publishes, plus its
line in SHA256SUMS, because a release asset a downloader cannot verify is worse
than one that is not there.

Signing and notarization are deliberately absent, not stubbed: they are gated on
an enrolment that has not happened, and a public repo gets no secrets on a fork
PR. The .pkg is what makes them possible later -- xcrun stapler refuses a bare
Mach-O -- and it is shipped honestly meanwhile, in the welcome pane, in
README.md, in the release notes and in BUILD-INFO.txt, none of which presents it
as the Gatekeeper fix.

The build installs it at / on the macOS leg and checks what came out: the
receipt's file list, version and location, then --version and the whole smoke
suite against /usr/local/bin/sendspin-cli. An installer nobody installed is not
shipped, and this was the untested half of the payload. Being in build.yml, that
happens on a tag as well as a push -- the one build that reaches a release is not
the one build that never gets installed.
Three corrections, each from something observed rather than reasoned about.

A CI run proves the install: the receipt reads `location: usr/local` and the
version it was given, and the full smoke suite passes against the installed
binary. It also shows the receipt listing no AppleDouble members at all -- the
.pkg's own BOM carries them, the receipt's does not, because installer folds
them back into attributes. The filter stays, since it is what keeps the
assertion right in both directions, but the comment now says which BOM has
them instead of claiming both do.

`cd` on a relative path resolved through a non-empty CDPATH prints where it
landed on stdout, and stdout there is the welcome pane -- so a developer with
CDPATH exported would have got a stray absolute path as its first line.

The unfiltered echo covers the filter, not a wrong identifier: that has already
failed at the --pkg-info above and never reaches it.
@chrisuthe
chrisuthe force-pushed the chrisuthe/task/macos-installer-pkg-from-the-install-payload branch from ceea15f to 105e822 Compare August 19, 2026 16:53
@chrisuthe
chrisuthe merged commit 11f07ae into main Aug 19, 2026
3 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant