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
| Dimension | Polyrepo | Monorepo |
| Deployment independence | ✓ Each service has its own pipeline | Requires path-filtered triggers in CI to achieve the same |
| Cross-service refactors | Painful — multiple PRs, coordination overhead | ✓ One atomic PR, one review, one merge |
| Dependency management | Each repo pins its own versions; drift is common | ✓ One version of every dependency; no version skew |
| Team autonomy | ✓ Teams own their repo end-to-end | Shared repo requires governance to avoid stepping on each other |
| CI cost | ✓ Only the changed repo's CI runs | Requires affected-service detection to avoid running all CI on every commit |
| Tooling complexity | Simple git, simple GitHub Actions | Needs Nx/Turborepo/Bazel/Pants or hand-rolled path filters for scale |
| Onboarding | New hire needs access to N repos | ✓ One clone, one PR flow, one CI system |
| Secret management | ✓ Secrets scoped per repo by default | Requires environment-scoped secrets + CODEOWNERS to limit access |
Decision framework
Answer these four questions:
- 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.
- 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.
- 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.
- 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).
* @acme/platform-leads
/services/payments/ @acme/payments-team
/services/auth/ @alice @acme/security-team
/services/notifications/ @acme/notifications-team
/shared/ @acme/platform-leads
/infra/ @acme/ops-team
*.tf @acme/ops-team
*.tfvars @acme/ops-team
.github/workflows/ @acme/platform-leads
/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:
on:
push:
branches: [main]
paths:
- 'services/payments/**'
- 'shared/utils/**'
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:
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
services: ${{ steps.changes.outputs.services }}
steps:
- uses: actions/checkout@v4
- id: changes
run: |
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 copied | What is NOT copied |
| All files and directories | Git history (new repo gets a fresh initial commit) |
| Branches (excluding the default branch name) | Issues, PRs, discussions, wiki pages |
.github/ workflows, CODEOWNERS, PR templates | Secrets, variables, environments |
| Topics, description | GitHub 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:
on:
repository_dispatch:
types: [repo-created]
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:
| Approach | How it works | Strengths | Real-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
$ git submodule add https://github.com/org/shared-lib.git libs/shared
$ git clone --recurse-submodules https://github.com/org/consumer.git
$ git submodule update --init --recursive
$ git submodule update --remote libs/shared
$ 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
| Capability | Branch Protection Rules (legacy) | Repository Rulesets (new) |
| Scope | One rule set per branch pattern, per repo | Org-wide or repo-level; one ruleset can cover multiple repos |
| Target | Branches only | Branches and tags |
| Enforcement modes | Active only | Active, Evaluate (audit log, no blocking), or Disabled |
| Bypass actors | Admins can bypass (coarse) | Explicitly configurable: specific users, teams, apps, or deployment environments |
| Layering | One protection per pattern — last write wins | Multiple rulesets stack; the most restrictive rule for any property wins |
| Required workflows | Not available | Can require a specific reusable workflow to pass (Enterprise) |
| API / Kotlin DSL | Full API support | Full API + GitHub App webhook events for rule evaluation |
Creating a ruleset via the UI
Settings → Rules → Rulesets → New ruleset → New branch ruleset
- Name:
protect-main
- Enforcement: Active
- Bypass list: add your release automation GitHub App (so it can push tags without a PR)
- Target branches: include
refs/heads/main and refs/heads/release/**
- Rules: restrict deletions, block force pushes, require PR with 1 approving review, require status checks to pass
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
| Pattern | Example | Use for |
service-name | payment-processor | Backend services / microservices |
app-platform-name | app-ios, app-android | Client applications |
lib-name | lib-auth-utils | Internal shared libraries |
infra-scope | infra-aws-prod | Infrastructure/Terraform repos |
data-pipeline-name | data-clickstream | Data engineering pipelines |
docs-scope | docs-api, docs-runbooks | Documentation 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
$ gh repo archive org/old-service --yes
$ 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.
| File | GitHub behaviour | Automation 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:  |
| 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
## 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:
$ gh repo rename-default-branch main --repo org/service-name
$ 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:
{
"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