Over the past few months, I have been turning the maintenance of the NSW Design System application fleet into a system of its own. The work now lives across nswds-devops, nswds-eslint-config, nswds-prettier-config and a shared Renovate policy.

None of those pieces is especially interesting in isolation. The useful part is how they fit together. Shared policy has one owner. Changes move through predictable channels. Low-risk work can merge itself, while risky changes remain visible and deliberate.

The problem was drift

The fleet includes 24 consumer repositories: mostly Next.js applications, plus package, email and infrastructure projects with their own constraints. The same commit scripts, branch rules, release configuration and CI workflows had been copied into each one. Unsurprisingly, those copies had drifted into three or four different versions.

That kind of drift is easy to ignore because every repository can still look healthy on its own. The cost appears later. A fix has to be repeated across a dozen apps. A new check lands in some pipelines but not others. An upgrade works in the newest project and fails in the oldest one for reasons nobody has written down.

The answer was not a longer setup checklist. Checklists describe policy. They do not keep applying it after the project has launched.

Use two paths for change

nswds-devops is now the source of truth for shared scripts, commit rules, release configuration and CI. It distributes those things in two different ways because they carry different levels of risk.

Files such as shell scripts and root configuration move through file-sync pull requests. Each repository gets a visible diff and its usual required checks still decide whether that change can merge. The change is authored once, but it does not bypass the evidence each consumer repo provides.

CI logic uses reusable GitHub Actions workflows. Consumer repositories contain small stubs pinned to a protected v1 tag, while the actual jobs live in nswds-devops. Moving that tag updates the fleet at once, so promotion is treated like a deployment: the target must be on main, its checks must be green, and the promotion records the previous commit for rollback.

That split is deliberate. Reviewable file changes travel by pull request. Shared CI behaviour can roll out or roll back in one operation. A breaking workflow change gets a new major tag and migrates through the stubs instead of quietly changing the meaning ofv1.

Make the green tick useful

The shared merge gate checks more than whether next build happens to pass. It scans for conflict markers, verifies the committed lockfile with a clean install, builds the application, then runs the lint, test and formatting jobs the repository exposes. Missing capabilities can be opted out explicitly, but they should not disappear because one app forgot to copy a workflow step.

The surrounding governance follows the same approach. Commit messages are checked against the release types. Branch names are validated against policy read from the pull request base, so a branch cannot weaken the rule that judges it. The two representations of allowed commit types are compared in CI so they cannot drift apart.

A required check only matters if it tests the failure mode the team thinks it tests. Much of this work has been about closing the quiet gaps between “configured” and “actually enforced”.

Package the rules apps should extend

Whole-file sync is right for files that should be identical. ESLint and Prettier are different. Applications need a shared base, but they also need room for generated files, build artefacts and framework-specific paths. That is why those rules moved into npm packages instead of another set of copied config files.

@nswds/eslint-config provides a Next.js flat-config entry point built oncore-web-vitals and TypeScript, plus a framework-free base for token pipelines, infrastructure and Node scripts. Both enforce the same Prettier integration and console policy. Repositories spread the shared config, then append the small set of ignores or overrides that genuinely belong to them.

The package also owns the compatibility work. When ESLint 10 removed an API still used by a React plugin inside eslint-config-next, the shim and its regression test went into one place. The test lints a real JSX file because loading a config successfully is not proof that a plugin can run.

@nswds/prettier-config is smaller on purpose. It publishes the common formatting options as plain JSON. Simple repositories reference it directly; Tailwind applications extend it locally to add import sorting, class sorting and their own stylesheet path. Its tests ask Prettier whether every option is recognised, catching a particularly dull failure mode where a misspelled key is silently ignored.

The migration reformatted zero files. The existing configs already agreed; the change removed duplication. That was a useful constraint. Centralisation should not create a giant cosmetic diff just to prove it happened.

Automate according to risk

Renovate connects the shared packages back to their consumers. Each repository gets a small synced renovate.json, while the actual policy lives in thenswds-devops preset. A policy change applies on Renovate's next run; changing the pointer itself still travels through the normal file-sync path.

The policy is intentionally more specific than “keep everything up to date”. Non-major updates arrive on a weekly schedule. Minor updates to 0.x packages are split into their own pull requests because SemVer does not promise they are safe. Development dependency patches can automerge after a three-day release age, as can the lint and formatting packages, because required checks measure their effect directly. Production dependencies and majors still need human review.

Monthly lockfile maintenance can also merge itself after a clean install passes. Known incompatible majors are blocked with the failure and removal condition written into the rule. Security update ownership stays with Snyk, avoiding two bots opening different fixes for the same vulnerability.

Renovate also rebases ordinary pull requests only when they conflict. With strict branch protection, the default behaviour could rebase every open dependency PR after every merge and rerun the entire CI suite across the fleet. Paying for repeated green checks that do not help the triggering merge is not governance. It is just churn.

Consistency does not mean pretending every repo is identical

Some repositories publish npm packages through OIDC. One has a Power Platform deployment workflow named release.yml. The UI monorepo has its own release and formatting structure. Those differences are encoded as sync groups and extension points rather than overwritten for the sake of a tidy diagram.

This is the tradeoff in central tooling: every improvement has a wider reach, but so does every mistake. Protected workflow promotion, consumer pull requests, package tests, required checks and explicit exceptions keep that reach useful. The goal is consistent outcomes, not identical repositories.

The useful lesson

Well-maintained Next.js applications do not come from occasionally finding time for a dependency week. They come from making maintenance part of the delivery system: one place to change policy, a controlled way to distribute it, checks that exercise real failure modes, and automation that earns its authority by staying inside clear risk boundaries.

The boring work still exists. It is now done once, reviewed at the right level, and allowed to propagate without relying on somebody remembering which repository missed the last upgrade. That is what makes the fleet easier to govern, and much less tiring to maintain.