Skip to main content
ship it and sleep

Template Repos, Golden Paths, and Scaffolding Automation

4 min read Chapter 65 of 66

Template Repos, Golden Paths, and Scaffolding Automation

The Failure

The team created a template repository. Six months and twelve services later, the template had been updated 15 times. But the twelve existing services still used the original template. The security scanner was upgraded from Trivy v0.45 to v0.50 in the template, but all existing services ran v0.45. The performance budget was tightened in the template, but existing services had the old budget. The template only helped new services.

Template sync keeps existing services aligned with the template.

The Mechanism

Template Lifecycle

  1. Create: Platform team builds the template with all pipeline components
  2. Scaffold: Developer creates a new service from the template
  3. Customize: Developer adds service-specific logic (API routes, business logic)
  4. Sync: When the template is updated, existing services receive a PR with the changes
  5. Override: Services can override template defaults via configuration

Syncable vs Non-Syncable Files

FileSyncable?Reason
.github/workflows/ci.ymlYesPipeline gates must be consistent
.github/workflows/security.ymlYesSecurity policy is global
DockerfilePartiallyBase image updates yes, app-specific stages no
k8s/base/deployment.yamlNoService-specific containers
.trivyignoreNoService-specific exceptions
tests/performance/budgets.yamlNoService-specific thresholds

The Implementation

Template Repository Structure

acme/service-template-go/
├── .template/
│   ├── config.yaml           # Template metadata
│   ├── sync-include.txt      # Files to sync to existing services
│   └── placeholders.yaml     # Replacement variables
├── .github/workflows/        # Synced ✓
├── k8s/base/                 # Synced (partially) ✓
├── Dockerfile                # Synced ✓
├── cmd/server/main.go        # Not synced (service-specific)
└── ...

Template Config

# .template/config.yaml
# HARDENED: Template metadata
name: go-service
version: "3.2.0"
languages: [go]
maintainer: platform-team

placeholders:
  SERVICE_NAME: "Name of the service (lowercase, hyphenated)"
  TEAM_NAME: "Owning team name"
  GO_VERSION: "1.22"
  PORT: "8080"

sync:
  include:
    - ".github/workflows/*.yml"
    - "Dockerfile"
    - ".trivyignore.template"
    - ".gitleaks.toml"
  exclude:
    - ".github/workflows/custom-*.yml"
    - "k8s/overlays/**"

Template Sync Workflow

# acme/service-template-go/.github/workflows/sync-template.yml
# HARDENED: Sync template changes to all derived services
name: Sync Template
on:
  push:
    branches: [main]
    paths:
      - ".github/workflows/**"
      - "Dockerfile"
      - ".gitleaks.toml"

jobs:
  sync:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        repo:
          - acme/catalog-service
          - acme/checkout-service
          - acme/inventory-service
          - acme/payments-service
          - acme/frontend-shell
    steps:
      - uses: actions/checkout@v4
        with:
          path: template

      - uses: actions/checkout@v4
        with:
          repository: ${{ matrix.repo }}
          token: ${{ secrets.SYNC_TOKEN }}
          path: target

      - name: Sync files
        run: |
          while IFS= read -r pattern; do
            for file in template/$pattern; do
              if [[ -f "$file" ]]; then
                rel="${file#template/}"
                mkdir -p "target/$(dirname "$rel")"
                cp "$file" "target/$rel"
              fi
            done
          done < template/.template/sync-include.txt

      - name: Create sync PR
        uses: peter-evans/create-pull-request@v6
        with:
          path: target
          token: ${{ secrets.SYNC_TOKEN }}
          branch: template-sync/v${{ github.run_number }}
          title: "chore: sync with service template"
          body: |
            Automated sync from [service-template-go](https://github.com/acme/service-template-go).

            Changes in this PR come from the platform template.
            Review carefully — some changes may need service-specific adjustments.
          labels: template-sync
          reviewers: ${{ github.event.pusher.name }}

Override Mechanism

# In a service repo: .template-overrides.yaml
# Services can override template defaults
overrides:
  ci:
    # Use a custom test command instead of template default
    test_command: "go test -tags=custom ./..."
  security:
    # Additional Trivy severity level
    trivy_severity: "CRITICAL,HIGH,MEDIUM"
  performance:
    # Service-specific budget (not synced from template)
    use_template_budget: false

The CI workflow reads overrides:

- name: Read overrides
  id: overrides
  run: |
    if [[ -f .template-overrides.yaml ]]; then
      TEST_CMD=$(yq '.overrides.ci.test_command // "go test ./..."' .template-overrides.yaml)
    else
      TEST_CMD="go test ./..."
    fi
    echo "test_cmd=$TEST_CMD" >> $GITHUB_OUTPUT

- name: Run tests
  run: ${{ steps.overrides.outputs.test_cmd }}

The Gate

The template sync PR is the gate. It must be reviewed and merged within one sprint. If a service rejects a sync PR, the platform team is notified. Persistent rejections indicate the template does not fit the service’s needs — which means either the template needs to be more flexible or the service is a snowflake that needs to be brought into alignment.

The Recovery

Sync PR has merge conflicts: The service modified a synced file. Resolve manually: keep the template’s pipeline structure but preserve service-specific customizations.

Too many sync PRs overwhelm developers: Batch template changes. Instead of syncing on every template commit, sync weekly or on template version tags.

Service needs a file that the template excludes from sync: Add it to .template-overrides.yaml. The service manages that file independently.