Skip to content

darwin tarball binaries are not notarized at release time #67140

Open

Description

@UlisesGascon

Thanks @sxa for bringing this to my attention. It has been a while since I last touched the macOS notarization, so I went back to it with fresh eyes.

Update 2026-10-31: the "no notarization ticket" findings below were produced with codesign --verify -R=notarized, which only reads the local ticket cache. Asking Apple's ticket service directly shows that 15 of the 18 most recent tarballs do have a ticket, created days after release by third parties that bundle our binary. Only v26.13.0 (both arches) and v26.12.0 x64 lack one. The root cause and the fix are unchanged; the gap is the first days after every release. Details in this comment. Struck text below is kept for context.

Context

We have had reports about badly signed or non notarized macOS binaries for a long time:

Historically this was hard to reproduce locally, which is why #52610 never got the testing it needed.

[Solved] local reproduction at least to debug

I wrote a script that downloads the darwin tarball and the pkg for a version, verifies signatures, asks Apple whether a notarization ticket exists, and compares the tarball binary with the one inside the pkg. It needs no credentials and executes nothing it downloads, so anyone can run it on any modern macOS version and architecture: https://gist.github.com/UlisesGascon/ddce84a3f80521324e1c77bbfba12dba.

sh macos-validate.sh v26.12.1 v26.13.0 v24.22.1
DIST=https://nodejs.org/download/nightly ARCH=arm64 sh macos-validate.sh v28.0.0-nightly202610304e48b9b06b

Relevant output from my execution, same result for all three releases and for the nightly:

$ codesign --verify --strict --verbose=2 node-v26.13.0-darwin-x64/bin/node
node-v26.13.0-darwin-x64/bin/node: valid on disk
node-v26.13.0-darwin-x64/bin/node: satisfies its Designated Requirement
  exit=0
$ codesign --verify --verbose=2 -R=notarized node-v26.13.0-darwin-x64/bin/node
test-requirement: code failed to satisfy specified code requirement(s)
  exit=3

$ pkgutil --check-signature node-v26.13.0.pkg
   Status: signed by a developer certificate issued by Apple for distribution
   Notarization: trusted by the Apple notary service
  exit=0
$ xcrun stapler validate node-v26.13.0.pkg
The validate action worked!
  exit=0

  CDHash DIFFER: tarball=f2a61f63d9bcc07d74e1f67c443c85cff7a5cdd5 pkg=940e23498f45871c88f11bdf04d07760a4e243a7

So every tarball bin/node is correctly signed with our Developer ID but has no notarization ticket, on x64 for 24.22.1, 26.12.1 and 26.13.0, and on arm64 for today's nightly. Correction: the signatures are fine, but the "no ticket" results came from codesign -R=notarized, which only reads the local cache. Apple's ticket service shows tickets for 24.22.1 and 26.12.1 on both arches (created 2026-10-21 to 10-23, by third parties) and none for 26.13.0 and the nightly. Every pkg is notarized and stapled. The pkg's bin/node is a different build from the tarball's, so the pkg ticket does not cover it. The "invalid signature" errors in the help issues do not reproduce on 15.0.1, 26.4.1 or 27.0.1.

