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.

2026-11-07

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.ts and 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-sso and @v4/code-of-conduct.
  • This project's model — packages/model/migrations/club/, the migration move log packages/model/migrations/renamed.json, and the template's generated migrations/core/0001-baseline.cjs (this repo has its own composed baselines).
  • This docs app (apps/docs), the root package.json, pnpm-lock.yaml and .claude/manifest.json.
  • The ticket tooling's own state — the cloneOwned paths in packages/tickets/domains.json: tickets.config.json (the ELBWEB board and ELB's Figma files), tickets.pushed.json, the generated cms-types.json and figma-frames.json (built from this space and ELB's Figma) and outstanding.json; the template's platform-only ticket scripts; and any ticket file marked clientSpec: true.
  • The Contentful apps' source (apps/contentful, packages/contentful-ui): the apps are built and released from the template and installed into this space by sync-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-upstream runs the steps below, then helps resolve what the sync could not apply cleanly.

Before you start

  • A template remote 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 template
    
  • A clean working tree on the branch the PR will target (develop). The runner refuses a dirty tree, and refuses to start from a sync-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]:

  1. Fetch the template ref.

  2. Diff templateCommit..template/<ref> over the offered paths (the previous page), leaving out the excluded ones, files this repo has deleted, and clientSpec ticket files. If nothing changed it stops: already up to date.

  3. Apply the diff on a new branch sync-upstream/<ref>-<sha> with git 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 and templateCommit is 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.json is 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.example commented out, and the .claude/manifest.json skills list is reconciled with what is on disk.

  4. Record and gate. templateCommit moves to the new template commit, the runner formats what it wrote, commits, and runs this repo's own gates: format:check, lint, check-types and @v4/tickets drift are 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.

  5. Open the PR (with --create-pr), titled sync-upstream: template @ <sha>, its body listing conflicts, failed gates, things to review and the clientSpec tickets.

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-vendor schema.graphql and 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.yml applies 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 drift should 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).