Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 50 additions & 10 deletions docs/node-maintenance-operator.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ service. Repository maintainers install the shared App; they do not host it.
The receiver verifies signed webhook deliveries and reads canonical repository
metadata with a token restricted to one repository. For managed PRs it also
reads the canonical PR and displays the selected package versions in the App's
**Dependency repair** Check. Check actions, dispatch, storage and dependency
repairs belong to later slices.
**Dependency repair** Check. A maintainer can request one harmless pilot run
through its native action. D1 retains the active request and returned Actions run
identity. Dependency repairs and completion handling belong to later slices.

## Check the selected batch

Expand All @@ -27,9 +28,31 @@ Malformed or missing selections produce a Check that asks for attention.

Editing selections or changing the PR head refreshes the Check from canonical
GitHub data. The Check displays the exact selected versions, bound to the PR
number, head and base. It has no Run button in this slice. Checkbox edits do not
start a run. The token grants only Contents read, Pull requests read and Checks
write; the service never updates another App's Check.
number, head and base. With D1 configured, a nonempty valid batch shows
**Run selected fixes**. Checkbox edits do not start a run. PR-event tokens grant
only Contents read, Pull requests read and Checks write. Action-event tokens
also grant Actions write, restricted to the same repository. The service never
updates another App's Check.

## Request the selected batch

Review the exact versions in the Check summary, then click **Run selected fixes**.
The service reads current write, maintain or admin permission and verifies the
canonical PR and Check again. A stale selection or head refreshes the Check and
requires another click; it starts no run.

The request UUID is the signed GitHub delivery ID. A single D1 insert reserves
one active request per installation, repository and PR before dispatch. Replayed
deliveries and competing clicks cannot admit another active request. The Check
loses its action and retains the request identity across PR edits. A new head can
have its own selection Check, but the existing PR lock still blocks dispatch.

Only `.github/workflows/dependency-repair.yml` on the current default branch is
dispatched, with the five C2 inputs. The API must return HTTP 200 with the exact
run ID and matching repository URLs. The known run link is saved in D1 and shown
in the Check. The fixture in `repair-app/pilot/` validates those inputs without
checking out code or changing dependency files. A successful pilot does not mean
a dependency repair was published.

## Check the receiver locally

