CI/CD & Deployment
Table of Contents
- Overview
- Monorepo CI/CD (platform-core)
- MFE CI/CD (polyrepos)
- Preview Environments
- Cross-Repo Dependency Management
- Worker Deployments
- Pipeline Security
- Monitoring and Notifications
- References
Overview
The platform operates two distinct CI/CD pipeline architectures that reflect its hybrid monorepo/polyrepo structure:
- Monorepo pipeline (
platform-core) -- Manages shared packages (design system, SDK, utilities, Workers) with coordinated versioning via Changesets and publishing to GitHub Packages. - MFE polyrepo pipelines -- Each micro frontend repository has its own independent pipeline that builds, tests, deploys, and registers the MFE with the Version Config Service.
Key Principles
| Principle | Implementation |
|---|---|
| Fast feedback | Turborepo remote caching, parallelized CI steps, incremental builds |
| Independent deployability | Each MFE deploys on its own cadence without coordinating with other teams |
| Automated version management | Changesets for monorepo packages; semantic git tags for MFEs |
| Preview environments | Every PR gets a unique preview URL for the changed MFE |
| Security | OIDC authentication to Cloudflare (planned), short-lived API tokens in CI |
Technology Stack
- CI/CD Platform: GitHub Actions
- Monorepo orchestration: Turborepo (with remote caching)
- Package management: pnpm (with workspace protocol in monorepo)
- Version management: Changesets (monorepo), semantic git tags (polyrepos)
- Package registry: GitHub Packages (npm)
- Deployment target: Cloudflare Workers, R2, KV
- Dependency automation: Renovate
Monorepo CI/CD (platform-core)
The platform-core monorepo contains all shared infrastructure: the design system, the MFE SDK, shared TypeScript configs, Workers (Shell App, Version Config Service, Auth Gateway), and shared utilities.
CI Pipeline (on every PR)
The CI pipeline runs on every pull request and push to main. It leverages Turborepo to only run tasks for packages that have changed, dramatically reducing CI times for targeted changes.
.github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
jobs:
ci:
name: Lint, Typecheck, Test, Build
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
# Full history is needed for Changesets to correctly determine
# which packages changed. fetch-depth: 2 is insufficient.
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
# Node 20 reaches EOL in April 2026. Node 24 is now Active LTS.
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run lint
run: pnpm turbo run lint
- name: Run typecheck
run: pnpm turbo run typecheck
- name: Run tests
run: pnpm turbo run test -- --coverage
- name: Run build
run: pnpm turbo run build
- name: Upload coverage reports
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@v6
with:
name: coverage-reports
path: packages/*/coverage/
retention-days: 7
How Turborepo caching works in CI:
- Each task (
lint,typecheck,test,build) declares its inputs inturbo.json. - On first run, outputs are hashed and uploaded to the Turborepo remote cache.
- Subsequent runs on the same inputs produce cache hits, skipping execution entirely.
- A PR that only modifies the design system will skip lint/test/build for Workers and the SDK.
Release Pipeline (Changesets)
The release pipeline automates version bumping, changelog generation, and publishing using Changesets. Developers add changeset files during development (pnpm changeset), and the release pipeline consumes them.
.github/workflows/release.yml
name: Release
on:
push:
branches: [main]
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
permissions:
contents: write
packages: write
pull-requests: write
jobs:
release:
name: Release Packages
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
# Node 20 reaches EOL in April 2026. Node 24 is now Active LTS.
node-version: 22
cache: pnpm
registry-url: https://npm.pkg.github.com
scope: '@org'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build all packages
run: pnpm turbo run build
- name: Create Release PR or Publish
id: changesets
uses: changesets/action@v1
with:
version: pnpm changeset version
publish: pnpm changeset publish
title: 'chore: version packages'
commit: 'chore: version packages'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Notify polyrepos of new releases
if: steps.changesets.outputs.published == 'true'
uses: actions/github-script@v8
with:
github-token: ${{ secrets.DISPATCH_TOKEN }}
script: |
const publishedPackages = JSON.parse('${{ steps.changesets.outputs.publishedPackages }}');
const repos = [
'myorg/mfe-dashboard',
'myorg/mfe-settings',
'myorg/mfe-analytics',
'myorg/mfe-billing',
];
const results = [];
for (const repo of repos) {
const [owner, repoName] = repo.split('/');
try {
await github.rest.repos.createDispatchEvent({
owner,
repo: repoName,
event_type: 'platform-core-release',
client_payload: {
packages: publishedPackages.map(p => ({
name: p.name,
version: p.version,
})),
},
});
results.push({ repo, status: 'success' });
} catch (error) {
// Log the error but do not fail the workflow -- dispatch
// failures should not block the release pipeline.
console.error(`Failed to dispatch to ${repo}: ${error.message}`);
results.push({ repo, status: 'failed', error: error.message });
}
}
const failed = results.filter(r => r.status === 'failed');
console.log(`Dispatched release notification to ${repos.length} repos (${failed.length} failed)`);
if (failed.length > 0) {
core.warning(`Repository dispatch failed for: ${failed.map(r => r.repo).join(', ')}. These repos will pick up updates on the next Renovate schedule.`);
}
Release flow:
- Developer creates a PR with code changes and a changeset file (e.g.,
.changeset/cool-feature.md). - PR is reviewed and merged to
main. - The Changesets Action detects pending changesets and opens (or updates) a "Version Packages" PR.
- The "Version Packages" PR contains bumped
package.jsonversions and updatedCHANGELOG.mdfiles. - When the team merges the "Version Packages" PR, the action publishes all bumped packages to GitHub Packages.
- After publishing, a
repository-dispatchevent notifies all MFE polyrepos so Renovate can immediately create update PRs.
Design System MF Remote Deployment
The design system package (@org/design-system) serves a dual role: it is published to GitHub Packages as an npm package and deployed as a Module Federation remote so MFEs can consume shared components at runtime.
After a successful npm publish, the release workflow also deploys the design system bundle to R2 and registers it with the Version Config Service.
Additional deploy step appended to .github/workflows/release.yml:
# --- Design System MF Remote Deployment ---
- name: Check if design system was published
id: check-ds
if: steps.changesets.outputs.published == 'true'
run: |
PUBLISHED='${{ steps.changesets.outputs.publishedPackages }}'
DS_VERSION=$(echo "$PUBLISHED" | jq -r '.[] | select(.name == "@org/design-system") | .version')
if [ -n "$DS_VERSION" ]; then
echo "version=$DS_VERSION" >> "$GITHUB_OUTPUT"
echo "published=true" >> "$GITHUB_OUTPUT"
else
echo "published=false" >> "$GITHUB_OUTPUT"
fi
- name: Build design system MF remote
if: steps.check-ds.outputs.published == 'true'
working-directory: packages/design-system
run: pnpm build:federation
env:
MF_PUBLIC_PATH: https://static.myplatform.com/design-system/${{ steps.check-ds.outputs.version }}/
- name: Upload design system to R2
if: steps.check-ds.outputs.published == 'true'
# Wrangler v3 reached EOL Q1 2026; use v4.
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
command: >
r2 object put
static-assets/design-system/${{ steps.check-ds.outputs.version }}/
--local packages/design-system/dist/
--recursive
- name: Register design system version
if: steps.check-ds.outputs.published == 'true'
run: |
curl -sf -X POST \
"${{ vars.VERSION_CONFIG_SERVICE_URL }}/api/remotes/design-system/versions" \
-H "Authorization: Bearer ${{ secrets.VCS_DEPLOY_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{
"version": "${{ steps.check-ds.outputs.version }}",
"manifestUrl": "https://static.myplatform.com/design-system/${{ steps.check-ds.outputs.version }}/mf-manifest.json",
"activateIn": ["dev"]
}'
This ensures that any new version of the design system is immediately available as a Module Federation remote in the dev environment. Promotion to staging and production is handled through the Admin UI or the Version Config Service API.
MFE CI/CD (polyrepos)
Each micro frontend lives in its own repository with its own CI/CD pipeline. The pipelines follow a consistent structure but can be customized per team.
CI Pipeline (on every PR)
.github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
packages: read
pull-requests: write
deployments: write
jobs:
ci:
name: Lint, Typecheck, Test
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
# Node 20 reaches EOL in April 2026. Node 24 is now Active LTS.
node-version: 22
cache: pnpm
registry-url: https://npm.pkg.github.com
scope: '@org'
- name: Install dependencies
run: pnpm install --frozen-lockfile
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Run lint
run: pnpm lint
- name: Run typecheck
run: pnpm typecheck
- name: Run unit tests
run: pnpm test -- --coverage
- name: Build MFE
run: pnpm build
env:
MF_PUBLIC_PATH: https://static.myplatform.com/${{ github.event.repository.name }}/pr-${{ github.event.pull_request.number }}/
- name: Upload build artifact
uses: actions/upload-artifact@v6
with:
name: mfe-build
path: dist/
retention-days: 3
e2e:
name: E2E Tests
needs: ci
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
# Node 20 reaches EOL in April 2026. Node 24 is now Active LTS.
node-version: 22
cache: pnpm
registry-url: https://npm.pkg.github.com
scope: '@org'
- name: Install dependencies
run: pnpm install --frozen-lockfile
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium
- name: Download build artifact
uses: actions/download-artifact@v8
with:
name: mfe-build
path: dist/
# NOTE: The E2E tests reference BASE_URL=http://localhost:3000 but
# there is no step that starts a local dev server to serve the
# downloaded build artifact. You must add a server step before
# running E2E tests. For example, use `pnpm exec serve -s dist -l 3000`
# or `npx http-server dist -p 3000` as a background process:
- name: Start local preview server
run: npx --yes serve -s dist -l 3000 &
- name: Wait for server to be ready
run: npx --yes wait-on http://localhost:3000 --timeout 30000
- name: Run E2E tests
run: pnpm test:e2e
env:
BASE_URL: http://localhost:3000
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v6
with:
name: playwright-report
path: playwright-report/
retention-days: 7
preview:
name: Deploy Preview
needs: ci
runs-on: ubuntu-latest
timeout-minutes: 10
environment:
name: preview
url: ${{ steps.preview-url.outputs.url }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Download build artifact
uses: actions/download-artifact@v8
with:
name: mfe-build
path: dist/
- name: Upload preview to R2
# Wrangler v3 reached EOL Q1 2026; use v4.
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
command: >
r2 object put
static-assets/${{ github.event.repository.name }}/pr-${{ github.event.pull_request.number }}/
--local dist/
--recursive
- name: Set preview URL
id: preview-url
run: |
SHELL_URL="${{ vars.SHELL_APP_DEV_URL }}"
MFE_NAME="${{ github.event.repository.name }}"
PR_NUM="${{ github.event.pull_request.number }}"
echo "url=${SHELL_URL}?mfe_${MFE_NAME#mfe-}=pr-${PR_NUM}" >> "$GITHUB_OUTPUT"
- name: Comment preview URL on PR
uses: actions/github-script@v8
with:
script: |
const mfeName = context.repo.repo;
const prNumber = context.payload.pull_request.number;
const previewUrl = '${{ steps.preview-url.outputs.url }}';
const directUrl = `https://static.myplatform.com/${mfeName}/pr-${prNumber}/`;
const body = [
`### Preview Deployment`,
``,
`| Resource | URL |`,
`|---|---|`,
`| **Shell with preview MFE** | [${previewUrl}](${previewUrl}) |`,
`| **Direct bundle** | [${directUrl}](${directUrl}) |`,
``,
`> Load this preview MFE in any environment by appending \`?mfe_${mfeName.replace('mfe-', '')}=pr-${prNumber}\` to the shell URL.`,
].join('\n');
// Find and update existing comment or create new one
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const existing = comments.data.find(c =>
c.user.login === 'github-actions[bot]' &&
c.body.includes('### Preview Deployment')
);
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body,
});
}
Key aspects of the MFE CI pipeline:
- The
.npmrcin each polyrepo is configured to pull@orgscoped packages from GitHub Packages. - The build step sets
MF_PUBLIC_PATHto the preview URL so that chunk references resolve correctly. - E2E tests run against the built MFE loaded in an isolated shell environment.
- The preview deployment uploads the build to R2 at a PR-specific path.
Deploy Pipeline (on merge to main)
.github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write
packages: read
deployments: write
jobs:
deploy:
name: Build and Deploy
runs-on: ubuntu-latest
timeout-minutes: 15
environment:
name: dev
url: ${{ steps.deploy-url.outputs.url }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
# Node 20 reaches EOL in April 2026. Node 24 is now Active LTS.
node-version: 22
cache: pnpm
registry-url: https://npm.pkg.github.com
scope: '@org'
- name: Install dependencies
run: pnpm install --frozen-lockfile
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Determine version
id: version
run: |
VERSION=$(node -p "require('./package.json').version")
SHORT_SHA=$(git rev-parse --short HEAD)
FULL_VERSION="${VERSION}+${SHORT_SHA}"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "full_version=${FULL_VERSION}" >> "$GITHUB_OUTPUT"
echo "Deploying version: ${FULL_VERSION}"
- name: Build MFE
run: pnpm build
env:
MF_PUBLIC_PATH: https://static.myplatform.com/${{ github.event.repository.name }}/${{ steps.version.outputs.version }}/
NODE_ENV: production
- name: Upload bundles to R2
# Wrangler v3 reached EOL Q1 2026; use v4.
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
command: >
r2 object put
static-assets/${{ github.event.repository.name }}/${{ steps.version.outputs.version }}/
--local dist/
--recursive
- name: Register version with Version Config Service
run: |
MFE_NAME="${{ github.event.repository.name }}"
VERSION="${{ steps.version.outputs.version }}"
FULL_VERSION="${{ steps.version.outputs.full_version }}"
COMMIT_SHA="${{ github.sha }}"
curl -sf -X POST \
"${{ vars.VERSION_CONFIG_SERVICE_URL }}/api/remotes/${MFE_NAME}/versions" \
-H "Authorization: Bearer ${{ secrets.VCS_DEPLOY_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{
\"version\": \"${VERSION}\",
\"buildId\": \"${FULL_VERSION}\",
\"commitSha\": \"${COMMIT_SHA}\",
\"manifestUrl\": \"https://static.myplatform.com/${MFE_NAME}/${VERSION}/mf-manifest.json\",
\"activateIn\": [\"dev\"]
}"
- name: Tag release
run: |
git tag "v${{ steps.version.outputs.version }}"
git push origin "v${{ steps.version.outputs.version }}"
- name: Set deploy URL
id: deploy-url
run: |
echo "url=${{ vars.SHELL_APP_DEV_URL }}" >> "$GITHUB_OUTPUT"
- name: Notify Slack
if: always()
uses: slackapi/slack-github-action@v2
with:
webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
webhook-type: incoming-webhook
payload: |
{
"text": "${{ job.status == 'success' && ':white_check_mark:' || ':x:' }} *${{ github.event.repository.name }}* v${{ steps.version.outputs.version }} deployment to *dev* ${{ job.status }}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "${{ job.status == 'success' && ':white_check_mark:' || ':x:' }} *${{ github.event.repository.name }}* `v${{ steps.version.outputs.version }}` deployment to *dev* *${{ job.status }}*\n\n*Commit:* <${{ github.event.head_commit.url }}|${{ github.event.head_commit.message }}>\n*Author:* ${{ github.event.head_commit.author.name }}"
}
}
]
}
Deployment flow:
- Code is merged to
main. - The pipeline builds the MFE with a production
MF_PUBLIC_PATHset to the versioned R2 path. - The built bundles (including
mf-manifest.json) are uploaded to R2 at/{mfe-name}/{version}/. - The version is registered with the Version Config Service and auto-activated in the
devenvironment. - A git tag is created for traceability.
- A Slack notification is sent with the deployment status.
Promotion to staging and production is performed through the Admin UI or API, not through the CI pipeline. This decouples deployment (uploading artifacts) from release (activating a version in an environment).
Version Strategy for MFEs
MFE versions follow semantic versioning with clear guidelines for when to bump each segment:
| Bump | When to Use | Example |
|---|---|---|
| Major | Breaking change to the Module Federation contract (renamed exports, removed exposed modules, changed shared dependency requirements) | 1.0.0 to 2.0.0 |
| Minor | New exposed module, new feature, non-breaking API additions | 1.0.0 to 1.1.0 |
| Patch | Bug fixes, performance improvements, styling changes | 1.0.0 to 1.0.1 |
The version is derived from package.json and tagged in git on every deployment to main.
scripts/version.sh
#!/usr/bin/env bash
set -euo pipefail
# Usage: ./scripts/version.sh <major|minor|patch>
# Bumps the version in package.json, commits, and prepares for deployment.
BUMP_TYPE="${1:?Usage: version.sh <major|minor|patch>}"
if [[ "$BUMP_TYPE" != "major" && "$BUMP_TYPE" != "minor" && "$BUMP_TYPE" != "patch" ]]; then
echo "Error: bump type must be one of: major, minor, patch"
exit 1
fi
# Ensure working directory is clean
if [[ -n "$(git status --porcelain)" ]]; then
echo "Error: working directory is not clean. Commit or stash changes first."
exit 1
fi
# Ensure we are on main
CURRENT_BRANCH=$(git branch --show-current)
if [[ "$CURRENT_BRANCH" != "main" ]]; then
echo "Warning: you are on branch '$CURRENT_BRANCH', not 'main'."
read -r -p "Continue anyway? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || exit 0
fi
# Read current version
CURRENT_VERSION=$(node -p "require('./package.json').version")
echo "Current version: $CURRENT_VERSION"
# Bump version using npm (updates package.json)
NEW_VERSION=$(npm version "$BUMP_TYPE" --no-git-tag-version)
NEW_VERSION="${NEW_VERSION#v}" # strip leading 'v'
echo "New version: $NEW_VERSION"
# Commit the version bump
git add package.json
git commit -m "chore: bump version to ${NEW_VERSION}"
echo ""
echo "Version bumped to ${NEW_VERSION}."
echo "Push to main to trigger deployment: git push origin main"
echo "The deploy pipeline will create the git tag automatically."
Teams can also use npm version patch/minor/major directly. The deploy pipeline reads from package.json and creates git tags automatically.
Preview Environments
Per-PR Preview Deployments
Every pull request in an MFE polyrepo gets a unique preview deployment. The preview MFE is uploaded to R2 at a PR-specific path and can be loaded by the shell app via a URL parameter override.
How it works:
- The CI pipeline builds the MFE with
MF_PUBLIC_PATHset tohttps://static.myplatform.com/{mfe-name}/pr-{number}/. - The built bundles are uploaded to R2 at
/{mfe-name}/pr-{number}/. - A comment is posted on the PR with a link to the shell app loading the preview MFE.
- The shell app detects the
?mfe_{name}=pr-{number}query parameter and overrides the manifest URL for that MFE. - When the PR is closed or merged, the preview is cleaned up.
Shell app query parameter handling (simplified):
// In the shell app's MFE resolution logic
function resolveManifestUrl(mfeName: string, defaultUrl: string): string {
if (typeof window === 'undefined') return defaultUrl;
const params = new URLSearchParams(window.location.search);
const override = params.get(`mfe_${mfeName}`);
if (override) {
// override can be "pr-123" or a full URL
if (override.startsWith('http')) {
return override;
}
return `https://static.myplatform.com/mfe-${mfeName}/${override}/mf-manifest.json`;
}
return defaultUrl;
}
Preview cleanup workflow (.github/workflows/preview-cleanup.yml):
name: Preview Cleanup
on:
pull_request:
types: [closed]
permissions:
contents: read
deployments: write
jobs:
cleanup:
name: Clean Up Preview
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Delete preview assets from R2
# Wrangler v3 reached EOL Q1 2026; use v4.
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
command: >
r2 object delete
static-assets/${{ github.event.repository.name }}/pr-${{ github.event.pull_request.number }}/
--recursive
- name: Deactivate GitHub deployment
uses: actions/github-script@v8
with:
script: |
const deployments = await github.rest.repos.listDeployments({
owner: context.repo.owner,
repo: context.repo.repo,
environment: 'preview',
ref: context.payload.pull_request.head.ref,
});
for (const deployment of deployments.data) {
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: deployment.id,
state: 'inactive',
});
}
- name: Comment on PR
uses: actions/github-script@v8
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: '> Preview environment has been cleaned up.',
});
Worker Preview Environments
For Worker changes in the monorepo, preview environments use Wrangler's built-in environment support.
Option 1: Named preview environment
# Deploy to a preview environment with separate KV/D1 bindings
wrangler deploy --env preview
# The wrangler.toml defines the preview environment:
# [env.preview]
# name = "shell-app-preview"
# kv_namespaces = [{ binding = "CONFIG", id = "preview-kv-id" }]
# d1_databases = [{ binding = "DB", database_name = "platform-preview", database_id = "preview-db-id" }]
Option 2: Remote dev session
# Start a remote preview session (uses actual Cloudflare infrastructure)
wrangler dev --remote
# This creates a temporary Worker that is accessible at a unique URL
# Useful for quick manual testing without a full deployment
Option 3: PR-specific Worker preview (in CI)
- name: Deploy Worker preview
if: github.event_name == 'pull_request'
# Wrangler v3 reached EOL Q1 2026; use v4.
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
workingDirectory: workers/shell-app
command: deploy --env preview --name shell-app-pr-${{ github.event.pull_request.number }}
Worker previews use separate KV namespaces and D1 databases to avoid polluting production data. The wrangler.toml for each Worker defines bindings for each environment ([env.preview], [env.staging], [env.production]).
Cross-Repo Dependency Management
Renovate Configuration
Renovate automates dependency updates across all repositories. A shared preset in the monorepo ensures consistent behavior.
Shared preset (platform-core/.github/renovate-preset.json):
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"description": "Shared Renovate preset for the micro frontends platform",
"extends": [
"config:recommended",
"helpers:pinGitHubActionDigests",
":semanticCommits"
],
"timezone": "Europe/London",
"schedule": ["before 9am on Monday"],
"prHourlyLimit": 4,
"prConcurrentLimit": 10,
"labels": ["dependencies"],
"hostRules": [
{
"matchHost": "npm.pkg.github.com",
"hostType": "npm",
"encrypted": {
"token": "ENCRYPTED_GITHUB_TOKEN_HERE"
}
}
],
"packageRules": [
{
"description": "Auto-merge patch updates from @org scope",
"matchPackagePatterns": ["^@org/"],
"matchUpdateTypes": ["patch"],
"automerge": true,
"automergeType": "pr",
"automergeStrategy": "squash",
"minimumReleaseAge": "0 days"
},
{
"description": "Group all @org packages in a single PR",
"matchPackagePatterns": ["^@org/"],
"matchUpdateTypes": ["minor", "major"],
"groupName": "platform-core packages",
"groupSlug": "platform-core"
},
{
"description": "Security updates get immediate PRs",
"matchCategories": ["security"],
"schedule": ["at any time"],
"minimumReleaseAge": "0 days",
"prPriority": 10,
"labels": ["dependencies", "security"]
},
{
"description": "Pin devDependencies",
"matchDepTypes": ["devDependencies"],
"rangeStrategy": "pin"
},
{
"description": "Group Rsbuild-related packages",
"matchPackagePatterns": ["^@rsbuild/", "^rsbuild"],
"groupName": "rsbuild",
"groupSlug": "rsbuild"
},
{
"description": "Group Module Federation packages",
"matchPackagePatterns": ["^@module-federation/"],
"groupName": "module-federation",
"groupSlug": "module-federation"
},
{
"description": "Group React-related packages",
"matchPackageNames": ["react", "react-dom", "@types/react", "@types/react-dom"],
"groupName": "react",
"groupSlug": "react"
},
{
"description": "Group Playwright packages",
"matchPackagePatterns": ["^@playwright/", "^playwright"],
"groupName": "playwright",
"groupSlug": "playwright"
}
]
}
Setup: Generating the encrypted Renovate token. The
ENCRYPTED_GITHUB_TOKEN_HEREplaceholder must be replaced with a real encrypted token before Renovate can access GitHub Packages. To generate it:
- Create a GitHub Personal Access Token (classic) with the
read:packagesscope.- Go to https://app.renovatebot.com/encrypt and paste the token. Select your Renovate GitHub App organization as the scope.
- Copy the encrypted value and replace
ENCRYPTED_GITHUB_TOKEN_HEREin the preset above.See the Renovate encrypted secrets documentation for details.
MFE polyrepo renovate.json (extends the shared preset):
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"github>myorg/platform-core:.github/renovate-preset.json"
]
}
Key behaviors:
- Patch updates from
@orgscope are auto-merged after CI passes (no manual review needed for non-breaking internal changes). - Minor and major updates from
@orgscope are grouped into a single PR to reduce noise. - Security vulnerabilities bypass the weekly schedule and create PRs immediately.
- All other dependencies follow the weekly Monday morning schedule.
Repository Dispatch Notifications
When the monorepo publishes new packages, Renovate may not detect the update immediately (its schedule is weekly). To ensure fast propagation, the release pipeline sends a repository-dispatch event that triggers Renovate to check for updates immediately.
Dispatch handler in MFE polyrepo (.github/workflows/renovate-trigger.yml):
name: Trigger Renovate
on:
repository_dispatch:
types: [platform-core-release]
permissions:
contents: write
pull-requests: write
jobs:
trigger-renovate:
name: Trigger Renovate Update Check
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Log received packages
run: |
echo "Received platform-core release notification:"
echo '${{ toJson(github.event.client_payload.packages) }}' | jq .
- name: Trigger Renovate via GitHub API
uses: actions/github-script@v8
with:
script: |
// Create a repository dispatch to trigger Renovate's on-demand check
await github.rest.repos.createDispatchEvent({
owner: context.repo.owner,
repo: context.repo.repo,
event_type: 'renovate',
});
console.log('Triggered Renovate update check');
This creates a tight feedback loop: monorepo publishes a new version, MFE repos get a Renovate PR within minutes, auto-merge kicks in for patch updates, and the MFE is rebuilt with the latest shared packages.
Worker Deployments
wrangler deploy
Each Worker in the monorepo is deployed using wrangler deploy with environment-specific configuration.
Deployment commands by Worker type:
# Shell App Worker
wrangler deploy --config workers/shell-app/wrangler.toml --env production
# Version Config Service Worker
wrangler deploy --config workers/version-config-service/wrangler.toml --env production
# Auth Gateway Worker
wrangler deploy --config workers/auth-gateway/wrangler.toml --env production
# Static Asset Server Worker (if not using R2 public bucket)
wrangler deploy --config workers/static-server/wrangler.toml --env production
Managing secrets:
# Set secrets for each Worker (done once, not in CI)
# Shell App
wrangler secret put WORKOS_API_KEY --config workers/shell-app/wrangler.toml --env production
wrangler secret put WORKOS_CLIENT_ID --config workers/shell-app/wrangler.toml --env production
wrangler secret put SESSION_SECRET --config workers/shell-app/wrangler.toml --env production
# Version Config Service
wrangler secret put DEPLOY_TOKEN --config workers/version-config-service/wrangler.toml --env production
wrangler secret put ADMIN_API_KEY --config workers/version-config-service/wrangler.toml --env production
# Auth Gateway
wrangler secret put WORKOS_API_KEY --config workers/auth-gateway/wrangler.toml --env production
wrangler secret put JWT_SIGNING_KEY --config workers/auth-gateway/wrangler.toml --env production
Worker deployment workflow (.github/workflows/deploy-workers.yml):
name: Deploy Workers
on:
push:
branches: [main]
paths:
- 'workers/**'
- 'packages/shared-utils/**'
concurrency:
group: deploy-workers-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
jobs:
detect-changes:
name: Detect Changed Workers
runs-on: ubuntu-latest
outputs:
shell-app: ${{ steps.changes.outputs.shell-app }}
version-config-service: ${{ steps.changes.outputs.version-config-service }}
auth-gateway: ${{ steps.changes.outputs.auth-gateway }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Detect changes
id: changes
uses: dorny/paths-filter@v3
with:
filters: |
shell-app:
- 'workers/shell-app/**'
- 'packages/shared-utils/**'
version-config-service:
- 'workers/version-config-service/**'
- 'packages/shared-utils/**'
auth-gateway:
- 'workers/auth-gateway/**'
- 'packages/shared-utils/**'
deploy-shell-app:
name: Deploy Shell App Worker
needs: detect-changes
if: needs.detect-changes.outputs.shell-app == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
environment:
name: production
url: https://app.myplatform.com
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
# Node 20 reaches EOL in April 2026. Node 24 is now Active LTS.
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build shared dependencies
run: pnpm turbo run build --filter=shell-app...
- name: Deploy to production
# Wrangler v3 reached EOL Q1 2026; use v4.
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
workingDirectory: workers/shell-app
command: deploy --env production
deploy-version-config-service:
name: Deploy Version Config Service
needs: detect-changes
if: needs.detect-changes.outputs.version-config-service == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
environment:
name: production
url: https://vcs.myplatform.com
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
# Node 20 reaches EOL in April 2026. Node 24 is now Active LTS.
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build shared dependencies
run: pnpm turbo run build --filter=version-config-service...
- name: Run D1 migrations
id: d1-migrate
# Wrangler v3 reached EOL Q1 2026; use v4.
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
workingDirectory: workers/version-config-service
command: d1 migrations apply platform-db --env production
- name: Verify D1 migrations succeeded
if: steps.d1-migrate.outcome != 'success'
run: |
echo "::error::D1 migrations failed. Aborting deployment to prevent deploying code against an incompatible database schema."
exit 1
- name: Deploy to production
# Only deploy if D1 migrations succeeded (the verify step above
# will have failed the workflow otherwise, but this condition
# provides an additional safety gate).
if: steps.d1-migrate.outcome == 'success'
# Wrangler v3 reached EOL Q1 2026; use v4.
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
workingDirectory: workers/version-config-service
command: deploy --env production
deploy-auth-gateway:
name: Deploy Auth Gateway
needs: detect-changes
if: needs.detect-changes.outputs.auth-gateway == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
environment:
name: production
url: https://auth.myplatform.com
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
# Node 20 reaches EOL in April 2026. Node 24 is now Active LTS.
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build shared dependencies
run: pnpm turbo run build --filter=auth-gateway...
- name: Deploy to production
# Wrangler v3 reached EOL Q1 2026; use v4.
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
workingDirectory: workers/auth-gateway
command: deploy --env production
Deployment Strategy
Cloudflare Workers are deployed atomically across the global network. There is no concept of "blue/green" or "canary" at the application level -- every deployment replaces the previous version worldwide within seconds.
Gradual rollout:
Cloudflare provides a built-in gradual deployment feature (called "Gradual Deployments" or "Versioned Deployments") that allows you to split traffic between two versions of a Worker.
# Deploy a new version without activating it
wrangler versions upload --config workers/shell-app/wrangler.toml --env production
# Gradually roll out: 10% new version, 90% current version
wrangler versions deploy \
--version-id <new-version-id>=10% \
--version-id <current-version-id>=90% \
--config workers/shell-app/wrangler.toml \
--env production
# Increase to 50/50
wrangler versions deploy \
--version-id <new-version-id>=50% \
--version-id <current-version-id>=50% \
--config workers/shell-app/wrangler.toml \
--env production
# Full rollout
wrangler versions deploy \
--version-id <new-version-id>=100% \
--config workers/shell-app/wrangler.toml \
--env production
Rollback:
# Instant rollback to the previous version
wrangler rollback --config workers/shell-app/wrangler.toml --env production
# Or redeploy a specific previous version
wrangler versions deploy \
--version-id <previous-version-id>=100% \
--config workers/shell-app/wrangler.toml \
--env production
For MFE rollbacks, the Version Config Service is used instead:
# Activate a previous MFE version in production
curl -X POST \
"https://vcs.myplatform.com/api/remotes/mfe-dashboard/activate" \
-H "Authorization: Bearer $DEPLOY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"version": "1.2.3",
"environment": "production"
}'
This immediately updates the version config, and the shell app starts loading the previous MFE version on the next page load.
Pipeline Security
GitHub Actions OIDC for Cloudflare
Instead of storing long-lived Cloudflare API tokens in GitHub secrets, the pipelines should use GitHub Actions OIDC (OpenID Connect) to obtain short-lived tokens.
# In the workflow job
jobs:
deploy:
permissions:
id-token: write # Required for OIDC
contents: read
steps:
- name: Deploy to Cloudflare
# Wrangler v3 reached EOL Q1 2026; use v4.
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
# When Cloudflare supports OIDC natively, this will change to:
# oidc:
# audience: "https://myplatform.cloudflareaccess.com"
TODO (target: Q3 2026): Replace
CLOUDFLARE_API_TOKENsecrets with GitHub Actions OIDC federation once Cloudflare adds native OIDC support for Wrangler/Workers deployments. This will eliminate all long-lived Cloudflare API tokens from CI secrets and align with the zero-trust security posture. Track progress at the Cloudflare OIDC feature request and update allcloudflare/wrangler-actionsteps to use theoidcconfiguration block instead ofapiToken.Current state: Cloudflare API tokens are stored as GitHub organization-level secrets. These tokens should be scoped with minimal permissions (e.g., Workers Scripts:Edit, R2:Edit, D1:Edit per account) and rotated quarterly until OIDC is available.
Secrets Management
# Secrets are organized by scope and stored in GitHub repository/organization secrets
# Organization-level secrets (shared across all repos):
# - CLOUDFLARE_API_TOKEN : Cloudflare API token for deployments
# - CLOUDFLARE_ACCOUNT_ID : Cloudflare account identifier
# - SLACK_DEPLOY_WEBHOOK : Slack webhook for deployment notifications
# - TURBO_TOKEN : Turborepo remote cache token
# Repository-level secrets (per-repo):
# - VCS_DEPLOY_TOKEN : Token for Version Config Service API
# - DISPATCH_TOKEN : GitHub PAT for cross-repo dispatch events
# Environment-level secrets (per deployment environment):
# - WORKOS_API_KEY : WorkOS API key (different per environment)
# - SESSION_SECRET : Session encryption secret
# - JWT_SIGNING_KEY : JWT signing key
Branch Protection Rules
All repositories enforce the following branch protection rules on main:
| Rule | Setting |
|---|---|
| Require pull request reviews | 1 approval minimum |
| Dismiss stale reviews on new pushes | Enabled |
| Require status checks to pass | CI workflow must pass |
| Require branches to be up to date | Enabled |
| Require signed commits | Optional (recommended) |
| Restrict who can push to matching branches | Only release automation |
| Allow force pushes | Disabled |
| Allow deletions | Disabled |
Dependency Scanning
# GitHub Dependabot is enabled for security alerts (in .github/dependabot.yml)
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
# Note: Renovate handles dependency PRs; Dependabot is only for security alerts
open-pull-requests-limit: 0
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
Renovate handles regular dependency updates, while Dependabot provides security vulnerability alerts and can create emergency security PRs.
Monitoring and Notifications
Slack Integration
All deployment pipelines send notifications to Slack with status, version, and commit information.
Slack notification configuration (reusable action .github/actions/notify-slack/action.yml):
name: Notify Slack
description: Send deployment notification to Slack
inputs:
webhook-url:
description: Slack webhook URL
required: true
status:
description: Deployment status (success, failure, cancelled)
required: true
environment:
description: Target environment name
required: true
version:
description: Deployed version
required: false
default: 'N/A'
service-name:
description: Name of the deployed service
required: true
runs:
using: composite
steps:
- name: Send Slack notification
uses: slackapi/slack-github-action@v2
with:
webhook: ${{ inputs.webhook-url }}
webhook-type: incoming-webhook
payload: |
{
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": "${{ inputs.status == 'success' && 'Deployment Succeeded' || inputs.status == 'failure' && 'Deployment Failed' || 'Deployment Cancelled' }}"
}
},
{
"type": "section",
"fields": [
{ "type": "mrkdwn", "text": "*Service:*\n${{ inputs.service-name }}" },
{ "type": "mrkdwn", "text": "*Version:*\n`${{ inputs.version }}`" },
{ "type": "mrkdwn", "text": "*Environment:*\n${{ inputs.environment }}" },
{ "type": "mrkdwn", "text": "*Status:*\n${{ inputs.status }}" }
]
},
{
"type": "context",
"elements": [
{
"type": "mrkdwn",
"text": "<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View workflow run> | Triggered by ${{ github.actor }}"
}
]
}
]
}
Deployment Event Logging
Every deployment is recorded in the Version Config Service's D1 database for auditing and the Admin UI dashboard.
// workers/version-config-service/src/handlers/deployments.ts
interface DeploymentEvent {
id: string;
remoteName: string;
version: string;
environment: string;
action: 'registered' | 'activated' | 'deactivated' | 'rolled_back';
triggeredBy: string;
commitSha: string | null;
buildId: string | null;
timestamp: string;
metadata: Record<string, string>;
}
export async function logDeploymentEvent(
db: D1Database,
event: Omit<DeploymentEvent, 'id' | 'timestamp'>
): Promise<DeploymentEvent> {
const id = crypto.randomUUID();
const timestamp = new Date().toISOString();
await db
.prepare(
`INSERT INTO deployment_events
(id, remote_name, version, environment, action, triggered_by, commit_sha, build_id, timestamp, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
)
.bind(
id,
event.remoteName,
event.version,
event.environment,
event.action,
event.triggeredBy,
event.commitSha,
event.buildId,
timestamp,
JSON.stringify(event.metadata)
)
.run();
return { ...event, id, timestamp };
}
export async function getDeploymentHistory(
db: D1Database,
remoteName: string,
options: { environment?: string; limit?: number } = {}
): Promise<DeploymentEvent[]> {
const { environment, limit = 50 } = options;
let query = `SELECT * FROM deployment_events WHERE remote_name = ?`;
const params: (string | number)[] = [remoteName];
if (environment) {
query += ` AND environment = ?`;
params.push(environment);
}
query += ` ORDER BY timestamp DESC LIMIT ?`;
params.push(limit);
const result = await db
.prepare(query)
.bind(...params)
.all<DeploymentEvent>();
return result.results;
}
Failed Deployment Alerts
Failed deployments trigger additional alerting beyond Slack:
# Added to deployment workflows as a final step
- name: Alert on failure
if: failure()
uses: actions/github-script@v8
with:
script: |
// Create a GitHub issue for tracking
const title = `Deployment failure: ${{ github.event.repository.name }} to ${{ job.environment }}`;
const body = [
`## Deployment Failure`,
``,
`| Field | Value |`,
`|---|---|`,
`| **Service** | ${{ github.event.repository.name }} |`,
`| **Environment** | ${{ job.environment || 'dev' }} |`,
`| **Workflow run** | [View logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) |`,
`| **Commit** | [${{ github.sha }}](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}) |`,
`| **Triggered by** | @${{ github.actor }} |`,
``,
`Please investigate and resolve.`,
].join('\n');
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title,
body,
labels: ['deployment-failure', 'priority:high'],
assignees: [context.actor],
});
Admin UI Deployment Dashboard
The Admin UI (an MFE itself) provides a deployment dashboard that reads from the Version Config Service API:
// In the Admin MFE: hooks/useDeploymentHistory.ts
import { useQuery } from '@tanstack/react-query';
interface DeploymentEvent {
id: string;
remoteName: string;
version: string;
environment: string;
action: 'registered' | 'activated' | 'deactivated' | 'rolled_back';
triggeredBy: string;
commitSha: string | null;
buildId: string | null;
timestamp: string;
}
export function useDeploymentHistory(
remoteName: string,
environment?: string
) {
return useQuery({
queryKey: ['deployment-history', remoteName, environment],
queryFn: async (): Promise<DeploymentEvent[]> => {
const params = new URLSearchParams();
if (environment) params.set('environment', environment);
const response = await fetch(
`/api/remotes/${remoteName}/deployments?${params}`,
{
headers: {
Authorization: `Bearer ${getAdminToken()}`,
},
}
);
if (!response.ok) {
throw new Error(`Failed to fetch deployment history: ${response.statusText}`);
}
return response.json();
},
refetchInterval: 30_000, // Poll every 30 seconds
});
}
The dashboard shows:
- A timeline of deployment events per MFE and environment.
- Currently active versions in each environment.
- Quick actions: activate a version, roll back, compare versions.
- Build metadata including commit SHA, build ID, and trigger source.
References
- GitHub Actions Documentation
- Turborepo CI Guide
- Turborepo Remote Caching
- Changesets Documentation
- Changesets GitHub Action
- GitHub Packages (npm)
- Wrangler Deploy Documentation
- Wrangler Versioned Deployments
- Cloudflare R2 Wrangler Commands
- Renovate Documentation
- Renovate Shared Presets
- GitHub Actions OIDC
- Module Federation v2 Documentation