DEV Community

Akash Pal
Akash Pal

Posted on Fully Autonomous

Shipping It — Scaffolding & CI/CD (Part 4)

Part 3 covered how login is unified across every mounted page. This part is how the platform actually ships: scaffolding a new domain team by leveraging the domain template, where the resulting bundles actually live, and the CI/CD pipeline that carries a team's page from a pull request to production.

Decisions locked before writing code

This platform resolves five open questions up front, specifically so they don't cause churn mid-scaffolding:

Decision Options on the table What was chosen
Monorepo tool Nx vs Turborepo vs polyrepo Turborepo — lighter config, sufficient for this repo's size
Issuer topology Single issuer vs multi-issuer-ready from day one Multi-issuer-ready (config array) — cheap now, expensive to retrofit (Part 3)
Issuer resolution mechanism default / email-domain / subdomain / selector default ships built; the other three are documented config swaps
Backend topology Single shared gateway vs per-team backends Left to whoever adopts the platform — the template doesn't take a position
Rendering Client-rendered only vs Next.js Multi-Zones vs edge rendering Client-rendered only, for now — matches Part 1's default choice; the Host and CI/CD are the documented swap points if that changes later

Scaffolding a new domain team

The goal from Part 2 — a new team scaffolds, builds, and deploys an authenticated page in under an hour — isn't aspirational; it's something worth actually timing, with someone unfamiliar with the repo. The mechanism for getting there is leveraging a template: apps/domain-template, a folder to copy, rather than a bespoke CLI — chosen deliberately as the cheaper thing to maintain at this stage (an npx create-org-mfe <name> CLI is a natural upgrade path later, once the copy-paste steps below are well-worn enough to automate):

# demo/README.md
1. Copy apps/domain-template to demo/<your-domain>.
2. Rename the package in package.json and update webpack.common.js's
   federation.name to match. Give it its own dev port.
3. Replace DomainApp.tsx with your own UI; keep exporting DomainMFEExport
   ({ routes, Component }) from src/index.tsx.
4. Add an entry to platform.manifest.dev.json with the new remote's
   remoteEntry URL and route — no Host rebuild required.
5. Add a dev line for it to the root package.json's dev script.
Enter fullscreen mode Exit fullscreen mode

apps/domain-template stays deliberately minimal — auth and shared-state wiring only, no business content — precisely so it stays a clean copy source every new team starts from. demo/orders is the proof this actually works: domain-template copied, renamed, filled in with real (if sample) content, running on its own port with its own federation name, wired in by one manifest entry. Host's own remotes: {} from Part 1 never had to know orders existed until it was already running in a browser tab — and the exact same copy-and-rename steps are how a third, fourth, or fiftieth domain team gets a page running.

Two deployment shapes, one running Host

Host, Components, Store, and Utilities don't deploy the same way a domain team's page does — and that difference isn't incidental, it's the same one Part 1 built the whole platform around:

Two deployment shapes converge on the running Host: platform bundles are npm-installed and version-pinned at build time, then ship together as one release that rebuilds Host. Domain bundles are uploaded to their own versioned CDN path and are only ever discovered through a manifest entry, fetched fresh at runtime, so Host never rebuilds to pick one up.

The left path is ordinary dependency management: Host's package.json and webpack.common.js declare which version of Components, Store, and Utilities it expects, npm installs them, and shipping a change to any of the four means rebuilding and redeploying Host itself — the four are versioned and released together. The right path is the one this whole series keeps coming back to: a domain team's bundle is uploaded to its own versioned path, and the only thing that connects it to Host is one entry in the manifest, fetched fresh at runtime. Same running application, two genuinely different deployment mechanisms feeding it — which is exactly why scaffolding a domain page (above) never touches Host's own build.

The CI/CD pipeline

Three stages, run by GitHub Actions: build and test the code into a deployable artifact, promote that artifact to dev automatically on every merge to main, then promote the same artifact to production once someone approves it. In the workflow file, that's build-test → deploy-dev → promote-prod, with promote-prod gated behind a GitHub Environment with required reviewers — no custom approval code needed, just a platform feature configured correctly:

The CI/CD pipeline: a pull request or push to main runs build-test — lint, typecheck, build, test, and the shared-dependency contract check. A pull request stops at that gate. A push to main continues to deploy-dev, which builds the bundle, uploads it, and calls update-manifest.js to point the dev manifest at it. Promotion to production waits for a human reviewer's approval, then repeats the same upload-and-update-manifest step against production.

