Skip to content

Release Shark Explorer separately from LeakCanary - #2926

Merged
pyricau merged 13 commits into
mainfrom
read-the-message-here
Aug 5, 2026
Merged

Release Shark Explorer separately from LeakCanary#2926
pyricau merged 13 commits into
mainfrom
read-the-message-here

Conversation

@pyricau

@pyricau pyricau commented Aug 3, 2026

Copy link
Copy Markdown
Member

Shark Explorer is a desktop app, not a library, so it ships as a signed macOS DMG on a GitHub release rather than to Maven Central. This adds the machinery for that: its own version line, its own tags, its own release workflow, and an in-app check that tells you when a newer release exists.

Nothing here changes anything about how LeakCanary itself is released.

The version can't say "alpha", and that isn't a preference

Every installer format validates the version, and I measured what they leave between them by building each one:

Version Result
3.0-alpha-10 Illegal version for 'Dmg', and for Msi. No qualifiers, in any format.
0.1.0 createDistributable fails: The first number in an app-version cannot be zero or negative
2026.8.0, 256.0.0 Illegal version for 'Msi', whose fields cap at 255.255.65535
1.0.0, 255.255.65535 Build fine

So the intersection is MAJOR.MINOR.PATCH with a major from 1 to 255. There is no number that means "before 1.0" — not 0.x, not a calendar version, no qualifier. The alpha is said by the release instead: release-shark-explorer.yml marks every release as a prerelease and titles it Shark Explorer <version> (alpha).

Splitting it across macOS.packageVersion and macOS.packageBuildVersion doesn't help either. The user-visible field is CFBundleShortVersionString, which is also what Munki compares for Managed Software Center updates, so pinning it would freeze updates for anyone who installed from there.

Signing

Through block/apple-codesign-action, which signs and notarizes with Block's Developer ID through an internal service the workflow reaches over OIDC. No certificate or Apple credential lives in this repository — that's what makes signing a public repo's artifacts safe, and it's how block/qrgo and block/buzz are signed.

It needs two repository secrets, OSX_CODESIGN_ROLE and CODESIGN_S3_BUCKET, provisioned by #mdx-ios. Until those exist the macos jobs will fail, so the first release can't be cut yet. Everything else in here works today.

entitlements.plist isn't optional: every key in it is something the JVM does that the hardened runtime forbids, so a notarized build without them launches and immediately dies.

The service signs the .app and rebuilds the DMG around it, so the DMG container itself stays unsigned. The workflow runs codesign --verify, stapler validate and spctl --assess on the app inside the DMG and prints what they say, rather than assuming.

The update check reports and nothing else

A bar naming the new version, a link, and a way to dismiss it. No self-update: replacing a running signed bundle needs a native helper, and that's a much bigger thing to get right than a link.

Update bar reading "Shark Explorer 99.0.0 is available. This run is 1.0.0." with Download and Not now buttons, above the heap dump bar

Two decisions worth calling out:

  • It doesn't use releases/latest. GitHub has one "latest" pointer per repository, and this repo publishes LeakCanary on v* tags, so that endpoint answers with the wrong release — it currently returns v3.0-alpha-9. The unauthenticated API is also 60 requests an hour per IP, which a shared corporate egress can exhaust. The app reads one small manifest off the release download CDN, which is unmetered.
  • Only promote-shark-explorer.yml writes that manifest. So publishing a release and offering it to everyone are two separate acts, and a release that turns out to be broken is one nobody was told about rather than one that has to be withdrawn.

Its own change log

Two release schedules means two change logs, so docs/shark-explorer-changelog.md is new and a Shark Explorer change never goes in the LeakCanary one — it would otherwise show up under a LeakCanary release it has nothing to do with. Same markers, minus 🐤, which is a library thing. It starts at ## Unreleased with * ✨ Initial release.

