Skip to content

Ship frontend build as a committed archive (extracted at cache warmup)#3779

Merged
markus-moser merged 33 commits into
2025.4from
feature/frontend-build-archive
Jul 9, 2026
Merged

Ship frontend build as a committed archive (extracted at cache warmup)#3779
markus-moser merged 33 commits into
2025.4from
feature/frontend-build-archive

Conversation

@markus-moser

@markus-moser markus-moser commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Changes in this pull request

Replaces the committed expanded public/build/ frontend tree with a single deterministic archive (build-dist/build-<id>.zip), reconstructed into public/build/ automatically — removing the per-build file churn and merge conflicts the "Automatic frontend build" commits caused.

  • Archive instead of expanded build: a package-build step zips the build into build-dist/build-<id>.zip (deterministic bytes, only the latest kept). public/build/ is gitignored; CI commits build-dist/.
  • Deterministic build id: content hash of the assets/ source tree (replaces the random v4()), so identical source ⇒ an identical archive (no git noise), while any source/config change bumps it.
  • Extraction: BuildArchiveExtractor, driven by a non-optional cache warmer at cache:warmup (while vendor/ is writable) and a writable-guarded provider fallback for local dev / git pull. Read-only-prod safe, re-extracts if the build dir is removed, never overwrites a manual npm run dev-app build, and selects the active build without relying on file mtimes.
  • Reusable: BuildArchiveProviderInterface + BuildArchiveExtractionTrait let other bundles adopt by shipping a zip and use-ing the trait (rollout is a follow-up).

See doc/06_Upgrade.md for the deployment requirement.

Additional info

  • The large public/build/ deletion is the one-time untracking of the previously committed build; build-dist/build-<id>.zip is built and committed by the frontend-build CI workflow.
  • The warm-up-extracts-at-deploy approach follows an existing Pimcore pattern — core MkdirCacheWarmer and pimcore/tinymce-bundle's CopyCacheWarmer likewise materialize files at warmup (non-optional, read-only-guarded), so read-only deployments are handled the same way Pimcore core already handles them.

🤖 Generated with Claude Code

markus-moser and others added 30 commits June 18, 2026 16:24
Replace the checked-in expanded public/build tree with a single
deterministic build-dist/build-<id>.zip that is reconstructed into
public/build automatically. This removes the per-build file churn and the
merge conflicts caused by committing the whole build on every pipeline run.

- BuildArchiveExtractor + a generic CacheWarmer + a provider trait extract
  the archive while vendor is still writable (composer/deploy), lazily in
  dev; read-only-filesystem safe and never overwrites a manual dev build
- Deterministic build id (content hash of assets/) gives stable output dir
  names and zip filename, so identical source produces an identical archive
  (no git noise)
- StaticResourcesResolver serves only the latest build by grouping dirs via
  their .build-id; package-build keeps only the newest archive and the
  extractor always picks the newest build-*.zip
- public/build is now gitignored; CI commits build-dist/ instead of public/

See UPGRADE.md: read-only deployments must run cache:warmup while vendor is
writable.
- assets/package-lock.json: add adm-zip + @types/adm-zip so `npm ci` passes
- use node: protocol imports in build-id.ts and package-build.cjs (S7772)
- BuildArchiveExtractor: use sha256 instead of md5 for the lock filename (S4790)
- wrap lines over 120 chars in StaticResourcesResolver and the iframe provider (S103)
- BuildArchiveExtractor: drop the hash() from the lock filename (use a fixed
  name in the target's parent dir) to avoid the S4790 security hotspot — the
  lock name is not a security context
- iframe provider: keep the interface list on one line under a wrapped
  `implements` to satisfy S103 (<=120) without the S1808 multiline alignment
- build-id.ts: use sha256 instead of sha1 (avoids the S4790 weak-hash security
  hotspot; the id is just a content fingerprint, but sha256 keeps Sonar happy)
- BuildArchiveProviderInterface now extends WebpackEntryPointProviderInterface,
  so the two providers implement a single short interface — fits on one line
  (no S103) with no multiline implements list (no S1808), and reads truthfully:
  a build-archive provider is an entry-point provider
Removes the `git rev-parse` subprocess (Sonar S4036 hotspot: command resolved
via PATH). The build id is now a content hash over the js/ source tree plus the
dependency manifests, using normalized relative paths so it stays stable across
checkout locations. No OS command, same deterministic-id guarantee, and it also
works when building outside a git checkout.
…erge)

Adds this PR branch to the push trigger so CI runs THIS branch's workflow
definition (with the package-build step) instead of the base-branch one used by
pull_request_target. Lets the pipeline generate build-dist/build-<id>.zip on the
PR. Does not touch 2025.4. To be reverted once the archive is generated.
The archive (build-dist/build-4e57a40d1ec3.zip) has been generated by CI; remove
the temporary branch entry from the push trigger so this PR does not alter the
trigger config on merge.
The concrete build-archive classes are implementation detail, not public API;
only BuildArchiveProviderInterface (the extension point, parallel to
WebpackEntryPointProviderInterface) stays public.
- Inject an optional PSR logger into BuildArchiveExtractor and log extraction,
  skips and failures — most importantly a warning when extraction is needed but
  the target is not writable (previously a silent failure on a deploy-critical
  path). The logger is nullable so the trait's dev fallback (`new ...`) still
  works; the cache warmer gets the autowired service with the logger.