Here's the same three stages as the actual workflow file:

# .github/workflows/mfe-ci-cd.yml
jobs:
  build-test:
    steps:
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck
      - run: npm run build
      - run: npm run test
      - run: npm run test:scripts
      - name: Verify shared-dependency contracts
        run: npm run check:contract

  deploy-dev:
    needs: build-test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment: dev
    steps:
      - run: npm run build
      - name: Upload build output to dev hosting
        run: echo "TODO -- upload apps/*/dist and demo/*/dist-remote to your dev CDN"
      - name: Point the dev manifest at the new build
        run: |
          npm run manifest:update -- \
            --route /orders --name orders \
            --version "$(node -p "require('./demo/orders/package.json').version")" \
            --remote-entry "${{ vars.DEV_CDN_BASE_URL }}/orders/remoteEntry.js"

  promote-prod:
    needs: deploy-dev
    if: github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'prod'
    environment: prod
    steps: # same shape as deploy-dev, against PROD_* vars
Enter fullscreen mode Exit fullscreen mode

Key idea: the "Point the dev manifest at the new build" step is what actually deploys a domain MFE. The Host isn't rebuilt — it just reads a new remoteEntry URL out of the manifest on its next load.

Updating the manifest registry is the actual deploy step, so it has to be safe under concurrency

Say that plainly: updating platform.manifest.json is deploying a domain MFE — not part of deploying it. Upload a beautiful new remoteEntry.js to a CDN and never touch the manifest, and Host keeps loading the old one forever, with no error, because nothing told it anything changed. So the deploy script's whole job is updating one JSON array safely when more than one CI run might be doing it at the same time:

// scripts/update-manifest.js
async function upsertManifestEntry(manifestPath, fields) {
  const lockPath = await acquireLock(manifestPath);
  try {
    const manifest = await readManifest(manifestPath);
    const index = manifest.findIndex((entry) => entry.route === fields.route);
    const merged = index === -1 ? { ...fields } : { ...manifest[index], ...fields };
    validateEntry(merged);
    // ...
    await writeManifestAtomic(manifestPath, nextManifest);
    return nextEntry;
  } finally {
    await releaseLock(lockPath);
  }
}
Enter fullscreen mode Exit fullscreen mode

Two properties earn their keep: locking — an exclusive-create lockfile with retry/backoff — because if two domain teams' pipelines finish around the same moment (the normal case once there's more than one team, not an edge case), a naive read-modify-write is a lost-update race waiting to happen. Atomic write — write to a temp file, then rename over the real one — because a same-directory rename is atomic on POSIX filesystems, so a crash mid-write can never leave the manifest half-written; the real file has either the old complete content or the new complete content, never something in between. A test proves the property that actually matters:

test('serializes concurrent updates to different routes — neither update is lost', async (t) => {
  const manifestPath = await withTempManifest(t, []);
  await Promise.all([
    upsertManifestEntry(manifestPath, { route: '/orders', ... }),
    upsertManifestEntry(manifestPath, { route: '/admin', ... }),
    upsertManifestEntry(manifestPath, { route: '/billing', ... }),
  ]);
  const manifest = await readManifest(manifestPath);
  assert.deepEqual(manifest.map((e) => e.route).sort(), ['/admin', '/billing', '/orders']);
});
Enter fullscreen mode Exit fullscreen mode

Three "deploys" fired at the exact same instant, on purpose — exactly what happens the moment the pipeline generalizes past one domain MFE, which a GitHub Actions matrix would drive in practice.

What's real vs. what's still a placeholder, stated honestly: locking and atomicity, the shared-contract check, and the workflow's job graph and gating are real and tested. The CDN upload steps are explicit echo "TODO" placeholders — deliberately, since this pipeline is written target-agnostic (no CDN or cloud provider chosen), and canary-rollout percentages and automatic rollback on regression can't be built honestly without a real target to build against; a rollback script that's never watched real error-rate metrics isn't tested, it's decorative.

Each environment — dev/staging/prod — gets an isolated manifest and its own platform.config.json issuer configuration, so a bad manifest update, or a bad auth config change, can never cross environments. Nothing above names a specific cloud on purpose: the mechanism — build once, upload, point the manifest at it — is what this template actually commits to, and it maps onto whichever object storage, CDN, and CI runner an adopter already runs.

The last part closes the series: who actually owns each piece of this, and what it takes to make this whole template yours.

Next: Part 5 — Ownership, and Making This Template Your Own

Top comments (0)