What is wrong here?

  1. The binary target in the Makefile only runs tools/osx-codesign.sh. tools/osx-notarize.sh is only called by the pkg target, takes a version id and hardcodes node-$pkgid.pkg as the thing to submit. Tarball binaries have never been submitted to Apple.
  2. On the Jenkins side the NOTARIZATION_* variables that gate the script are only exported on the pkg axis, so even a Makefile change would be a no-op on the tar nodes until the job is adjusted.
  3. There is no verification step for tarballs. The pkg gets spctl --assess and a stapler check; the tar job has nothing, so an unticketed binary ships silently.
  4. The one tarball known to carry a ticket (26.12.1 arm64, per v26.13.0 Darwin tarball binaries are not notarized build#4545) is the exception, probably from a manual submission during the test-http-regr-gh-2928 is flakey in parallel runs #4527 debugging, not evidence of a working path. Correction: most tarball binaries do end up with a ticket, one to seven days after release, because third parties notarize apps that bundle our signed bin/node and Apple issues a ticket for every nested binary. That is accidental and late, not a working path; 26.13.0 shows what the first days after a release look like. The pkg's bin/node cannot cover it: the pkg target configures with the default prefix and the tarball target with --prefix=/ and PORTABLE=1, so the two binaries always differ.

Proposal

Two PRs plus one Jenkins change:

  1. nodejs/node: make tools/osx-notarize.sh take a path, keep the pkg flow as is, and add a binary flow: zip bin/node with ditto, submit with notarytool, verify that Apple's ticket service returns a ticket for the binary's cdhash, using the existing retry helper, fail the build if no ticket appears. Call it from the binary target after codesign. The binary is not modified, so the published checksums do not change. Backport to 22, 24 and 26.
  2. nodejs/build: a tools/macos-tarball-notarization.sh that checks any published version for a ticket against Apple's ticket service (codesign -R=notarized is not a reliable check, see the update) and, with --submit and the release keychain profile, backfills the ones that lack it. Plus docs in release-overview.md describing the signing and notarization flow and how to enable it on a job axis.
  3. ci-release iojs+release: export NOTARIZATION_ID and NOTARIZATION_TEAM_ID on the macOS tarball axes. NOTARIZATION_PASSWORD already reaches every axis through the job-level credentials binding apple-notarization-password, and the release image already has the cert and the NODE_RELEASE_PROFILE keychain profile on every macOS release VM, so nothing changes in ansible or orka. The job has one conditional step per axis type; the pkg one injects the variables, the tar one does not. The change is two lines in the tar step's "Inject environment variables" block:
 <org.jenkinsci.plugins.conditionalbuildstep.ConditionalBuilder>
   <runCondition class="org.jenkins_ci.plugins.run_condition.core.ExpressionCondition">
     <expression>osx1(\d|\d\d\d)-release-tar|macos1(\d|\d\d\d)-release-tar</expression>
     <label>${ENV, var="nodes"}</label>
   </runCondition>
   <conditionalbuilders>
     <EnvInjectBuilder>
       <info>
-        <propertiesContent>CODESIGN_CERT=Developer ID Application: Node.js Foundation</propertiesContent>
+        <propertiesContent>CODESIGN_CERT=Developer ID Application: Node.js Foundation
+NOTARIZATION_ID=REDACTED
+NOTARIZATION_TEAM_ID=REDACTED</propertiesContent>
       </info>
     </EnvInjectBuilder>
     <hudson.tasks.Shell>
       <command>... security unlock-keychain -p ${SSH_RELEASE_PASSWORD} ... make -j $JOBS binary-upload ...</command>
     </hudson.tasks.Shell>
   </conditionalbuilders>
 </org.jenkinsci.plugins.conditionalbuildstep.ConditionalBuilder>

Backlog

We can split into 2 goals (notarize on release and backfill) as they are independent and the priorities differ too.

Notarize on release

Backfill

Activity

  1. self-assigned this
    on Oct 30, 2026
  2. added
    buildIssues and PRs related to Node.js builds or CI infrastructure.
    build-agendaIssues and PRs to discuss during Build Working Group meetings.
    on Oct 30, 2026
  3. sxa commented on Oct 30, 2026

    @sxa
    Member

    Sounds good to me - thanks for investigating and figuring out how to fix it. I feel we wouldn't want to go back and re-submit previous ones so we should ideally try to get this in place for the next feasible release on each line.

  4. UlisesGascon commented on Oct 30, 2026

    @UlisesGascon
    MemberAuthor

    While working on nodejs/build#4549 and shaping the backfill script I discovered quite interesting things, so basically:

    • codesign --verify -R=notarized is not an online check. It only reads the local ticket cache. On a Mac that never fetched a ticket for a binary it fails even when Apple has one. Running spctl --assess first triggers the fetch and the same codesign call then passes. All the "no ticket" results in this issue and in the logs above came from that command, so most of them were false negatives. Sorry for the noise.
    • Asking Apple directly tells a different story. Apple's Gatekeeper ticket service can be queried by CDHash, the same lookup syspolicyd does, and it is cache-free. For the 18 most recent tarballs (three releases per supported line, both arches): 15 have a notarization ticket, 3 do not: v26.13.0 x64, v26.13.0 arm64 and v26.12.0 x64.
    • The tickets are not ours. Our .pkg tickets are created within a minute of the pkg signature. The tarball tickets are created one to seven days after each release and get re-issued regularly. That is third parties: when someone notarizes an app that bundles our unmodified, Developer ID signed bin/node, Apple issues a ticket for every nested binary, including ours. Popular versions get covered within days; a brand new one (26.13.0 was built on 10-28) or one nobody bundles (26.12.0 x64) does not. This follows from how tickets work, none of it is Node specific:
      • A ticket is a signed list of cdhashes with "at least one entry for every item of executable code in your product", and a cdhash "uniquely identifies executable code that's been notarised" (Apple DTS, Notarisation Fundamentals). So a bundled bin/node gets its own entry under the bundler's submission, keyed by the same hash as our tarball binary.
      • Gatekeeper checks "the notarization status of any piece of signed code, using only its code signing digest", and without a stapled ticket it fetches it from an Apple CloudKit service (Mothers Ruin, Notarization Tickets). The same DTS post shows syspolicyd fetching from api.apple-cloudkit.com/.../com.apple.gk.ticket-delivery/.../records/lookup, which is the endpoint I queried; the record name is 2/<digest_type>/<cdhash> (apple-codesign ticket_lookup).
      • Gatekeeper "looks for the app's ticket online" first and only falls back to a stapled ticket offline (Xcode help), which is why a bare binary with no stapled ticket still runs clean once anyone has notarized that hash.

    So the tarball binaries are not "never notarized"; they are notarized late, by accident, by other people. The real gap is the first days after every release, which is when most users upgrade, plus any version nobody happens to bundle. That is exactly the window where nodejs/build#4545 was hit.

    What this changes:

    I will edit the issue body to strike the wrong claims and point here.Edit: Already up to date

  5. UlisesGascon commented on Oct 30, 2026

    @UlisesGascon
    MemberAuthor

    I feel we wouldn't want to go back and re-submit previous ones so we should ideally try to get this in place for the next feasible release on each line.

    @sxa I did split the effort into two independent pieces, so we can decide without pressure on the backfill. I am checking now how many versions are affected after the last findings

  6. changed the title [-]darwin tarball binaries are signed but not notarized[/-] [+]darwin tarball binaries are not notarized at release time[/+] on Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

buildIssues and PRs related to Node.js builds or CI infrastructure.build-agendaIssues and PRs to discuss during Build Working Group meetings.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions