PHASE 2 OF 14

Repository Architecture & Monorepo Strategy

Choosing between polyrepo, monorepo, and hybrid layouts; CODEOWNERS-driven ownership; repository templates; rulesets; naming conventions; and the .github directory — the decisions that shape how your whole team works

Monorepo CODEOWNERS Rulesets Templates Org Architecture
2.1

Polyrepo vs Monorepo vs Hybrid

This decision shapes your CI architecture, dependency management, code-sharing patterns, team autonomy, and on-call blast radius for years. There's no universally correct answer — but there is a decision framework that cuts through the religion around it.

The core trade-off

DimensionPolyrepoMonorepo
Deployment independence✓ Each service has its own pipelineRequires path-filtered triggers in CI to achieve the same
Cross-service refactorsPainful — multiple PRs, coordination overhead✓ One atomic PR, one review, one merge
Dependency managementEach repo pins its own versions; drift is common✓ One version of every dependency; no version skew
Team autonomy✓ Teams own their repo end-to-endShared repo requires governance to avoid stepping on each other
CI cost✓ Only the changed repo's CI runsRequires affected-service detection to avoid running all CI on every commit
Tooling complexitySimple git, simple GitHub ActionsNeeds Nx/Turborepo/Bazel/Pants or hand-rolled path filters for scale
OnboardingNew hire needs access to N repos✓ One clone, one PR flow, one CI system
Secret management✓ Secrets scoped per repo by defaultRequires environment-scoped secrets + CODEOWNERS to limit access

Decision framework

Answer these four questions:

  1. Do services share code that changes together? If yes, and that shared code is a non-trivial library that consumers need to update in lockstep, monorepo wins. If the shared code is stable and versioned independently (like an npm package), polyrepo is fine.
  2. How many teams, and how independent are they? Two teams sharing a monorepo with clear CODEOWNERS is fine. Twenty teams with conflicting release cycles and different tech stacks is a recipe for monorepo pain.
  3. What's your CI build time budget? Monorepo only works at scale if you can detect and run only the affected projects. If you're not willing to invest in that tooling, polyrepo keeps CI fast by default.
  4. How often do cross-service changes happen? If two services always change together, they should be in one repo. If they almost never change together, separate repos reduce noise.

The hybrid (most pragmatic for growing teams)

Group services that genuinely evolve together into a monorepo per domain, keep truly independent services in their own repos. Example:

github.com/acme/ platform/ ← monorepo: auth, billing, notifications (always deployed together) data-pipeline/ ← monorepo: ingestion, transforms, warehouse (data team owns) mobile-ios/ ← standalone: iOS app (separate release cadence) mobile-android/ ← standalone: Android app docs/ ← standalone: public documentation site
LEAD INSIGHT
The monorepo vs polyrepo debate is often a proxy argument for a deeper question: who owns the deployment pipeline? Solve that governance question first, then the repo structure usually becomes obvious.
2.2

GitHub Monorepo at Scale: CODEOWNERS & Path Filters

Two GitHub features make monorepos tractable: CODEOWNERS for ownership and required reviews, and path filters in Actions for targeted CI.

CODEOWNERS

The CODEOWNERS file lives in the repo root, .github/, or docs/. It maps file paths to GitHub users or teams using gitignore-style patterns. When a PR changes a file, the matching owners are automatically added as required reviewers (if branch protection requires CODEOWNER approval).

# .github/CODEOWNERS # Default owner for everything not matched below * @acme/platform-leads # Service-level ownership /services/payments/ @acme/payments-team /services/auth/ @alice @acme/security-team /services/notifications/ @acme/notifications-team # Shared libraries — anyone can read, leads must approve changes /shared/ @acme/platform-leads # Infrastructure as code — ops team required /infra/ @acme/ops-team *.tf @acme/ops-team *.tfvars @acme/ops-team # CI config — platform team must review any workflow changes .github/workflows/ @acme/platform-leads # Docs — anyone on the team is fine /docs/ @acme/all-engineers

Pattern precedence: the last matching pattern wins. Put general patterns first, specific ones last. A file matched by * and then /services/auth/ is owned by @acme/security-team, not the default owner.

GOTCHA
CODEOWNERS only triggers required reviews if the branch protection rule "Require review from Code Owners" is enabled. Having a CODEOWNERS file without this setting means owners are suggested, not required. Verify your branch protection settings match your ownership intentions.

Path-filtered Actions for monorepo CI

Without path filters, every push to any file triggers all workflows — CI costs explode and developer feedback slows. Use paths and paths-ignore to scope each workflow:

# .github/workflows/payments-ci.yml on: push: branches: [main] paths: - 'services/payments/**' - 'shared/utils/**' # also trigger if shared utils change pull_request: paths: - 'services/payments/**' - 'shared/utils/**' jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: sparse-checkout: | services/payments shared/utils
PATH FILTER CAVEAT
A required status check with paths filtering will be skipped (not failed) when no matching files change. GitHub treats a skipped required check as passing — which is usually what you want. But if you're relying on a required check to block merges, verify this behaviour in your specific branch protection setup.

Affected-service detection with dynamic matrices

For complex monorepos, compute the affected services at runtime rather than maintaining one workflow per service:

# .github/workflows/affected-ci.yml jobs: detect-changes: runs-on: ubuntu-latest outputs: services: ${{ steps.changes.outputs.services }} steps: - uses: actions/checkout@v4 - id: changes run: | # Build JSON array of changed service directories SERVICES=$(git diff --name-only origin/main... \ | grep '^services/' \ | cut -d/ -f2 \ | sort -u \ | jq -R . | jq -sc .) echo "services=$SERVICES" >> $GITHUB_OUTPUT test: needs: detect-changes if: ${{ needs.detect-changes.outputs.services != '[]' }} strategy: matrix: service: ${{ fromJSON(needs.detect-changes.outputs.services) }} runs-on: ubuntu-latest steps: - run: echo "Testing ${{ matrix.service }}"
2.3

Repository Templates

A repository marked as a template lets anyone in your org create a new repo pre-populated with its files. This is the right way to standardise new service scaffolding — not manually copying from a reference repo.

What a template copies — and what it doesn't

What IS copiedWhat is NOT copied
All files and directoriesGit history (new repo gets a fresh initial commit)
Branches (excluding the default branch name)Issues, PRs, discussions, wiki pages
.github/ workflows, CODEOWNERS, PR templatesSecrets, variables, environments
Topics, descriptionGitHub Pages settings
Repository settings (if using the API)Branch protection rules

The missing pieces — secrets, branch protection rules, team access — need to be wired up separately. Use a GitHub App + Actions workflow triggered on repository: created to automate this:

# .github/workflows/new-repo-setup.yml (lives in your .github org repo) on: repository_dispatch: types: [repo-created] # Alternatively, trigger via GitHub App webhook on repository.created event jobs: configure: runs-on: ubuntu-latest steps: - name: Apply branch protection run: | gh api repos/$REPO/branches/main/protection \ --method PUT \ --field required_status_checks='{"strict":true,"contexts":["ci/test"]}' \ --field enforce_admins=true \ --field required_pull_request_reviews='{"required_approving_review_count":1}' env: GH_TOKEN: ${{ secrets.SETUP_APP_TOKEN }} REPO: ${{ github.event.client_payload.repo }}

What to put in an org template

.github/ workflows/ ci.yml ← standard lint + test pipeline release.yml ← release-please or semantic-release codeql.yml ← default CodeQL scanning dependabot-automerge.yml ← auto-merge patch updates CODEOWNERS ← placeholder, team fills in PULL_REQUEST_TEMPLATE.md ISSUE_TEMPLATE/ bug_report.yml feature_request.yml config.yml .gitignore ← language-appropriate defaults .editorconfig ← consistent formatting across editors README.md ← structure with TOC placeholder CONTRIBUTING.md SECURITY.md dependabot.yml ← enabled for the package ecosystem
2.4

