Your team's engineering playbook
A playbook is the short set of rules your team agrees on: how branches are named, how big a PR may be, who approves, what "done" means, when you release and how you hotfix. A product manager does not write the code but is often the best person to get these rules written down, agreed and kept, and every developer is the one who follows them. This lesson gives you a complete starter set and a way to measure whether it works.
By the end you can
- Name the ten decisions every team playbook must answer, and a sensible default for each
- Write a
CONTRIBUTING.md, PR template and CODEOWNERS file for a real repo - Turn the rules that matter into a GitHub ruleset, so they are enforced instead of remembered
- Explain the DORA metrics and measure lead time and deploy frequency from GitHub data with a script
- Run a playbook review with the team without it becoming a blame session
The everyday picture
Every Mangrove store runs on the same standard operating procedures: how stock is received, how a goods received note (GRN) is posted, how the cash drawer is counted at shift change, what to do when the till goes offline. A cashier who moves to a store across town can work on day one because the rules are the same. Nobody argues every morning about how to count the drawer; that decision was made once, written down, and improved when something went wrong.
An engineering team without a playbook argues every day instead. Each PR is a new debate about naming, size, who should look at it and whether it is ready. A playbook moves those debates to one meeting a quarter, and leaves the daily work for actual work. Like a good SOP, it is short, specific, and changed by the people who follow it.
The ten decisions
Most playbooks answer the same questions. You do not need to invent them; you need the team to agree on an answer to each, and write it in one place. These defaults suit a team of three to ten people shipping a web API or app (they are the ones used in Build it):
| Decision | A sensible default | Where it lives | Learn more |
|---|---|---|---|
| Branching | GitHub flow: short branches off main, main always releasable | CONTRIBUTING, ruleset | Branches and releases |
| Branch names | feat/123-short-name, fix/123-... | CONTRIBUTING | Branches |
| Commit format | Conventional Commits; PR title becomes the squash commit | CONTRIBUTING, lint check | Everyday Git |
| PR size and content | Under about 400 changed lines; template filled; issue linked | PR template | Pull requests |
| Review | First response within one working day; one code-owner approval, two for risky paths | CONTRIBUTING, CODEOWNERS, ruleset | Pull requests |
| Merge method | Squash only, so main has one commit per PR | Ruleset | Pull requests |
| Definition of done | A checklist: criteria met, tests, safe migration, docs, seen on staging | CONTRIBUTING, PR template | Debug and test |
| Release cadence and versions | Weekly tag vX.Y.0; semantic versioning; changelog | CONTRIBUTING, release workflow | Branches and releases, Node and npm |
| Hotfix procedure | Incident issue, failing test, small PR, same CI, patch tag at once, note within 3 days | CONTRIBUTING | Ship a project |
| Onboarding | Clone to first PR in one day, with written steps | CONTRIBUTING, README | Remotes |
Two principles make the table work. First, enforce what you can, write down the rest. "No direct pushes to main" belongs in a ruleset, where nobody can forget it. "Respond to reviews within a day" cannot be enforced by a tool, so it goes in CONTRIBUTING.md and into the metrics below. Second, every rule needs a reason a new developer would accept. "Under 400 lines" is not arbitrary: review quality drops on very large PRs, and large PRs wait longest (you will see it in the data).
Quick check
Which of these rules can a GitHub ruleset enforce on its own?
The merge method is a setting GitHub can enforce on every PR. Response times and written reasons depend on people, so they go in CONTRIBUTING.md, and response time also goes into the numbers you review.
main. A hotfix skips the weekly wait but none of the gates.Review expectations: the rule that matters most
In most teams the slowest part of shipping is not writing code; it is waiting for review. A PR that waits two days goes stale, collects merge conflicts (Merge conflicts without panic) and gets merged with a tired "LGTM". So make the expectations explicit:
- Response time. First response within one working day. "Response" can be a full review or "I'll get to this after lunch". Count working days, and say which days they are: Monday to Friday in many countries, Sunday to Thursday in others, and teams across time zones may need both.
- Who approves. CODEOWNERS routes each PR to the right people. The last matching line in the file wins, so put the general rule first and specific paths below it.
- What a comment means. Agree that "Request changes" blocks and a plain comment is a suggestion. Some teams prefix suggestions with "nit:" so the author knows it is optional.
- Who merges. The author merges after approval. They know when it is safe to ship and will watch for problems after.
- Small first. Reviewers pick up small PRs before big ones. That rewards authors for splitting work.
A product manager can help here without reading code: in the sprint review, look at the slowest PRs of the last two weeks (the script below lists them) and ask why. The answer is almost never "the reviewer was lazy". It is usually "the PR was 2,000 lines" or "nobody knew who should review it", and both have playbook fixes.
Quick check
A reviewer leaves a plain comment that starts with "nit:". Under this playbook, what does it mean?
Only "Request changes" blocks. A plain comment is a suggestion, and "nit:" makes that explicit. Agreeing on this stops small preferences from holding up a PR for a day.
DORA metrics: is the playbook working?
Rules are only worth keeping if they make delivery faster and safer. DORA research has shown, across many organisations, that a few delivery measures move together with better outcomes. The original four keys are:
- Deployment frequency: how often you release to production.
- Change lead time: how long a change takes from commit to running in production.
- Change fail rate: what share of deployments need an immediate fix, such as a hotfix or rollback.
- Time to restore: how long it takes to recover when a deployment fails. DORA renamed it "failed deployment recovery time" in 2023.
In 2024 DORA added a fifth, deployment rework rate (unplanned deployments caused by production incidents), and now groups them as throughput (how fast) and instability (how safely). The four keys are still the common starting point. The research finding that matters for you: speed and stability go together. Teams that release small changes often also break things less, because each change is small enough to review well and easy to roll back.
All four can be read from GitHub if your team follows the playbook: releases are tags, every change is a PR, and every incident is an issue with a label. That is a second reason to write the rules down. Without consistent tags and labels there is no data.
Quick check
A team made 10 releases last month, and 3 of them needed a hotfix or a rollback straight away. What is its change fail rate?
Change fail rate is the share of deployments that needed an immediate fix: 3 out of 10 is 30%. The 70% is the share that went fine, and 3% would mean 3 out of 100.
Build it
A complete playbook for partner-api, the service behind Mangrove's Partner app: four files you can copy, a ruleset that enforces the important parts, and a script that measures lead time and deploys. Replace your-org and team names with yours. The core of the lead-time calculation is also a Practice exercise you can run in your browser.
-
Write CONTRIBUTING.md
GitHub links to this file when someone opens an issue or PR, and shows it as a "Contributing" tab on the repo page. Put it in the repo root or
.github/. Keep it to one screen of rules; link out for detail.Markdown# Contributing to partner-api This is how we work. If something here is wrong or slows you down, open a PR to change it. ## Your first day 1. Get access: GitHub team `partner-team`, the staging VPN, and the team chat channel. 2. `git clone git@github.com:your-org/partner-api.git && cd partner-api` 3. `nvm use` (Node version from `.nvmrc`), then `npm ci`, then `cp .env.example .env` 4. `npm run check` must pass. If it does not, tell us: that is a bug in this guide. 5. Pick an issue labelled `good first issue` and open a draft PR before lunch. ## Branches - `main` is always releasable. Nobody pushes to it directly; the ruleset blocks it. - Branch from `main`: `feat/123-short-name`, `fix/123-short-name`, `chore/short-name`. - Delete the branch after merge (GitHub does it for you). ## Commits - Conventional Commits: `feat(api): add stock endpoint`, `fix(pos): round VAT in cents`. - Small and focused. The squash commit on `main` is the PR title, so make the title good. ## Pull requests - Aim for under 400 changed lines. Split bigger work behind a feature flag. - Fill in the template. Link the issue with `Closes #123`. - Open as draft early; mark ready when CI is green. ## Reviews - First response within one working day (Monday to Friday). Small PRs first. - One approval from a code owner. Two for anything under `src/payments/` or `prisma/`. - "Request changes" means it must change; "Comment" means a suggestion. Say which. - The author resolves threads and merges. ## Definition of done - [ ] Acceptance criteria in the issue are met - [ ] Tests added or updated; `npm run check` passes in CI - [ ] Database migration is backwards compatible (expand, then contract) - [ ] Logs and errors make sense to the on-call person - [ ] README, API docs or `.env.example` updated if behaviour or config changed - [ ] Product owner has seen it on staging (screenshots or a link in the PR) ## Releases and versions - Release every Tuesday after lunch: tag `vX.Y.0` on `main`. The release workflow deploys. - Semantic versioning: breaking API change = major, new feature = minor, fix = patch. - `CHANGELOG.md` is updated in the release PR from the merged PR titles. ## Hotfixes 1. Open an issue labelled `incident` with what users see. 2. Branch `fix/123-...` from `main`, write a failing test, fix, PR marked `hotfix`. 3. One approval is enough; CI is still required. 4. Tag `vX.Y.Z+1` straight after merge. Post the release link in the team channel. 5. Within 3 working days: a short blameless note on the issue (cause, fix, prevention). -
Add the PR template
Saved as
.github/pull_request_template.md, it fills every new PR description (Pull requests and code review). The "Risk" boxes tell reviewers, and you, where to look hardest.Markdown## What and why <!-- One or two sentences. Link the issue: Closes #123 --> ## How to test <!-- Steps a reviewer can follow on staging or locally --> ## Screenshots or API examples <!-- For UI changes: before and after. For API changes: a request and response --> ## Risk - [ ] Database migration - [ ] Changes money, stock or prices - [ ] Touches login, permissions or secrets - [ ] Needs a feature flag or a config change on deploy ## Checklist - [ ] Tests added or updated - [ ] Under 400 changed lines, or explained why not - [ ] Docs or `.env.example` updated if needed -
Add CODEOWNERS
Save as
.github/CODEOWNERS. Owners must be people or teams with write access. Because the last match wins, a change undersrc/payments/goes to the leads team, and everything else to the whole team. The/.github/line protects the workflows, the rules and this file itself.Output# .github/CODEOWNERS: who is asked to review what. The LAST matching line wins. * @your-org/partner-team /src/payments/ @your-org/partner-leads /prisma/ @your-org/partner-leads /.github/ @your-org/partner-leads /docs/ @your-org/partner-team @your-org/product -
Enforce the rules with rulesets
Keep the ruleset JSON in the repo, under
.github/rulesets/, so changes to the rules are reviewed like code. The branch ruleset requires a PR, one approval from a code owner, approval of the latest push by someone other than its author, resolved threads, squash merges and green CI. A second ruleset stops release tags being moved or deleted, sov2.4.1always means the same code. Both files validated against GitHub's published REST API schema.JSON{ "name": "main: reviewed, green, squashed", "target": "branch", "enforcement": "active", "conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } }, "rules": [ { "type": "deletion" }, { "type": "non_fast_forward" }, { "type": "pull_request", "parameters": { "required_approving_review_count": 1, "require_code_owner_review": true, "dismiss_stale_reviews_on_push": true, "require_last_push_approval": true, "required_review_thread_resolution": true, "allowed_merge_methods": ["squash"] } }, { "type": "required_status_checks", "parameters": { "strict_required_status_checks_policy": true, "required_status_checks": [ { "context": "check (22)" }, { "context": "check (24)" } ] } } ] }JSON{ "name": "release tags: never moved or deleted", "target": "tag", "enforcement": "active", "conditions": { "ref_name": { "include": ["refs/tags/v*"], "exclude": [] } }, "rules": [ { "type": "deletion" }, { "type": "non_fast_forward" }, { "type": "update" } ] }Terminalgh api repos/{owner}/{repo}/rulesets --method POST --input .github/rulesets/main.json gh api repos/{owner}/{repo}/rulesets --method POST --input .github/rulesets/release-tags.json gh api repos/{owner}/{repo}/rulesets --jq '.[] | "\(.id) \(.name) \(.enforcement)"'On GitHub (we could not run this from here): each POST returns the created ruleset with its
id, and the last command lists both. To change a rule later, edit the file in a PR and apply it withgh api repos/{owner}/{repo}/rulesets/ID --method PUT --input .... The two status check names must match your CI job names exactly; with a matrix they arecheck (22)andcheck (24). If a hotfix ever needs someone to bypass the rules, add that person or team underbypass_actorsdeliberately, and review the bypass log. -
Measure lead time and deploys with a script
This script uses
gh, so it runs with your existing login and permissions. It finds PRs merged intomainin the last N days with one GraphQL search (paginated, 100 at a time), lists the releases in the same window, and works out three lead times. "First commit to released" is the DORA change lead time; the other two show where the time goes.JavaScript// scripts/lead-times.mjs: lead time and deploys for the last N days, from GitHub data. // Usage, inside the repo with gh logged in: node scripts/lead-times.mjs 30 import { execFileSync } from 'node:child_process'; const days = Number(process.argv[2] ?? 30); const since = new Date(Date.now() - days * 864e5).toISOString().slice(0, 10); const gh = (...args) => JSON.parse(execFileSync('gh', args, { encoding: 'utf8', maxBuffer: 64e6 })); const repo = gh('repo', 'view', '--json', 'nameWithOwner').nameWithOwner; const query = `query($q: String!, $endCursor: String) { search(query: $q, type: ISSUE, first: 100, after: $endCursor) { nodes { ... on PullRequest { number createdAt mergedAt additions deletions commits(first: 1) { nodes { commit { authoredDate } } } } } pageInfo { hasNextPage endCursor } } }`; const prs = gh('api', 'graphql', '--paginate', '--slurp', '-f', `query=${query}`, '-f', `q=repo:${repo} is:pr is:merged base:main merged:>=${since}`).flatMap((p) => p.data.search.nodes); const releases = gh('release', 'list', '--limit', '200', '--json', 'tagName,publishedAt') .filter((r) => r.publishedAt >= since).toSorted((a, b) => a.publishedAt.localeCompare(b.publishedAt)); const hours = (a, b) => (new Date(b) - new Date(a)) / 36e5; const rows = prs.map((pr) => { const firstCommit = pr.commits.nodes[0].commit.authoredDate; const shipped = releases.find((r) => r.publishedAt >= pr.mergedAt); // first release after the merge return { number: pr.number, size: pr.additions + pr.deletions, review: hours(pr.createdAt, pr.mergedAt), toMerge: hours(firstCommit, pr.mergedAt), toProd: shipped ? hours(firstCommit, shipped.publishedAt) : null }; }); const pct = (xs, p) => { const s = xs.toSorted((a, b) => a - b); return s[Math.min(s.length - 1, Math.floor(p * s.length))]; }; const fmt = (h) => (h < 48 ? `${h.toFixed(1)} h` : `${(h / 24).toFixed(1)} days`); const line = (label, xs) => console.log(`${label.padEnd(24)} median ${fmt(pct(xs, 0.5)).padEnd(9)} p90 ${fmt(pct(xs, 0.9))}`); console.log(`${repo}: ${rows.length} PRs merged into main since ${since}`); line('PR opened -> merged', rows.map((r) => r.review)); line('First commit -> merged', rows.map((r) => r.toMerge)); line('First commit -> released', rows.filter((r) => r.toProd !== null).map((r) => r.toProd)); const patches = releases.filter((r) => !r.tagName.endsWith('.0')); console.log(`Releases: ${releases.length} in ${days} days (${patches.length} patch/hotfix), ${rows.filter((r) => r.toProd === null).length} PRs not released yet`); const slow = rows.toSorted((a, b) => b.review - a.review).slice(0, 3); console.log('Slowest reviews:', slow.map((r) => `#${r.number} ${fmt(r.review)}, ${r.size} lines`).join(' | '));Run it inside the repo:
Terminalnode scripts/lead-times.mjs 30The script was not run against GitHub's live API for this lesson, so we checked it two ways. The GraphQL query validated against GitHub's published schema (a misspelt field such as
mergedAttis rejected with "Cannot query field"), and we ran the script unchanged against a stand-inghthat returned 22 PRs and 7 releases shaped exactly like GitHub's responses. Your numbers will differ; the output looks like this:Outputyour-org/partner-api: 22 PRs merged into main since 2026-09-07 PR opened -> merged median 9.0 h p90 2.1 days First commit -> merged median 21.0 h p90 2.6 days First commit -> released median 3.6 days p90 6.2 days Releases: 7 in 30 days (2 patch/hotfix), 0 PRs not released yet Slowest reviews: #148 4.1 days, 2000 lines | #159 2.9 days, 1500 lines | #143 2.1 days, 1125 linesRead it like a product manager. A PR waits a median of 9 hours for review, but the slowest three were 1,000 to 2,000 lines, which supports the size rule. Lead time to production is about three and a half days, mostly the wait for the weekly release; moving to two releases a week would cut it more than any coding speed-up. Two of seven releases were patches, a change fail rate of roughly 2 in 7, worth watching. Note the script's two simplifications: it treats every release as a deploy, and every non-
.0tag as a fix. -
Measure recovery time from incident issues
If every production incident gets an issue labelled
incident, opened when it starts and closed when users are fine again, then recovery time is the time between the two.ghhas jq built in, so one command gives the hours:Terminalgh issue list --label incident --state closed --search "created:>=2026-09-07" \ --json number,createdAt,closedAt \ --jq 'map((.closedAt | fromdateiso8601) - (.createdAt | fromdateiso8601)) | map(. / 3600) | {incidents: length, hoursToRestore: map(. * 10 | round / 10)}'On two sample incidents (opened 08:10 and closed 13:05 UTC, opened 10:40 and closed 15:20 UTC), the same jq expression printed:
Output{"incidents":2,"hoursToRestore":[4.9,4.7]} -
Review the playbook with the team every quarter
Book 45 minutes. Bring the four numbers for the last three months and the five slowest PRs. Ask three questions: which rule did we break most, and why? Which rule slowed us down without catching anything? What one change do we try next quarter? Record the decision as a PR to
CONTRIBUTING.md, so the history of the playbook lives in Git like everything else.
Bring it to your team
Use these to start the first playbook conversation, then keep them for the quarterly review.
- If a new developer joins on Monday, what exactly do they do to open their first PR by Monday evening? Is it written down?
- Which of our rules are enforced by GitHub, and which do we only remember?
- Who reviews billing and payment code, and what happens when that person is away?
- How long did our five slowest PRs last month wait for review, and how big were they?
- What is our hotfix path, and how long did the last one take from report to live?
- Can we see deploy frequency and lead time for the last three months? What would make them better?
Red flags
- The rules exist only in one senior developer's head.
- Admins bypass the ruleset "only for urgent things", most weeks.
- CODEOWNERS lists a single person for everything.
- Metrics shown per developer, or used in performance reviews.
- A playbook nobody has changed in a year, or one longer than two screens.
Practice
Calculate change lead time for a PR
Write two functions like the heart of the lead-time script. leadTimeHours(pr, releases) returns the hours from pr.firstCommitAt to the first release published at or after pr.mergedAt, or null if no release has shipped it yet. The releases may come in any order. medianHours(values) returns the median of the numbers, ignoring null values; for an even count, take the average of the two middle values. All dates are ISO strings in UTC.
function leadTimeHours(pr, releases) {
// Uses the first release in the list. Is that the one that shipped this PR?
const shipped = releases[0];
return (new Date(shipped.publishedAt) - new Date(pr.firstCommitAt)) / 36e5; // ms to hours
}
function medianHours(values) {
// The middle value, ignoring nulls (PRs not released yet)
}
const pr148 = { number: 148, firstCommitAt: '2026-09-08T09:00:00Z', mergedAt: '2026-09-08T15:00:00Z' };
const releases = [
{ tagName: 'v2.5.0', publishedAt: '2026-09-15T13:00:00Z' },
{ tagName: 'v2.4.0', publishedAt: '2026-09-09T13:00:00Z' },
{ tagName: 'v2.3.0', publishedAt: '2026-09-01T13:00:00Z' },
];
test('measures from the first commit to the release that shipped the merge', () => {
expect(leadTimeHours(pr148, releases)).toBe(28);
});
test('a release published before the merge did not ship it', () => {
const late = { number: 151, firstCommitAt: '2026-09-09T08:00:00Z', mergedAt: '2026-09-10T10:00:00Z' };
expect(leadTimeHours(late, releases)).toBe(149);
});
test('a PR that no release has shipped yet gives null', () => {
const open = { number: 160, firstCommitAt: '2026-09-16T08:00:00Z', mergedAt: '2026-09-16T12:00:00Z' };
expect(leadTimeHours(open, releases)).toBe(null);
});
test('the median of an odd count is the middle value, nulls ignored', () => {
expect(medianHours([30, null, 5, 12])).toBe(12);
});
test('the median of an even count averages the two middle values', () => {
expect(medianHours([40, 10, 20, 30])).toBe(25);
});
Show a solution
function leadTimeHours(pr, releases) {
const shipped = releases
.filter((r) => r.publishedAt >= pr.mergedAt) // ISO strings in UTC sort as text
.toSorted((a, b) => a.publishedAt.localeCompare(b.publishedAt))[0];
if (!shipped) return null; // merged, not released yet
return (new Date(shipped.publishedAt) - new Date(pr.firstCommitAt)) / 36e5;
}
function medianHours(values) {
const xs = values.filter((v) => v !== null).toSorted((a, b) => a - b);
const mid = Math.floor(xs.length / 2);
return xs.length % 2 ? xs[mid] : (xs[mid - 1] + xs[mid]) / 2;
}The script in Build it uses a slightly simpler percentile for its p90 line. For a team's monthly review, either is fine; what matters is using the same method every month.
Read another team's numbers and propose one change
Here is the script's output for the store-app team (sample numbers). Write four or five sentences for the team's quarterly review: where does the time go, what else stands out, and which one playbook change would you try first, with how you would know it worked?
your-org/store-app: 31 PRs merged into main since 2026-09-07
PR opened -> merged median 1.9 days p90 6.4 days
First commit -> merged median 2.8 days p90 7.9 days
First commit -> released median 4.1 days p90 9.0 days
Releases: 9 in 30 days (5 patch/hotfix), 2 PRs not released yet
Slowest reviews: #412 6.9 days, 240 lines | #398 6.1 days, 180 lines | #405 5.8 days, 310 lines- You named waiting for review as the biggest part of lead time (about 1.9 of the 4.1 days).
- You noticed that the slowest PRs are small, so PR size is not the cause here; routing or reviewer availability is more likely.
- You worked out a change fail rate of about 5 in 9 releases and flagged it as high.
- You proposed one concrete change, such as a first-response rule or a second code owner, with a number to watch next quarter.
- Nothing in your note blames or ranks a person.
Show a model answer
"Almost half of our lead time is waiting for review: a median of 1.9 days from PR opened to merged, out of 4.1 days from first commit to release. Our slowest reviews were all small PRs (180 to 310 lines), so size is not the problem; they probably waited for the one person who owns that code. Separately, 5 of our 9 releases were patches, a change fail rate of roughly 55%, which deserves its own look at what the hotfixes fixed. For next quarter, we propose a one-working-day first-response rule and a second code owner for each area in CODEOWNERS. We will call it a success if the median review time drops below one day without the patch share going up."
Check yourself
The team agrees "no direct pushes to main". Where should that rule live?
A rule a tool can enforce should be enforced, because written rules are forgotten under pressure, usually during an incident. The document still matters: it explains the reason and the exceptions. The PR template only helps once a PR exists, which is exactly what a direct push skips.
The lead-time script shows PRs merge within a day, but change lead time to production is about four days. What is the most likely fix?
If merge is fast and production is slow, the time is spent waiting for the next release. More frequent, smaller releases cut lead time directly, and DORA's research finds this also tends to lower change fail rate. Removing review or allowing bigger PRs trades speed for stability, which the data says you do not need to do.
In this CODEOWNERS file, who is requested to review a change to src/payments/vat.ts?* @your-org/partner-team/src/payments/ @your-org/partner-leads
CODEOWNERS works like .gitignore: the last matching line takes precedence. That is why the catch-all * goes first and specific paths go below it. If you want both teams on payments, list both on the payments line.
Flashcards
Key takeaways
- A playbook answers about ten decisions once, so the team stops re-arguing them in every PR. Keep it to two screens and give every rule a reason.
- Enforce what tools can enforce (rulesets, CODEOWNERS, required checks); write down the rest in CONTRIBUTING.md and the PR template.
- Review waiting time is usually the biggest delay. Set a response time, route reviews with CODEOWNERS, and keep PRs small.
- DORA's keys (deploy frequency, lead time, change fail rate, recovery time) show whether the rules help. Read them from tags, PRs and incident issues, per team, as a trend.
- Review the playbook every quarter and change it through a PR, like any other code.