Expand All @@ -43,21 +66,38 @@ npm run check
```

The unit suite reports coverage for the receiver, selection parser and Check
updates. `selection-fixtures.json` holds the shared C1 cases and expected
updates, action authorization, fixed dispatch and request-state failures.
`selection-fixtures.json` holds the shared C1 cases and expected
fingerprints. The runtime test executes the receiver
in local Cloudflare Workers with synthetic credentials and intercepted GitHub
requests. The final command bundles the preview with `--dry-run`; it does not
requests and an actual local D1 binding for concurrency and replay checks.
The final command bundles the preview with `--dry-run`; it does not
deploy. The CI job runs the same checks without App or Cloudflare credentials.

## Stop deliveries or recover

Disable the App webhook or uninstall the affected installation to stop new
deliveries. Restore a previous approved Worker version when one exists. This
receiver slice has no request database, dispatched runs or repository writes.
service does not cancel an already dispatched Actions run; cancel it separately.
Restoring Worker code does not roll back the D1 database.

Webhook processing shares an eight-second budget across input and GitHub reads.
Timed-out requests fail without an automatic retry. After recovery, redeliver
failed deliveries through the GitHub App's webhook delivery controls.
Timed-out requests fail without an automatic dispatch retry. Before a reservation
exists, a failed delivery can be redelivered after recovery. After reservation,
inspect the request record and Actions first:

- `reserved`: a crash or Check update failure may have interrupted the attempt.
- `unknown`: the dispatch response was lost, invalid or unexpected. A run may exist.
- `dispatched`: the exact returned run ID and URLs are retained.

Active locks never expire automatically. Replaying the same delivery never
repeats its dispatch POST. Do not delete an ambiguous row or start another attempt
until an operator proves no run remains active and reconciles any observed run
with the exact request inputs, repository, workflow and actor.

Completion updates and releasing finished requests arrive in slice 4. Until then,
even a finished harmless pilot keeps its active lock. Do not describe its
in-progress Check as proof that Actions is still running.

Keep App credentials in operator storage and Worker secrets. Keep verification
receipts outside the repository, without credentials or webhook payloads. A
Expand Down
21 changes: 21 additions & 0 deletions repair-app/pilot/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Harmless infrastructure fixture

For the approved pilot, copy `dependency-repair.yml` to
`.github/workflows/dependency-repair.yml` on the repository's default branch.
Review that concrete change before merging it. This fixture is the service's
fixed dispatch target; there is no alternate dispatch endpoint or pilot mode.

The five C2 inputs are validated and retained in the Actions job summary.
The workflow checks out no code, uses read-only permissions and changes no
repository files. A successful run proves input delivery, not dependency repair.

For controlled completion probes, select the synthetic package `pilot-failure`
to fail after writing the receipt. Select `pilot-cancellation` to wait for two
minutes so an operator can cancel the run. Use any increasing stable same-minor
patch pair. These probes belong only to this installation fixture.

Slice 3 proves action admission, replay rejection and one returned run ID.
Completion and releasing active requests belong to slice 4. Until that slice is
deployed, a finished pilot still retains its active request lock. Inspect its
known Actions run and D1 record; do not remove the lock or dispatch again merely
because the Check still says the workflow started.
84 changes: 84 additions & 0 deletions repair-app/pilot/dependency-repair.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
name: Dependency repair infrastructure pilot
on:
workflow_dispatch:
inputs:
request-id:
description: Verified request UUID
required: true
type: string
pull-request:
description: Managed PR number
required: true
type: string
expected-head:
description: Confirmed PR head SHA
required: true
type: string
expected-base:
description: Confirmed PR base SHA
required: true
type: string
selections:
description: Exact selected items as compact JSON
required: true
type: string
permissions:
contents: read
jobs:
validate-inputs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Validate and summarize the five dispatch inputs
env:
REQUEST_ID: ${{ inputs['request-id'] }}
PULL_REQUEST: ${{ inputs['pull-request'] }}
EXPECTED_HEAD: ${{ inputs['expected-head'] }}
EXPECTED_BASE: ${{ inputs['expected-base'] }}
SELECTIONS: ${{ inputs.selections }}
shell: python
run: |
import json
import os
import re
import time

def require(condition):
if not condition:
raise SystemExit('Invalid pilot input')

require(re.fullmatch(r'[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}', os.environ['REQUEST_ID']))
require(re.fullmatch(r'[1-9][0-9]*', os.environ['PULL_REQUEST']))
for key in ['EXPECTED_HEAD', 'EXPECTED_BASE']:
require(re.fullmatch(r'[a-f0-9]{40}', os.environ[key]))
items = json.loads(os.environ['SELECTIONS'])
require(isinstance(items, list) and 1 <= len(items) <= 20)
names = []
for item in items:
require(isinstance(item, dict) and list(item) == ['name', 'from', 'to'])
require(all(isinstance(value, str) for value in item.values()))
name = item['name']
require(len(name) <= 214 and name not in ['node_modules', 'favicon.ico'])
require(re.fullmatch(r'(?:@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*', name))
for version in [item['from'], item['to']]:
require(re.fullmatch(r'(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)', version))
before, after = item['from'].split('.'), item['to'].split('.')
require(before[:2] == after[:2])
require((len(before[2]), before[2]) < (len(after[2]), after[2]))
names.append(name)
require(names == sorted(set(names)))
with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as summary:
summary.write('### Infrastructure pilot input receipt\n\n')
summary.write('No PR code was checked out. No dependency files or repository contents were changed.\n\n')
summary.write('```json\n' + json.dumps({
'request-id': os.environ['REQUEST_ID'],
'pull-request': os.environ['PULL_REQUEST'],
'expected-head': os.environ['EXPECTED_HEAD'],
'expected-base': os.environ['EXPECTED_BASE'],
'selections': items,
}, indent=2) + '\n```\n')
# Controlled infrastructure probes exist only in this harmless fixture.
if 'pilot-failure' in names:
raise SystemExit('Requested pilot failure')
if 'pilot-cancellation' in names:
time.sleep(120)
120 changes: 120 additions & 0 deletions repair-app/runtime-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,123 @@ try {
} finally {
await service.dispose();
}