Submodules vs Subtrees vs Dependency Managers

This comes up whenever a team wants to share code between repos. All three options work; all three have costs. The honest comparison:

ApproachHow it worksStrengthsReal-world pain
Git submodules A pointer (commit SHA) to another repo, stored in .gitmodules. The submodule content is not in the parent repo's history. Independent versioning per consumer; each consumer can pin to a specific commit
  • Clones need --recurse-submodules
  • Updating requires two commits (submodule + parent)
  • Detached HEAD state inside submodule is a constant gotcha
  • CI setup is more complex
Git subtree Merges another repo's history directly into a subdirectory of the parent repo. The content becomes a first-class part of the parent's history. No special clone command needed; contributors unaware of subtrees work normally; can contribute back upstream with git subtree push
  • History can become confusing with squashed subtree merges
  • Pulling updates is a manual step
  • Not a good fit if multiple repos consume the same shared code
Dependency manager
(npm, Maven, pip, Go modules)
Shared code is published as a versioned package to a registry (GitHub Packages, npm, Maven Central). Consumers declare a version dependency in their manifest. Standard practice; tooling (Dependabot, Renovate) handles updates; clear version contracts; works across languages
  • Cross-cutting changes require: PR in shared lib → publish new version → PR in consumer → wait for CI
  • Slow feedback loop during co-development
RECOMMENDATION
Default to dependency managers for stable shared code. Use submodules only when you need exact-commit pinning of an external repo you don't control (e.g., vendored firmware). Avoid subtrees for anything more than a one-time import. If co-development friction with dependency managers is too high, that's a signal the two things should be in a monorepo together.

Submodule quick reference

# Add a submodule $ git submodule add https://github.com/org/shared-lib.git libs/shared # Clone a repo that has submodules $ git clone --recurse-submodules https://github.com/org/consumer.git # Update an existing clone's submodules $ git submodule update --init --recursive # Update a submodule to the latest commit on its tracking branch $ git submodule update --remote libs/shared # Remove a submodule cleanly $ git submodule deinit libs/shared $ git rm libs/shared $ rm -rf .git/modules/libs/shared
2.5

Repository Rulesets vs Branch Protection Rules

GitHub shipped Repository Rulesets in 2023 as the next-generation replacement for Branch Protection Rules. Both still exist; understanding which to use and how to migrate matters for tech leads managing org-wide policy.

Key differences

CapabilityBranch Protection Rules (legacy)Repository Rulesets (new)
ScopeOne rule set per branch pattern, per repoOrg-wide or repo-level; one ruleset can cover multiple repos
TargetBranches onlyBranches and tags
Enforcement modesActive onlyActive, Evaluate (audit log, no blocking), or Disabled
Bypass actorsAdmins can bypass (coarse)Explicitly configurable: specific users, teams, apps, or deployment environments
LayeringOne protection per pattern — last write winsMultiple rulesets stack; the most restrictive rule for any property wins
Required workflowsNot availableCan require a specific reusable workflow to pass (Enterprise)
API / Kotlin DSLFull API supportFull API + GitHub App webhook events for rule evaluation

Creating a ruleset via the UI

