chore(release): bump to 2.7.0, audit clean, and fix sdk-watch suppression (#2379)

* fix: teach sdk-watch the `app/` author spelling so suppression works again

`normalizeLogin` lowercased a login and stripped a trailing `[bot]`, but the
installed `gh` reports this account as `app/github-actions` from
`gh issue list --json author`. The `app/` prefix survived, so
`isSweepAuthored()` returned false for every issue the sweep itself wrote, the
marker was never trusted, and the re-file check found nothing to suppress
against. The sweep filed the same ext-apps 2.0.0 issue three nights running
(#2325, #2332, #2348) before anyone noticed by hand.

The same predicate gates analysis-retry suppression and supersession notes, so
both were affected too — an analyzed issue could be re-queued for a fresh Opus
run every night.

Strip the prefix alongside the suffix. That widens nothing an outsider can
claim: `/` is not legal in a GitHub username, so no human account can normalize
onto `github-actions`, and the labels half of the check (`chore` +
`dependencies`, both write-access-only) is independent regardless.

Because the failure mode is silence, also guard the class rather than only its
instance: `sweepIssues` now reports any issue carrying this sweep's marker AND
its write-access-only labels whose author does not normalize onto
`AUTOMATION_LOGIN`. That combination cannot be produced by an outsider, and it
is exactly the signature a fourth spelling would present. It warns rather than
throws, so a cosmetic upstream rename does not take the nightly sweep down.

Tests cover all three known spellings plus a mixed-case form, five near-miss
logins that must stay rejected, and both halves of the split predicate.

Closes #2377

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>

* chore(release): bump version to 2.7.0

Step 1 of the v2.7.0 release. The bump goes on `v2/main` before the milestone
merge (#2010), so the release lands on `main` with the version already correct
and `v2/main` is never left behind `main`.

`npm version minor --no-git-tag-version` — no tag here deliberately. The release
tag has to point at the merge commit on `main`, so tagging on `v2/main` would
create a tag on a commit that is never released.

`npm audit --audit-level=high` was run first across all five installs (root,
web, cli, tui, launcher) and reported zero vulnerabilities at every severity —
info through critical — so the bump sits on a tree that was checked and no audit
fixes were forced.

Closes #2378

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>

* test: cover the drift guard through main(), not only as a unit (#2379 review)

`warnOnUnrecognizedAuthors` was unit-tested, but its call from `sweepIssues`
was not: deleting that line left all 65 tests green. So the guard against a
silent normalization drift could itself have become disconnected silently —
the exact failure mode it exists to prevent.

Two main-level cases, driven through the injected spawn:

- an issue carrying our marker and our write-access-only labels whose author is
  `apps/github-actions` (a spelling `normalizeLogin` has never seen) must
  produce the warning, and the refile it causes is asserted alongside it so the
  test shows the consequence rather than only the message;
- the three readable spellings must produce no warning AND must suppress the
  refile, because a guard that fires on the ordinary path is noise.

Deleting the `sweepIssues` call now fails 1 test rather than 0, and restoring
the original `app/` bug fails 5 rather than 4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: cliffhall <cliff@futurescale.com>

---------

Signed-off-by: cliffhall <cliff@futurescale.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-09-15 23:22:38 -04:00 • committed by GitHub
commit 20e679bb1f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -137,10 +137,27 @@ export const ANALYSIS_MARKER = "<!-- sdk-watch:analysis -->";
* * forge a supersession marker so the genuine note is never posted.
*
* So a marker counts only when the thing carrying it was written by this
* automation. `gh issue list --json author` reports a bot with the `[bot]`
* suffix stripped and `is_bot: true`, while the REST comments endpoint reports
* `github-actions[bot]` with `type: "Bot"` — hence the normalization in both
* predicates rather than one spelling assumed.
* automation.
*
* ⚠️ **The same account has THREE spellings, and normalizing only some of them
* disables suppression silently.** That is not hypothetical — it shipped, and
* cost three identical issues on three consecutive nights (#2377):
*
* | Source | Spelling |
* | --- | --- |
* | REST comments endpoint | `github-actions[bot]`, `type: "Bot"` |
* | older `gh issue list --json author` | `github-actions`, `is_bot: true` |
* | newer `gh issue list --json author` | **`app/github-actions`** |
*
* `normalizeLogin` therefore strips an `app/` **prefix** as well as a `[bot]`
* suffix. Stripping the prefix widens nothing an outsider can claim: `/` is not
* a legal character in a GitHub username, so no human account can normalize
* onto `github-actions` — and the labels half of `isSweepAuthored` is an
* independent check regardless.
*
* A fourth spelling would break it again, and the failure mode is silence. So
* `sweepIssues` also reports any issue that carries this sweep's marker and its
* labels but fails the author check — see `warnOnUnrecognizedAuthors`.
*/
export const AUTOMATION_LOGIN = "github-actions";
@ -150,6 +167,7 @@ export const SWEEP_LABELS = ["chore", "dependencies"];
const normalizeLogin = (login) =>
String(login ?? "")
.toLowerCase()
.replace(/^app\//, "")
.replace(/\[bot\]$/, "");
/**
@ -165,14 +183,90 @@ const normalizeLogin = (login) =>
* @returns {boolean}
*/
export function isSweepAuthored(issue) {
return hasSweepAuthor(issue) && hasSweepLabels(issue);
}
/**
* The author half of `isSweepAuthored`, on its own.
*
* Split out so `warnOnUnrecognizedAuthors` can distinguish the two ways an
* issue fails the check. "Wrong labels" is the ordinary case — somebody else's
* issue that mentions the sweep. "Right labels, unrecognized author" is the
* signature of a login spelling this script does not know about, which is a
* defect in `normalizeLogin` rather than anything about the issue.
*
* @param {{author?: {login?: string, is_bot?: boolean}}} issue
* @returns {boolean}
*/
export function hasSweepAuthor(issue) {
if (normalizeLogin(issue?.author?.login) !== AUTOMATION_LOGIN) return false;
// `is_bot` is absent on some `gh` versions; only an explicit `false` — a human
// account that happens to carry the name — is disqualifying.
if (issue?.author?.is_bot === false) return false;
return issue?.author?.is_bot !== false;
}
/**
* The labels half of `isSweepAuthored`, on its own.
*
* This is the half an outsider cannot forge: `chore` and `dependencies` both
* need write access on the repo.
*
* @param {{labels?: Array<{name?: string}>}} issue
* @returns {boolean}
*/
export function hasSweepLabels(issue) {
const names = new Set((issue?.labels ?? []).map((l) => l?.name));
return SWEEP_LABELS.every((label) => names.has(label));
}
/**
* Report issues that look like this sweep's own but whose author it does not
* recognize.
*
* ⚠️ **This is the guard against the whole class of #2377, not just its
* instance.** Suppression, analysis-retry suppression and supersession notes
* all gate on `isSweepAuthored`, and when a login spelling stops normalizing
* they do not fail — they quietly decide the sweep has never filed anything,
* and the sweep refiles the same issue every night forever. Nothing goes red,
* so the only way anyone finds out is by noticing the duplicates by hand, which
* took three nights last time.
*
* An issue carrying a valid marker **and** both write-access-only labels, whose
* author does not normalize onto `AUTOMATION_LOGIN`, is that signature. It
* cannot be produced by an outsider, because it needs the labels.
*
* Reporting rather than throwing is deliberate: a hard failure here would take
* the nightly sweep down over a cosmetic upstream rename, and the sweep's job —
* noticing an SDK release — is still worth doing while the spelling is fixed.
*
* @param {Array<{author?: {login?: string}, body?: string, labels?: Array<{name?: string}>}>} issues
* @param {(msg: string) => void} [warn]
* @returns {string[]} the unrecognized logins, deduplicated
*/
export function warnOnUnrecognizedAuthors(issues, warn = console.warn) {
const unrecognized = [
...new Set(
(issues ?? [])
.filter(
(issue) =>
!hasSweepAuthor(issue) &&
hasSweepLabels(issue) &&
parseMarker(issue?.body) !== null,
)
.map((issue) => String(issue?.author?.login ?? "")),
),
];
if (unrecognized.length > 0) {
warn(
`sdk-watch: ⚠️ ${unrecognized.length} author spelling(s) carry this sweep's marker AND its ` +
`labels but do not normalize onto "${AUTOMATION_LOGIN}": ${unrecognized.map((l) => JSON.stringify(l)).join(", ")}. ` +
"Suppression, analysis retries and supersession notes are all disabled for those issues — " +
"teach `normalizeLogin` the spelling (see #2377).",
);
}
return unrecognized;
}
/**
* Was this comment written by the automation?
*
@ -562,7 +656,10 @@ function sweepIssues(repo, spawn) {
if (result.status !== 0) {
throw new Error(`gh issue list failed: ${(result.stderr ?? "").trim()}`);
}
return JSON.parse(result.stdout || "[]")
const issues = JSON.parse(result.stdout || "[]");
// Before filtering them away: say so if any of them are ours but unreadable.
warnOnUnrecognizedAuthors(issues);
return issues
.filter(isSweepAuthored)
.map((issue) => ({ ...issue, marker: parseMarker(issue.body) }))
.filter((issue) => issue.marker && semver.valid(issue.marker.target));