Promoting Baselines After Green Main Builds
A performance baseline only stays useful if it reflects what is actually running in production. This how-to shows the safest moment to update it: the instant a green build lands on your default branch. It is part of the Historical Baseline Calibration reference, and it pairs closely with Automating Baseline Promotion Workflows, which covers the broader orchestration around the promotion event.
The core idea is baseline-as-code: the numbers your CI gate compares against live in a version-controlled file, not in a dashboard someone edits by hand. When a pull request merges and the post-merge build on main passes every gate, a job recomputes the current metrics and commits them back as the new baseline, keyed by the commit SHA that produced them. Nothing is promoted from a pull request, nothing is promoted from a red build, and every promotion is a traceable commit you can revert. Below we build that pipeline end to end, add a guard so a barely-passing regression can never silently become the new normal, and wire in a rollback path.
Why Promote Only on Green Main, Never on a PR
A pull request measures a proposed change against the baseline of main. If you promoted a PR's numbers, you would be moving the goalposts before the change even shipped, and two PRs opened against the same base could each promote conflicting baselines. Promotion belongs to exactly one event: a commit that has already merged to main and whose post-merge build is fully green.
Promoting after merge also means the baseline is measured on the merge result, not on the PR's pre-merge preview. A PR that looked clean in isolation can interact with code that landed while it was open, and only the post-merge build on main measures that reality. Treat the baseline as a photograph of the branch everyone ships from, taken only when the shutter (the green build) actually fires.
Baseline as Code, Keyed by Commit SHA
Store the baseline as a small JSON file committed to the repository, for example perf/baseline.json. Each metric records its value, the percentile it represents, the device and connection context it was captured under, and the commit that produced it. Keying every entry to a commit SHA gives you an audit trail: you can answer "when did our mobile LCP baseline move, and which merge moved it?" by reading git log on that one file.
{
"generatedAt": "2026-07-24T09:14:00Z",
"commit": "a1b9f30c4e77d2c8f1a6b0e5d4c3b2a19f8e7d6c",
"context": {
"device": "mid-range mobile (Moto G Power class)",
"connection": "Fast 3G, 4x CPU throttle",
"runs": 5
},
"metrics": {
"lcp": { "p75": 3180, "unit": "ms" },
"inp": { "p75": 168, "unit": "ms" },
"cls": { "p75": 0.06, "unit": "score" },
"js_kb": { "p75": 214, "unit": "kb" }
}
}
Every number carries its percentile and its capture context inline, so a reviewer never has to guess whether 3180 is a P75 mobile figure or a desktop median. Here the P75 LCP of 3180 ms and P75 INP of 168 ms are both measured on a mid-range mobile device throttled to Fast 3G with a 4x CPU slowdown, matching the emulation profile your gate uses. If you run desktop and mobile lanes separately, keep two files or two top-level keys so a fast desktop run never overwrites the mobile ceiling. The percentile choice itself is worth deliberate thought, covered in Choosing Between P75 and P90 Budget Targets.
Guarding Against Promoting a Regression
A green build only means the run passed the gate; it does not mean the run is better than the previous baseline. If your gate allows a 5% headroom and every merge nudges LCP up by 4%, an unguarded promoter would ratchet the baseline worse on every commit until performance has silently doubled. The guard is a simple rule applied before writing: never promote a metric to a value worse than the current baseline by more than a small tolerance.
Concretely, compute the freshly measured P75 on mid-range mobile at Fast 3G, then compare each metric against the committed baseline. Allow promotion only when the new value is either an improvement or within a tight tolerance (say 2%) of the old value. Anything worse than that tolerance means the merge shipped a real regression that happened to sit under the absolute gate ceiling, and it should be surfaced as an alert rather than absorbed into the baseline. This is the same suspicion that drives Automated Regression Detection; promotion is simply the write side of that read.
const fs = require('fs');
// tolerance: never let a metric ratchet worse than 2% without an alert
const TOLERANCE = 0.02;
const lowerIsBetter = new Set(['lcp', 'inp', 'cls', 'js_kb']);
function guardPromotion(oldBase, fresh) {
const rejected = [];
for (const [key, val] of Object.entries(fresh.metrics)) {
const prev = oldBase.metrics[key];
if (!prev) continue; // new metric, accept
if (lowerIsBetter.has(key)) {
const ceiling = prev.p75 * (1 + TOLERANCE);
if (val.p75 > ceiling) {
rejected.push(`${key}: ${val.p75} exceeds ${prev.p75} +${TOLERANCE * 100}%`);
}
}
}
return rejected;
}
const oldBase = JSON.parse(fs.readFileSync('perf/baseline.json', 'utf8'));
const fresh = JSON.parse(fs.readFileSync('perf/current.json', 'utf8'));
const rejected = guardPromotion(oldBase, fresh);
if (rejected.length) {
console.error('Refusing to promote regressed metrics:\n' + rejected.join('\n'));
process.exit(1);
}
fs.writeFileSync('perf/baseline.json', JSON.stringify(fresh, null, 2) + '\n');
console.log('Baseline promoted to commit ' + fresh.commit);
The guard treats a 2% drift as noise you accept and anything larger as a decision a human should make. If your CI runners are noisy, widen the tolerance or, better, feed the guard a value that already smooths run-to-run variance, such as a rolling figure from Rolling Median Baseline Windows, so a single unlucky run never triggers a false rejection.
The GitHub Actions Job
The promoter runs only on pushes to main, after the performance job it depends on has succeeded. It measures, applies the guard, and commits the updated baseline back with the triggering commit's SHA. Because it writes to the repository, give it contents: write permission and guard it with if: github.ref == 'refs/heads/main' so it can never fire from a fork or a pull request. This job is one piece of the wider setup described in Automating Baseline Promotion Workflows, and it slots naturally next to your existing GitHub Actions Performance Matrices.
name: promote-baseline
on:
push:
branches: [main]
permissions:
contents: write
jobs:
promote:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install
run: npm ci
- name: Measure current metrics
run: node scripts/measure.js --out perf/current.json --commit "${{ github.sha }}"
- name: Guard and promote
run: node scripts/promote-baseline.js
- name: Commit new baseline
run: |
if git diff --quiet perf/baseline.json; then
echo "Baseline unchanged, nothing to commit"
exit 0
fi
git config user.name "perf-baseline-bot"
git config user.email "[email protected]"
git add perf/baseline.json
git commit -m "chore(perf): promote baseline from ${{ github.sha }} [skip ci]"
git push
The [skip ci] tag on the commit message stops the promotion commit from triggering another build, which would otherwise loop. The git diff --quiet check means a run where the guard rejected every change, or where nothing moved, exits cleanly without an empty commit. Pair this with the caching described in Setting Up GitHub Actions Caching for Faster CI so the measurement step stays fast.
Verification and Rollback
After the job runs, verify two things. First, that the baseline commit exists and references the right source SHA; git log -1 --format=%s perf/baseline.json should name the merge it came from. Second, that the promoted numbers are internally consistent: every metric still carries a percentile and the device and connection context, and no value regressed past tolerance.
# Confirm the latest baseline commit and inspect the promoted values
git log -1 --format='%h %s' -- perf/baseline.json
jq '.commit, .context, .metrics.lcp' perf/baseline.json
# Roll back to the previous baseline if a bad promotion slipped through
git revert --no-edit $(git log -1 --format=%H -- perf/baseline.json)
git push
Rollback is deliberately just git revert of the promotion commit, because the baseline is code. There is no separate database state to reconcile and no dashboard to hand-edit; reverting the commit restores the prior ceilings for every metric at once, and the revert itself is auditable. Keep the summary table below as your acceptance checklist for any promotion.
| Check | Signal | Pass condition |
|---|---|---|
| Trigger | Workflow event | push to main, not a PR |
| Build health | Upstream perf job | green before promote runs |
| Guard | Regression tolerance | no metric worse than baseline +2% |
| Provenance | Baseline commit field |
equals the triggering github.sha |
| Context | Each metric entry | states P75 and device + connection |
| Rollback | git revert of promotion |
restores prior baseline in one commit |
The mobile figures in this checklist assume the P75 on a mid-range mobile device at Fast 3G with 4x CPU throttle; if you also gate desktop, run the same checklist against a separate desktop baseline so a fast desktop run at broadband never relaxes the mobile ceiling.
Frequently Asked Questions
Why not promote the baseline from the pull request that changed performance?
A pull request is measured against the baseline of the branch it will merge into, so promoting from the PR would move the target before the change ships and let two open PRs write conflicting baselines. Promotion belongs to the single post-merge build on main, which measures the actual merge result rather than a pre-merge preview.
What stops a slow ratchet where each green build makes the baseline a little worse?
The promotion guard compares each freshly measured P75 against the committed baseline and refuses to write any metric that is worse by more than a small tolerance, typically 2%. Improvements and tiny drift promote automatically; a real regression under the absolute gate ceiling is surfaced as a build alert instead of being absorbed silently.
How do I roll back a bad baseline promotion?
Because the baseline is a version-controlled file, rollback is a plain git revert of the promotion commit followed by a push. That restores every metric ceiling at once, needs no database or dashboard edits, and leaves an auditable revert commit in the history.
Why key each baseline entry to a commit SHA?
Keying every promotion to the commit that produced it turns the file's git history into an audit log: you can trace exactly when a metric ceiling moved and which merge moved it. It also lets verification assert that the baseline's commit field matches the triggering build's SHA.