Settings → Rules → Rulesets → New ruleset → New branch ruleset

  1. Name: protect-main
  2. Enforcement: Active
  3. Bypass list: add your release automation GitHub App (so it can push tags without a PR)
  4. Target branches: include refs/heads/main and refs/heads/release/**
  5. Rules: restrict deletions, block force pushes, require PR with 1 approving review, require status checks to pass
# Create a ruleset via REST API curl -X POST \ -H "Authorization: Bearer $GH_TOKEN" \ -H "Accept: application/vnd.github+json" \ https://api.github.com/repos/ORG/REPO/rulesets \ -d '{ "name": "protect-main", "target": "branch", "enforcement": "active", "conditions": { "ref_name": { "include": ["refs/heads/main"], "exclude": [] } }, "rules": [ {"type": "deletion"}, {"type": "non_fast_forward"}, { "type": "pull_request", "parameters": { "required_approving_review_count": 1, "dismiss_stale_reviews_on_push": true, "require_code_owner_review": true } }, { "type": "required_status_checks", "parameters": { "strict_required_status_checks_policy": true, "required_status_checks": [ {"context": "ci/test", "integration_id": 0} ] } } ] }'
MIGRATION STRATEGY
Don't delete existing branch protection rules while migrating to rulesets. Create the ruleset in Evaluate mode first — it runs without blocking anything but logs every violation in the audit log. After a week of evaluating, flip to Active and then delete the old branch protection rule. This gives you a safe rollout with no disruption to developers.
2.6

Naming Conventions, Visibility & Archiving at Org Scale

At 50+ repos, inconsistent naming makes discoverability, automation, and access management painful. Establish conventions early; they compound in value.

Repository naming

PatternExampleUse for
service-namepayment-processorBackend services / microservices
app-platform-nameapp-ios, app-androidClient applications
lib-namelib-auth-utilsInternal shared libraries
infra-scopeinfra-aws-prodInfrastructure/Terraform repos
data-pipeline-namedata-clickstreamData engineering pipelines
docs-scopedocs-api, docs-runbooksDocumentation repos
.github(exactly this name)Org-level defaults (see 2.8)

Visibility policy

  • Internal (GitHub Enterprise): visible to all org members, not the public. Default for most product code — balances discoverability with security.
  • Private: explicitly scoped access. Use for repos containing credentials, sensitive customer data processing, or unreleased product features.
  • Public: requires a deliberate decision + security review. Enables Dependabot on public repos for free, but exposes your code, commit history, and any accidentally committed secrets permanently.
IMPORTANT
Making a private repo public is irreversible in practice — GitHub's API can flip the flag back to private, but any secrets, tokens, or sensitive data committed while public may already be scraped by credential scanners. Treat "make public" as a one-way door and scan with git-secrets or trufflehog before flipping.

Archiving strategy

Archived repos are read-only and hidden from the default org repo list. Archive when:

  • A service is fully decommissioned but the code is valuable as reference
  • An experiment or spike was completed and won't be resumed
  • A library has been superseded and all consumers have migrated
# Archive a repo via CLI $ gh repo archive org/old-service --yes # Bulk archive all repos matching a pattern (careful) $ gh repo list org --json name,updatedAt \ --jq '.[] | select(.updatedAt < "2024-01-01") | .name' \ | xargs -I{} gh repo archive org/{} --yes

Before archiving: ensure runbook links in incident management tools, internal wikis, and Slack bookmarks are updated to point to the archive or the successor.

2.7

README, CONTRIBUTING, SECURITY.md & Friends

GitHub surfaces these files at specific URL paths and in the UI. Each one has an automation hook beyond its human-readable purpose.

FileGitHub behaviourAutomation hook
README.md Rendered on repo homepage and on the org profile if in .github/profile/ Use shields.io badges for build status, coverage, version. Can embed workflow status: ![CI](https://github.com/org/repo/actions/workflows/ci.yml/badge.svg)
CONTRIBUTING.md Linked automatically when a user opens a new issue or PR Include required local setup steps so contributors don't open "how do I run this?" issues
SECURITY.md Shown in the Security tab; enables "Report a vulnerability" button for private disclosure Enables GitHub's private vulnerability reporting. Without it, researchers open public issues to report security bugs.
CODE_OF_CONDUCT.md Linked in community health score; required for GitHub's Open Source program Sets expectations for issue/PR tone; GitHub moderators reference it for content removal requests
SUPPORT.md Linked when users open issues; redirects support questions away from GitHub Issues Reduces noise in the issue tracker by directing users to Slack, Discourse, or a support form instead
FUNDING.yml Adds a "Sponsor" button to the repo Supports GitHub Sponsors, Patreon, Open Collective, custom URLs

Minimal SECURITY.md template

# SECURITY.md ## Reporting a Vulnerability **Do not open a public GitHub issue for security vulnerabilities.** Use GitHub's private vulnerability reporting: Settings → Security → Report a vulnerability Or email: security@acme.com (PGP key: https://acme.com/pgp) We aim to respond within 48 hours and release a patch within 14 days for critical issues. We follow coordinated disclosure — we'll work with you on timing before any public announcement.
TIP
Enable GitHub's private vulnerability reporting (Settings → Security → Private vulnerability reporting → Enable) so researchers can submit reports directly into a private draft security advisory without your email being involved at all.
2.8

Default Branch Naming, .github/ Layout & the Org .github Repo

Default branch naming

GitHub now defaults new repos to main. Set the org-level default under Organization Settings → Member privileges → Repository default branch. This affects new repos; existing repos with master need a manual rename:

# Rename default branch on an existing repo (GitHub CLI) $ gh repo rename-default-branch main --repo org/service-name # Or via REST API (also updates open PRs targeting the old branch) $ gh api repos/org/service-name/branches/master/rename \ --method POST --field new_name=main

.github/ directory layout

The .github/ directory is the convention for everything GitHub-specific. A complete layout:

.github/ workflows/ ci.yml ← CI pipeline release.yml ← release automation codeql.yml ← security scanning dependabot-automerge.yml ← auto-merge patch updates ISSUE_TEMPLATE/ bug_report.yml ← YAML form (preferred over .md) feature_request.yml config.yml ← disables blank issues, adds links actions/ ← composite actions local to this repo setup-node/ action.yml CODEOWNERS PULL_REQUEST_TEMPLATE.md dependabot.yml labeler.yml ← used by actions/labeler release-please-config.json ← release-please config

The org-level .github repository

Create a repository named exactly .github inside your GitHub organization. Files placed here become org-wide defaults — they apply to any repo in the org that doesn't have its own version of the file. This is the single most leveraged thing you can do for org-level standardisation.

github.com/acme/.github/ ← the special org default repo profile/ README.md ← renders on github.com/acme org homepage workflow-templates/ ci.yml ← suggested workflow shown in Actions tab of new repos ci.properties.json ← metadata for the suggestion (name, description, icon) CONTRIBUTING.md ← default for all repos without their own SECURITY.md ← default for all repos without their own CODE_OF_CONDUCT.md ← default for all repos without their own SUPPORT.md ← default for all repos without their own
SCOPE NOTE
The .github org repo provides defaults for community health files (CONTRIBUTING, SECURITY, etc.) and workflow templates. It does not automatically apply CODEOWNERS or workflows to other repos — those must exist in each repo. For org-wide workflow enforcement, use Required Workflows under org-level rulesets (GitHub Enterprise).

Workflow templates (workflow-templates/)

Files in .github/workflow-templates/ appear as suggestions in the Actions → New workflow tab of every repo in your org. Pair each .yml with a .properties.json:

// workflow-templates/ci.properties.json { "name": "Acme Standard CI", "description": "Lint, test and build — Acme standard pipeline", "iconName": "octicon workflow", "categories": ["CI"], "filePatterns": ["package.json$"] }

When a developer in your org opens the Actions tab and clicks "New workflow," your template appears prominently alongside GitHub's built-in ones. This is the lowest-friction way to nudge teams toward org standards without forcing it.

Up Next — Phase 3: Branching Strategies & Code Review Workflows

GitFlow vs trunk-based development, merge queues, CODEOWNERS-enforced required reviews, review SLAs, draft PRs, and auto-merge — the policies that control how code reaches production.

Continue to Phase 3 → Back to Hub