Take template updates
How template changes reach this repo as /sync-upstream pull requests, what they never touch, and what to do after merging one.
What the template owns
This repo was generated from the Publishing Hub v4 template (stadionHQ/publishing-hub)
by its new-club scaffolder, and the template is still maintained. Its improvements — design
system fixes, model migrations, tooling, Claude rules and skills — reach this repo only as
pull requests you choose to merge: nothing is pushed here automatically, and nothing
upstream can redden this repo's CI. .claude/manifest.json's templateCommit records the
template commit last taken in (aba41ea71; the repo was first generated at 4b0e7f3b).
The sync works on paths, so whether a file is the template's or ours decides what happens to an edit you make to it.
Offered by a sync (template-owned)
pnpm sync-upstream diffs the template over these paths only (SHARED_PATHS in
packages/template-config/scripts/sync-upstream.ts):
| Area | Paths |
|---|---|
| Design system | packages/ghost-core, packages/ghost-content, packages/tokens, packages/token-parser |
| Content routes | packages/content-app |
| Media and images | packages/media, packages/image, packages/image-contentful, packages/image-scoreplay, packages/image-cloudinary |
| Config and tooling | packages/template-config, packages/eslint-config, packages/typescript-config, packages/docs-mcp, scripts/ |
| Content model | packages/model/src/core, packages/model/src/content, packages/model/scripts, packages/model/apps, packages/model/migrations/core, packages/model/migrations/content, the model's root-level tests and lint config |
| Field contracts | packages/contentful-contracts |
| Test apps | apps/storybook, apps/e2e |
| Ticket tooling | packages/tickets/src, packages/tickets/scripts, packages/tickets/domains.json |
| Claude | .claude/rules, .claude/skills, .claude/agents, .claude/reference, CLAUDE.md |
Plus each composed domain's packages, read from this repo's own packages/model/domains.json
(the domain's template.packages): for video that is packages/ghost-video,
packages/video-app, packages/media and packages/model/src/video. basketball lists
packages/competition-engine and packages/model/src/basketball, but the template has
neither, so nothing ever arrives there. packages/model/migrations/video/ is in neither list,
so a new template video migration is not offered: take one by hand if it is wanted.
Edit a template-owned file here and the next sync applies the template's changes on top with a three-way merge: your change survives where the template did not touch the same lines, and a conflict is left where it did. A fix the template should have too is better made there.
Never offered (ours)
Everything outside those paths is this project's, and some paths inside them are excluded
outright (NEVER in the same file):
apps/web— the whole app,club.config.tsand the crest included.- This project's packages —
@v4/tokens-euro-league,@v4/ghost-basketball,@v4/basketball-app,@v4/wsc,@v4/eltv,@v4/ecal,@v4/analytics,@v4/cortex-ssoand@v4/code-of-conduct. - This project's model —
packages/model/migrations/club/, the migration move logpackages/model/migrations/renamed.json, and the template's generatedmigrations/core/0001-baseline.cjs(this repo has its own composed baselines). - This docs app (
apps/docs), the rootpackage.json,pnpm-lock.yamland.claude/manifest.json. - The ticket tooling's own state — the
cloneOwnedpaths inpackages/tickets/domains.json:tickets.config.json(the ELBWEB board and ELB's Figma files),tickets.pushed.json, the generatedcms-types.jsonandfigma-frames.json(built from this space and ELB's Figma) andoutstanding.json; the template's platform-only ticket scripts; and any ticket file markedclientSpec: true. - The Contentful apps' source (
apps/contentful,packages/contentful-ui): the apps are built and released from the template and installed into this space bysync-apps; only their registry (packages/model/apps) and contracts travel.
A file the template changed but this repo has deleted is not offered either: the sync names it and the deletion stands.
Taking a template domain later
A domain the template ships but this site does not compose would come in through
pnpm add-feature (/add-feature), not through a sync — see
Add a template domain. Basketball, this
project's own domain, is described on
A project-owned domain.
Run the sync
⚡ Do it with Claude —
/sync-upstreamruns the steps below, then helps resolve what the sync could not apply cleanly.
Before you start
A
templateremote pointing at the template repository. This checkout has one; on a fresh clone add it once:git remote add template git@github.com:stadionHQ/publishing-hub.git git fetch templateA clean working tree on the branch the PR will target (
develop). The runner refuses a dirty tree, and refuses to start from async-upstream/*branch — it makes its own.
Run it
pnpm sync-upstream -- --ref=develop [--remote=template] [--create-pr]
--ref is the template branch to take (default main; this repo has always synced from the
template's develop), --remote the remote's name (default template), and --create-pr
pushes the branch and opens the pull request with gh. Without it the branch is left local.
What it does
The runner prints its progress as [n/5]:
Fetch the template ref.
Diff
templateCommit..template/<ref>over the offered paths (the previous page), leaving out the excluded ones, files this repo has deleted, andclientSpecticket files. If nothing changed it stops: already up to date.Apply the diff on a new branch
sync-upstream/<ref>-<sha>withgit apply --3way, so each hunk merges against this repo's own edits; a hunk that cannot merge is left with conflict markers. If the patch applied nothing at all, the branch is deleted andtemplateCommitis left where it was — it is never advanced by hand.It then re-asserts a few rules this repo must keep, and lists each change it makes: the template's platform-only ticket scripts and platform-only ticket files are removed,
tickets.config.jsonis reshaped (values untouched) and never left pointing at the template's board, the ticket package's scripts are merged three ways, environment variables new in the template are appended to.env.examplecommented out, and the.claude/manifest.jsonskills list is reconciled with what is on disk.Record and gate.
templateCommitmoves to the new template commit, the runner formats what it wrote, commits, and runs this repo's own gates:format:check,lint,check-typesand@v4/tickets driftare reported (a failure is named in the commit and the PR, and the sync still lands), while the migration lint is blocking. Anything a gate rewrote — usually generated files a build step regenerated — is put back, not committed.Open the PR (with
--create-pr), titledsync-upstream: template @ <sha>, its body listing conflicts, failed gates, things to review and theclientSpectickets.
The blocking migration lint
packages/model/scripts/lint-migrations.mjs checks migration numbering as well as
ownership. Two files with the same number in one directory make migrate.mjs refuse the
whole sequence, so a sync that carries one would pass review and then fail the model PR gate
and every model apply after merge. While the lint fails, the runner opens no PR and pushes
nothing; the branch is left local and the lint names the collision.
The usual cause is a migration of ours written into a template directory. This project's
migrations belong in packages/model/migrations/club/ and nowhere else; one that was put in
content/ is moved there and the move recorded in migrations/renamed.json, so environments
that already ran it do not run it again (see
Migrations in this repo).
Fix the cause, delete the local branch, and re-run.
Review and merge
The pull request is the review: nothing in it is merged automatically, and it is reviewed
like any other change to develop.
Resolve conflicts on the branch
A hunk that met an edit of ours is left with <<<<<<< markers, and the commit and PR say so.
Resolve each one on the sync branch and push a commit — the earlier syncs did exactly this
(sync-upstream: resolve for euro-league). The usual call is to keep what this site needs
and take the template's change around it; if the template's change makes ours unnecessary,
take theirs.
Read the "to review" list
The runner leaves some things unchanged on purpose and lists them in the PR body:
- A new migration that changes a reference field's allowed types. That changes the
GraphQL schema: once it has run, a fragment spreading a type it removed fails codegen. The
model PR gate (
model-pr-gate.yml) applies the migrations to a throwaway environment and must pass; fix any such fragment, re-vendorschema.graphqland regenerate the types in this PR. The gate is not a required check, so look at it rather than at the merge button. - Ticket files holding our own spec (
clientSpec: true), with the template's diff for each: fold in by hand what applies. The board copy of a ticket is never written by a sync. - Files changed upstream that this repo deleted — the deletion stands; check nothing in the template's change is needed.
- Ticket package scripts and config keys the template added that need a decision here.
Check the gates
Every gate the runner reported as failing has to be green before merge, and CI runs the full set on the PR (Pull requests & gates). A sync that touches components often moves resolved token values, so expect e2e and Storybook story-suite changes to need a look too.
Merge with a merge commit
Merge the sync PR with a merge commit, not a squash — the same as any branch other work is
stacked on (Merging). Abandoning the branch is safe:
templateCommit only moves on the sync branch, so the next run offers the same changes again.
After merging
A merged sync changes code the whole repo builds on, so treat the first run after it like a fresh checkout.
1. Install, compose, verify
pnpm install # the template may have changed a package's dependencies
pnpm compose # regenerate shims and composition if template-config changed
pnpm verify # prettier, eslint, types, unit suites, bridges, compose and codegen drift
The root package.json and pnpm-lock.yaml are never synced, so a dependency the template
added to the ROOT has to be added here by hand; one added inside a synced package arrives with
it. pnpm compose -- --check is one of verify's gates — if a new compose-club writes the
shims differently, commit what pnpm compose regenerated. The web app consumes the packages'
built dist, so rebuild them (or run pnpm build) and restart pnpm dev before judging the
site.
2. The content model
New template migrations land in packages/model/migrations/core/ or content/ and are
applied by the normal pipeline, never by hand:
- On merge to
develop,model-apply-develop.ymlapplies them to the develop environment and syncs the Contentful apps. Do not run them against develop from your machine before the PR merges — the drift gate on every other open PR would go red. - Staging and production get them through the deploy pipeline, like every other migration (The model deploy pipeline).
- After develop has them,
pnpm --filter @v4/model driftshould report nothing. A difference means a schema or generated-type change the sync PR should have carried; fix it in a follow-up PR rather than regenerating over it.
A synced fragment or transform can also select a field this site has hidden or removed in
its own migrations/club/ — codegen fails on it, and the fix belongs in the sync PR.
3. Tickets
A synced feature arrives with its ticket files, but the ELBWEB board has no ticket for it yet. Push only the new ones, one epic at a time, dry run first:
pnpm --filter @v4/tickets build-cms-model -- --from=contentful
pnpm --filter @v4/tickets push-jira -- --epic=<slug> --create # lists only new tickets
pnpm --filter @v4/tickets push-jira -- --epic=<slug> --create --apply --verify
Commit tickets.pushed.json afterwards. The rules for the board are in
.claude/rules/tickets/platform-and-client.md.
4. Claude assets and docs
Synced .claude skills, rules and agents are in effect from the next session. This docs app
is never synced: where a sync changes how something here works, update the page that
describes it in the same PR — the docs-drift check flags the pages mapped to the changed code
(The docs app).