From e64200a22a794148cfd876f2bd1e02ffe20b6a6a Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 17 Jul 2026 09:11:20 +0200 Subject: [PATCH 1/2] feat: adopt weekly template-sync with the tenant-template ownership model Fixes #291 Co-Authored-By: Claude Fable 5 --- .github/workflows/template-sync.yaml | 33 +++++++++++++++++ .templatesyncignore | 55 ++++++++++++++++++++++++++++ AGENTS.md | 2 +- README.md | 28 ++++++++++++++ 4 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/template-sync.yaml create mode 100644 .templatesyncignore diff --git a/.github/workflows/template-sync.yaml b/.github/workflows/template-sync.yaml new file mode 100644 index 0000000..6772605 --- /dev/null +++ b/.github/workflows/template-sync.yaml @@ -0,0 +1,33 @@ +name: 🔄 Template Sync + +on: + schedule: + - cron: "0 6 * * 1" # weekly, Monday 06:00 UTC + workflow_dispatch: + +permissions: {} + +jobs: + template-sync: + # Skipped in the template repo itself (it is the sync source); runs in every + # instance created from this template, where it opens a PR with template + # changes. Outside devantler-tech the org-provided APP_PRIVATE_KEY secret is + # absent and the scheduled run would only fail — so personal-account + # instances are off by default and opt in by adding the secret plus a + # repository variable TEMPLATE_SYNC_ENABLED=true. + if: >- + github.repository != 'devantler-tech/dotnet-template' && + (github.repository_owner == 'devantler-tech' || vars.TEMPLATE_SYNC_ENABLED == 'true') + # The reusable workflow's job opens the sync PR, so the caller must grant it + # write to contents + pull-requests. A reusable workflow cannot be granted + # more than the caller, and the top-level `permissions: {}` grants none, so + # without this the run fails at startup (reusable-workflow permission check) + # — both here and in every instance where the job actually runs. + permissions: + contents: write + pull-requests: write + uses: devantler-tech/actions/.github/workflows/template-sync.yaml@0f27c13a9785608e0bbf9998777d27761b4de617 # v10.0.2 + with: + source-repo-path: devantler-tech/dotnet-template + secrets: + APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} diff --git a/.templatesyncignore b/.templatesyncignore new file mode 100644 index 0000000..cb00485 --- /dev/null +++ b/.templatesyncignore @@ -0,0 +1,55 @@ +# .templatesyncignore — files an INSTANCE created from this template OWNS. +# +# Same syntax as .gitignore. The weekly template-sync workflow +# (devantler-tech/actions .github/workflows/template-sync.yaml, using +# AndreasAugustin/actions-template-sync) is skipped in this template repo itself +# and runs in every instance created from it — opening a PR that brings template +# changes downstream. It only ever touches files that exist in this template, and +# it SKIPS anything matched here, so an instance's tailored files are never +# clobbered by an upstream sync. +# +# Everything NOT listed here is TEMPLATE-OWNED shared plumbing kept current in +# every instance by template-sync — change it upstream in +# devantler-tech/dotnet-template, never by editing it in an instance. +# Template-owned plumbing includes the +# .github/workflows/{ci,publish,release,template-sync,todos}.yaml workflows, +# the shared configs (.editorconfig, .gitattributes, .pre-commit-config.yaml, +# global.json — the SDK pin propagates so instances stay on the current +# toolchain), and the CLAUDE.md/GEMINI.md shims. (ci.yaml is deliberately +# template-owned here — unlike the stack-neutral gitops-tenant-template, every +# .NET instance shares the same required-checks aggregation, so CI fixes must +# propagate.) + +# --- Identity & docs (instance-specific) --- +AGENTS.md +README.md +LICENSE +.github/CODEOWNERS +.claude/skills/maintain/SKILL.md + +# --- Release / dependency config the instance tailors --- +.releaserc +.gitignore +.github/dependabot.yaml + +# --- The instance's own solution and code --- +# The solution, projects, and code are renamed from Example.* by +# scripts/rename-placeholders.sh at scaffold time and evolve with the instance; +# a sync must never clobber them — nor re-introduce the template's Example.slnx +# after the rename. src/ and tests/ are ignored WHOLESALE so a template file +# added later at a path the instance already uses can never touch instance code. +Example.slnx +src/ +tests/ + +# --- Scaffolding machinery (template-only; dead code in a live instance) --- +# These run when a NEW repo is created from this template ("use this template" +# copies everything, so scaffolding still arrives at creation time). A +# scaffolded instance never runs them again — ignoring them stops template-sync +# (re-)introducing them downstream, so an instance that deletes them stays clean. +scripts/rename-placeholders.sh +scripts/rename-placeholders.test.sh +.github/workflows/validate-scaffold.yaml + +# --- This ignore-list itself (the instance curates its own ownership) --- +.templatesyncignore diff --git a/AGENTS.md b/AGENTS.md index 7b159c5..23c244e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ - `AGENTS.md` — the canonical, cross-tool project instructions. - `CLAUDE.md` / `GEMINI.md` — exact one-line `@AGENTS.md` shims; never copy guidance into them. - `.editorconfig` — formatting and analyzer rules enforced at build. -- `.github/workflows/` — `ci.yaml` (required-checks aggregation on PRs/merge queue), `validate-scaffold.yaml` (template-repo-only gate that exercises the agent shims and scaffold-rename script — no-ops downstream), `publish.yaml` (publishes the NuGet library on `v*` tags via the reusable `publish-dotnet-library` workflow), `release.yaml`, `sync-labels.yaml`, `todos.yaml`, and `copilot-setup-steps.yml`. +- `.github/workflows/` — `ci.yaml` (required-checks aggregation on PRs/merge queue), `validate-scaffold.yaml` (template-repo-only gate that exercises the agent shims and scaffold-rename script — no-ops downstream), `publish.yaml` (publishes the NuGet library on `v*` tags via the reusable `publish-dotnet-library` workflow), `template-sync.yaml` (skipped in this repo; in instances it opens a weekly PR syncing template-owned plumbing, honouring `.templatesyncignore` — see the README's *Staying current* ownership classes), `release.yaml`, `sync-labels.yaml`, `todos.yaml`, and `copilot-setup-steps.yml`. - `.pre-commit-config.yaml` — a [pre-commit](https://pre-commit.com) config with a local `dotnet-format` hook that runs `dotnet format` on staged C# changes (opt in with `pre-commit install`); `.releaserc` — semantic-release configuration. - `scripts/validate-agent-shims.test.sh` — hermetic structural check that both tool-specific shims contain exactly `@AGENTS.md` plus one newline. Run with `sh scripts/validate-agent-shims.test.sh`; CI runs it via `validate-scaffold.yaml`. diff --git a/README.md b/README.md index 3cb8709..1a285e2 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,34 @@ dotnet test Run the script with no argument to derive a PascalCase name from your `origin` GitHub remote. It leaves the **Use this template** links above and the maintenance docs untouched; review the result with `git diff`. (Prefer to rename by hand? Repoint `Example` across the `.slnx`, `src/`, `tests/`, and README references yourself.) +## 🔄 Staying current + +A weekly **template-sync** workflow opens a PR in your repository whenever this +template's shared plumbing changes, so instances never drift from the +portfolio's CI/lint/agent-file conventions. It never touches your code: every +file falls into one of three ownership classes: + +- **Template-owned plumbing** — synced downstream by the weekly PR: the + `.github/workflows/` CI/publish/release workflows, the shared configs + (`.editorconfig`, `.gitattributes`, `.pre-commit-config.yaml`, `global.json` + — the SDK pin propagates so instances stay on the current toolchain), and + the `CLAUDE.md`/`GEMINI.md` shims. Change these upstream in the template, + never by hand in an instance. +- **Instance-owned** — listed in [`.templatesyncignore`](.templatesyncignore), + never touched by a sync: your solution and code (the `.slnx`, all of `src/` + and `tests/`), identity and docs (`README.md`, `AGENTS.md`, `LICENSE`, + `CODEOWNERS`), and the configs you tailor (`.releaserc`, `.gitignore`, + `dependabot.yaml`). +- **Scaffold-time-only** — the rename script and the template's own + `validate-scaffold.yaml` gate arrive when the repo is created and are ignored + by sync afterwards, so you can delete them and they stay gone. + +The sync workflow no-ops in this template repository itself. In devantler-tech +instances it works out of the box (the org provides the `APP_PRIVATE_KEY` +secret); an instance elsewhere is off by default — opt in by adding an +`APP_PRIVATE_KEY` secret (a GitHub App key that can open PRs) and setting the +repository variable `TEMPLATE_SYNC_ENABLED=true`. + ## 📝 Usage ### Add a project to the solution From 6478fd6f870e7dde1a5dee9fbee29c04948e83cd Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 17 Jul 2026 12:44:59 +0200 Subject: [PATCH 2/2] fix(template-sync): instance-owned toolchain floor, placeholder-free restore, external opt-in docs (codex round 1) - global.json is instance-owned: a synced SDK-floor bump without its matching TargetFrameworks violates the lockstep toolchain policy (go.mod parity in go-template). - copilot-setup-steps restores with bare 'dotnet restore' so the template-owned workflow survives the instance's solution rename. - External opt-in documents the APP_CLIENT_ID variable the reusable workflow mints its App token from, and how external instances take ownership of ci.yaml via their own .templatesyncignore. Co-Authored-By: Claude Fable 5 --- .github/workflows/copilot-setup-steps.yml | 6 +++++- .github/workflows/template-sync.yaml | 9 +++++--- .templatesyncignore | 25 ++++++++++++++++------- README.md | 25 ++++++++++++++--------- 4 files changed, 44 insertions(+), 21 deletions(-) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index b55dcfb..4dc576c 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -43,4 +43,8 @@ jobs: global-json-file: global.json - name: 📦 Restore dependencies - run: dotnet restore Example.slnx + # Bare `dotnet restore` resolves the repo root's single solution file, + # so this workflow stays template-owned and placeholder-independent: it + # keeps working after an instance renames Example.slnx, and template + # syncs can keep propagating hardening/version bumps to it. + run: dotnet restore diff --git a/.github/workflows/template-sync.yaml b/.github/workflows/template-sync.yaml index 6772605..a7ff858 100644 --- a/.github/workflows/template-sync.yaml +++ b/.github/workflows/template-sync.yaml @@ -11,10 +11,13 @@ jobs: template-sync: # Skipped in the template repo itself (it is the sync source); runs in every # instance created from this template, where it opens a PR with template - # changes. Outside devantler-tech the org-provided APP_PRIVATE_KEY secret is + # changes. Outside devantler-tech the org-provided App credentials are # absent and the scheduled run would only fail — so personal-account - # instances are off by default and opt in by adding the secret plus a - # repository variable TEMPLATE_SYNC_ENABLED=true. + # instances are off by default and opt in by supplying their OWN GitHub App + # (one that can open PRs): the APP_PRIVATE_KEY secret plus the APP_CLIENT_ID + # repository variable (the reusable workflow mints its token from that + # variable/secret pair), and setting the repository variable + # TEMPLATE_SYNC_ENABLED=true. if: >- github.repository != 'devantler-tech/dotnet-template' && (github.repository_owner == 'devantler-tech' || vars.TEMPLATE_SYNC_ENABLED == 'true') diff --git a/.templatesyncignore b/.templatesyncignore index cb00485..0fd76bf 100644 --- a/.templatesyncignore +++ b/.templatesyncignore @@ -12,13 +12,15 @@ # every instance by template-sync — change it upstream in # devantler-tech/dotnet-template, never by editing it in an instance. # Template-owned plumbing includes the -# .github/workflows/{ci,publish,release,template-sync,todos}.yaml workflows, -# the shared configs (.editorconfig, .gitattributes, .pre-commit-config.yaml, -# global.json — the SDK pin propagates so instances stay on the current -# toolchain), and the CLAUDE.md/GEMINI.md shims. (ci.yaml is deliberately -# template-owned here — unlike the stack-neutral gitops-tenant-template, every -# .NET instance shares the same required-checks aggregation, so CI fixes must -# propagate.) +# .github/workflows/{ci,publish,release,template-sync,todos,copilot-setup-steps} +# workflows, the shared configs (.editorconfig, .gitattributes, +# .pre-commit-config.yaml), and the CLAUDE.md/GEMINI.md shims. (ci.yaml is +# deliberately template-owned for devantler-tech instances — they all share the +# same ruleset-injected build/test workflows and this required-checks +# aggregation, so CI fixes must propagate. An instance OUTSIDE devantler-tech +# does not inherit those org rulesets and must replace ci.yaml with real +# build/test jobs — after doing so, add `.github/workflows/ci.yaml` to this +# file, which the instance owns, so a later sync cannot revert it.) # --- Identity & docs (instance-specific) --- AGENTS.md @@ -32,6 +34,15 @@ LICENSE .gitignore .github/dependabot.yaml +# --- Toolchain floor (instance-owned so SDK and TFMs move in lockstep) --- +# AGENTS.md's toolchain-floor policy requires global.json's SDK floor and every +# to move together. The project trees below are ignored, so a +# synced global.json bump would move the SDK floor WITHOUT its matching TFMs — +# a lockstep violation delivered downstream. The instance owns the whole +# toolchain upgrade instead (the .NET parity to go-template, where the +# instance-owned go.mod carries the Go toolchain floor). +global.json + # --- The instance's own solution and code --- # The solution, projects, and code are renamed from Example.* by # scripts/rename-placeholders.sh at scaffold time and evolve with the instance; diff --git a/README.md b/README.md index 1a285e2..e24cb7f 100644 --- a/README.md +++ b/README.md @@ -46,24 +46,29 @@ file falls into one of three ownership classes: - **Template-owned plumbing** — synced downstream by the weekly PR: the `.github/workflows/` CI/publish/release workflows, the shared configs - (`.editorconfig`, `.gitattributes`, `.pre-commit-config.yaml`, `global.json` - — the SDK pin propagates so instances stay on the current toolchain), and - the `CLAUDE.md`/`GEMINI.md` shims. Change these upstream in the template, + (`.editorconfig`, `.gitattributes`, `.pre-commit-config.yaml`), and the + `CLAUDE.md`/`GEMINI.md` shims. Change these upstream in the template, never by hand in an instance. - **Instance-owned** — listed in [`.templatesyncignore`](.templatesyncignore), never touched by a sync: your solution and code (the `.slnx`, all of `src/` - and `tests/`), identity and docs (`README.md`, `AGENTS.md`, `LICENSE`, - `CODEOWNERS`), and the configs you tailor (`.releaserc`, `.gitignore`, - `dependabot.yaml`). + and `tests/`), the toolchain floor (`global.json` — the SDK floor and every + `` must move in lockstep, so the whole upgrade is yours), + identity and docs (`README.md`, `AGENTS.md`, `LICENSE`, `CODEOWNERS`), and + the configs you tailor (`.releaserc`, `.gitignore`, `dependabot.yaml`). - **Scaffold-time-only** — the rename script and the template's own `validate-scaffold.yaml` gate arrive when the repo is created and are ignored by sync afterwards, so you can delete them and they stay gone. The sync workflow no-ops in this template repository itself. In devantler-tech -instances it works out of the box (the org provides the `APP_PRIVATE_KEY` -secret); an instance elsewhere is off by default — opt in by adding an -`APP_PRIVATE_KEY` secret (a GitHub App key that can open PRs) and setting the -repository variable `TEMPLATE_SYNC_ENABLED=true`. +instances it works out of the box (the org provides the App credentials); an +instance elsewhere is off by default — opt in by supplying your own GitHub App +(one allowed to open PRs in your repository): add its private key as the +`APP_PRIVATE_KEY` secret, its client ID as the `APP_CLIENT_ID` repository +variable, and set the repository variable `TEMPLATE_SYNC_ENABLED=true`. Note +that outside devantler-tech the synced `ci.yaml` is only an empty required-check +aggregator (the real build/test workflows are injected by devantler-tech org +rulesets): replace it with your own CI and add `.github/workflows/ci.yaml` to +your `.templatesyncignore` so later syncs preserve your version. ## 📝 Usage