From c5f5ea6afc187ef4697d12b66beaddf45ce86b09 Mon Sep 17 00:00:00 2001 From: Sanjay Rai Date: Tue, 18 Aug 2026 17:35:36 -0700 Subject: [PATCH] ci(mwpw-204307): add safe Dependabot auto-merge --- .github/dependabot.yml | 12 +- .github/dependabot/README.md | 48 +++ .github/dependabot/safe-automerge.mjs | 355 ++++++++++++++++++ .github/dependabot/safe-automerge.test.mjs | 115 ++++++ .github/qa/agent-review.mjs | 4 +- .../workflows/dependabot-safe-automerge.yml | 47 +++ 6 files changed, 576 insertions(+), 5 deletions(-) create mode 100644 .github/dependabot/README.md create mode 100644 .github/dependabot/safe-automerge.mjs create mode 100644 .github/dependabot/safe-automerge.test.mjs create mode 100644 .github/workflows/dependabot-safe-automerge.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a2e2104ae..68979efc7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,16 +3,20 @@ updates: - package-ecosystem: "npm" directory: "/" - # Schedule: Only Mondays at 9am Pacific (not constantly!) + # Check each weekday morning so safe updates can flow continuously. + # Security updates are advisory-triggered and do not wait for this schedule. schedule: - interval: "weekly" - day: "monday" - time: "09:00" + interval: "daily" + time: "06:00" timezone: "America/Los_Angeles" # Limit: Max 5 open PRs at once (prevents overwhelming flood) open-pull-requests-limit: 5 + # Dependabot normally rebases automatically. Keep this explicit because the + # safe-automerge controller requires every candidate to include latest main. + rebase-strategy: "auto" + # No grouping: Dependabot will open individual PRs per update # Safety: Ignore major version bumps (too risky to auto-update) diff --git a/.github/dependabot/README.md b/.github/dependabot/README.md new file mode 100644 index 000000000..df8bc198f --- /dev/null +++ b/.github/dependabot/README.md @@ -0,0 +1,48 @@ +# Dependabot safe autonomy + +The controller in this directory separates update creation from merge policy: + +1. Dependabot checks npm each weekday morning and may keep up to five PRs open. +2. The controller evaluates every open Dependabot PR, but can arm only one merge + at a time. +3. The first rollout permits only direct development dependency patch/minor + updates that touch `package.json` and `package-lock.json`. +4. The shipped bundle must be byte-identical (`build-output-diff: NO_CHANGE`), + Agent QA must report `PASS` for the current head SHA, and the deterministic + PR build, lint, unit, coverage, E2E, accessibility, and performance suite + must pass. +5. Anything else receives `dependencies-needs-human` and remains open without + blocking other safe updates. + +## Rollout + +The workflow defaults to observation mode. It labels and records PR decisions, +but does not merge. Before changing the repository variable +`DEPENDABOT_AUTOMERGE_MODE` to `merge`: + +- enable **Allow auto-merge** in repository settings; +- update the default-branch `review-gate` ruleset so required status checks are + strict (the PR branch must be current with `main`); +- review several observation-mode decisions and confirm the byte-diff and Agent + QA signals match human judgment. + +The controller refuses merge mode if either repository safety setting is absent. +It uses `BOT_TOKEN`, then `WORKFLOW_TOKEN`, falling back to `GITHUB_TOKEN`. + +## Conflict recovery + +Dependabot's automatic rebasing remains enabled explicitly. When a pure +Dependabot branch stays behind or conflicted, the scheduled controller: + +1. waits 30 minutes for the normal automatic rebase; +2. requests `@dependabot rebase`; +3. after two more hours, requests `@dependabot recreate` once; +4. after another two hours, labels the PR `dependencies-needs-human`. + +Branches containing human commits are never recreated automatically. Every new +head SHA must pass the complete policy again before auto-merge is armed. If an +armed PR becomes conflicted, unsafe, or starts waiting on a new head, the +controller disables its stale auto-merge request immediately. + +Decision comments record PR creation time, decision time, elapsed time, head SHA, +and reason. GitHub's `mergedAt` timestamp completes raised-to-merge metrics. diff --git a/.github/dependabot/safe-automerge.mjs b/.github/dependabot/safe-automerge.mjs new file mode 100644 index 000000000..6ebe4dc31 --- /dev/null +++ b/.github/dependabot/safe-automerge.mjs @@ -0,0 +1,355 @@ +#!/usr/bin/env node +/** + * Conservative Dependabot controller. + * + * Only pure dev-dependency minor/patch PRs are eligible. They must touch only + * the root npm manifest/lockfile, include latest main, produce a byte-identical + * shipped bundle, receive a current Agent QA PASS, and pass the full PR suite. + * + * Runtime I/O intentionally goes through `gh`; pure policy helpers are exported + * for unit tests. This file is always executed from trusted default-branch code + * by workflow_run/schedule, never from the Dependabot branch. + */ +import { execFileSync, spawnSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; + +const DEPENDABOT = 'dependabot[bot]'; +const ALLOWED_FILES = new Set(['package.json', 'package-lock.json']); +const REQUIRED_CHECKS = [ + 'Adobe CLA Signed?', + 'agent-review', + 'check-build', + 'check-coverage-thresholds', + 'check-linting', + 'check-test-requirements', + 'deployment', + 'run-accessibility-checks', + 'run-core-web-vitals-checks', + 'run-e2e-tests', + 'run-unit-tests', +]; +const REQUIRED_STATUSES = ['review-score-gate']; +const DECISION_MARKER = ''; +const CONFLICT_MARKER = '')); + if (!comment) return { verdict: 'MISSING', current: false }; + const match = comment.body.match(//); + if (!match) return { verdict: 'MISSING', current: false }; + try { + const state = JSON.parse(Buffer.from(match[1], 'base64').toString('utf8')); + // New comments carry headSha explicitly. The header fallback keeps the + // first rollout compatible with comments created before this change. + const reviewedSha = state.headSha || ((comment.body.match(/commit\s+`([0-9a-f]{7,40})`/i) || [])[1] || ''); + return { verdict: state.verdict || 'UNKNOWN', current: reviewedSha.startsWith(shortSha) }; + } catch { + return { verdict: 'UNKNOWN', current: false }; + } +} + +export function extractDependencyMetadata(commits) { + const dependencyTypes = []; + const updateTypes = []; + for (const commit of commits) { + const body = commit.messageBody || ''; + for (const match of body.matchAll(/dependency-type:\s*"?([^\s"\n]+)"?/g)) dependencyTypes.push(match[1]); + for (const match of body.matchAll(/update-type:\s*"?([^\s"\n]+)"?/g)) updateTypes.push(match[1]); + } + return { dependencyTypes, updateTypes }; +} + +export function hasOnlyDependabotCommits(commits) { + return commits.length > 0 && commits.every((commit) => + (commit.authors || []).length > 0 && commit.authors.every((author) => author.login === DEPENDABOT)); +} + +function resultState(collection, name) { + const item = collection.find((entry) => (entry.name || entry.context) === name); + return item ? String(item.conclusion || item.state || item.status || '').toUpperCase() : 'MISSING'; +} + +export function evaluateCandidate(candidate) { + const { + files, commits, comments, headSha, behindBy, mergeStateStatus, + checkRuns, statuses, + } = candidate; + + if (!files.length || files.some((file) => !ALLOWED_FILES.has(file.path))) { + return { state: 'human', reason: 'changes files outside package.json and package-lock.json' }; + } + if (!hasOnlyDependabotCommits(commits)) { + return { state: 'human', reason: 'contains commits not authored by Dependabot' }; + } + + const metadata = extractDependencyMetadata(commits); + if (!metadata.dependencyTypes.length) { + return { state: 'human', reason: 'Dependabot dependency-type metadata is missing' }; + } + if (metadata.dependencyTypes.some((type) => type !== 'direct:development')) { + return { state: 'human', reason: 'first rollout only allows direct development dependencies' }; + } + if (!metadata.updateTypes.length) { + return { state: 'human', reason: 'Dependabot update-type metadata is missing' }; + } + if (metadata.updateTypes.some((type) => !/version-update:semver-(patch|minor)$/.test(type))) { + return { state: 'human', reason: 'first rollout only allows patch and minor version updates' }; + } + + if (mergeStateStatus === 'DIRTY') return { state: 'conflict', reason: 'branch has merge conflicts' }; + if (behindBy > 0 || mergeStateStatus === 'BEHIND') return { state: 'behind', reason: `branch is ${behindBy || 1} commit(s) behind main` }; + + const buildDiff = statuses.find(({ context }) => context === 'build-output-diff'); + if (!buildDiff) return { state: 'waiting', reason: 'waiting for build-output-diff' }; + if (String(buildDiff.state || '').toUpperCase() !== 'SUCCESS') { + return { state: 'waiting', reason: `waiting for successful build-output-diff (${buildDiff.state || 'unknown'})` }; + } + if (!/NO_CHANGE/i.test(buildDiff.description || '')) { + return { state: 'human', reason: `shipped build output is not byte-identical (${buildDiff.description || 'unknown'})` }; + } + + const agent = extractAgentVerdict(comments, headSha); + if (!agent.current) return { state: 'waiting', reason: 'waiting for Agent QA on the current commit' }; + if (agent.verdict !== 'PASS') return { state: 'human', reason: `Agent QA verdict is ${agent.verdict}` }; + + for (const name of REQUIRED_CHECKS) { + const state = resultState(checkRuns, name); + if (['FAILURE', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STALE'].includes(state)) { + return { state: 'human', reason: `${name} concluded ${state}` }; + } + if (state !== 'SUCCESS') return { state: 'waiting', reason: `waiting for ${name} (${state})` }; + } + for (const name of REQUIRED_STATUSES) { + const state = resultState(statuses, name); + if (['FAILURE', 'ERROR'].includes(state)) return { state: 'human', reason: `${name} concluded ${state}` }; + if (state !== 'SUCCESS') return { state: 'waiting', reason: `waiting for ${name} (${state})` }; + } + + return { state: 'eligible', reason: 'byte-identical build, current Agent QA PASS, and all deterministic checks passed' }; +} + +export function nextConflictAction({ headUpdatedAt, comments, headSha, pureDependabotCommits, now = Date.now() }) { + if (!pureDependabotCommits) return { action: 'human', reason: 'extra commits prevent Dependabot automatic rebasing' }; + if (now - Date.parse(headUpdatedAt) < THIRTY_MINUTES_MS) return { action: 'wait', reason: 'giving Dependabot time to rebase automatically' }; + + const actions = comments + .filter(({ body = '' }) => body.includes(CONFLICT_MARKER) && body.includes(headSha)) + .map((comment) => ({ + action: (comment.body.match(/dependabot-conflict-recovery:(rebase|recreate):/) || [])[1], + at: Date.parse(comment.createdAt), + })) + .filter(({ action, at }) => action && Number.isFinite(at)) + .sort((a, b) => b.at - a.at); + + const recreate = actions.find(({ action }) => action === 'recreate'); + if (recreate) { + return now - recreate.at >= TWO_HOURS_MS + ? { action: 'human', reason: 'still conflicted two hours after recreate' } + : { action: 'wait', reason: 'waiting for requested recreate' }; + } + const rebase = actions.find(({ action }) => action === 'rebase'); + if (rebase) { + return now - rebase.at >= TWO_HOURS_MS + ? { action: 'recreate', reason: 'still conflicted two hours after rebase' } + : { action: 'wait', reason: 'waiting for requested rebase' }; + } + return { action: 'rebase', reason: 'automatic rebase did not clear the conflict within 30 minutes' }; +} + +function gh(args, { allowFailure = false } = {}) { + const result = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + if (result.status !== 0 && !allowFailure) throw new Error(`gh ${args.join(' ')} failed: ${(result.stderr || '').trim()}`); + return result.status === 0 ? result.stdout.trim() : ''; +} + +function ghJson(args, options) { + const output = gh(args, options); + return output ? JSON.parse(output) : null; +} + +function elapsed(from, to = Date.now()) { + const minutes = Math.max(0, Math.round((to - Date.parse(from)) / 60000)); + if (minutes < 60) return `${minutes}m`; + return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; +} + +function ensureLabel(repo, name, color, description) { + gh(['label', 'create', name, '--repo', repo, '--color', color, '--description', description, '--force']); +} + +function setLabel(repo, pr, currentLabels, add, remove = []) { + const current = new Set((currentLabels || []).map((label) => label.name || label)); + for (const label of remove) { + if (current.has(label)) gh(['pr', 'edit', String(pr), '--repo', repo, '--remove-label', label], { allowFailure: true }); + } + if (add && !current.has(add)) gh(['pr', 'edit', String(pr), '--repo', repo, '--add-label', add]); +} + +function disableAutoMerge(repo, pr) { + gh(['pr', 'merge', String(pr), '--repo', repo, '--disable-auto'], { allowFailure: true }); +} + +function postOnce(repo, pr, marker, body) { + const comments = ghJson(['api', `repos/${repo}/issues/${pr}/comments?per_page=100`]) || []; + if (comments.some((comment) => (comment.body || '').includes(marker))) return; + gh(['api', '-X', 'POST', `repos/${repo}/issues/${pr}/comments`, '-f', `body=${marker}\n${body}`]); +} + +function recordDecision(repo, pr, data, state, reason) { + const marker = `${DECISION_MARKER}\n`; + const heading = state === 'ready' ? '✅ Safe Dependabot auto-merge is ready' : '🧑‍💻 Dependabot update needs human review'; + postOnce(repo, pr, marker, [ + `### ${heading}`, + '', + `- PR raised: ${data.createdAt}`, + `- Decision recorded: ${new Date().toISOString()}`, + `- Time to decision: ${elapsed(data.createdAt)}`, + `- Evaluated head: \`${data.headSha.slice(0, 12)}\``, + `- Reason: ${reason}`, + ].join('\n')); +} + +function conflictCommand(repo, pr, data, action, reason) { + const marker = `${CONFLICT_MARKER}${action}:${data.headSha} -->`; + const comments = ghJson(['api', `repos/${repo}/issues/${pr}/comments?per_page=100`]) || []; + if (comments.some((comment) => (comment.body || '').includes(marker))) return; + const body = [ + `@dependabot ${action}`, + '', + `Conflict watchdog: ${reason}. The updated head must pass every gate again before merge.`, + marker, + ].join('\n'); + gh(['api', '-X', 'POST', `repos/${repo}/issues/${pr}/comments`, '-f', `body=${body}`]); +} + +function strictUpToDateEnabled(repo, defaultBranch) { + const rulesets = ghJson(['api', `repos/${repo}/rulesets?includes_parents=true`], { allowFailure: true }) || []; + for (const summary of rulesets.filter(({ enforcement }) => enforcement === 'active')) { + const ruleset = ghJson(['api', `repos/${repo}/rulesets/${summary.id}`], { allowFailure: true }); + const includesDefault = (ruleset?.conditions?.ref_name?.include || []).some((ref) => ref === '~DEFAULT_BRANCH' || ref.endsWith(`/${defaultBranch}`)); + const required = (ruleset?.rules || []).find(({ type }) => type === 'required_status_checks'); + if (includesDefault && required?.parameters?.strict_required_status_checks_policy === true) return true; + } + const protection = ghJson(['api', `repos/${repo}/branches/${defaultBranch}/protection`], { allowFailure: true }); + return protection?.required_status_checks?.strict === true; +} + +async function main() { + const repo = process.env.GITHUB_REPOSITORY; + if (!repo) throw new Error('GITHUB_REPOSITORY is required'); + const mode = process.env.AUTOMERGE_MODE === 'merge' ? 'merge' : 'observe'; + const repository = ghJson(['api', `repos/${repo}`]); + const defaultBranch = repository.default_branch; + const strict = strictUpToDateEnabled(repo, defaultBranch); + + ensureLabel(repo, 'dependencies-automerge-ready', '0e8a16', 'Dependabot update passed every safe-automerge gate'); + ensureLabel(repo, 'dependencies-needs-human', 'd73a4a', 'Dependabot update requires human intervention'); + ensureLabel(repo, 'dependencies-conflict', 'fbca04', 'Dependabot branch is behind or conflicted'); + + const prs = ghJson(['pr', 'list', '--repo', repo, '--state', 'open', '--author', 'app/dependabot', '--limit', '100', + '--json', 'number,title,baseRefName,headRefOid,mergeStateStatus,createdAt,updatedAt,autoMergeRequest,url,labels']) || []; + if (!prs.length) { + console.log('No open Dependabot PRs.'); + return; + } + + const baseSha = gh(['api', `repos/${repo}/git/ref/heads/${defaultBranch}`, '--jq', '.object.sha']); + const candidates = []; + for (const pr of prs.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt))) { + if (pr.baseRefName !== defaultBranch) continue; + const view = ghJson(['pr', 'view', String(pr.number), '--repo', repo, '--json', 'files,commits,comments']); + const checkData = ghJson(['api', `repos/${repo}/commits/${pr.headRefOid}/check-runs?per_page=100`]); + const statusData = ghJson(['api', `repos/${repo}/commits/${pr.headRefOid}/status`]); + const comparison = ghJson(['api', `repos/${repo}/compare/${baseSha}...${pr.headRefOid}`]); + const data = { + ...pr, + headSha: pr.headRefOid, + files: view.files || [], + commits: view.commits || [], + comments: view.comments || [], + checkRuns: checkData.check_runs || [], + statuses: statusData.statuses || [], + behindBy: comparison.behind_by || 0, + headUpdatedAt: (view.commits || []).at(-1)?.committedDate || pr.updatedAt, + }; + + const decision = evaluateCandidate(data); + console.log(`#${pr.number} ${decision.state}: ${decision.reason}`); + + // A rebase or recreate can invalidate advisory gates that GitHub itself + // does not require. Never leave a previous native auto-merge request armed + // while the current head is waiting, conflicted, or requires a person. + if (pr.autoMergeRequest && decision.state !== 'eligible') { + disableAutoMerge(repo, pr.number); + // Keep the loop snapshot consistent so this run may arm a different, + // fully eligible PR instead of waiting for the next schedule tick. + pr.autoMergeRequest = null; + console.log(`#${pr.number} disabled stale auto-merge for ${data.headSha.slice(0, 12)}.`); + } + + if (decision.state === 'conflict' || decision.state === 'behind') { + setLabel(repo, pr.number, pr.labels, 'dependencies-conflict', [ + 'dependencies-automerge-ready', + 'dependencies-needs-human', + ]); + const recovery = nextConflictAction({ + headUpdatedAt: data.headUpdatedAt, + comments: data.comments, + headSha: data.headSha, + pureDependabotCommits: hasOnlyDependabotCommits(data.commits), + }); + if (recovery.action === 'rebase' || recovery.action === 'recreate') { + conflictCommand(repo, pr.number, data, recovery.action, recovery.reason); + } else if (recovery.action === 'human') { + setLabel(repo, pr.number, [{ name: 'dependencies-conflict' }, ...pr.labels], 'dependencies-needs-human', ['dependencies-conflict']); + recordDecision(repo, pr.number, data, 'human', recovery.reason); + } + continue; + } + + setLabel(repo, pr.number, pr.labels, null, ['dependencies-conflict']); + if (decision.state === 'human') { + setLabel(repo, pr.number, pr.labels, 'dependencies-needs-human', ['dependencies-automerge-ready']); + recordDecision(repo, pr.number, data, 'human', decision.reason); + } else if (decision.state === 'eligible') { + setLabel(repo, pr.number, pr.labels, 'dependencies-automerge-ready', ['dependencies-needs-human']); + recordDecision(repo, pr.number, data, 'ready', decision.reason); + candidates.push(data); + } else { + // A new head starts unclassified. Remove conclusions recorded for an old + // head until every gate has completed again. + setLabel(repo, pr.number, pr.labels, null, [ + 'dependencies-automerge-ready', + 'dependencies-needs-human', + ]); + } + } + + if (mode !== 'merge') { + console.log(`Observation mode: ${candidates.length} PR(s) ready; set DEPENDABOT_AUTOMERGE_MODE=merge to arm native auto-merge.`); + return; + } + if (!repository.allow_auto_merge) throw new Error('Merge mode requires repository setting “Allow auto-merge”.'); + if (!strict) throw new Error('Merge mode requires strict up-to-date status checks on the default-branch ruleset.'); + if (prs.some(({ autoMergeRequest }) => autoMergeRequest)) { + console.log('A Dependabot PR already has auto-merge armed; waiting before arming another.'); + return; + } + if (!candidates.length) return; + + const candidate = candidates[0]; + execFileSync('gh', ['pr', 'merge', String(candidate.number), '--repo', repo, '--auto', '--squash', '--match-head-commit', candidate.headSha], { + stdio: 'inherit', + }); + console.log(`Armed auto-merge for #${candidate.number}.`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); + }); +} diff --git a/.github/dependabot/safe-automerge.test.mjs b/.github/dependabot/safe-automerge.test.mjs new file mode 100644 index 000000000..69e27282e --- /dev/null +++ b/.github/dependabot/safe-automerge.test.mjs @@ -0,0 +1,115 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + evaluateCandidate, + extractAgentVerdict, + extractDependencyMetadata, + nextConflictAction, +} from './safe-automerge.mjs'; + +const SHA = '1234567890abcdef1234567890abcdef12345678'; +const agentComment = (verdict = 'PASS', headSha = SHA.slice(0, 7)) => ({ + body: `\n`, +}); +const success = (name) => ({ name, conclusion: 'success' }); +const requiredChecks = [ + 'Adobe CLA Signed?', 'agent-review', 'check-build', 'check-coverage-thresholds', + 'check-linting', 'check-test-requirements', 'deployment', 'run-accessibility-checks', + 'run-core-web-vitals-checks', 'run-e2e-tests', 'run-unit-tests', +].map(success); + +function candidate(overrides = {}) { + return { + files: [{ path: 'package.json' }, { path: 'package-lock.json' }], + commits: [{ + authors: [{ login: 'dependabot[bot]' }], + messageBody: 'dependency-type: direct:development\nupdate-type: version-update:semver-patch', + }], + comments: [agentComment()], + headSha: SHA, + behindBy: 0, + mergeStateStatus: 'CLEAN', + checkRuns: requiredChecks, + statuses: [ + { context: 'build-output-diff', state: 'success', description: 'NO_CHANGE' }, + { context: 'review-score-gate', state: 'success' }, + ], + ...overrides, + }; +} + +test('extracts current Agent QA verdict', () => { + assert.deepEqual(extractAgentVerdict([agentComment()], SHA), { verdict: 'PASS', current: true }); + assert.deepEqual(extractAgentVerdict([agentComment('PASS', 'fffffff')], SHA), { verdict: 'PASS', current: false }); +}); + +test('accepts the legacy Agent QA header only for its reviewed head', () => { + const encoded = Buffer.from(JSON.stringify({ verdict: 'PASS', sinceSha: '' })).toString('base64'); + const comment = { body: `\n_Last updated now · commit \`1234567\`._\n` }; + assert.deepEqual(extractAgentVerdict([comment], SHA), { verdict: 'PASS', current: true }); +}); + +test('extracts Dependabot metadata', () => { + assert.deepEqual(extractDependencyMetadata(candidate().commits), { + dependencyTypes: ['direct:development'], + updateTypes: ['version-update:semver-patch'], + }); +}); + +test('allows only a fully safe candidate', () => { + assert.equal(evaluateCandidate(candidate()).state, 'eligible'); + assert.equal(evaluateCandidate(candidate({ + statuses: [{ context: 'build-output-diff', state: 'success', description: 'CHANGED' }], + })).state, 'human'); + assert.equal(evaluateCandidate(candidate({ comments: [agentComment('FAIL')] })).state, 'human'); + assert.equal(evaluateCandidate(candidate({ behindBy: 2 })).state, 'behind'); +}); + +test('keeps production and major updates human-reviewed', () => { + assert.equal(evaluateCandidate(candidate({ + commits: [{ + authors: [{ login: 'dependabot[bot]' }], + messageBody: 'dependency-type: direct:production\nupdate-type: version-update:semver-patch', + }], + })).state, 'human'); + assert.equal(evaluateCandidate(candidate({ + commits: [{ + authors: [{ login: 'dependabot[bot]' }], + messageBody: 'dependency-type: direct:development\nupdate-type: version-update:semver-major', + }], + })).state, 'human'); +}); + +test('keeps grouped indirect updates human-reviewed even without update-type metadata', () => { + const result = evaluateCandidate(candidate({ + commits: [{ + authors: [{ login: 'dependabot[bot]' }], + messageBody: 'dependency-type: direct:development\ndependency-type: indirect\ndependency-group: npm_and_yarn', + }], + })); + assert.deepEqual(result, { + state: 'human', + reason: 'first rollout only allows direct development dependencies', + }); +}); + +test('conflict recovery waits, rebases, recreates, then escalates', () => { + const now = Date.parse('2026-08-18T12:00:00Z'); + const base = { headSha: SHA, pureDependabotCommits: true, now, comments: [] }; + assert.equal(nextConflictAction({ ...base, headUpdatedAt: '2026-08-18T11:45:00Z' }).action, 'wait'); + assert.equal(nextConflictAction({ ...base, headUpdatedAt: '2026-08-18T10:00:00Z' }).action, 'rebase'); + assert.equal(nextConflictAction({ + ...base, + headUpdatedAt: '2026-08-18T08:00:00Z', + comments: [{ createdAt: '2026-08-18T09:00:00Z', body: `${CONFLICT('rebase')} ${SHA}` }], + }).action, 'recreate'); + assert.equal(nextConflictAction({ + ...base, + headUpdatedAt: '2026-08-18T06:00:00Z', + comments: [{ createdAt: '2026-08-18T09:00:00Z', body: `${CONFLICT('recreate')} ${SHA}` }], + }).action, 'human'); +}); + +function CONFLICT(action) { + return ``; +} diff --git a/.github/qa/agent-review.mjs b/.github/qa/agent-review.mjs index c7c04ca7d..db1fd2ffc 100755 --- a/.github/qa/agent-review.mjs +++ b/.github/qa/agent-review.mjs @@ -174,7 +174,9 @@ if (shortSha) headerMeta += ` · commit \`${shortSha}\``; if (nFiles) headerMeta += ` · ${nFiles} file${nFiles === 1 ? '' : 's'} changed`; headerMeta += '._'; -const verdictState = Buffer.from(JSON.stringify({ verdict, sinceSha: qa.since }), 'utf8').toString('base64'); +// Include the reviewed head SHA so downstream automation can prove that a PASS +// belongs to the PR's current commit rather than a stale pre-rebase run. +const verdictState = Buffer.from(JSON.stringify({ verdict, sinceSha: qa.since, headSha: shortSha }), 'utf8').toString('base64'); const comment = [ MARKER, '## Agent QA review — interactive + visual diff (advisory, non-blocking)', diff --git a/.github/workflows/dependabot-safe-automerge.yml b/.github/workflows/dependabot-safe-automerge.yml new file mode 100644 index 000000000..48ed37b32 --- /dev/null +++ b/.github/workflows/dependabot-safe-automerge.yml @@ -0,0 +1,47 @@ +name: Dependabot Safe Autonomy + +on: + # Agent QA is the last safety signal unique to this policy. The scheduled + # pass catches slower CI completions and drives bounded conflict recovery. + workflow_run: + workflows: ["Agent QA Review"] + types: [completed] + schedule: + - cron: "17,47 * * * *" + workflow_dispatch: + +concurrency: + group: dependabot-safe-autonomy + cancel-in-progress: false + +# This privileged controller always executes the version on main. It never +# checks out or executes Dependabot PR code; it only reads API results, labels, +# comments, and enables native auto-merge. +permissions: + actions: read + checks: read + contents: write + issues: write + pull-requests: write + statuses: read + +jobs: + evaluate: + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + GH_TOKEN: ${{ secrets.BOT_TOKEN || secrets.WORKFLOW_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + # Safe rollout: absent/anything except "merge" records decisions without + # merging. Set the repository variable to "merge" after observing results. + AUTOMERGE_MODE: ${{ vars.DEPENDABOT_AUTOMERGE_MODE }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + + - name: Test policy evaluator + run: node --test .github/dependabot/safe-automerge.test.mjs + + - name: Evaluate PRs, recover conflicts, and arm one safe merge + run: node .github/dependabot/safe-automerge.mjs