A change log nobody is told to update ships empty, so the release process now says so: cutting a version includes renaming the Unreleased heading, and there's a step to deploy the site, since the release notes link to the page.

Verified, not assumed

  • ./gradlew build green; :shark:shark-explorer:shark-explorer-app:check green with 22 new tests.

  • packageDmg builds a 69 MB DMG whose Info.plist carries com.squareup.leakcanary.shark-explorer and version 1.0.0, with the generated version resource inside the packaged jar.

  • The app launched and the live check ran against the real URL: latest.properties answered 404, logged, no bar, on a worker thread. That's the expected answer until the first promotion.

  • Both workflows parse, and the tag/version check and manifest-writing shell were run locally. UpdateCheckTest pins the manifest format from the app's side, since the workflow writes it in bash and nothing else keeps the two agreeing.

  • mkdocs build renders both new pages with no broken links; the only warnings are the pre-existing ones for docs/api, which siteDokka generates and isn't committed.

Known gap

There's no user-facing Shark Explorer docs page yet, so the release notes describe the app rather than linking to one. Worth writing before the first release goes out, since discoverability is the point — it's being written separately and will land in the same Shark Explorer nav section as the change log.

🤖 Generated with Claude Code

The explorer is a desktop app, not a library, so it goes out as a signed macOS
DMG on a GitHub release rather than to Maven Central, on `shark-explorer-*`
tags and its own version line.

It also has to have its own version line. Every installer format validates the
version, and between them they leave only three integers with a major from 1 to
255: `3.0-alpha-10` builds nothing, and neither does `0.1.0`, which macOS
rejects with "The first number in an app-version cannot be zero or negative".
So no number can mean "before 1.0", and the alpha is said by the release being
a prerelease titled that way instead.

macOS signing goes through block/apple-codesign-action, which signs and
notarizes with Block's Developer ID via an internal service the workflow
reaches over OIDC. No certificate or Apple credential lives in this
repository, which is what makes signing a public repo's artifacts safe.
Windows and Linux build unsigned.

The app tells the user when a newer release exists and does nothing else: no
self-update, since replacing a running signed bundle needs a native helper and
is a much bigger thing to get right than a link is. It reads one manifest off
the release download CDN rather than the GitHub API, because `releases/latest`
answers with the newest release of either line — usually a LeakCanary one — and
because the unauthenticated API allows 60 requests an hour per IP, which a
shared corporate egress can exhaust.

Only promote-shark-explorer.yml writes that manifest, so publishing a release
and offering it to everyone stay two separate acts. A release that turns out to
be broken is then one nobody was told about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread .github/workflows/release-shark-explorer.yml Fixed
Comment thread .github/workflows/release-shark-explorer.yml Fixed
Two release schedules means two change logs: someone reading either one is
asking about one release line, and a Shark Explorer entry in the LeakCanary
change log would show up in a LeakCanary release it has nothing to do with.

The release process has to say so, or a release ships without an entry: cutting
a version now includes renaming the Unreleased heading, and deploying the site,
since the release notes link to the page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pyricau and others added 3 commits August 3, 2026 22:05
GitHub Advanced Security flagged both setup-gradle steps (zizmor's
cache-poisoning audit). An Actions cache is writable from any branch of the
repository and restorable by a tag build, so a cache entry is an untrusted
input on a workflow whose output people download — and, on macOS, download
signed and notarized as Block.

cache-read-only would not fix it: restoring is the attack. A release runs a few
times a year, so there is no build time worth that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A first run against the real signing service produced an app signed as Block
with the hardened runtime and every entitlement — that Apple had no
notarization record of, and that does not launch: it hangs in dyld with no
output, where the same bundle re-signed ad hoc starts in two seconds.

stapler is the only check that catches it. codesign is happy, and spctl answers
"accepted, source=Developer ID" because nothing in the workflow carries the
quarantine attribute that makes Gatekeeper insist on a ticket. So the warning
becomes an error: a release nobody can launch is worse than no release.

