Brand rollout meta-repo
Why this exists
Multi-stack product fleets drift visually because each repo hardcodes its own colors / fonts / logos. Forcing them onto one framework is high-cost. A meta-repo solves this with a small, stack-agnostic package consumed via pnpm git URL.
Architecture (what to build)
A single repo (e.g. org/brand) containing:
assets/brand/ mark-{16,24,32,48,64,96,128,192,256,512}.png
favicon.svg, og-image.png
tokens/
index.js ESM tokens object — colors, fonts, radii, shadows
index.cjs CJS mirror (kept in sync by validator)
index.d.ts TypeScript types
tokens.css CSS custom properties (--wc-bg, --wc-accent, ...)
tailwind/
preset.cjs Drop-in Tailwind preset (CJS so it works for both ESM+CJS Tailwind configs)
consumers.json Registry of dependent repos
scripts/
validate.mjs Asset-existence + ESM/CJS/CSS parity gates
.github/workflows/
validate-package.yml Runs validate on PR
propagate.yml On tag push → fan out bump PRs to every consumer
README.md Contract document (how consumers use it)
package.json "type": "module", with both "exports" and "files" fields
package.json exports map (critical):
{
"type": "module",
"exports": {
".": { "import": "./tokens/index.js", "require": "./tokens/index.cjs", "types": "./tokens/index.d.ts" },
"./tokens": { "import": "./tokens/index.js", "require": "./tokens/index.cjs", "types": "./tokens/index.d.ts" },
"./tokens.css": "./tokens/tokens.css",
"./tailwind-preset": "./tailwind/preset.cjs",
"./assets/*": "./assets/*",
"./package.json": "./package.json"
},
"files": ["assets/**", "tokens/**", "tailwind/**", "components/**", "README.md"]
}
Distribution mechanism
"@org/brand": "github:org/brand#semver:^1.0.0"
Why this beats npm registry / submodules / shared dirs:
- Zero auth in CI (works for private repos via deploy keys or PAT)
- Works identically for Astro, Next, React+Vite, and plain HTML
- pnpm pins to exact commit on install, semver upgrade is a one-line PR
- No registry maintenance, no scopes to set up
Consumer wiring pattern
Each consumer repo:
pnpm add 'github:org/brand#semver:^1.0.0'- Add a
prebuildscript that mirrors brand assets intopublic/brand/(or equivalent):// scripts/sync-brand-assets.mjs — copies node_modules/@org/brand/assets/brand/* // into public/brand/, cleaning destination first - Gitignore
public/brand/(auto-synced, no merge noise on bumps) - For Astro/Next: import tokens via
@org/brand/tokens.cssin the layout/global CSS, or use the Tailwind preset - For React+Vite: same —
import '@org/brand/tokens.css'in main.jsx
The killer feature — auto-propagation
.github/workflows/propagate.yml runs on push: tags: ['v*']. It:
- Reads
consumers.json([{repo: "org/web", branch: "main"}, ...]) - For each consumer:
- Clones the repo using a fine-grained PAT (
PROPAGATE_PATsecret) - Bumps the
@org/branddep to the new version (regex over package.json + lockfile) - Runs
pnpm install --no-frozen-lockfileto refresh pnpm-lock - Commits + pushes to
chore/brand-bump-vX.Y.Z - Opens a PR titled
chore(brand): bump @org/brand → vX.Y.Z
- Clones the repo using a fine-grained PAT (
- Each consumer's existing CI deploys the PR after merge
Critical design notes:
- Dry-run mode by default when the PAT secret is missing — logs intent without modifying repos. Lets you test the pipeline before granting any permissions.
- PAT scopes: fine-grained, contents:write + pull-requests:write on each consumer repo
- Don't auto-merge. Each PR is a review gate so brand changes can't ship silently.
- Workflow YAML pitfalls:
inputs.Xreferences insideif:cause "empty jobs" parse failures unless the workflow is dispatched. Wrap in${{ github.event_name == 'workflow_dispatch' && inputs.X || 'default' }}.- PR body strings with colons break heredoc YAML — write the body to a file and pass
--body-filetogh.
Validator script (mandatory)
Before any tag, the validator must pass:
- All required mark sizes exist (16/24/32/48/64/96/128/192/256/512)
- ESM + CJS token files have identical exported keys (parity check)
- CSS custom property names match token JS keys
- No stray asset files outside the documented set
Example check:
// scripts/validate.mjs
const required = ['mark-16.png','mark-24.png',...];
for (const f of required) assert(existsSync(`assets/brand/${f}`));
// import esm and cjs, deep-compare
Pitfalls discovered
require()of ESM in modern Node 22 silently works (require(esm) feature) — but Tailwind users on older Node break. Ship both.jsand.cjsmirrors of tokens.- pnpm caches git deps aggressively. When testing iteratively,
pnpm install --forceto re-resolve the git ref. - Tailwind preset must be CJS (
.cjsextension) when the package is"type": "module", because Tailwind config loaders historically use CJS resolution. - Don't include
recipes-*or product-specific assets in the meta-repo. It's brand-level only — product logos belong in their product repos. - GitHub Actions doesn't index workflows on feature branches unless the workflow file already exists on the default branch with the same name. First-time workflow runs require a merge to main, or a manual
workflow_dispatchfrom the API after the workflow appears in the index. gh secret setreads from stdin when no-bis given — useful for pipingcat key.pem | gh secret set DEPLOY_SSH_KEY.
Rollout order (tested)
- Create the brand repo, validator, and CI workflows. Ship v1.0.0 with no consumers.
- Wire ONE consumer (the most actively-deployed) as the canary. Verify the build works end-to-end.
- Tag v1.0.1 (no real change), confirm propagate.yml dry-run logs the right intent.
- Mint the PAT, set the secret, tag v1.0.2 — first real propagation.
- Migrate remaining consumers one PR at a time. Don't batch.
Related
- Sister skill:
wisechef-portal-v3-deploy— the auto-deploy pattern each consumer mirrors - Sister skill:
cloudflare-tunnel-token-auth-ingress— for routing brand-consumer products behind unified tunnels