- .gitattributes: match build-dist/build-*.zip (the id is content-derived, so the
  filename varies) instead of the fixed build.zip, so binary/merge=ours/generated
  actually apply. Update stale build.zip references in docs/comments.
Use #[Required] setter autowiring so the provider services (and their lazy
fallback path) use the same DI BuildArchiveExtractor instance — with the logger
— instead of constructing a logger-less one. Verified the setter is autowired in
a real Symfony container.
- BuildArchiveExtractor: key the extraction decision off whether the expanded
  build is actually present (not just the marker), so an orphaned marker left
  behind when the build dirs are deleted no longer skips re-extraction. Rename
  the marker to a visible `extracted-archive.json` (was a hidden dotfile that
  survived deleting the visible dirs). Freshness now compares the archive name
  (content-hash id, so name == content) and archive selection is deterministic —
  no filemtime anywhere. TEMP: logs extraction duration (remove before merge).
- StaticResourcesResolver: pick the active build group without filemtime — prefer
  the build the extractor recorded in extracted-archive.json, else deterministic.
- build-id: fingerprint the whole assets/ tree (excluding node_modules/dist) so
  config/font/api-spec changes also bump the id, not just js/ source.
… generic

The build-archive selection (group by .build-id, prefer the extracted marker)
lived in StaticResourcesResolver, which is generic and iterates every entry-point
provider — including third-party ones that ship no archive. Move it to
BuildArchiveExtractor::entryPointLocations() (it already owns the marker and the
on-disk build layout); the trait's getEntryPointsJsonLocations() delegates to it,
so only providers that opt into the archive get the selection. The resolver is
generic again.
Replace the verbose root UPGRADE.md with doc/06_Upgrade.md following the
studio-backend-bundle blueprint (# Upgrade Information, concise per-version
sections, > Note callouts), targeted at the next release 2025.4.6, and link it
from the README documentation overview.
Benchmarking confirmed extraction is ~0.4s; the timing log is no longer needed.
- package-build: pick the build id deterministically (sorted) instead of by
  mtime, consistent with the runtime selection.
- Drop comments that just restate the code; keep the ones explaining non-obvious
  behaviour (no-clobber, read-only, concurrency).
- BuildArchiveExtractor: final readonly class (stateless; only a readonly logger),
  consistent with the other Build value/service classes.
- services.yaml: drop the explicit $extractor argument on the cache warmer — it is
  autowired by type; only the tagged iterator needs wiring. Fix a stale comment.
  Verified the container still compiles and the warmer keeps its kernel.cache_warmer tag.
Align with pimcore/ee-tinymce-bundle's CopyCacheWarmer: extraction must run during
warmup (the writable deploy phase), not be skippable and fall back to the lazy
provider path, which cannot write on a read-only production filesystem.
When the build must be extracted but the target is not writable and no build is
present (a read-only deploy that didn't warm the cache while writable), throw
BuildArchiveNotWritableException from the entry point provider instead of a vague
'entry point not found'. If a build is already present it keeps being served. The
warmer logs the cause and never fails warmup (matching pimcore core/tinymce warmers).
- build id selection: sort with localeCompare instead of the default sort.
- drop the redundant pairDirs.sort() (files are sorted globally afterwards).
pull_request_target runs the base 2025.4 workflow (no packaging), so the committed
archive goes stale. Add this branch to the push trigger so each push rebuilds and
commits build-dist/build-<id>.zip with the current build id. REMOVE BEFORE MERGE.
…ents

Cover the extraction decision branches: fresh extract, up-to-date no-op,
manual-build no-clobber, stale re-extract, read-only (throws), multiple-archive
deterministic pick, and entryPointLocations selection (marker-preferred and
deterministic fallback).

Also correct two comments left over from the earlier mtime/"newest" design that
no longer matched the deterministic content-hash selection.
Cover the warmer behaviour with fake providers and a real extractor on temp dirs
(no container): isOptional() is false, archive providers are extracted during
warmUp, non-archive and null-archive providers are skipped, and extractor
exceptions are swallowed so warmup never fails.
The build id is the source-tree hash and is reproducible, but the compiled
output is not byte-for-byte: Module Federation's mf-stats.json lists modules in
non-deterministic order, so re-zipping produced a churned "Automatic frontend
build" commit on every CI run even with no source change.

Treat the id as the archive's identity: if build-<id>.zip already exists, keep
it untouched (still sweeping any stray archive from another id to preserve the
one-archive invariant); only a new id writes a fresh zip.
Clarify in build-id.ts that the bundler scripts themselves are part of the id
fingerprint. This is an assets/ change, so the build id changes and CI should
commit a new build-<id>.zip (Automatic frontend build).
markus-moser and others added 3 commits June 22, 2026 07:36
Add a docblock to the extractor test class. This touches tests/ only, not
assets/, so the build id is unchanged and CI should NOT produce a new
build-<id>.zip / Automatic frontend build commit.
Restores build-id.ts to its pre-test state. assets/ returns to the prior
content, so the id computes back to 1e1780b17a24 and CI will restore that
archive.
@sonarqubecloud

Copy link
Copy Markdown

@markus-moser markus-moser marked this pull request as ready for review July 9, 2026 09:52
@markus-moser markus-moser merged commit febb03b into 2025.4 Jul 9, 2026
7 checks passed
@markus-moser markus-moser deleted the feature/frontend-build-archive branch July 9, 2026 10:00
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 9, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant