← Files okrdevARCHIVED FILE
templates/github/workflows/okr-gate.yml
16.9 KB · Oct 4, 2026 · 12:30 UTC
# okr-gate — the PR alignment nudge (okrdev Level 2)
#
# Installs to: .github/workflows/okr-gate.yml
#
# What it does: reads the PR description for a KR: line — a key result id like
# `KR: 1.2`, or one of the classifications `side-quest` / `maintenance` /
# `emergency` — and validates numeric ids against the active cycle file in
# okrdev/okrs/. It maintains exactly one comment on the PR (upserted, never
# spammed) and a `needs-kr` label. It never edits the PR itself.
#
# Warn by default: missing or unresolvable KR lines get a comment and the
# `needs-kr` label, but the check stays green. Nudge by default, gate by choice.
#
# Strict mode: set the repository variable OKRDEV_STRICT_GATE to `true`
# (Settings → Secrets and variables → Actions → Variables). Then a missing or
# invalid KR line fails this check. Escape hatch: a human applies the
# `okr-override` label and the gate passes — and the comment instructs the coach
# to log the override in this week's check-in. Nothing here is human-unoverridable.
# Note: strict mode only actually blocks merges if "okr-gate" is added to the
# required status checks in branch protection. Also note the trigger list below
# does not include `labeled` — after applying `okr-override`, re-run the check
# from the PR's Checks tab (any edit or push re-runs it too).
#
# Auto-classified as maintenance, no questions asked: PRs authored by
# dependabot[bot] or renovate[bot], and PRs whose title starts with "Revert ".
# Bots don't answer comments, and reverts undo work rather than add it.
#
# ── SAFETY ────────────────────────────────────────────────────────────────────
# This workflow uses `pull_request_target`, which runs with write access to the
# base repository — including for PRs from forks. That is safe here for exactly
# one reason: the job reads PR *metadata* only (title, body, author, labels) and
# fetches okrdev files from the BASE repo's default branch via the GitHub API.
# It NEVER checks out, builds, or executes anything from the PR head. Do not add
# an actions/checkout step or any other use of the PR's code to this workflow.
# ──────────────────────────────────────────────────────────────────────────────
name: okr-gate
on:
pull_request_target:
types: [opened, edited, reopened, synchronize]
permissions:
contents: read # fetch okrdev/okrs/* from the base repo via the API
issues: write # create/update the gate comment, manage labels
pull-requests: write
concurrency:
group: okr-gate-${{ github.event.pull_request.number }}
cancel-in-progress: true # rapid edits: only the latest state matters
jobs:
okr-gate:
name: okr-gate
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check the PR's KR line (metadata only — no checkout)
uses: actions/github-script@v7
env:
OKRDEV_STRICT_GATE: ${{ vars.OKRDEV_STRICT_GATE }}
with:
script: |
// ── Inputs & constants ─────────────────────────────────────────
const pr = context.payload.pull_request;
const { owner, repo } = context.repo;
const defaultBranch = context.payload.repository.default_branch;
const strict = (process.env.OKRDEV_STRICT_GATE || '').toLowerCase() === 'true';
const MARKER = '<!-- okrdev:okr-gate -->';
const NEEDS_KR_LABEL = 'needs-kr'; // "needs a KR line", not "unaligned" — it's a prompt, not a verdict
const OVERRIDE_LABEL = 'okr-override';
const BOT_ALLOWLIST = ['dependabot[bot]', 'renovate[bot]'];
// The canonical KR-line grammar (docs/method.md). Case-insensitive;
// the FIRST matching line anywhere in the description wins.
const KR_LINE = /^KR:\s*(([0-9]{4}-[QC][0-9]+\/)?(KR)?[0-9]+\.[0-9]+|side-quest|maintenance|emergency)\s*$/im;
// ── Base-repo file access (API only — never PR code) ───────────
async function fetchBaseFile(path) {
try {
const res = await github.rest.repos.getContent({ owner, repo, path, ref: defaultBranch });
if (Array.isArray(res.data) || !res.data.content) return null;
return Buffer.from(res.data.content, 'base64').toString('utf8');
} catch (e) {
if (e.status === 404) return null;
throw e;
}
}
async function findActiveCycle() {
let listing;
try {
listing = await github.rest.repos.getContent({ owner, repo, path: 'okrdev/okrs', ref: defaultBranch });
} catch (e) {
if (e.status === 404) return null; // okrdev not installed, or Level 0/1 with no cycles yet
throw e;
}
if (!Array.isArray(listing.data)) return null;
const names = listing.data
.filter((f) => f.type === 'file' && /^[0-9]{4}-[QC][0-9]+\.md$/i.test(f.name))
.map((f) => f.name)
.sort()
.reverse(); // newest cycle id first — the active one is almost always newest
for (const name of names) {
const text = await fetchBaseFile('okrdev/okrs/' + name);
if (!text) continue;
const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!fm) continue;
if (!/^status:\s*active\b/im.test(fm[1])) continue;
const cycleMatch = fm[1].match(/^cycle:\s*(\S+)/im);
return { cycle: cycleMatch ? cycleMatch[1] : name.replace(/\.md$/i, ''), text };
}
return null;
}
function lookupKr(cycleText, krId) {
// Headings look like "## KR1.2: <metric> from <baseline> to <target>".
// \b after the id so KR1.2 never matches KR1.23.
const headingRe = new RegExp('^##\\s*' + krId.replace(/\./g, '\\.') + '\\b\\s*:?\\s*(.*)$', 'im');
const m = headingRe.exec(cycleText);
if (!m) return null;
const after = cycleText.slice(m.index + m[0].length);
const nextHeading = after.search(/^#{1,2}\s/m);
const section = nextHeading === -1 ? after : after.slice(0, nextHeading);
// Dropped KRs keep their number (ids are frozen) but get "Status: dropped".
return { title: (m[1] || '').trim(), dropped: /^status:\s*dropped\b/im.test(section) };
}
// ── One comment, upserted — never a pile of stale bot comments ──
async function upsertComment(bodyText, createIfMissing) {
const body = MARKER + '\n' + bodyText;
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: pr.number, per_page: 100,
});
const mine = comments.find((c) => c.body && c.body.includes(MARKER));
if (mine) {
if (mine.body !== body) {
await github.rest.issues.updateComment({ owner, repo, comment_id: mine.id, body });
}
} else if (createIfMissing) {
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
}
}
async function addNeedsKr() {
try {
await github.rest.issues.getLabel({ owner, repo, name: NEEDS_KR_LABEL });
} catch (e) {
if (e.status !== 404) throw e;
await github.rest.issues.createLabel({
owner, repo, name: NEEDS_KR_LABEL, color: 'bfd4f2',
description: 'PR description needs a valid KR: line',
});
}
await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [NEEDS_KR_LABEL] });
}
async function removeNeedsKr() {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: pr.number, name: NEEDS_KR_LABEL });
} catch (e) {
if (e.status !== 404) throw e; // 404 = label wasn't on the PR; fine
}
}
// ── Outcomes ────────────────────────────────────────────────────
const hasOverride = (pr.labels || []).some((l) => l.name.toLowerCase() === OVERRIDE_LABEL);
async function pass(commentText, createIfMissing) {
await removeNeedsKr();
await upsertComment(commentText, Boolean(createIfMissing));
}
async function warn(summary, commentLines) {
if (hasOverride) {
// A human decided. The gate passes — and asks the coach to write it down.
await removeNeedsKr();
await upsertComment(commentLines.concat([
'',
'**Overridden.** A human applied the `' + OVERRIDE_LABEL + '` label, so the gate passes.',
'Coach: log this override in the **Judgment calls** section of this week\'s',
'check-in file (`okrdev/checkins/<cycle>/<yyyy-Www>.md`).',
]).join('\n'), true);
core.notice('okr-gate: ' + summary + ' — passed via ' + OVERRIDE_LABEL + ' label.');
return;
}
await addNeedsKr();
const tail = strict ? [
'',
'**Strict mode is on** (repo variable `OKRDEV_STRICT_GATE=true`), so this check',
'fails until the description carries a valid `KR:` line — or a maintainer applies',
'the `' + OVERRIDE_LABEL + '` label and re-runs the check from the Checks tab',
'(edits and pushes re-run it automatically).',
] : [
'',
'This is a nudge, not a block — the check stays green either way. Fix the line',
'and this comment updates on the next edit or push.',
];
await upsertComment(commentLines.concat(tail).join('\n'), true);
if (strict) {
core.setFailed('okr-gate (strict): ' + summary);
} else {
core.warning('okr-gate: ' + summary);
}
}
// ── 1. Trusted bots and reverts classify themselves ─────────────
const author = pr.user ? pr.user.login : '';
if (BOT_ALLOWLIST.includes(author)) {
await pass('**okr-gate:** auto-classified as `maintenance` — authored by `' + author + '`.', false);
core.info('Auto-maintenance: bot author ' + author);
return;
}
if (/^Revert /.test(pr.title || '')) {
await pass('**okr-gate:** auto-classified as `maintenance` — reverts undo work, they don\'t add it.', false);
core.info('Auto-maintenance: revert title');
return;
}
// ── 2. Find the KR line ─────────────────────────────────────────
const match = KR_LINE.exec(pr.body || '');
if (!match) {
await warn('no KR: line in the PR description', [
'### okr-gate: no `KR:` line',
'',
'Every substantive PR carries one line in its description saying what it serves.',
'Add one of these (the first matching line wins):',
'',
'```text',
'KR: 1.2 a key result in the active cycle',
'KR: side-quest sanctioned and time-boxed — log it in okrdev/PARKING_LOT.md',
'KR: maintenance bugfixes, config, docs, small refactors',
'KR: emergency reviewed post-hoc at the next check-in',
'```',
]);
return;
}
const raw = match[1];
const lower = raw.toLowerCase();
// ── 3. Classification words need no validation ──────────────────
if (lower === 'side-quest' || lower === 'maintenance' || lower === 'emergency') {
await pass('**okr-gate:** classified as `' + lower + '`.', false);
core.info('Classified: ' + lower);
return;
}
// ── 4. Numeric id → validate against the cycle file ─────────────
// Accepts "1.2", "KR1.2", or the cross-cycle form "2026-Q3/KR1.2".
const idMatch = raw.match(/^(([0-9]{4}-[QC][0-9]+)\/)?(?:KR)?([0-9]+\.[0-9]+)$/i);
const referencedCycle = idMatch[2] ? idMatch[2].toUpperCase() : null;
const krId = 'KR' + idMatch[3]; // canonical short form, e.g. KR1.2
const active = await findActiveCycle();
let cycleName;
let cycleText;
let isActiveCycle;
if (referencedCycle && (!active || referencedCycle !== active.cycle)) {
// Explicit cross-cycle reference — validate against that cycle's file.
cycleName = referencedCycle;
cycleText = await fetchBaseFile('okrdev/okrs/' + referencedCycle + '.md');
isActiveCycle = false;
if (!cycleText) {
await warn('cycle ' + referencedCycle + ' not found', [
'### okr-gate: cycle `' + referencedCycle + '` not found',
'',
'The KR line references `' + referencedCycle + '`, but there is no',
'`okrdev/okrs/' + referencedCycle + '.md` on `' + defaultBranch + '`. Check the cycle id.',
]);
return;
}
} else if (active) {
cycleName = active.cycle;
cycleText = active.text;
isActiveCycle = true;
} else {
await warn('no active cycle to validate ' + krId + ' against', [
'### okr-gate: cannot validate `' + krId + '` — no active cycle',
'',
'No file in `okrdev/okrs/` has `status: active`, so there is nothing to check',
'this id against. Between cycles? Run `/okrdev:plan`. Not running cycles here',
'yet? Use `KR: maintenance` / `KR: side-quest` / `KR: emergency` instead, or',
'remove this workflow.',
]);
return;
}
const kr = lookupKr(cycleText, krId);
if (!kr) {
await warn(krId + ' not found in ' + cycleName, [
'### okr-gate: `' + krId + '` not found in `' + cycleName + '`',
'',
'`okrdev/okrs/' + cycleName + '.md` has no heading for `' + krId + '`. KR ids are',
'frozen when a cycle goes active and never renumbered, so an unknown id is',
'usually a typo. Check the cycle file and fix the `KR:` line.',
]);
return;
}
if (kr.dropped) {
await warn(cycleName + '/' + krId + ' is marked dropped', [
'### okr-gate: `' + cycleName + '/' + krId + '` is marked `Status: dropped`',
'',
'Tagging new work to a dropped KR is usually a mistake. If this work serves a',
'different KR now, update the line. If it genuinely revives this KR, that is a',
'cycle-file amendment (a PR with a `Revised:` block), not just a PR tag.',
]);
return;
}
// ── 5. Valid — record the full resolution ───────────────────────
// Short ids like "1.2" are ambiguous across cycles; writing the
// canonical form onto the PR makes the alignment audit-proof.
const canonical = cycleName + '/' + krId;
const lines = [
'### okr-gate: aligned',
'',
'`KR: ' + raw + '` resolves to **`' + canonical + '`**' + (kr.title ? ' — ' + kr.title : '') + '.',
'',
'Recorded here so the short id stays unambiguous after this cycle is scored.',
];
if (!isActiveCycle) {
lines.push('', 'Note: `' + cycleName + '` is not the active cycle. Cross-cycle references',
'are legal — this note just makes it visible.');
}
await pass(lines.join('\n'), true);
core.notice('okr-gate: validated ' + canonical + '.');
SHA-256: 5b1bd7930689ddefbf05bb4ae3f17298cfee17293f080dec1ba13599452b47db