Back to Blog

    From Chaos to Clarity: Enforcing Project Standards Across Teams

    August 20, 2026Kenny Belitzky
    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"
        fi

    Start 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-service

    Sources 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: payments

    Now 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.