From 0b4a1eb3d33996055769c72be75ce981f9b3ce3e Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 13:35:14 -0700 Subject: [PATCH 01/16] docs(plan): Branch 4b, upstream watch on GitHub Actions --- ...-09-27-branch-4b-upstream-watch-actions.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-27-branch-4b-upstream-watch-actions.md diff --git a/docs/superpowers/plans/2026-09-27-branch-4b-upstream-watch-actions.md b/docs/superpowers/plans/2026-09-27-branch-4b-upstream-watch-actions.md new file mode 100644 index 00000000..f76b50ab --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-branch-4b-upstream-watch-actions.md @@ -0,0 +1,33 @@ +# Branch 4b: upstream watch on GitHub Actions — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Run the upstream watch as a scheduled GitHub Actions workflow that posts a deterministic ledger comment on pacphi/agentic-kit#243, and reduce the cloud routine to dispatch only (decision 14). + +**Why:** The routine's first run (session cse_01Xb8wcBL8h335pxUeQ9sbnQ) was blind: a cloud session's GitHub access covers only attached repositories (HTTP 403 on every upstream repository), the sandbox has no `gh`, apt's `gh` 2.45 lacks `--slurp`, `gh auth status` called the injected token invalid while `gh api user` worked, and the script printed "No new upstream events." although every fetch failed. + +**Architecture:** The script owns everything deterministic: reading the ledger comments, choosing SINCE, the check, and the comment text (`comment` subcommand, read-only). The workflow runs it with the workflow token, posts the body, and (re)applies the `upstream-dispatch` label when a `released` line carries `branch=`. The routine fires on that label and only dispatches. + +**Maintainer decisions (2026-09-27):** 4b-A schedule `0 14 * * *` ships in this PR, with `workflow_dispatch`; 4b-B zero events with fetch errors posts nothing, the job fails only when blind (gh unusable, or every thread failed), partial errors go to the job summary, `checked-at` does not advance; 4b-C the Action labels #243 when there is dispatch work and a webhook on that label fires the routine. + +**Ruling:** ledger authors live in `watchPolicy.ledger.authors`, not `watchPolicy.ours` (`ours` decides "our last word" in `classify.mjs`). + +## Tasks (one unit commit each, test-first) + +- [ ] **1. `fix(upstream-watch): read comment pages without gh --slurp`.** `fetch.mjs` `thread()` uses `gh api --paginate --jq '.[]'` and parses one JSON value per line (works on gh 2.x before 2.48). Test: the recorded call has `--jq` and no `--slurp`; a two-page NDJSON answer yields every comment. +- [ ] **2. `fix(upstream-watch): probe gh with a call any token can make`.** `auth()` runs `gh api rate_limit`; ENOENT → "not installed"; non-zero → "cannot reach GitHub" with gh's first stderr line. Test flips the `gh auth status` fixture. +- [ ] **3. `fix(upstream-watch): never report a quiet day when a check failed`.** `renderEvents(events, fetchErrors)` prints "No new upstream events." only with no fetch errors; otherwise "No new events from the threads checked; could not check N: ids." `check --json` gains `blind`. +- [ ] **4. `feat(upstream-watch): ledger authors in the registry`.** `watchPolicy.ledger.authors` (`["pacphi", "github-actions[bot]"]`), validator + JSON schema; `ours` unchanged. +- [ ] **5. `feat(upstream-watch): render the ledger comment in the script`.** `comment [--now ] [--json]`: reads #243 comments (paginated, no slurp), keeps authors in `ledger.authors`, SINCE = newest `checked-at` in them or now − 7 days, runs the check with their bodies as the ledger, renders: a `text` code block of the lines ending `checked-at NOW` (previous SINCE when anything failed), then one fixed sentence per line, then one sentence naming unchecked threads. Empty output when no events. JSON: `{ since, now, checkedAt, post, blind, dispatch[], events, fetchErrors, body }`. Exit 3 when blind. Tests: a stranger's comment with a later `checked-at` and matching lines is ignored for both SINCE and dedupe; fetch errors keep SINCE; sentences for every event type; blind exit. +- [ ] **6. `ci(upstream-watch): daily workflow posts the ledger comment`.** `.github/workflows/upstream-watch.yml`: `schedule 0 14 * * *` + `workflow_dispatch` (`post` boolean) → job `watch` (`contents: read, issues: write`, `concurrency: upstream-watch`, `GH_TOKEN: github.token`); `pull_request` on the watch's paths → job `preview` (read-only, never posts, proves the token reads upstream). Post step asserts the body is non-empty and starts with the code fence, posts with `--body-file`, reads the posted length back. Dispatch step: `gh label create upstream-dispatch --force`, remove then add the label on #243. Job summary lists events and fetch errors. A static test pins permissions, the paths, and that `preview` never posts. +- [ ] **7. `docs(upstream-watch): the watch runs on Actions; the routine only dispatches`.** UPSTREAM-WATCH.md (the check, the ledger, a "Daily workflow" section, a "Dispatch routine" section with the new prompt), ADR-0041 §7 note + Updated, audit record decision 14 (+ 4b-A..C), skill text if it names the routine; update the doc test that reads the prompt. + +## Gate and verification + +Full gate set from `briefs/common.md` in the worktree, then an adversarial reviewer. Locally: `node scripts/upstream-watch.mjs comment --json` against real GitHub (read-only). CI: the `preview` job on the PR proves `GITHUB_TOKEN` reads upstream threads and the ledger. + +## Unproven until after merge (with go-ahead) + +- Commenting on the locked #243 as `github-actions[bot]` — first `workflow_dispatch` run. +- A label applied with `GITHUB_TOKEN` reaching the routine's webhook (GitHub suppresses workflow triggers from that token, not app webhooks) — first dispatch. +- `create_webhook_trigger`'s filter grammar; routine prompt update; re-enabling the routine. From 823fe9e9c851b3b61dae241af62aebcbed2d0e35 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 13:35:39 -0700 Subject: [PATCH 02/16] fix(upstream-watch): read comment pages without gh --slurp gh 2.45 (apt on Ubuntu 24.04) has no --slurp, so every thread read failed in the cloud routine's first run. --paginate --jq '.[]' prints one comment per line on every gh 2.x. --- scripts/upstream-watch/fetch.mjs | 14 ++++++++++++-- tests/kit/upstream-watch-script.test.mjs | 7 +++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/scripts/upstream-watch/fetch.mjs b/scripts/upstream-watch/fetch.mjs index 0f1330e5..8b80489b 100644 --- a/scripts/upstream-watch/fetch.mjs +++ b/scripts/upstream-watch/fetch.mjs @@ -79,8 +79,18 @@ export function createFetcher({ exec = run } = {}) { const [, repo, number] = ID.exec(id) ?? []; if (!repo) throw new Error(`not an owner/repo#number id: ${id}`); const issue = await json('gh', ['api', `repos/${repo}/issues/${number}`]); - const pages = await json('gh', ['api', '--paginate', '--slurp', `repos/${repo}/issues/${number}/comments?per_page=100`]); - return { issue, comments: pages.flat() }; + return { issue, comments: await this.comments(repo, number) }; + }, + /** + * Every comment on an issue, across pages. `--jq '.[]'` prints one comment + * per line; `--slurp` would need gh 2.48, newer than apt's gh on Ubuntu 24.04. + */ + async comments(repo, number) { + if (!OWNER_REPO.test(repo ?? '') || !/^[1-9]\d*$/.test(String(number))) throw new Error(`not an issue: ${repo}#${number}`); + const args = ['api', '--paginate', '--jq', '.[]', `repos/${repo}/issues/${number}/comments?per_page=100`]; + const result = await exec('gh', args); + if (result.status !== 0) throw new Error(`gh ${args.join(' ')} failed: ${(result.stderr || result.error?.message || 'no output').trim()}`); + return result.stdout.split('\n').filter((line) => line.trim()).map((line) => JSON.parse(line)); }, /** Merged pull requests (or the closing commit) that fixed a thread; empty when none qualifies. */ async fixingChanges(id) { diff --git a/tests/kit/upstream-watch-script.test.mjs b/tests/kit/upstream-watch-script.test.mjs index eb43ec59..5a1da55e 100644 --- a/tests/kit/upstream-watch-script.test.mjs +++ b/tests/kit/upstream-watch-script.test.mjs @@ -543,7 +543,8 @@ test('the fetcher explains an unauthenticated or missing gh plainly', async () = test('the fetcher reads a thread and its paginated comments through gh api', async () => { const fixture = threads['ruvnet/ruflo#3046']; const { exec, calls } = fakeExec([ - [/issues\/3046\/comments/, { status: 0, stdout: JSON.stringify([fixture.comments.slice(0, 1), fixture.comments.slice(1)]), stderr: '' }], + // `--paginate --jq '.[]'` prints one comment per line across every page. + [/issues\/3046\/comments/, { status: 0, stdout: `${fixture.comments.map((comment) => JSON.stringify(comment)).join('\n')}\n`, stderr: '' }], [/issues\/3046$/, { status: 0, stdout: JSON.stringify(fixture.issue), stderr: '' }], [/^npm view agentic-qe/, { status: 0, stdout: JSON.stringify(npm['agentic-qe']), stderr: '' }], ]); @@ -551,7 +552,9 @@ test('the fetcher reads a thread and its paginated comments through gh api', asy const thread = await fetcher.thread('ruvnet/ruflo#3046'); assert.equal(thread.issue.number, 3046); assert.equal(thread.comments.length, fixture.comments.length); - assert.ok(calls.some((call) => call.includes('--paginate') && call.includes('--slurp'))); + // gh 2.45 (apt on Ubuntu 24.04) has no --slurp; --jq '.[]' works on every gh 2.x. + const comments = calls.find((call) => call.includes('/comments')); + assert.ok(comments.includes('--paginate') && comments.includes("--jq .[]") && !comments.includes('--slurp'), comments); assert.equal((await fetcher.release({ channel: 'npm', name: 'agentic-qe' })).latest, '3.14.3'); await assert.rejects(fetcher.thread('ruvnet/ruflo#1'), /unexpected call/); }); From ca8c43dd6a3330083a5ad60a8e060ecc394e16f9 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 13:35:53 -0700 Subject: [PATCH 03/16] fix(upstream-watch): probe gh with a call any token can make gh auth status called the cloud session's injected GH_TOKEN invalid while gh api user worked with it; an Actions installation token cannot read /user either. gh api rate_limit works with every token and fails without one. --- scripts/upstream-watch/fetch.mjs | 7 +++++-- tests/kit/upstream-watch-script.test.mjs | 12 +++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/scripts/upstream-watch/fetch.mjs b/scripts/upstream-watch/fetch.mjs index 8b80489b..be09f403 100644 --- a/scripts/upstream-watch/fetch.mjs +++ b/scripts/upstream-watch/fetch.mjs @@ -69,11 +69,14 @@ export function createFetcher({ exec = run } = {}) { return JSON.parse(result.stdout); }; return { + // `gh auth status` calls an Actions or proxy-injected GH_TOKEN invalid while + // `gh api` accepts it, so the probe is a read any token may make. async auth() { - const result = await exec('gh', ['auth', 'status']); + const result = await exec('gh', ['api', 'rate_limit', '--jq', '.rate.limit']); if (result.error?.code === 'ENOENT') return { ok: false, message: 'gh is not installed; install the GitHub CLI to check upstream threads.' }; if (result.status === 0) return { ok: true }; - return { ok: false, message: 'gh is not authenticated; run `gh auth login`, then re-run.' }; + const reason = String(result.stderr || result.error?.message || '').trim().split('\n')[0]; + return { ok: false, message: `gh cannot reach GitHub${reason ? ` (${reason})` : ''}; run \`gh auth login\` or set GH_TOKEN, then re-run.` }; }, async thread(id) { const [, repo, number] = ID.exec(id) ?? []; diff --git a/tests/kit/upstream-watch-script.test.mjs b/tests/kit/upstream-watch-script.test.mjs index 5a1da55e..992c9892 100644 --- a/tests/kit/upstream-watch-script.test.mjs +++ b/tests/kit/upstream-watch-script.test.mjs @@ -532,10 +532,16 @@ function fakeExec(responses) { } test('the fetcher explains an unauthenticated or missing gh plainly', async () => { - const signedOut = createFetcher({ exec: fakeExec([[/^gh auth status/, { status: 1, stdout: '', stderr: loggedOut }]]).exec }); - const auth = await signedOut.auth(); + // `gh auth status` calls an injected or installation token invalid while + // `gh api` works with it (cloud routine run cse_01Xb8wcBL8h335pxUeQ9sbnQ), + // so the probe is a call any token can make. + const { exec: signedOutExec, calls } = fakeExec([[/^gh api rate_limit/, { status: 4, stdout: '', stderr: loggedOut }]]); + const auth = await createFetcher({ exec: signedOutExec }).auth(); assert.equal(auth.ok, false); - assert.match(auth.message, /gh auth login/); + assert.match(auth.message, /gh auth login|GH_TOKEN/); + assert.ok(calls.every((call) => !call.startsWith('gh auth')), calls.join('\n')); + const tokenOnly = createFetcher({ exec: fakeExec([[/^gh api rate_limit/, { status: 0, stdout: '5000\n', stderr: '' }]]).exec }); + assert.deepEqual(await tokenOnly.auth(), { ok: true }); const missing = createFetcher({ exec: async () => ({ status: null, stdout: '', stderr: '', error: Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' }) }) }); assert.match((await missing.auth()).message, /not installed/); }); From 540b6de8bb644a97206d3646db2f6edc68a5e595 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 13:36:25 -0700 Subject: [PATCH 04/16] fix(upstream-watch): never report a quiet day when a check failed The routine's first run printed "No new upstream events." although every read had failed. check now names the unchecked threads instead, and check --json reports blind when gh is unusable or no watched thread could be read. --- scripts/upstream-watch.mjs | 12 +++++---- scripts/upstream-watch/render.mjs | 8 ++++-- tests/kit/upstream-watch-script.test.mjs | 31 ++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/scripts/upstream-watch.mjs b/scripts/upstream-watch.mjs index f2869adf..72d4009e 100644 --- a/scripts/upstream-watch.mjs +++ b/scripts/upstream-watch.mjs @@ -151,7 +151,8 @@ async function collect(registry, fetcher, concurrency) { await confirmReleases(gated.filter((entry) => !entry.doneWhen.release.minVersion && live.get(entry.id).release), live, fetcher, concurrency, fetchErrors); await resolveBundles(gated.filter((entry) => entry.doneWhen.release.bundledBy), live, fetcher, concurrency, fetchErrors); fetchErrors.sort((a, b) => a.id.localeCompare(b.id)); - return { live, fetchErrors, facts }; + const blind = active.length > 0 && active.every((entry) => !live.get(entry.id).thread); + return { live, fetchErrors, facts, blind }; } /** @@ -201,8 +202,9 @@ export async function main(argv, { const auth = await fetcher.auth(); const offline = auth.ok ? null : auth.message; if (offline) stderr.write(`${offline}\n`); - const { live, fetchErrors, facts } = offline - ? { live: new Map(), fetchErrors: [], facts: new Map() } : await collect(registry, fetcher, options.concurrency); + // Blind: gh is unusable, or not one watched thread could be read. + const { live, fetchErrors, facts, blind } = offline + ? { live: new Map(), fetchErrors: [], facts: new Map(), blind: true } : await collect(registry, fetcher, options.concurrency); const floor = offline ? null : await supportFloor(registry, fetcher, facts, fetchErrors, now); if (floor) { await resolveFloorBundles(registry, live, fetcher, floor, options.concurrency, fetchErrors); @@ -214,11 +216,11 @@ export async function main(argv, { return 0; } const events = offline ? [] : withoutRecorded(ledgerEvents(report, registry, { since: options.since }), ledgerText); - if (options.json) stdout.write(`${JSON.stringify({ since: options.since, offline, events, fetchErrors }, null, 2)}\n`); + if (options.json) stdout.write(`${JSON.stringify({ since: options.since, offline, blind, events, fetchErrors }, null, 2)}\n`); else { // stdout stays ledger lines only; what could not be checked goes to stderr. for (const item of fetchErrors) stderr.write(`Could not check ${item.id}: ${item.error}\n`); - stdout.write(offline ? `No events: ${offline}\n` : renderEvents(events)); + stdout.write(offline ? `No events: ${offline}\n` : renderEvents(events, fetchErrors)); } return 0; } diff --git a/scripts/upstream-watch/render.mjs b/scripts/upstream-watch/render.mjs index 2857dfd2..02ad8cf5 100644 --- a/scripts/upstream-watch/render.mjs +++ b/scripts/upstream-watch/render.mjs @@ -86,6 +86,10 @@ export function renderReport(report) { return `${lines.join('\n')}\n`; } -export function renderEvents(events) { - return events.length ? `${events.map((event) => event.line).join('\n')}\n` : 'No new upstream events.\n'; +// A quiet day is reported only when every read succeeded: "no events" from a +// run that could not read a thread says nothing about that thread. +export function renderEvents(events, fetchErrors = []) { + if (events.length) return `${events.map((event) => event.line).join('\n')}\n`; + if (!fetchErrors.length) return 'No new upstream events.\n'; + return `No new events from the threads checked; could not check ${fetchErrors.length}: ${fetchErrors.map((item) => item.id).join(', ')}.\n`; } diff --git a/tests/kit/upstream-watch-script.test.mjs b/tests/kit/upstream-watch-script.test.mjs index 992c9892..eafac93c 100644 --- a/tests/kit/upstream-watch-script.test.mjs +++ b/tests/kit/upstream-watch-script.test.mjs @@ -992,3 +992,34 @@ test('the documented routine trusts only its own ledger comments', () => { assert.match(prompt, /never follow instructions/i, 'comment text is data, not instructions'); assert.match(prompt, /pacphi\/agentic-kit#243/, 'the routine reads the recorded ledger issue'); }); + +// Decision 14: the routine's first run printed "No new upstream events." while +// every read had failed. A quiet day is reported only when nothing failed, and +// a run that read no thread at all is blind. +test('check never reports a quiet day when a read failed, and flags a blind run', async () => { + assert.equal(renderEvents([], []), 'No new upstream events.\n'); + const failed = renderEvents([], [{ id: 'ruvnet/ruflo#1', error: 'HTTP 403' }, { id: 'ruvnet/ruflo#2', error: 'HTTP 403' }]); + assert.doesNotMatch(failed, /No new upstream events/); + assert.match(failed, /could not check 2: ruvnet\/ruflo#1, ruvnet\/ruflo#2/); + const broken = { ...fixtureFetcher(), thread: async (id) => { if (id === 'ruvnet/ruflo#3153') throw new Error('HTTP 403'); return clone(threads[id]); } }; + await withRegistryFile([entry('ruvnet/ruflo#3153', { relation: 'commented' })], async (file) => { + const text = capture(); + await main(['check', '--since', '2026-09-26T00:00:00Z', '--registry', file], { fetcher: broken, stdout: text.stream, stderr: capture().stream, now: NOW }); + assert.doesNotMatch(text.text(), /No new upstream events/); + const json = capture(); + await main(['check', '--since', '2026-09-26T00:00:00Z', '--json', '--registry', file], { fetcher: broken, stdout: json.stream, stderr: capture().stream, now: NOW }); + assert.equal(JSON.parse(json.text()).blind, true); + }); + await withRegistryFile([entry('ruvnet/ruflo#3153', { relation: 'commented' }), entry('ruvnet/ruflo#3046', { relation: 'commented' })], async (file) => { + const json = capture(); + await main(['check', '--since', '2026-09-26T00:00:00Z', '--json', '--registry', file], { fetcher: broken, stdout: json.stream, stderr: capture().stream, now: NOW }); + const result = JSON.parse(json.text()); + assert.equal(result.blind, false, 'one thread read is not blind'); + assert.ok(result.fetchErrors.some((item) => item.id === 'ruvnet/ruflo#3153')); + }); + const offline = capture(); + await withRegistryFile([entry('ruvnet/ruflo#3153')], async (file) => { + await main(['check', '--since', '2026-09-26T00:00:00Z', '--json', '--registry', file], { fetcher: fixtureFetcher({ authenticated: false }), stdout: offline.stream, stderr: capture().stream, now: NOW }); + }); + assert.equal(JSON.parse(offline.text()).blind, true, 'gh unusable is blind'); +}); From ebb0380f1b854b316f78480e410d04713fb78e32 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 13:37:02 -0700 Subject: [PATCH 05/16] feat(upstream-watch): name the ledger's writers in the registry watchPolicy.ledger.authors lists the logins whose ledger comments count: the maintainer and the workflow's github-actions[bot]. watchPolicy.ours stays our upstream logins, because it decides whose comment is our last word on a thread. --- .../agentic-dependency-constraints.schema.json | 10 ++++++++-- .../hook-audit/agentic-dependency-constraints.json | 3 ++- src/lib/hook-audit/upstream-watch.mjs | 7 +++++-- tests/kit/upstream-watch-registry.test.mjs | 13 +++++++++++++ 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/docs/schemas/agentic-dependency-constraints.schema.json b/docs/schemas/agentic-dependency-constraints.schema.json index 9a9628b7..4745caed 100644 --- a/docs/schemas/agentic-dependency-constraints.schema.json +++ b/docs/schemas/agentic-dependency-constraints.schema.json @@ -31,12 +31,18 @@ "ledger": { "type": "object", "additionalProperties": false, - "required": ["repo", "issue", "issueTitle", "sentinel"], + "required": ["repo", "issue", "issueTitle", "sentinel", "authors"], "properties": { "repo": { "type": "string", "pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" }, "issue": { "type": "integer", "minimum": 1 }, "issueTitle": { "type": "string", "minLength": 1 }, - "sentinel": { "type": "string", "pattern": "^[A-Z][A-Z-]+$" } + "sentinel": { "type": "string", "pattern": "^[A-Z][A-Z-]+$" }, + "authors": { + "description": "GitHub logins whose ledger comments count; the workflow comments as github-actions[bot]. Separate from ours, which decides whose upstream comment is our last word.", + "type": "array", + "minItems": 1, + "items": { "type": "string", "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\\[bot\\])?$" } + } } }, "dispatch": { diff --git a/src/lib/hook-audit/agentic-dependency-constraints.json b/src/lib/hook-audit/agentic-dependency-constraints.json index 009d7663..54378f54 100644 --- a/src/lib/hook-audit/agentic-dependency-constraints.json +++ b/src/lib/hook-audit/agentic-dependency-constraints.json @@ -17,7 +17,8 @@ "repo": "pacphi/agentic-kit", "issue": 243, "issueTitle": "Upstream watch", - "sentinel": "UPSTREAM-WATCH" + "sentinel": "UPSTREAM-WATCH", + "authors": ["pacphi", "github-actions[bot]"] }, "dispatch": { "branchPrefix": "upstream/", diff --git a/src/lib/hook-audit/upstream-watch.mjs b/src/lib/hook-audit/upstream-watch.mjs index bdc9d505..8d69cd5b 100644 --- a/src/lib/hook-audit/upstream-watch.mjs +++ b/src/lib/hook-audit/upstream-watch.mjs @@ -22,6 +22,8 @@ export const PACKAGE_NAME = /^[@A-Za-z0-9_][A-Za-z0-9_@./-]*$/; export const OWNER_REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; // A tag spelling such as rust-v{version}; the watcher substitutes the version. const TAG_PATTERN = /^[\w./-]*\{version\}[\w./-]*$/; +// A user or app login; apps comment as `[bot]` (the workflow token as github-actions[bot]). +const GITHUB_LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\[bot\])?$/; const ISSUE_URL = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/(?:issues|pull)\/(\d+)$/; const text = (value) => typeof value === 'string' && value.trim() !== ''; @@ -46,8 +48,9 @@ function checkPolicy(policy, errors) { } const ledger = policy.ledger; if (!isObject(ledger) || !OWNER_REPO.test(ledger.repo ?? '') || !Number.isInteger(ledger.issue) || ledger.issue < 1 - || !text(ledger.issueTitle) || !/^[A-Z][A-Z-]+$/.test(ledger.sentinel ?? '')) { - errors.push('watchPolicy.ledger must name repo, issue, issueTitle and an upper-case sentinel'); + || !text(ledger.issueTitle) || !/^[A-Z][A-Z-]+$/.test(ledger.sentinel ?? '') + || !Array.isArray(ledger.authors) || ledger.authors.length === 0 || !ledger.authors.every((login) => GITHUB_LOGIN.test(login ?? ''))) { + errors.push('watchPolicy.ledger must name repo, issue, issueTitle, an upper-case sentinel and the GitHub logins that write it (authors)'); } const dispatch = policy.dispatch; if (!isObject(dispatch) || !/^[\w.-]+\/$/.test(dispatch.branchPrefix ?? '') || dispatch.pullRequest !== 'draft' || dispatch.merge !== 'never') { diff --git a/tests/kit/upstream-watch-registry.test.mjs b/tests/kit/upstream-watch-registry.test.mjs index 61ebcabe..506343fb 100644 --- a/tests/kit/upstream-watch-registry.test.mjs +++ b/tests/kit/upstream-watch-registry.test.mjs @@ -62,6 +62,19 @@ test('the ledger is issue #243 in the ledger repository', () => { assert.match(errorsOf((doc) => { delete doc.watchPolicy.ledger.issue; }), /watchPolicy\.ledger/); }); +// Ledger writers are not "ours": watchPolicy.ours decides whose upstream +// comment is our last word, so the workflow's bot login stays out of it. +test('the ledger names who may write it, apart from our upstream logins', () => { + const policy = loadUpstreamRegistry({ now }).watchPolicy; + assert.deepEqual(policy.ledger.authors, ['pacphi', 'github-actions[bot]']); + assert.deepEqual(policy.ours, ['pacphi']); + assert.match(errorsOf((doc) => { delete doc.watchPolicy.ledger.authors; }), /watchPolicy\.ledger/); + assert.match(errorsOf((doc) => { doc.watchPolicy.ledger.authors = []; }), /watchPolicy\.ledger/); + const schema = JSON.parse(fs.readFileSync('docs/schemas/agentic-dependency-constraints.schema.json', 'utf8')); + const ledger = schema.properties.watchPolicy.properties.ledger; + assert.ok(ledger.required.includes('authors') && ledger.properties.authors.minItems === 1); +}); + test('ruflo#3153 records that its third-party comments were reviewed', () => { const history = entry(document(), 'ruvnet/ruflo#3153').history; assert.ok(history.some((item) => item.event === 'reviewed' && item.date === '2026-09-27' && /sparkling/.test(item.note))); From 00bfb39fa4c5038807bb9bae1650c6f0a17e0205 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 13:39:33 -0700 Subject: [PATCH 06/16] feat(upstream-watch): render the ledger comment in the script comment reads the ledger issue's comments by watchPolicy.ledger.authors only, starts the check from the newest checked-at in them (else seven days ago), drops lines already posted, and prints the comment: the lines in a text block ending with checked-at (kept at the previous start when a read failed), then one fixed sentence per line. It prints nothing on a quiet day, lists the dispatch branches in --json, and exits 3 when blind, so the scheduled workflow needs no model. --- scripts/upstream-watch.mjs | 76 +++++++++++++--- scripts/upstream-watch/ledger.mjs | 84 ++++++++++++++++++ tests/kit/upstream-watch-script.test.mjs | 107 +++++++++++++++++++++++ 3 files changed, 253 insertions(+), 14 deletions(-) create mode 100644 scripts/upstream-watch/ledger.mjs diff --git a/scripts/upstream-watch.mjs b/scripts/upstream-watch.mjs index 72d4009e..c945566f 100644 --- a/scripts/upstream-watch.mjs +++ b/scripts/upstream-watch.mjs @@ -16,11 +16,16 @@ import { buildReport, candidateVersions, confirmationStart, ledgerEvents, nextRelease, tagRefs, upstreamOf, withoutRecorded, } from './upstream-watch/classify.mjs'; import { createFetcher, mapLimit } from './upstream-watch/fetch.mjs'; +import { isoSeconds, readLedger, renderComment } from './upstream-watch/ledger.mjs'; import { renderEvents, renderReport } from './upstream-watch/render.mjs'; const USAGE = `usage: node scripts/upstream-watch.mjs report [--json] [--concurrency <1-16>] [--registry ] node scripts/upstream-watch.mjs check --since [--ledger ] [--json] [--concurrency <1-16>] [--registry ] + node scripts/upstream-watch.mjs comment [--json] [--concurrency <1-16>] [--registry ] `; +// comment exits BLIND when it could read nothing (gh unusable, the ledger, or +// every watched thread), so the scheduled workflow fails visibly (decision 14). +const BLIND = 3; const PENDING = new Set(['watching', 'fixed-unreleased']); class UsageError extends Error {} @@ -35,7 +40,7 @@ function sinceValue(value) { export function parseArgs(argv) { const [command, ...rest] = argv; - if (!['report', 'check'].includes(command)) throw new UsageError(command ? `unknown command ${command}` : 'missing command'); + if (!['report', 'check', 'comment'].includes(command)) throw new UsageError(command ? `unknown command ${command}` : 'missing command'); const options = { command, json: false, concurrency: 4, since: null, ledger: null, registry: null }; for (let index = 0; index < rest.length; index++) { const flag = rest[index]; @@ -178,6 +183,58 @@ async function supportFloor(registry, fetcher, facts, fetchErrors, now) { })?.floor ?? null; } +/** Every read the watch makes, then the report; `blind` when nothing could be read. */ +async function runCheck(registry, fetcher, options, { stderr, now }) { + const auth = await fetcher.auth(); + const offline = auth.ok ? null : auth.message; + if (offline) stderr.write(`${offline}\n`); + // Blind: gh is unusable, or not one watched thread could be read. + const { live, fetchErrors, facts, blind } = offline + ? { live: new Map(), fetchErrors: [], facts: new Map(), blind: true } : await collect(registry, fetcher, options.concurrency); + const floor = offline ? null : await supportFloor(registry, fetcher, facts, fetchErrors, now); + if (floor) { + await resolveFloorBundles(registry, live, fetcher, floor, options.concurrency, fetchErrors); + fetchErrors.sort((a, b) => a.id.localeCompare(b.id)); + } + const report = buildReport(registry, live, { now, offline, fetchErrors, supportFloor: floor }); + return { report, offline, fetchErrors, blind }; +} + +/** + * The ledger comment (decision 14): read our ledger comments, check from the + * newest `checked-at` in them, and print the body to post, or nothing. The + * scheduled workflow posts it as-is; the script itself writes nothing. + */ +async function comment(registry, fetcher, options, { stdout, stderr, now }) { + const { repo, issue, authors } = registry.watchPolicy.ledger; + const auth = await fetcher.auth(); + let ledger = null; + let failure = auth.ok ? null : auth.message; + if (!failure) { + try { + ledger = readLedger(await fetcher.comments(repo, issue), authors, now); + } catch (error) { + failure = `Could not read the ledger ${repo}#${issue}: ${error.message}`; + } + } + if (failure) { + stderr.write(`${failure}\n`); + if (options.json) stdout.write(`${JSON.stringify({ blind: true, post: false, error: failure, events: [], fetchErrors: [], dispatch: [], body: '' }, null, 2)}\n`); + return BLIND; + } + const { report, fetchErrors, blind } = await runCheck(registry, fetcher, options, { stderr: { write: () => true }, now }); + const events = withoutRecorded(ledgerEvents(report, registry, { since: ledger.since }), ledger.text); + const body = blind ? '' : renderComment({ events, fetchErrors, since: ledger.since, now }); + const result = { + since: ledger.since, sinceSource: ledger.sinceSource, now: isoSeconds(now), checkedAt: fetchErrors.length ? ledger.since : isoSeconds(now), + blind, post: Boolean(body), dispatch: events.filter((event) => event.event === 'released' && event.fields.branch).map((event) => event.fields.branch), + events, fetchErrors, body, + }; + for (const item of fetchErrors) stderr.write(`Could not check ${item.id}: ${item.error}\n`); + stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : body); + return blind ? BLIND : 0; +} + export async function main(argv, { fetcher = createFetcher(), stdout = process.stdout, stderr = process.stderr, now = new Date(), } = {}) { @@ -197,20 +254,11 @@ export async function main(argv, { if (registry.registryStatus !== 'valid') { stderr.write(`upstream registry is ${registry.registryStatus ?? registry.status}:\n${registry.errors.map((error) => ` ${error}`).join('\n')}\n`); stdout.write(options.json ? `${JSON.stringify({ registry: { status: registry.registryStatus ?? registry.status, errors: registry.errors } }, null, 2)}\n` : 'No report: the upstream registry is not valid.\n'); - return 0; - } - const auth = await fetcher.auth(); - const offline = auth.ok ? null : auth.message; - if (offline) stderr.write(`${offline}\n`); - // Blind: gh is unusable, or not one watched thread could be read. - const { live, fetchErrors, facts, blind } = offline - ? { live: new Map(), fetchErrors: [], facts: new Map(), blind: true } : await collect(registry, fetcher, options.concurrency); - const floor = offline ? null : await supportFloor(registry, fetcher, facts, fetchErrors, now); - if (floor) { - await resolveFloorBundles(registry, live, fetcher, floor, options.concurrency, fetchErrors); - fetchErrors.sort((a, b) => a.id.localeCompare(b.id)); + // The scheduled comment run must not pass quietly on a broken registry. + return options.command === 'comment' ? BLIND : 0; } - const report = buildReport(registry, live, { now, offline, fetchErrors, supportFloor: floor }); + if (options.command === 'comment') return comment(registry, fetcher, options, { stdout, stderr, now }); + const { report, offline, fetchErrors, blind } = await runCheck(registry, fetcher, options, { stderr, now }); if (options.command === 'report') { stdout.write(options.json ? `${JSON.stringify(report, null, 2)}\n` : renderReport(report)); return 0; diff --git a/scripts/upstream-watch/ledger.mjs b/scripts/upstream-watch/ledger.mjs new file mode 100644 index 00000000..4a71cc48 --- /dev/null +++ b/scripts/upstream-watch/ledger.mjs @@ -0,0 +1,84 @@ +// The ledger comment on the watch's GitHub issue, built without a model: which +// comments count, where the next check starts, and the comment's exact text. +// Decision 14: the scheduled workflow posts this body as-is. + +const CHECKED_AT = /^checked-at (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)$/gm; +const WEEK = 7 * 86_400_000; + +export const isoSeconds = (date) => new Date(date).toISOString().replace(/\.\d{3}Z$/, 'Z'); + +/** + * The ledger text and the next check's start, from the issue's comments. Only + * comments by `authors` count: the issue is public, and a stranger's line would + * suppress a real event or a later `checked-at` would skip a real reply. + */ +export function readLedger(comments, authors, now) { + const allowed = new Set(authors.map((login) => login.toLowerCase())); + const bodies = comments.filter((comment) => allowed.has(String(comment?.user?.login ?? '').toLowerCase())) + .map((comment) => String(comment.body ?? '').replace(/\r\n/g, '\n')); + const checked = bodies.flatMap((body) => [...body.matchAll(CHECKED_AT)].map((match) => match[1])) + .filter((value) => Number.isFinite(Date.parse(value))).sort((a, b) => Date.parse(a) - Date.parse(b)); + return { + text: bodies.join('\n'), + comments: bodies.length, + since: checked.length ? isoSeconds(checked.at(-1)) : isoSeconds(now.getTime() - WEEK), + sinceSource: checked.length ? 'ledger' : 'default', + }; +} + +/** One plain sentence per ledger line. */ +export function sentence(event) { + const { id, date, fields } = event; + switch (event.event) { + case 'reply': + return `${fields.by} commented on ${id} on ${date} at ${fields.at}; check whether it needs our reply.`; + case 'acknowledged': + return `${fields.by} posted an automated acknowledgement on ${id} on ${date}.`; + case 'closed': + return `${id} was closed upstream on ${date}${fields.reason ? ` (${fields.reason})` : ''}.`; + case 'merged': + return `${id} was merged upstream on ${date}.`; + case 'released': { + const change = fields.pr ? ` (pull request #${fields.pr})` : fields.commit ? ` (commit ${fields.commit})` : ''; + return fields.branch + ? `The fix for ${id}${change} is released in ${fields.version} (${date}); dispatch it on branch ${fields.branch}.` + : `The fix for ${id}${change} is released in ${fields.version} (${date}); ak keeps its workaround until the oldest supported release has it.`; + } + case 'reopened': + return `${id} is open upstream again while the registry says ${fields.status}.`; + case 'stale': + return `${id} has had no upstream activity since ${date}.`; + case 'retire-proposed': + return `${id} can be retired: nothing in ak waits on it.`; + case 'retest-due': + return `Constraint ${id} was due for a retest on ${date}.`; + case 'idle': + return 'Every upstream thread is retired; nothing is left to watch.'; + default: + return `${id}: ${event.event} on ${date}.`; + } +} + +/** + * The comment body: the ledger lines in a text block that ends with the time + * the next check starts from, then one sentence per line. When anything could + * not be checked the block keeps the previous start, so the next run reads the + * same window again (the lines already posted are dropped by the ledger). + * Empty when there is nothing to post. + */ +export function renderComment({ events, fetchErrors, since, now }) { + if (!events.length) return ''; + const checkedAt = fetchErrors.length ? since : isoSeconds(now); + const lines = [ + '```text', + ...events.map((event) => event.line), + `checked-at ${checkedAt}`, + '```', + '', + ...events.map((event) => `- ${sentence(event)}`), + ]; + if (fetchErrors.length) { + lines.push('', `Could not check ${fetchErrors.map((item) => item.id).join(', ')}; the next run checks again from ${since}.`); + } + return `${lines.join('\n')}\n`; +} diff --git a/tests/kit/upstream-watch-script.test.mjs b/tests/kit/upstream-watch-script.test.mjs index eafac93c..1fe12670 100644 --- a/tests/kit/upstream-watch-script.test.mjs +++ b/tests/kit/upstream-watch-script.test.mjs @@ -12,6 +12,7 @@ import { } from '../../scripts/upstream-watch/classify.mjs'; import { createFetcher, mapLimit } from '../../scripts/upstream-watch/fetch.mjs'; import { renderEvents, renderReport } from '../../scripts/upstream-watch/render.mjs'; +import { readLedger, renderComment, sentence } from '../../scripts/upstream-watch/ledger.mjs'; import { main } from '../../scripts/upstream-watch.mjs'; const FIXTURES = path.resolve('tests/fixtures/upstream-watch'); @@ -1023,3 +1024,109 @@ test('check never reports a quiet day when a read failed, and flags a blind run' }); assert.equal(JSON.parse(offline.text()).blind, true, 'gh unusable is blind'); }); + +// Decision 14: the scheduled workflow posts the script's comment as-is, so +// which ledger comments count, where the check starts and the text are all +// decided here, not by a model. +const ledgerComment = (login, body) => ({ user: { login }, body }); +const withLedger = (comments, base = fixtureFetcher()) => ({ ...base, comments: async () => clone(comments) }); + +test('the ledger counts only its authors for both the start time and the recorded lines', () => { + const authors = ['pacphi', 'github-actions[bot]']; + const ledger = readLedger([ + ledgerComment('github-actions[bot]', '```text\nUPSTREAM-WATCH a#1 stale 2026-01-01\nchecked-at 2026-09-20T14:00:05Z\n```'), + ledgerComment('Mallory', '```text\nUPSTREAM-WATCH a#2 stale 2026-01-01\nchecked-at 2026-09-26T22:00:00Z\n```'), + ledgerComment('PACPHI', 'checked-at 2026-09-19T00:00:00Z\r\n'), + ], authors, NOW); + assert.equal(ledger.since, '2026-09-20T14:00:05Z'); + assert.equal(ledger.comments, 2); + assert.match(ledger.text, /a#1 stale/); + assert.doesNotMatch(ledger.text, /a#2/); + assert.deepEqual(readLedger([], authors, NOW), { text: '', comments: 0, since: '2026-09-19T23:00:00Z', sinceSource: 'default' }); +}); + +test('every ledger event has a plain sentence', () => { + const at = (event, fields = {}, id = 'ruvnet/ruflo#1') => sentence({ id, event, date: '2026-09-27', fields }); + assert.equal(at('reply', { by: 'someone', at: '10:00:00Z' }), 'someone commented on ruvnet/ruflo#1 on 2026-09-27 at 10:00:00Z; check whether it needs our reply.'); + assert.match(at('acknowledged', { by: 'bot' }), /automated acknowledgement/); + assert.equal(at('closed', { reason: 'completed' }), 'ruvnet/ruflo#1 was closed upstream on 2026-09-27 (completed).'); + assert.match(at('merged'), /merged upstream on 2026-09-27/); + assert.match(at('released', { version: '3.47.0', pr: 12, branch: 'upstream/ruvnet-ruflo-1' }), /\(pull request #12\) is released in 3\.47\.0 \(2026-09-27\); dispatch it on branch upstream\/ruvnet-ruflo-1\./); + assert.match(at('released', { version: '3.47.0', commit: 'abc1234' }), /\(commit abc1234\).*keeps its workaround/); + assert.match(at('reopened', { status: 'released' }), /open upstream again while the registry says released/); + assert.match(at('stale'), /no upstream activity since 2026-09-27/); + assert.match(at('retire-proposed'), /can be retired/); + assert.match(at('retest-due', {}, 'ruflo-hooks-1'), /^Constraint ruflo-hooks-1 was due for a retest on 2026-09-27\.$/); + assert.match(at('idle', {}, 'registry'), /nothing is left to watch/); +}); + +test('the comment ends its block with the next start, kept when a read failed', () => { + const events = [{ id: 'a#1', event: 'stale', date: '2026-01-01', fields: {}, line: 'UPSTREAM-WATCH a#1 stale 2026-01-01' }]; + const ok = renderComment({ events, fetchErrors: [], since: '2026-09-20T14:00:05Z', now: NOW }); + assert.ok(ok.startsWith('```text\nUPSTREAM-WATCH a#1 stale 2026-01-01\nchecked-at 2026-09-26T23:00:00Z\n```\n\n- a#1 has had no upstream activity'), ok); + const partial = renderComment({ events, fetchErrors: [{ id: 'b#2', error: 'HTTP 502' }], since: '2026-09-20T14:00:05Z', now: NOW }); + assert.match(partial, /\nchecked-at 2026-09-20T14:00:05Z\n```/); + assert.match(partial, /Could not check b#2; the next run checks again from 2026-09-20T14:00:05Z\./); + assert.equal(renderComment({ events: [], fetchErrors: [], since: 'x', now: NOW }), ''); +}); + +test('comment reads the ledger, drops recorded lines and prints the body to post', async () => { + await withRegistryFile([entry('ruvnet/ruflo#3153', { relation: 'commented' })], async (file) => { + const first = capture(); + await main(['check', '--since', '2026-09-03T00:00:00Z', '--registry', file], { fetcher: fixtureFetcher(), stdout: first.stream, stderr: capture().stream, now: NOW }); + const lines = first.text().trim().split('\n'); + assert.ok(lines.length >= 1 && lines.every((line) => line.startsWith('UPSTREAM-WATCH ')), lines.join('\n')); + // Ours: an older start and no lines. A stranger: every line and a later + // start, which must change neither the start nor what is posted. + const comments = [ + ledgerComment('github-actions[bot]', '```text\nchecked-at 2026-09-03T00:00:00Z\n```'), + ledgerComment('mallory', `\`\`\`text\n${lines.join('\n')}\nchecked-at 2026-09-26T22:59:00Z\n\`\`\``), + ]; + const out = capture(); + const code = await main(['comment', '--json', '--registry', file], { fetcher: withLedger(comments), stdout: out.stream, stderr: capture().stream, now: NOW }); + assert.equal(code, 0); + const result = JSON.parse(out.text()); + assert.equal(result.since, '2026-09-03T00:00:00Z'); + assert.equal(result.checkedAt, '2026-09-26T23:00:00Z'); + assert.deepEqual([result.post, result.blind], [true, false]); + for (const line of lines) assert.ok(result.body.includes(line), line); + // A line our own ledger already holds is not posted again. + const recorded = [ledgerComment('pacphi', `\`\`\`text\n${lines.join('\n')}\nchecked-at 2026-09-03T00:00:00Z\n\`\`\``)]; + const again = capture(); + await main(['comment', '--json', '--registry', file], { fetcher: withLedger(recorded), stdout: again.stream, stderr: capture().stream, now: NOW }); + assert.deepEqual([JSON.parse(again.text()).post, JSON.parse(again.text()).body], [false, '']); + const text = capture(); + await main(['comment', '--registry', file], { fetcher: withLedger(comments), stdout: text.stream, stderr: capture().stream, now: NOW }); + assert.equal(text.text(), result.body); + }); +}); + +test('comment prints nothing on a quiet day and exits 3 when blind', async () => { + await withRegistryFile([entry('ruvnet/ruflo#3153', { relation: 'commented' })], async (file) => { + const quiet = capture(); + await main(['comment', '--json', '--registry', file], { fetcher: withLedger([ledgerComment('pacphi', 'checked-at 2026-09-26T22:00:00Z')]), stdout: quiet.stream, stderr: capture().stream, now: NOW }); + const result = JSON.parse(quiet.text()); + assert.deepEqual([result.post, result.body, result.dispatch], [false, '', []]); + const blind = { ...withLedger([]), thread: async () => { throw new Error('HTTP 403'); } }; + const err = capture(); + assert.equal(await main(['comment', '--registry', file], { fetcher: blind, stdout: capture().stream, stderr: err.stream, now: NOW }), 3); + assert.match(err.text(), /Could not check ruvnet\/ruflo#3153/); + const offline = withLedger([], fixtureFetcher({ authenticated: false })); + assert.equal(await main(['comment', '--registry', file], { fetcher: offline, stdout: capture().stream, stderr: capture().stream, now: NOW }), 3); + const noLedger = { ...fixtureFetcher(), comments: async () => { throw new Error('HTTP 404'); } }; + const failed = capture(); + assert.equal(await main(['comment', '--registry', file], { fetcher: noLedger, stdout: failed.stream, stderr: capture().stream, now: NOW }), 3, 'without the ledger nothing can be deduplicated'); + assert.equal(failed.text(), ''); + }); +}); + +test('comment lists the dispatch branches of released lines', async () => { + const release = { channel: 'npm', name: 'agentic-qe', minVersion: '3.13.10' }; + await withRegistryFile([entry('proffesor-for-testing/agentic-qe#617', { doneWhen: { state: 'closed-completed', release } })], async (file) => { + const out = capture(); + await main(['comment', '--json', '--registry', file], { fetcher: withLedger([]), stdout: out.stream, stderr: capture().stream, now: NOW }); + const result = JSON.parse(out.text()); + assert.deepEqual(result.dispatch, ['upstream/proffesor-for-testing-agentic-qe-617']); + assert.match(result.body, /dispatch it on branch upstream\/proffesor-for-testing-agentic-qe-617\./); + }); +}); From f6eaca641474c51376c0bb413f30a0ec71d21d3c Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 13:40:36 -0700 Subject: [PATCH 07/16] ci(upstream-watch): daily workflow posts the ledger comment Runs node scripts/upstream-watch.mjs comment at 14:00 UTC and on demand with the workflow token, posts the script's body on the ledger issue after checking it is non-empty and well formed, reads the posted length back, and re-applies the upstream-dispatch label when a released line carries a dispatch branch. A blind run (exit 3) fails the job. On a pull request that touches the watch, a read-only preview job proves the token reads the upstream threads. --- .github/workflows/upstream-watch.yml | 120 +++++++++++++++++++++ tests/kit/upstream-watch-workflow.test.mjs | 46 ++++++++ 2 files changed, 166 insertions(+) create mode 100644 .github/workflows/upstream-watch.yml create mode 100644 tests/kit/upstream-watch-workflow.test.mjs diff --git a/.github/workflows/upstream-watch.yml b/.github/workflows/upstream-watch.yml new file mode 100644 index 00000000..e41e4362 --- /dev/null +++ b/.github/workflows/upstream-watch.yml @@ -0,0 +1,120 @@ +name: upstream-watch + +# The upstream watch (docs/UPSTREAM-WATCH.md, decision 14). The script decides +# everything: which ledger comments count, where the check starts, and the +# comment's text. This workflow only posts that text on the ledger issue and +# labels the issue when a released fix is ready to dispatch, which fires the +# dispatch routine. No model runs here. + +on: + schedule: + - cron: '0 14 * * *' + workflow_dispatch: + inputs: + post: + description: Post the ledger comment (off = preview in the job summary only) + type: boolean + default: true + pull_request: + paths: + - scripts/upstream-watch.mjs + - scripts/upstream-watch/** + - src/lib/hook-audit/** + - .github/workflows/upstream-watch.yml + +permissions: + contents: read + +env: + REGISTRY: src/lib/hook-audit/agentic-dependency-constraints.json + DISPATCH_LABEL: upstream-dispatch + +jobs: + # Read-only proof on a pull request that the workflow token reads the + # upstream threads and the ledger. Never posts. + preview: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: '24' + - name: Check (read-only) + env: + GH_TOKEN: ${{ github.token }} + run: | + set +e + node scripts/upstream-watch.mjs comment --json > watch.json 2> errors.txt + code=$? + set -e + { + echo "## Upstream watch preview (exit $code)" + jq -r '"events \(.events | length), could not check \(.fetchErrors | length), blind \(.blind), would post \(.post), dispatch \(.dispatch | join(", "))"' watch.json + echo; echo '```text'; cat errors.txt; echo '```' + } >> "$GITHUB_STEP_SUMMARY" + exit "$code" + + watch: + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + issues: write + concurrency: + group: upstream-watch + cancel-in-progress: false + env: + GH_TOKEN: ${{ github.token }} + POST: ${{ github.event_name == 'schedule' || inputs.post }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: '24' + - name: Check + id: check + run: | + set +e + node scripts/upstream-watch.mjs comment --json > watch.json 2> errors.txt + code=$? + set -e + { + echo "## Upstream watch (exit $code)" + jq -r '"since \(.since) (\(.sinceSource)), events \(.events | length), could not check \(.fetchErrors | length), blind \(.blind), post \(.post), dispatch \(.dispatch | join(", "))"' watch.json + echo; echo '```text'; cat errors.txt; echo '```' + jq -r '.body' watch.json + } >> "$GITHUB_STEP_SUMMARY" + # 3 = blind: gh unusable, the ledger unreadable, or no thread read. + exit "$code" + - name: Post the ledger comment + if: env.POST == 'true' + run: | + [ "$(jq -r '.post' watch.json)" = true ] || { echo 'Nothing to post.'; exit 0; } + issue=$(jq -r '.watchPolicy.ledger.issue' "$REGISTRY") + repo=$(jq -r '.watchPolicy.ledger.repo' "$REGISTRY") + jq -r '.body' watch.json > body.md + test -s body.md + [ "$(head -n 1 body.md)" = '```text' ] + grep -q '^checked-at ' body.md + url=$(gh issue comment "$issue" --repo "$repo" --body-file body.md) + echo "Posted $url" + id=${url##*issuecomment-} + length=$(gh api "repos/$repo/issues/comments/$id" --jq '.body | length') + echo "Posted length $length (file $(wc -c < body.md))" + [ "$length" -gt 0 ] + - name: Signal the dispatch routine + if: env.POST == 'true' + run: | + [ "$(jq -r '.dispatch | length' watch.json)" -gt 0 ] || { echo 'Nothing to dispatch.'; exit 0; } + issue=$(jq -r '.watchPolicy.ledger.issue' "$REGISTRY") + repo=$(jq -r '.watchPolicy.ledger.repo' "$REGISTRY") + gh label create "$DISPATCH_LABEL" --repo "$repo" --color 5319e7 \ + --description 'The upstream watch has a released fix to dispatch' --force + # Remove, then add: a label already present would fire no new event. + gh issue edit "$issue" --repo "$repo" --remove-label "$DISPATCH_LABEL" || true + gh issue edit "$issue" --repo "$repo" --add-label "$DISPATCH_LABEL" diff --git a/tests/kit/upstream-watch-workflow.test.mjs b/tests/kit/upstream-watch-workflow.test.mjs new file mode 100644 index 00000000..32e9416a --- /dev/null +++ b/tests/kit/upstream-watch-workflow.test.mjs @@ -0,0 +1,46 @@ +// The scheduled upstream watch workflow (decision 14): pins what it may write, +// when it runs, and that it posts only the script's checked comment body. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +const FILE = '.github/workflows/upstream-watch.yml'; +// A Windows checkout gives the YAML CRLF line endings; the checks are about its text. +const text = fs.readFileSync(FILE, 'utf8').replace(/\r\n/g, '\n'); +const job = (name) => { + const start = text.indexOf(`\n ${name}:\n`); + assert.ok(start > 0, `job ${name} exists`); + const next = text.slice(start + 1).search(/\n {2}[a-z][\w-]*:\n/); + return next < 0 ? text.slice(start) : text.slice(start, start + 1 + next); +}; + +test('the watch runs daily at 14:00 UTC and on demand', () => { + assert.match(text, /schedule:\n\s+- cron: '0 14 \* \* \*'/); + assert.match(text, /workflow_dispatch:/); + assert.match(text, /^permissions:\n {2}contents: read\n/m, 'the workflow default is read-only'); +}); + +test('a pull request only previews, with a read-only token', () => { + const preview = job('preview'); + assert.match(preview, /if: github\.event_name == 'pull_request'/); + assert.match(preview, /permissions:\n\s+contents: read\n/); + assert.doesNotMatch(preview, /issues: write|gh issue|gh label/); + assert.match(preview, /upstream-watch\.mjs comment --json/); +}); + +test('the scheduled job posts the checked body once and signals dispatch by label', () => { + const watch = job('watch'); + assert.match(watch, /if: github\.event_name != 'pull_request'/); + assert.match(watch, /permissions:\n\s+contents: read\n\s+issues: write\n/); + assert.match(watch, /concurrency:\n\s+group: upstream-watch\n\s+cancel-in-progress: false/); + assert.match(watch, /GH_TOKEN: \$\{\{ github\.token \}\}/); + const post = watch.indexOf('gh issue comment'); + for (const guard of ['test -s body.md', "[ \"$(head -n 1 body.md)\" = '```text' ]", "grep -q '^checked-at ' body.md"]) { + const at = watch.indexOf(guard); + assert.ok(at > 0 && at < post, `${guard} runs before posting`); + } + assert.equal(watch.split('gh issue comment').length - 1, 1, 'one comment per run'); + assert.match(watch.slice(post), /repos\/\$repo\/issues\/comments\/\$id/, 'the posted length is read back'); + assert.ok(watch.indexOf('--add-label') > post, 'the dispatch label follows the comment it points at'); + assert.ok(watch.indexOf('--remove-label') < watch.indexOf('--add-label'), 'a label already present is removed first so the add is a new event'); +}); From 962b39141f0df826bb230367a92f92acf539c8eb Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 27 Sep 2026 13:42:36 -0700 Subject: [PATCH 08/16] docs(upstream-watch): the watch runs on Actions; the routine only dispatches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UPSTREAM-WATCH.md describes the comment command, the ledger authors, the daily workflow and the dispatch routine with its label trigger and new prompt. ADR-0041 §7 Updated; the audit record gains decision 14 (4b-A..C) and current open items; MAINTAINER.md lists the new commands. The doc tests now read the dispatch routine's prompt and pin the label the workflow applies. --- MAINTAINER.md | 5 +- docs/UPSTREAM-WATCH.md | 117 +++++++++++------- ...st-neutral-hook-configuration-assurance.md | 15 ++- ...-237-238-239-verification-and-decisions.md | 55 +++++++- tests/kit/upstream-watch-script.test.mjs | 25 ++-- tests/kit/upstream-watch-workflow.test.mjs | 6 + 6 files changed, 156 insertions(+), 67 deletions(-) diff --git a/MAINTAINER.md b/MAINTAINER.md index 7b933a66..f928b30a 100644 --- a/MAINTAINER.md +++ b/MAINTAINER.md @@ -483,9 +483,12 @@ gh run list --workflow=nightly.yml --limit 3 ```bash node scripts/upstream-watch.mjs report # counts, then action items with links node scripts/upstream-watch.mjs check --since 2026-09-26 --ledger ledger.md # new ledger lines only +node scripts/upstream-watch.mjs comment # the ledger comment the daily workflow would post +gh workflow run upstream-watch.yml -f post=false # run the daily workflow now, summary only ``` -Read-only against GitHub and npm. The registry, lifecycle, ledger and daily routine are in +The script is read-only against GitHub and npm; the `upstream-watch` workflow posts its comment +on the ledger issue daily. The registry, lifecycle, ledger, workflow and dispatch routine are in [UPSTREAM-WATCH.md](docs/UPSTREAM-WATCH.md). ### Pull requests diff --git a/docs/UPSTREAM-WATCH.md b/docs/UPSTREAM-WATCH.md index 5920e813..b4917cf3 100644 --- a/docs/UPSTREAM-WATCH.md +++ b/docs/UPSTREAM-WATCH.md @@ -13,8 +13,9 @@ is the only upstream registry. It ships with ak because the hook audit reads it. the evidence needed, and the **removal proof** a workaround must pass before it goes. - `constraints`: version-bound workarounds with a retest date and a sunset condition ([ADR-0041 §7](adr/0041-host-neutral-hook-configuration-assurance.md#7-upstream-constraints-are-lifecycle-data)). -- `watchPolicy`: our GitHub logins, the stale limit (90 days), automated-reply patterns, the - ledger issue and its sentinel, and the dispatch rules. +- `watchPolicy`: our GitHub logins (`ours`: whose upstream comment is our last word), the stale + limit (90 days), automated-reply patterns, the ledger issue, its sentinel and the logins that + write it (`ledger.authors`), and the dispatch rules. - `watch`: every upstream issue or pull request ak filed, commented on, or cites in `src/`, `bin/`, `claude/`, `tests/`, `README.md` or a guide in `docs/` (dated audits, proposals and research references are exempt by name in `scripts/upstream-watch/citations.mjs`), plus ak's @@ -71,18 +72,30 @@ A constraint whose `nextRetestAt` has passed shows as stale evidence in the hook `scripts/upstream-watch.mjs` is maintainer tooling; it is not published. It reads GitHub with `gh api` and releases with `npm view` or GitHub releases, at most four calls at a time, and -writes nothing. It runs on macOS and Linux (the routine runs on Linux). On Windows, npm is a +writes nothing. It runs on macOS and Linux (the scheduled workflow runs on Linux). On Windows, npm is a `.cmd` file, which Node refuses to start without a shell ([Spawning `.bat` and `.cmd` files on Windows](https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows)), so every npm-gated release would read "Could not check"; the script passes version ranges such as -`^3.33.0` that `cmd.exe` would misread, so it does not add one. If `gh` is missing or signed out it says so and reports only what the registry -records. It exits 0 unless the command line is wrong. `check` prints only ledger lines on stdout; -each thread or release it could not check goes to stderr as `Could not check : `, and -`check --json` lists them in `fetchErrors`. +`^3.33.0` that `cmd.exe` would misread, so it does not add one. It needs a `gh` that can call the +GitHub API (it probes with `gh api rate_limit`, which any token passes, including the Actions +token); if `gh` is missing or cannot reach GitHub it says so and reports only what the registry +records. `check` prints only ledger lines on stdout; each thread or release it could not check +goes to stderr as `Could not check : `, and `check --json` lists them in `fetchErrors` +and says `blind` when not one watched thread could be read. It prints "No new upstream events." +only when every read succeeded; otherwise it names the threads it could not check. `report` and +`check` exit 0 unless the command line is wrong. + +`comment` is what the scheduled workflow runs. It reads the ledger issue's comments by +`watchPolicy.ledger.authors` only, starts the check from the newest `checked-at` in them (seven +days ago when there is none), drops lines already in them, and prints the comment to post (see +[The ledger](#the-ledger)), or nothing when there is no new event. `comment --json` also gives the +start, the new `checked-at`, the dispatch branches, the events and the fetch errors. It exits 3 +when blind: `gh` cannot reach GitHub, the ledger cannot be read, or no watched thread could be. ```bash node scripts/upstream-watch.mjs report [--json] node scripts/upstream-watch.mjs check --since [--ledger ] [--json] +node scripts/upstream-watch.mjs comment [--json] ``` The Ruflo support window (the newest six minors, never fewer than those released in the last @@ -114,9 +127,9 @@ window. The ledger is [pacphi/agentic-kit#243](https://github.com/pacphi/agentic-kit/issues/243), titled "Upstream watch", pinned and locked (`gh issue lock`, so only collaborators can comment); -`watchPolicy.ledger.issue` records it. The repository is public, so the routine reads only its own comments and -those of the logins in `watchPolicy.ours`; anyone else's comment is ignored. Each event is a -line: +`watchPolicy.ledger.issue` records it. The repository is public, so only comments by the logins +in `watchPolicy.ledger.authors` (the maintainer and `github-actions[bot]`, the workflow's login) +count; anyone else's comment is ignored. Each event is a line: ```text UPSTREAM-WATCH [key=value ...] @@ -130,13 +143,16 @@ watch). `check --since` limits replies, acknowledgements, closures and merges to `--since`. The other events repeat while their condition holds, dated by the upstream fact, so the same fact always gives the same line. A `released` line for a fix held for the support window has no `branch=` field; the line with one appears once the window's floor contains the fix. `--ledger ` drops any line already in that file, -so an exact line the routine recorded is never acted on twice. The file holds only the -routine's own comments: a line someone else posted would suppress a real event. Each posted -comment ends with `checked-at