Also run the Windows job under bash. It defaulted to pwsh, where `./gradlew`
does nothing and the step still passes, so the MSI was never built and the
failure surfaced as a later step not finding a file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both were guesses at a component before; now they are a function each.
Signing an app whose name has a space works — it is the lambda's reply that
fails — and notarization is skipped because notarytool's exit status is read
in place of the status it reports.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pyricau
pyricau force-pushed the read-the-message-here branch from 6176433 to 6701535 Compare August 4, 2026 18:47
pyricau and others added 6 commits August 4, 2026 12:15
Its own canary goes through the same pipeline and comes back notarized and
stapled, so the earlier wording was too broad: the refusal is about this bundle,
and what Apple objected to is not recoverable from the artifact.

Note the one thing spctl does tell you while being a false green on the verdict:
`source=Notarized Developer ID` against a plain `source=Developer ID`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Block's signing service is handed the .app as a zip named after packageName, and
its lambda parses that name as a URI, so `Shark Explorer.app.zip` fails with
`bad URI(is not URI?)` after a mac worker has already signed the app.
squareup/tf-mobuild-workers#1365 fixes that; this is what gets a signed build in
the meantime.

Going back is one line, and it renames the .app for everyone who installed one,
so it belongs in a release of its own rather than in the first commit after that
lambda ships.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The merged fix makes the pipeline fail on a refusal instead of returning an
artifact that will not launch, which is what the retry did. Worth writing down
that this is the fix working rather than a new problem, and that the reason
Apple gave is only readable in Buildkite: the lambda turns any failed build
into a generic 400, so the log output the fix added never reaches CI here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
skiko's runtime jar ships both architectures' dylibs. Compose extracts the one
it packages for into the app directory and leaves the other inside the jar,
where nothing loads it and nothing signing the bundle can reach it: a signer
walks files, and that is an entry in a zip.

Apple's notary service opens jars, so it was the one Mach-O arriving unsigned,
and one is enough to have the whole app refused. Every file a signer can see was
signed correctly, which is why no local check found this and why the DMG that
came back passed codesign, spctl and its entitlements while hanging in dyld on
launch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jlink includes only the modules this list names, and the list named one. So the
update check — the only thing in the app that fetches anything — died on
NoClassDefFoundError: java/net/http/HttpClient in every packaged build, logged
once at startup and then never mentioned a new version again. `run` has the whole
JDK on hand, so nothing about working on the app shows this.

The four are what suggestRuntimeModules reports, which is the task to re-run when
the dependencies change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The name stays one word. squareup/tf-mobuild-workers#1365 has merged, and a
tagged build after that merge still failed on `bad URI(is not URI?)` about the
same S3 key, because the lambda is Ruby that terraform packages: it serves the
deployed zip until the rollout pipeline applies, and every check on that pull
request is a plan.

So the thing to wait for is the rollout rather than the merge, and a tagged build
is what says whether it has happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pyricau
pyricau force-pushed the read-the-message-here branch from 9e9e689 to d14d799 Compare August 4, 2026 21:56
pyricau and others added 2 commits August 4, 2026 15:26
The name went to one word because a space in it reached Block's signing service
as an S3 key and broke the reply that service sends back, five minutes after a
mac worker had already signed the app. squareup/tf-mobuild-workers#1365 has now
rolled out to production, so the name can say what it means.

Done before the first release rather than after, since renaming the .app once
anyone has installed one is a migration and right now nobody has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verifying the notarized DMG, a quarantined first launch produced no output, no
log and no CPU for over five minutes, which is indistinguishable from the failure
this page warns about — and was only the screen being locked. The same bundle and
command started in four seconds unlocked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pyricau
pyricau merged commit fcce834 into main Aug 5, 2026
16 checks passed
@pyricau
pyricau deleted the read-the-message-here branch August 5, 2026 01:55
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.

2 participants