// A separate native Worker instance uses an actual local D1 binding. These
// constraints and concurrent admissions are not simulated with an object map.
const {readFileSync} = await import('node:fs');
const {createHash, randomUUID} = await import('node:crypto');
let nativeItems = [{name: 'launch-editor', from: '2.14.1', to: '2.14.2'}];
const nativePR = {number: 12, state: 'open', user: {type: 'Bot'},
body: '<!-- node-repair:selection:v1 -->\n- [x] Allow `launch-editor` `2.14.1 → 2.14.2` by updating its explicit pin.\n<!-- /node-repair:selection -->',
head: {sha: 'a'.repeat(40), ref: 'maintenance/node-security', repo: {id: 7}},
base: {sha: 'b'.repeat(40), ref: 'main', repo: {id: 7}}};
let nativeFingerprint = createHash('sha256').update(JSON.stringify([12, nativePR.head.sha, nativePR.base.sha, nativeItems])).digest('hex');
const nativeCheck = {id: 77, app: {id: 42}, name: 'Dependency repair', head_sha: nativePR.head.sha,
external_id: `node-repair:12:${nativeFingerprint}`, status: 'completed', conclusion: 'neutral'};
const documentedCheck = value => { const response = {...value}; delete response.actions; return response; };
const nativeCalls = [];
const admissionService = new Miniflare(convertV4MiniflareOptions({
modules: true, scriptPath: new URL('./worker.mjs', import.meta.url).pathname,
compatibilityDate: '2026-10-19', d1Databases: ['DB'],
bindings: {APP_ID: '42', APP_PRIVATE_KEY: keys.privateKey, WEBHOOK_SECRET: secret},
outboundService: async request => {
const url = new URL(request.url), path = url.pathname;
const body = request.method === 'POST' || request.method === 'PATCH' ? await request.json() : null;
assert.equal(url.origin, 'https://api.github.com');
assert.equal(request.headers.get('X-GitHub-Api-Version'), '2026-03-10');
nativeCalls.push({method: request.method, path, body});
if (path === '/app/installations/3/access_tokens') {
assert.deepEqual(body.repository_ids, [7]);
assert.equal(body.permissions.contents, 'read');
assert.equal(body.permissions.checks, 'write');
return Response.json({token: 'ghs_42.native-action-token'});
}
assert.equal(request.headers.get('authorization'), 'Bearer ghs_42.native-action-token');
if (path === '/repos/fixture/repo') return Response.json({id: 7, full_name: 'fixture/repo', default_branch: 'main'});
if (path === '/repos/fixture/repo/pulls/12') return Response.json(nativePR);
if (path === '/repos/fixture/repo/collaborators/maintainer/permission') return Response.json({permission: 'maintain', user: {login: 'maintainer'}});
if (path === '/repos/fixture/repo/check-runs/77') {
if (request.method === 'PATCH') Object.assign(nativeCheck, body);
return Response.json(request.method === 'GET' ? documentedCheck(nativeCheck) : nativeCheck);
}
if (path.includes('/commits/') && path.endsWith('/check-runs')) return Response.json({total_count: 1, check_runs: [documentedCheck(nativeCheck)]});
assert.equal(path, '/repos/fixture/repo/actions/workflows/dependency-repair.yml/dispatches');
assert.equal(request.method, 'POST');
assert.equal(body.ref, 'main');
assert.deepEqual(Object.keys(body.inputs), ['request-id', 'pull-request', 'expected-head', 'expected-base', 'selections']);
assert.equal(body.inputs['pull-request'], '12');assert.equal(body.inputs['expected-head'], nativePR.head.sha);
assert.equal(body.inputs['expected-base'], nativePR.base.sha);assert.equal(body.inputs.selections, JSON.stringify(nativeItems));
return Response.json({workflow_run_id: 900, run_url: 'https://api.github.com/repos/fixture/repo/actions/runs/900',
html_url: 'https://github.com/fixture/repo/actions/runs/900'});
},
}));
try {
const db = await admissionService.getD1Database('DB');
await db.exec(readFileSync(new URL('./schema.sql', import.meta.url), 'utf8').replaceAll('\n', ' '));
const insert = (id, installation = 3, repository = 7) => db.prepare(`INSERT OR IGNORE INTO requests
(id, installation_id, repository_id, repository, pr, head, base, dispatched_ref, fingerprint, check_id, actor_login, created_at, state)
VALUES (?, ?, ?, 'fixture/repo', 12, ?, ?, 'main', ?, 77, 'maintainer', 1, 'reserved')`)
.bind(id, installation, repository, nativePR.head.sha, nativePR.base.sha, nativeFingerprint).run();
const admitted = await Promise.all(Array.from({length: 12}, () => insert(randomUUID())));
assert.equal(admitted.reduce((sum, r) => sum + r.meta.changes, 0), 1);
const winner = await db.prepare('SELECT id FROM requests').first();
assert.equal((await insert(winner.id)).meta.changes, 0);
assert.equal((await insert(randomUUID(), 4)).meta.changes, 1);
assert.equal((await insert(randomUUID(), 3, 8)).meta.changes, 1);
await db.prepare("UPDATE requests SET state = 'unknown' WHERE id = ?").bind(winner.id).run();
assert.equal((await insert(randomUUID())).meta.changes, 0);
await db.exec('DELETE FROM requests');
const click = (id, checkRun = nativeCheck) => {
const input = signed({action: 'requested_action', requested_action: {identifier: 'repair-selected'},
installation: {id: 3}, repository: {id: 7, full_name: 'fixture/repo'},
check_run: {...checkRun, app: {...checkRun.app}}, sender: {type: 'User', login: 'maintainer'}}, 'check_run');
input.headers['x-github-delivery'] = id;
return admissionService.dispatchFetch('https://worker.test/webhook', input);
};
const first = randomUUID();
assert.deepEqual((await Promise.all([click(first), click(randomUUID())])).map(r => r.status), [200, 200]);
assert.equal((await click(first)).status, 200);
const rows = await db.prepare('SELECT * FROM requests').all();
assert.equal(rows.results.length, 1);assert.equal(rows.results[0].state, 'dispatched');assert.equal(rows.results[0].run_id, 900);
assert.equal(nativeCalls.filter(c => c.path.endsWith('/dispatches')).length, 1);
assert.equal(nativeCheck.external_id, `node-repair-request:${rows.results[0].id}`);
assert.equal(nativeCheck.status, 'in_progress');assert.deepEqual(nativeCheck.actions, []);

// A queued A click must not admit the refreshed B selection or a changed base.
await db.exec('DELETE FROM requests');
nativePR.body = nativePR.body.replace('2.14.2', '2.14.3');
nativeItems = [{name: 'launch-editor', from: '2.14.1', to: '2.14.3'}];
nativeFingerprint = createHash('sha256').update(JSON.stringify([12, nativePR.head.sha, nativePR.base.sha, nativeItems])).digest('hex');
Object.assign(nativeCheck, {external_id: `node-repair:12:${nativeFingerprint}`, status: 'completed', conclusion: 'neutral', actions: []});
const queuedA = {id: nativeCheck.id, app: {...nativeCheck.app}, name: nativeCheck.name,
head_sha: nativeCheck.head_sha, external_id: `node-repair:12:${createHash('sha256').update(JSON.stringify([12, nativePR.head.sha, 'b'.repeat(40), [{name: 'launch-editor', from: '2.14.1', to: '2.14.2'}]])).digest('hex')}`,
status: 'completed', conclusion: 'neutral'};
const staleSelection = await click(randomUUID(), queuedA);
assert.equal(staleSelection.status, 200);
assert.equal((await db.prepare('SELECT * FROM requests').all()).results.length, 0);
assert.equal(nativeCalls.filter(c => c.path.endsWith('/dispatches')).length, 1);

const queuedB = {...nativeCheck, app: {...nativeCheck.app}};
nativePR.base.sha = 'c'.repeat(40);
nativeFingerprint = createHash('sha256').update(JSON.stringify([12, nativePR.head.sha, nativePR.base.sha, nativeItems])).digest('hex');
Object.assign(nativeCheck, {external_id: `node-repair:12:${nativeFingerprint}`, status: 'completed', conclusion: 'neutral', actions: []});
const staleBase = await click(randomUUID(), queuedB);
assert.equal(staleBase.status, 200);
assert.equal((await db.prepare('SELECT * FROM requests').all()).results.length, 0);
assert.equal(nativeCalls.filter(c => c.path.endsWith('/dispatches')).length, 1);

const missingIdentity = {...queuedB};
delete missingIdentity.head_sha; delete missingIdentity.external_id;
assert.equal((await click(randomUUID(), missingIdentity)).status, 200);
assert.equal((await db.prepare('SELECT * FROM requests').all()).results.length, 0);

const fresh = await click(randomUUID(), {...nativeCheck, app: {...nativeCheck.app}});
assert.equal(fresh.status, 200);
const freshRows = await db.prepare('SELECT * FROM requests').all();
assert.equal(freshRows.results.length, 1);
assert.equal(nativeCalls.filter(c => c.path.endsWith('/dispatches')).length, 2);
assert.equal(JSON.parse(nativeCalls.findLast(c => c.path.endsWith('/dispatches')).body.inputs.selections)[0].to, '2.14.3');
console.log('Native D1 passed: concurrent atomic admission, UUID replay, scoped active locks, ambiguous-lock retention, complete clicked Check identity binding across stale selection/base changes, missing identity rejection, and one fresh exact dispatch.');
} finally {
await admissionService.dispose();
}
23 changes: 23 additions & 0 deletions repair-app/schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
CREATE TABLE IF NOT EXISTS requests (
id TEXT PRIMARY KEY,
installation_id INTEGER NOT NULL CHECK (installation_id > 0),
repository_id INTEGER NOT NULL CHECK (repository_id > 0),
repository TEXT NOT NULL,
pr INTEGER NOT NULL CHECK (pr > 0),
head TEXT NOT NULL,
base TEXT NOT NULL,
dispatched_ref TEXT NOT NULL,
fingerprint TEXT NOT NULL,
check_id INTEGER NOT NULL CHECK (check_id > 0),
actor_login TEXT NOT NULL,
created_at INTEGER NOT NULL,
completed_at INTEGER,
state TEXT NOT NULL CHECK (state IN ('reserved', 'dispatched', 'unknown', 'success', 'failure', 'cancelled')),
run_id INTEGER CHECK (run_id > 0),
run_url TEXT,
html_url TEXT,
conclusion TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS one_active_request
ON requests (installation_id, repository_id, pr)
WHERE state IN ('reserved', 'dispatched', 'unknown');
Loading
Loading