Skip to content

fix(CodeSigningPlugin): sign assets at processAssets ANALYSE stage before REPORT#1379

Open
JhohellsDL wants to merge 2 commits intocallstack:mainfrom
JhohellsDL:fix/code-signing-plugin-zephyr-timing
Open

fix(CodeSigningPlugin): sign assets at processAssets ANALYSE stage before REPORT#1379
JhohellsDL wants to merge 2 commits intocallstack:mainfrom
JhohellsDL:fix/code-signing-plugin-zephyr-timing

Conversation

@JhohellsDL
Copy link
Copy Markdown

Summary

Fixes #1377

CodeSigningPlugin was signing bundles in compiler.hooks.assetEmitted,
which fires after processAssets completes. When using withZephyr(),
Zephyr captures and uploads assets at PROCESS_ASSETS_STAGE_REPORT (5000)
— before assetEmitted fires — resulting in unsigned bundles being uploaded
to the CDN, making verifyScriptSignature: 'strict' ineffective.

Changes

  • Moved signing logic from assetEmitted to processAssets at
    PROCESS_ASSETS_STAGE_ANALYSE (2000), before Zephyr's REPORT stage (5000)
  • Assets are now signed in memory via compilation.updateAsset()
    instead of reading/writing from disk
  • Removed chunkFilenames Set and emit hook — no longer needed since
    signing iterates compilation.chunks directly inside processAssets
  • Added test verifying assets are signed before REPORT stage
  • Updated documentation with ## Behavior section explaining the signing stage

Testing

  • All existing tests pass
  • Added new test simulating a plugin at REPORT stage confirming
    assets are already signed when captured
  • Verified end-to-end in production with withZephyr() — bundles
    uploaded to CDN now contain the signature and verifyScriptSignature: 'strict'
    works correctly

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 11, 2026

🦋 Changeset detected

Latest commit: 94a9e64

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@callstack/repack Patch
@callstack/repack-plugin-expo-modules Patch
@callstack/repack-plugin-nativewind Patch
@callstack/repack-plugin-reanimated Patch
@callstack/repack-dev-server Patch
@callstack/repack-init Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 11, 2026

@JhohellsDL is attempting to deploy a commit to the Callstack Team on Vercel.

A member of the Team first needs to authorize it.

@dannyhw
Copy link
Copy Markdown
Collaborator

dannyhw commented Apr 13, 2026

Is there some simple way to test this locally? What would you recommend? @JhohellsDL

Copy link
Copy Markdown

@MikitasK MikitasK left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the PR is pretty solid 👍

I tested it locally with apps/tester-app using temporary processAssets REPORT-stage capture plugin & the results satisfied my expectations:

  1. capture report showed all remote chunks signed at REPORT stage (they're showed unsigned on main branch tho)
  2. remote chunk loads successfully when verifyScriptSignature: 'strict' & public key embedded in the app
Screen.Recording.2026-04-14.at.17.17.40.mp4

can you just consider a few suggestions before merge:

compiler.hooks.thisCompilation.tap(
'RepackCodeSigningPlugin',
(compilation) => {
// @ts-ignore — sources is available on both rspack and webpack compilers
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it possible to get rid of @ts-ignore here?

expect(chunkBundle.length).toBeGreaterThan(1280);
});

it('exposes signed chunk assets to processAssets REPORT (after ANALYSE signing)', async () => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this test proves assets are signed by REPORT, which is good 👍 but what about one more test for asserting the actual delta from old behavior? it might be done by checking that content at REPORT is larger than unsigned chunk or by detecting /* RCSSB */ substring in content
wdyt?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call. I extended the test so it doesn’t only assert the JWT-shaped tail at REPORT.

There is now a processAssets tap at one stage before ANALYSE (same chunk iteration as REPORT) to snapshot the asset before CodeSigningPlugin runs. Then at REPORT we assert:

the pre-sign snapshot has no /* RCSSB / marker
the REPORT snapshot includes /
RCSSB */
REPORT content is strictly longer than the pre-sign snapshot
The existing regex checks on the chunk vs main bundle are unchanged. That should document the regression we care about: anything observing assets at REPORT must see the already signed bytes, not only the final emitted file.

Comment on lines +98 to +117
const source = asset.source.source();
const content = Buffer.isBuffer(source)
? source
: Buffer.from(source);

logger.debug(`Signing ${file}`);
/** generate bundle hash */
const hash = crypto
.createHash('sha256')
.update(content)
.digest('hex');
/** generate token */
const token = jwt.sign({ hash }, privateKey, {
algorithm: 'RS256',
});
/** combine the bundle and the token */
const signedBundle = Buffer.concat(
[content, Buffer.from(BEGIN_CS_MARK), Buffer.from(token)],
content.length + TOKEN_BUFFER_SIZE
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit]
what about making this logic a bit more readable by moving this code into dedicated signAsset() helper?

Suggested change
const source = asset.source.source();
const content = Buffer.isBuffer(source)
? source
: Buffer.from(source);
logger.debug(`Signing ${file}`);
/** generate bundle hash */
const hash = crypto
.createHash('sha256')
.update(content)
.digest('hex');
/** generate token */
const token = jwt.sign({ hash }, privateKey, {
algorithm: 'RS256',
});
/** combine the bundle and the token */
const signedBundle = Buffer.concat(
[content, Buffer.from(BEGIN_CS_MARK), Buffer.from(token)],
content.length + TOKEN_BUFFER_SIZE
);
const signedBundle = this.signAsset(file, asset, privateKey, BEGIN_CS_MARK, TOKEN_BUFFER_SIZE);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed the nit: extracted the hash/JWT/buffer concat path into a private signAsset() helper so the processAssets callback stays easier to scan. Behavior unchanged; CodeSigningPlugin tests still pass.

Comment on lines +80 to +83
// Sign at ANALYSE (2000) so assets are signed before any plugin
// running at REPORT (5000) — e.g. withZephyr() — captures them.
// The original assetEmitted hook fires after processAssets completes,
// which is too late when Zephyr uploads assets at REPORT stage.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] this comment might be a bit shorter

Suggested change
// Sign at ANALYSE (2000) so assets are signed before any plugin
// running at REPORT (5000) — e.g. withZephyr() — captures them.
// The original assetEmitted hook fires after processAssets completes,
// which is too late when Zephyr uploads assets at REPORT stage.
// Sign at ANALYSE (2000) so later processAssets consumers,
// such as Zephyr at REPORT (5000), receive already-signed assets

Copy link
Copy Markdown

@MikitasK MikitasK left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 🙌

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.

CodeSigningPlugin signs too late (assetEmitted) – incompatible with in-memory asset consumers (e.g. Zephyr)

3 participants