From Chaos to Clarity: Enforcing Project Standards Across Teams

Every platform team has written the document. It is called something like "Service Standards" or "Repository Conventions," it lives in Confluence or Notion, and it is genuinely good. It says which files a service needs, how CI should be wired, where the CODEOWNERS file goes, what the Dockerfile should look like.
It also has no enforcement mechanism whatsoever. A document cannot stop a repository from drifting. Six months after publication you have the document, twenty repositories, and no reliable way to know which of the twenty still match it.
The gap is not knowledge. Everyone knows the standard. The gap is that "apply the standard to this repository" is manual work, and manual work does not happen at scale.
Reframing the problem
The instinct is to reach for a linter — something that scans a repository and complains when it deviates. That is one valid model, and it is worth being clear that StructKit is not it. StructKit does not scan an existing repository and produce a compliance report. structkit validate validates a .struct.yaml configuration file, not a repository's conformance to it.
StructKit uses a different model, and it is worth understanding why it is often the better one: generation is the check.
If your standard is expressed as a StructKit definition, then regenerating a repository from that definition and looking at the resulting diff tells you exactly how far it has drifted. An empty diff means compliant. A non-empty diff is not just a report — it is the fix, already written, ready to review.
A linter tells you that .github/workflows/ci.yml is out of date. Regeneration hands you the corrected file.
Writing the standard down
Start by expressing your conventions as a definition. The important move is to reference canonical content rather than copying it:
files:
- .github/workflows/ci.yml:
file: github://acme/platform-templates/main/workflows/{{@ language @}}-ci.yml
- .github/CODEOWNERS:
content: |
* @acme/{{@ owning_team @}}
- .editorconfig:
file: github://acme/platform-templates/main/configs/.editorconfig
- .pre-commit-config.yaml:
file: github://acme/platform-templates/main/configs/pre-commit.yaml
- SECURITY.md:
file: github://acme/platform-templates/main/SECURITY.md
- README.md:
skip_if_exists: true
content: |
# {{@ service_name @}}
Owned by @acme/{{@ owning_team @}}.
folders:
- ./:
struct:
- github/templates
- configs/editor-config
variables:
- service_name:
description: "Name of the service"
type: string
- owning_team:
description: "GitHub team that owns this service"
type: string
- language:
description: "Primary language (python, go, nodejs)"
type: string
default: "python"Note the deliberate split. SECURITY.md and the CI workflow are remote references — the platform team owns them, and a change there should reach every service. README.md carries skip_if_exists: true, because a service's README is the team's to write and regeneration should never clobber it.
That distinction is the whole design of a good standards definition. Decide, file by file, whether the platform team owns the content or merely owns the requirement that the file exists. Files in the first category get remote references or inline content. Files in the second get skip_if_exists: true.
Checking drift locally
Before anything touches CI, the loop a single engineer runs:
structkit generate -d --diff .struct.yaml .-d is a dry run — nothing is written. --diff prints what would change. On a compliant repository the output is empty. On a drifted one you see precisely which standard files are missing or stale.
Applying it, with the safety rails you want:
# Add what's missing, never overwrite anything that exists
structkit generate -f skip .struct.yaml .
# Or take the platform team's version, keeping a backup
structkit generate -f backup -b /tmp/structkit-backup .struct.yaml .The five strategies for -f / --file-strategy are overwrite (default), skip, append, rename, and backup. For a first pass across repositories you did not write, skip is the humane choice: it converges the missing pieces without arguing about the existing ones.
Making it a pull request instead of a policy
The mechanism that actually changes behavior is a workflow that opens a PR. StructKit ships a reusable GitHub Actions workflow for exactly this:
name: run-struct
on:
workflow_dispatch:
schedule:
- cron: "0 9 * * 1"
jobs:
generate:
uses: httpdss/structkit/.github/workflows/struct-generate.yaml@main
secrets:
token: ${{ secrets.STRUCT_RUN_TOKEN }}It checks out the repository, installs StructKit, runs structkit generate --non-interactive against .struct.yaml, and opens a pull request with whatever changed — labelled automated-pr, against main by default.
If your definitions live in a separate repository, point at it:
jobs:
generate:
uses: httpdss/structkit/.github/workflows/struct-generate.yaml@main
with:
custom_structure_repository: acme/platform-structures
custom_structure_path: structures
struct_file: .struct.yaml
args: "-f skip"
pr_title: "chore: sync repository to platform standards"
pr_assignees: platform-team
version: "3.2.1"
secrets:
token: ${{ secrets.STRUCT_RUN_TOKEN }}Pinning version matters. A scheduled job that installs the latest StructKit on every run is a job whose behavior can change without you doing anything.
Run this weekly across your services and standards drift becomes a queue of small, reviewable pull requests instead of an audit nobody has time for. When the platform team updates the canonical CI workflow, Monday's run opens twenty PRs carrying that change. Each one has a human in the loop, which is what you want — automation that merges itself into twenty repositories is a different and worse risk.
Building it into CI directly
If you would rather have CI fail on drift than open a PR, the dry-run diff gives you that:
- name: Install StructKit
run: pip install structkit==3.2.1
- name: Check for structure drift
env:
STRUCTKIT_NON_INTERACTIVE: "true"
run: |
structkit generate -d --diff .struct.yaml . | tee drift.txt
if [ -s drift.txt ]; then
echo "::warning::Repository has drifted from platform standards"
cat drift.txt >> "$GITHUB_STEP_SUMMARY"
fiStart this as a warning, not a failure. A gate that blocks merges on day one, before anyone has converged, mostly teaches people to route around it.
Several environment variables are useful in CI, since they let you set defaults without threading flags through every invocation: STRUCTKIT_NON_INTERACTIVE, STRUCTKIT_FILE_STRATEGY, STRUCTKIT_BACKUP_PATH, STRUCTKIT_STRUCTURES_PATH, and STRUCTKIT_LOG_LEVEL. Command-line arguments always take precedence over them.
Distributing the definitions
For an organization, the definitions themselves need a home. Keep them in one repository and register it as a named source:
structkit sources add platform github://acme/platform-structures@v1.4.0/structures
structkit list --source platform
structkit generate platform/service/python ./new-serviceSources can be local directories, GitHub shorthand (acme/platform-structures), github:// URLs with an optional @ref, or git URLs over HTTPS or SSH. Git-backed sources are cloned into a cache and refreshed with git fetch when resolved.
Better still for reproducibility, pin the source inside the committed .struct.yaml so nobody's local configuration affects the result:
sources:
platform:
url: github://acme/platform-structures@v1.4.0/structures
folders:
- ./:
struct: platform/service/python
with:
service_name: billing-api
owning_team: paymentsNow the repository is self-describing. Anyone — a new engineer, a CI runner, an AI assistant — can run structkit generate and get the same result, from the same pinned version of the standard. Prefer tags or commit SHAs over branches here; a definition pinned to main is a definition whose output can change underneath you.
Versioning the source repository is what makes standards updates deliberate. Bumping @v1.4.0 to @v1.5.0 in a service's .struct.yaml is a visible, reviewable act. That is a meaningfully different posture from a template repository that silently means something new every week.
What this does not solve
Worth stating plainly. Generation converges files. It does not verify that a file's contents still make sense, catch a repository that has added something it should not have, or stop someone from editing a generated file the day after it lands.
For the last one, the practical mitigation is a comment at the top of generated files pointing at the definition that produced them, so the next person to edit it knows where the change belongs:
files:
- .github/workflows/ci.yml:
content: |
# Generated by StructKit from acme/platform-structures.
# Edit the template, not this file.
{%@ include_workflow @%}Wrap-up
The document was never the problem. The problem was that a document has no way to apply itself. Expressing the standard as a StructKit definition, pinning it to a version, and putting a scheduled workflow in front of it turns "we have conventions" into a weekly stream of pull requests that carry those conventions into every repository — reviewed by a human, and cheap enough that it actually happens.
Details in GitHub Integration, File Handling, and Environment Variables.