Skip to content

fix(packaging): stop macOS packages relocating themselves elsewhere - #46

Open
glennmichael123 wants to merge 1 commit into
mainfrom
fix/pkg-bundle-relocation
Open

fix(packaging): stop macOS packages relocating themselves elsewhere#46
glennmichael123 wants to merge 1 commit into
mainfrom
fix/pkg-bundle-relocation

Conversation

@glennmichael123

Copy link
Copy Markdown
Contributor

Every .pkg craft has ever produced can install itself somewhere other than where it says, and report success.

createPKG let pkgbuild infer its component. pkgbuild's default for a bundle is BundleIsRelocatable = true, so the package carries:

<relocate><bundle id="dev.example.app"/></relocate>

That directive tells installer the payload path is only a suggestion. It looks the bundle identifier up on the target volume and, if the system has a copy of that bundle registered anywhere, writes the payload over that copy instead — and exits 0. The user gets "The install was successful" and finds nothing at /Applications.

I had this wrong, twice

I told you this was Intel-specific. It isn't — darwin-arm64 failed with a byte-identical ENOENT in an adjacent run, on the same code and the same runner image.

And the relocation directive alone isn't the cause either — it's present in packages from green runs too. It's the enabler; the trigger is a registered copy of the same bundle id existing when the install happens. That's why it's intermittent, and what actually separates green from red is how long the job had already been running:

lifecycle step began outcome
~22 minutes into the job all 3 failing macOS legs
20–124 seconds in all 8 passing macOS legs

Which is what a race against the system's bundle indexer looks like. The lifecycle script leaves two copies of the fixture bundle in its work directory — those are the relocation target.

The fix

Write the component plist instead of inferring it, with BundleIsRelocatable off. Verified on a real fixture package, not just in the unit test:

relocate:          <relocate/>
install root mode: drwxr-xr-x  "."
payload exe count: 1

Empty <relocate/> — nothing for installer to redirect to — with the payload unchanged.

Note the pkg-info attribute relocatable="false" appears either way and does not govern this; only the <relocate> element does. I built both variants side by side to confirm that, because that attribute is exactly the thing that makes the current package look fine on inspection.

BundleIsVersionChecked is off too: with it on, installing an older version over a newer one is skipped — silently turning the rollback step, and a user's rollback, into a no-op.

Two things found alongside

  • The package asked for / to be 0700. The staging dir came from mkdtemp (mode 0700), and pkgbuild records the --root directory's own mode as the mode of . — which under --install-location / is the target volume's root. Measured: drwx------ ".". Now staged in a 0755 child, measured drwxr-xr-x.
  • The failure left no evidence. installer reporting success while writing elsewhere surfaced only as a bare ENOENT from the launch step, which cannot distinguish "nothing installed" from "installed elsewhere". The receipt is now recorded after each install, into report.json and the uploaded evidence — location: is where the payload actually landed. The workflow uploads the whole evidence directory too; the compiled fixtures sat outside the old upload path, which is why this needed a workflow dispatch to diagnose rather than an artifact download.

Verification

  • bun test — 471 pass, including 3 new ones; I confirmed they discriminate by flipping BundleIsRelocatable back to true and watching 18 pass / 1 fail
  • tsc --noEmit clean, pickier clean
  • real fixture package rebuilt and inspected with pkgutil --expand / lsbom / pkgutil --payload-files

This is a user-facing bug, not a CI one. The lifecycle workflow is just the thing that noticed.

`createPKG` let `pkgbuild` infer its component, and `pkgbuild`'s default
for a bundle is `BundleIsRelocatable = true`. Every .pkg craft has ever
produced therefore carries:

    <relocate><bundle id="dev.example.app"/></relocate>

which tells `installer` the payload path is a suggestion. It looks the
bundle identifier up on the target volume and, if the system has a copy
of that bundle registered anywhere, writes the payload over *that* copy
instead — and exits 0. The user is told "The install was successful" and
finds nothing at /Applications.

That is the native lifecycle workflow's macOS failure, which I had
called Intel-specific. It is not: darwin-arm64 failed identically in an
adjacent run on the same code and the same image. What separates a green
leg from a red one is how long the job had been running beforehand — the
three failing legs reached the lifecycle step ~22 minutes in, the eight
passing ones 20-124 seconds in — which is what a race against the
system's bundle indexer looks like. The script leaves two copies of the
fixture bundle in its work directory, and those are the relocation
target.

Fixed by writing the component plist instead of inferring it, with
BundleIsRelocatable off, which reduces the directive to a bare
`<relocate/>` — nothing for installer to redirect to. Verified on a real
fixture package, not just in the unit test.

`BundleIsVersionChecked` is off too: with it on, installing an older
version over a newer one is skipped, which silently turns the rollback
step — and a user's rollback — into a no-op.

Two things found alongside:

- The staging directory came from mkdtemp, mode 0700, and pkgbuild
  records the --root directory's own mode as the mode of `.`. Under
  `--install-location /`, `.` is the target volume's root, so the
  package asked for `/` to be 0700. Now staged in a 0755 child.
- `installer` reporting success while writing elsewhere left no trace
  beyond a bare ENOENT from the launch step. The receipt is recorded
  after each install now, into report.json and the uploaded evidence:
  `location:` is where the payload actually landed. The workflow also
  uploads the whole evidence directory, since the compiled fixtures sat
  outside the old path and made the artifact useless for diagnosing this.

This is a user-facing bug, not a CI one. Any app packaged by craft can
install over a stray copy of itself and report success.
@github-actions

Copy link
Copy Markdown

🔴 Benchmark Results

Performance regression detected!

Metric Value
Startup Time 36ms
Memory Usage 1856KB
Change 18ms (100.00%)
Benchmark Details
  • Startup time measured as average of 5 runs
  • Regression threshold: 20% slower than baseline

@github-actions

Copy link
Copy Markdown

✅ Binary Size Report

Metric Value
Current Size 14214KB (13.88MB)
Change 0KB (0%) unchanged
Size limits
  • Warning: 14.50MB
  • Maximum: 16.00MB

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