autonomous-sdlc-harness 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -108,7 +108,7 @@ import { FORCED_SIGNAL_ID } from '../detect/signals.js';
108
108
  import { CLAUDE_MD_PATH, SETUP_PENDING_CLOSE, SETUP_PENDING_OPEN, SKELETON_GUIDANCE_MARKER, TASK_OFFER_PATH, UNFILLED_STUB_MARKER, } from '../generators/claudeContext.js';
109
109
  import { caseLabelMatches, isGlobPattern, PRE_PUSH_HOOK, readProtectedCaseLabel, resolveGithooksDir, resolveProtectedBranches, } from '../generators/githooks.js';
110
110
  import { PUSH_CMD_KEY, PUSH_DESTINATION_PLACEHOLDER, PUSH_URL_KEY, pushEnvCandidates, } from '../generators/notifications.js';
111
- import { IN_FLIGHT_RUNS_NOTE, pinnedCliCommand, upgradeWorkflowsCommand } from '../generators/githubWorkflows.js';
111
+ import { IN_FLIGHT_RUNS_NOTE, pinnedCliCommand, unparseableControlRoute, upgradeWorkflowsCommand } from '../generators/githubWorkflows.js';
112
112
  import { DOCS_SEARCH_SERVER_SCRIPT_NAME, outerLoopScriptsDir } from '../generators/outerLoopScripts.js';
113
113
  import { bashScriptRule, entryWord, isUnderDirectory, namesBrowserTool, normalizedRoot, pluginRootDirectories, pluginRootEntries, pluginRootEntryTarget, pluginRootHelpers, PLUGIN_ROOT_ENTRIES_FLAG, PROFILE_PATH, renderProfile, TEMPLATE_PATH as PROFILE_TEMPLATE_PATH, } from '../generators/permissionProfile.js';
114
114
  import { ENABLED_PLUGINS_KEY, MARKETPLACE_ENTRY_SHAPE, MARKETPLACE_FLAG, MARKETPLACES_KEY, MARKETPLACE_NAME, marketplaceEntryDefect, PLUGIN_KEY, SETTINGS_PATH, SLUG_SHAPE, } from '../generators/projectSettings.js';
@@ -118,7 +118,7 @@ import { selectedStateDirs } from '../generators/stateDir.js';
118
118
  import { machineConfigDir } from '../machine/paths.js';
119
119
  import { installedPluginsPath, knownMarketplacesPath, pluginInstallRoot, pluginRuntimeRoot, pluginScriptsDir, } from '../machine/plugins.js';
120
120
  import { inspect, readRegistry, registryPath } from '../machine/registry.js';
121
- import { API_KEY_SECRET, CLI_VERSION_VARIABLE, DEFAULT_GH_CLI, COMMAND_HANDLE, COMMAND_VERBS, DEFAULT_TRIGGER_LABEL, GH_CLI_VARIABLE, ghCli, GIT_TOKEN_SECRET, LEGACY_TRIGGER_LABEL, OAUTH_TOKEN_SECRET, PR_CREATE_SETTING, PR_CREATE_SETTING_PATH, PUSH_URL_SECRET, REMOTE_STOP_VARIABLE, renderedCliVersions, runGh, RUNNER_VARIABLE, TRIGGER_ALLOWED_BOTS_VARIABLE, TRIGGER_LABEL_VARIABLE, triggerFallbackLabel, WORKFLOW_CONTROL_FILE, WORKFLOW_CONTROL_PATH, WORKFLOW_RESUME_FILE, WORKFLOW_RESUME_PATH, WORKFLOW_RUN_FILE, WORKFLOW_RUN_PATH, WORKFLOW_TRIGGER_FILE, WORKFLOW_TRIGGER_PATH, } from '../remote/githubActions.js';
121
+ import { API_KEY_SECRET, carriesRunActors, CLI_VERSION_VARIABLE, DEFAULT_GH_CLI, COMMAND_HANDLE, COMMAND_VERBS, DEFAULT_TRIGGER_LABEL, effectiveRunActors, GH_CLI_VARIABLE, ghCli, GIT_TOKEN_SECRET, LEGACY_TRIGGER_LABEL, OAUTH_TOKEN_SECRET, PR_CREATE_SETTING, PR_CREATE_SETTING_PATH, PUSH_URL_SECRET, REMOTE_STOP_VARIABLE, renderedCliVersions, runActorAdmitted, runGh, RUN_ACTORS_EVERY_WRITER, RUN_ACTORS_VARIABLE, RUNNER_VARIABLE, TRIGGER_ALLOWED_BOTS_VARIABLE, TRIGGER_LABEL_VARIABLE, triggerFallbackLabel, WORKFLOW_CONTROL_FILE, WORKFLOW_CONTROL_PATH, WORKFLOW_RESUME_FILE, WORKFLOW_RESUME_PATH, WORKFLOW_RUN_FILE, WORKFLOW_RUN_PATH, WORKFLOW_TRIGGER_FILE, WORKFLOW_TRIGGER_PATH, } from '../remote/githubActions.js';
122
122
  import { modelFilesPresent } from '../retrieval/models.js';
123
123
  import { launcherSearchPath, parseSelfCheck, PYTHON_DATABASE_URL_VARIABLE, PYTHON_FETCH_MODELS_SUB_COMMAND, PYTHON_RETRIEVAL_COMMAND, PYTHON_SELF_CHECK_SUB_COMMAND, pythonDatabaseUrl, SELF_CHECK_INDEX_NOT_ATTEMPTED, } from '../retrieval/pythonBackend.js';
124
124
  import { retrievalCliEntry, retrievalModelCacheDir, retrievalRuntimeDir, retrievalRuntimeState, } from '../retrieval/runtime.js';
@@ -156,6 +156,11 @@ const REMOTE_RUN_SCRIPT = 'remote-run.sh';
156
156
  * YAML file spells this endpoint.
157
157
  */
158
158
  const ARTIFACT_RETENTION_ENDPOINT = 'repos/{owner}/{repo}/actions/permissions/artifact-and-log-retention';
159
+ /**
160
+ * The `gh api` path {@link REMOTE_GITHUB_CHECK} reads the repository's workflow listing from, to find
161
+ * a harness workflow GitHub lists by its path. Local for {@link ARTIFACT_RETENTION_ENDPOINT}'s reason.
162
+ */
163
+ const WORKFLOWS_ENDPOINT = 'repos/{owner}/{repo}/actions/workflows?per_page=100';
159
164
  /**
160
165
  * The `gh api` path {@link REMOTE_GITHUB_CHECK} reads the *Allow GitHub Actions to create and approve
161
166
  * pull requests* setting from — `can_approve_pull_request_reviews`. Local for
@@ -163,6 +168,19 @@ const ARTIFACT_RETENTION_ENDPOINT = 'repos/{owner}/{repo}/actions/permissions/ar
163
168
  * usually can.
164
169
  */
165
170
  const PR_SETTING_ENDPOINT = 'repos/{owner}/{repo}/actions/permissions/workflow';
171
+ /**
172
+ * The `gh api` path {@link REMOTE_GITHUB_CHECK} reads the repository's owner from, to grade an unset
173
+ * {@link RUN_ACTORS_VARIABLE}. Local for {@link ARTIFACT_RETENTION_ENDPOINT}'s reason.
174
+ */
175
+ const REPOSITORY_ENDPOINT = 'repos/{owner}/{repo}';
176
+ /**
177
+ * The `gh api` path {@link REMOTE_GITHUB_CHECK} reads the repository's collaborators from, to name
178
+ * writers {@link RUN_ACTORS_VARIABLE} does not admit. Local for {@link ARTIFACT_RETENTION_ENDPOINT}'s
179
+ * reason. Listing collaborators needs push access.
180
+ */
181
+ const COLLABORATORS_ENDPOINT = 'repos/{owner}/{repo}/collaborators?per_page=100';
182
+ /** A full {@link COLLABORATORS_ENDPOINT} page: at this many entries more collaborators may exist. */
183
+ const COLLABORATORS_PAGE_SIZE = 100;
166
184
  /** Below this many days of artifact retention {@link REMOTE_GITHUB_CHECK} warns; argued there. */
167
185
  const ARTIFACT_RETENTION_WARN_DAYS = 30;
168
186
  /** The permission lists a generated profile carries, in the order the template writes them. */
@@ -1865,7 +1883,11 @@ const DAEMON_PATH_CHECK = {
1865
1883
  *
1866
1884
  * **Every finding is reported, and the grade is the worst of them.** Two `fail`s: no
1867
1885
  * `harness-run.yml`, because no remote run can be dispatched; and no `gh`, because the watcher
1868
- * dispatches through it. Four `warn`s: a `harness-run.yml` pinned (`remote/githubActions.ts` →
1886
+ * dispatches through it. Five `warn`s: a `harness-run.yml` whose text does not read
1887
+ * {@link RUN_ACTORS_VARIABLE} ({@link carriesRunActors}), remedied by {@link upgradeWorkflowsCommand} —
1888
+ * local evidence, because the workflow is what GitHub runs and only its text says whether the list
1889
+ * reaches the gate and the scripts; listed beside the pin warning when both fire, and an unreadable
1890
+ * file adds nothing to the pin note. A `harness-run.yml` pinned (`remote/githubActions.ts` →
1869
1891
  * {@link renderedCliVersions}) to a version other than this CLI's — never a `fail`, because the job
1870
1892
  * installs its pin and the adopter may stay on it deliberately; the remedy is
1871
1893
  * `generators/githubWorkflows.ts` → {@link upgradeWorkflowsCommand}, the alternative `doctor` at the
@@ -1954,6 +1976,9 @@ const REMOTE_EXECUTION_CHECK = {
1954
1976
  else if (pins !== undefined) {
1955
1977
  pinnedHere = true;
1956
1978
  }
1979
+ if (text !== undefined && !carriesRunActors(text)) {
1980
+ warnings.push(`\`${WORKFLOW_RUN_PATH}\` was written before \`${RUN_ACTORS_VARIABLE}\`: its run job launches for any writer's dispatch or re-run, and its collect job passes the scripts no list, so a review round's authors are held to an unset list (the repository owner alone, or nobody in an organisation-owned repository) once the scripts are re-rendered; \`${upgradeWorkflowsCommand(version)}\` re-renders it after a .bak (docs/remote-execution.md, section 7, Upgrading)`);
1981
+ }
1957
1982
  if (!branchUsable) {
1958
1983
  notes.push(`whether GitHub's default branch carries ${WORKFLOW_RUN_PATH} is not graded, because defaultBranch is not a branch name (see the config check)`);
1959
1984
  }
@@ -2026,6 +2051,32 @@ function retentionDaysOf(stdout) {
2026
2051
  const days = parsed.days;
2027
2052
  return typeof days === 'number' && Number.isInteger(days) && days > 0 ? days : undefined;
2028
2053
  }
2054
+ /**
2055
+ * Each `workflows[]` entry's `name`, keyed by its `path`, of an Actions workflow listing — entries
2056
+ * lacking a string `name` or `path` are skipped — with the listing's `total_count` (the entry count when
2057
+ * absent) and the number of entries listed, or `undefined` when there is no `workflows` array.
2058
+ */
2059
+ function workflowNamesByPathOf(stdout) {
2060
+ let parsed;
2061
+ try {
2062
+ parsed = JSON.parse(stdout);
2063
+ }
2064
+ catch {
2065
+ return undefined;
2066
+ }
2067
+ if (!isJsonObject(parsed))
2068
+ return undefined;
2069
+ const { workflows, total_count: totalCount } = parsed;
2070
+ if (!Array.isArray(workflows))
2071
+ return undefined;
2072
+ const names = new Map();
2073
+ for (const item of workflows) {
2074
+ if (isJsonObject(item) && typeof item.name === 'string' && typeof item.path === 'string')
2075
+ names.set(item.path, item.name);
2076
+ }
2077
+ const total = typeof totalCount === 'number' && Number.isInteger(totalCount) ? totalCount : workflows.length;
2078
+ return { names, total, listed: workflows.length };
2079
+ }
2029
2080
  /** The boolean `can_approve_pull_request_reviews` of a workflow-permissions answer, or `undefined` for any other shape. */
2030
2081
  function prApprovalSettingOf(stdout) {
2031
2082
  let parsed;
@@ -2040,6 +2091,49 @@ function prApprovalSettingOf(stdout) {
2040
2091
  const allowed = parsed.can_approve_pull_request_reviews;
2041
2092
  return typeof allowed === 'boolean' ? allowed : undefined;
2042
2093
  }
2094
+ /** The string `.owner.login` and `.owner.type` of a repository answer, or `undefined` for any other shape. */
2095
+ function repositoryOwnerOf(stdout) {
2096
+ let parsed;
2097
+ try {
2098
+ parsed = JSON.parse(stdout);
2099
+ }
2100
+ catch {
2101
+ return undefined;
2102
+ }
2103
+ if (!isJsonObject(parsed))
2104
+ return undefined;
2105
+ const owner = parsed.owner;
2106
+ if (!isJsonObject(owner))
2107
+ return undefined;
2108
+ const { login, type } = owner;
2109
+ return typeof login === 'string' && typeof type === 'string' ? { login, type } : undefined;
2110
+ }
2111
+ /**
2112
+ * Each `login` whose `permissions.push` is `true` — write, maintain and admin all carry it — of a
2113
+ * collaborators answer, with the array's length, or `undefined` for any other shape.
2114
+ */
2115
+ function writersOf(stdout) {
2116
+ let parsed;
2117
+ try {
2118
+ parsed = JSON.parse(stdout);
2119
+ }
2120
+ catch {
2121
+ return undefined;
2122
+ }
2123
+ if (!Array.isArray(parsed))
2124
+ return undefined;
2125
+ const logins = [];
2126
+ for (const item of parsed) {
2127
+ if (!isJsonObject(item) || typeof item.login !== 'string')
2128
+ return undefined;
2129
+ const permissions = item.permissions;
2130
+ if (!isJsonObject(permissions))
2131
+ return undefined;
2132
+ if (permissions.push === true)
2133
+ logins.push(item.login);
2134
+ }
2135
+ return { logins, count: parsed.length };
2136
+ }
2043
2137
  /**
2044
2138
  * What GitHub says about the remote setup — asked only under {@link CheckContext.probeGithub}.
2045
2139
  *
@@ -2049,9 +2143,14 @@ function prApprovalSettingOf(stdout) {
2049
2143
  *
2050
2144
  * Grades, worst wins and every finding is reported:
2051
2145
  * - `fail` — `gh` does not spawn or `gh auth status` refuses (nothing further is asked); GitHub does
2052
- * not know `harness-run.yml`; neither credential secret is set.
2146
+ * not know `harness-run.yml`; neither credential secret is set; a harness workflow GitHub lists by
2147
+ * its path, which is how it lists a file it could not parse — judged from the workflow listing
2148
+ * ({@link WORKFLOWS_ENDPOINT}) because `gh workflow view` answers for an unparsed file too, as Gate 12
2149
+ * round 7 observed. `harness-trigger.yml` and `harness-control.yml` are judged only when
2150
+ * {@link forgeTriggerApplies}.
2053
2151
  * - `warn` — `HARNESS_PUSH_URL` absent; `harness-resume.yml` unknown to GitHub; `HARNESS_REMOTE_STOP`
2054
- * set; artifact retention below {@link ARTIFACT_RETENTION_WARN_DAYS} days; when
2152
+ * set; the workflow listing unread, or not reaching a judged harness workflow because the repository
2153
+ * has more workflows than one page lists, so whether GitHub could parse it cannot be told; artifact retention below {@link ARTIFACT_RETENTION_WARN_DAYS} days; when
2055
2154
  * {@link forgeTriggerApplies}, `harness-trigger.yml` or `harness-control.yml` unknown to GitHub, no
2056
2155
  * label by the effective trigger name, or the pull-request setting off with no `HARNESS_GIT_TOKEN`
2057
2156
  * secret; and any call that timed out, could not reach GitHub, or answered in a shape not
@@ -2072,9 +2171,25 @@ function prApprovalSettingOf(stdout) {
2072
2171
  * request with that token instead.
2073
2172
  * - all three trigger answers positive — both workflows known and the label present — is confirmed on
2074
2173
  * every outcome that reaches the trigger reads, `fail` and `warn` included, so an unrelated finding
2075
- * never hides it; any answer not positive is already among the warnings. An outcome returned before
2174
+ * never hides it; any answer not positive is already among the warnings, and a forge workflow
2175
+ * GitHub lists by its path withholds it, being among the failures. An outcome returned before
2076
2176
  * those reads — `gh` not runnable, no usable login, or no readable answer to the login probe — asks
2077
2177
  * GitHub nothing about the trigger.
2178
+ * - who may start, steer, answer and review a run is {@link effectiveRunActors} of
2179
+ * `HARNESS_RUN_ACTORS`, read only when the variable listing was. An unset list adds a read of the
2180
+ * repository's owner ({@link REPOSITORY_ENDPOINT}); any answer but a readable owner — refused, timed
2181
+ * out, unreachable or an unread shape — is a *cannot tell* warning, and the list then grades as
2182
+ * admitting nobody with no second warning. `*`, a list of logins, and an unset list in a user-owned
2183
+ * repository are each a note naming who acts; an unset list whose owner is not a user is a `warn`
2184
+ * with the `gh variable set` remedy. `*` while `CLAUDE_CODE_OAUTH_TOKEN` is set is a `warn` whether
2185
+ * or not `ANTHROPIC_API_KEY` is also set: the token is one person's subscription, readable by every
2186
+ * writer.
2187
+ * - a list of logins or an owner-only list while `CLAUDE_CODE_OAUTH_TOKEN` is set adds a read of the
2188
+ * collaborators ({@link COLLABORATORS_ENDPOINT}). A writer the list does not admit is a `warn` naming
2189
+ * them: the list stops their spending through the harness, but a writer can still read the secret
2190
+ * through an edited workflow run from a branch. A full first page with no such writer is a *cannot
2191
+ * tell* warning, as are a timed-out, unreachable or unread answer; a refused read is a note on the
2192
+ * retention read's terms, since listing collaborators needs push access.
2078
2193
  *
2079
2194
  * **Why 30 days.** A parked run waits on a human answer and a usage-paused one on a reset, and the
2080
2195
  * `harness-state` bundle is the only remote copy of either; once the repository's retention expires
@@ -2152,16 +2267,55 @@ const REMOTE_GITHUB_CHECK = {
2152
2267
  if (resume.answer.kind === 'refused') {
2153
2268
  warnings.push(`GitHub does not know ${WORKFLOW_RESUME_FILE} (${resume.call}: ${resume.answer.why}), so usage auto-resume is unavailable: push ${WORKFLOW_RESUME_PATH} to the repository's default branch`);
2154
2269
  }
2270
+ const listing = ask(['api', WORKFLOWS_ENDPOINT]);
2271
+ if (listing.answer === undefined)
2272
+ return fail(noSpawn);
2273
+ const unparsed = new Set();
2274
+ const parsable = 'whether GitHub could parse the harness workflows';
2275
+ if (listing.answer.kind !== 'answered') {
2276
+ warnings.push(cannotTell(listing.call, listing.answer.why, parsable));
2277
+ }
2278
+ else {
2279
+ const listingRead = workflowNamesByPathOf(listing.answer.stdout);
2280
+ if (listingRead === undefined) {
2281
+ warnings.push(`cannot tell ${parsable}: ${listing.call} answered in a shape this check does not read`);
2282
+ }
2283
+ else {
2284
+ const version = ownManifestString('version');
2285
+ const judged = forgeTriggerApplies(ctx.config)
2286
+ ? [WORKFLOW_RUN_PATH, WORKFLOW_RESUME_PATH, WORKFLOW_TRIGGER_PATH, WORKFLOW_CONTROL_PATH]
2287
+ : [WORKFLOW_RUN_PATH, WORKFLOW_RESUME_PATH];
2288
+ const unseen = [];
2289
+ for (const path of judged) {
2290
+ const name = listingRead.names.get(path);
2291
+ if (name === undefined) {
2292
+ if (listingRead.total > listingRead.listed)
2293
+ unseen.push(path);
2294
+ continue;
2295
+ }
2296
+ if (name !== path)
2297
+ continue;
2298
+ unparsed.add(path);
2299
+ const route = path === WORKFLOW_CONTROL_PATH
2300
+ ? unparseableControlRoute(version)
2301
+ : `Check ${path} with actionlint, or re-render it with \`${pinnedCliCommand(version)} init --force\`, which keeps a .bak; then commit it and push it to the repository's default branch.`;
2302
+ failures.push(`GitHub lists ${path} by its path rather than its name, which it does for a workflow file it cannot parse, so that workflow runs for no event: ${route}`);
2303
+ }
2304
+ if (unseen.length > 0) {
2305
+ warnings.push(`cannot tell whether GitHub could parse ${nameList(unseen)}: ${listing.call} listed ${listingRead.listed} of the repository's ${listingRead.total} workflows, and not ${unseen.length === 1 ? 'that one' : 'those'}`);
2306
+ }
2307
+ }
2308
+ }
2155
2309
  const variables = ask(['variable', 'list', '--json', 'name,value']);
2156
2310
  if (variables.answer === undefined)
2157
2311
  return fail(noSpawn);
2158
2312
  const variableValues = variables.answer.kind === 'answered' ? ghJsonEntries(variables.answer.stdout) : undefined;
2159
2313
  let runner;
2160
2314
  if (variables.answer.kind !== 'answered') {
2161
- warnings.push(cannotTell(variables.call, variables.answer.why, `the ${RUNNER_VARIABLE} and ${REMOTE_STOP_VARIABLE} variables`));
2315
+ warnings.push(cannotTell(variables.call, variables.answer.why, `the ${RUNNER_VARIABLE}, ${REMOTE_STOP_VARIABLE} and ${RUN_ACTORS_VARIABLE} variables`));
2162
2316
  }
2163
2317
  else if (variableValues === undefined) {
2164
- warnings.push(`cannot tell the ${RUNNER_VARIABLE} and ${REMOTE_STOP_VARIABLE} variables: ${variables.call} answered in a shape this check does not read`);
2318
+ warnings.push(`cannot tell the ${RUNNER_VARIABLE}, ${REMOTE_STOP_VARIABLE} and ${RUN_ACTORS_VARIABLE} variables: ${variables.call} answered in a shape this check does not read`);
2165
2319
  }
2166
2320
  else {
2167
2321
  const label = variableValues.get(RUNNER_VARIABLE)?.trim() ?? '';
@@ -2189,6 +2343,73 @@ const REMOTE_GITHUB_CHECK = {
2189
2343
  warnings.push(`the repository keeps artifacts for ${retentionDays} days, so a remote run parked or paused longer than that loses its state bundle: raise it under Settings → Actions → General → Artifact and log retention`);
2190
2344
  }
2191
2345
  }
2346
+ // Undefined only when the variables listing was unread, whose warning above already names the list.
2347
+ let runActors;
2348
+ if (variableValues !== undefined) {
2349
+ const listed = variableValues.get(RUN_ACTORS_VARIABLE);
2350
+ const unset = effectiveRunActors(listed, undefined).kind === 'nobody';
2351
+ let owner;
2352
+ if (unset) {
2353
+ const repository = ask(['api', REPOSITORY_ENDPOINT]);
2354
+ if (repository.answer === undefined)
2355
+ return fail(noSpawn);
2356
+ const whoOwns = 'who owns the repository, so who may act on a run';
2357
+ if (repository.answer.kind !== 'answered') {
2358
+ warnings.push(cannotTell(repository.call, repository.answer.why, whoOwns));
2359
+ }
2360
+ else {
2361
+ owner = repositoryOwnerOf(repository.answer.stdout);
2362
+ if (owner === undefined)
2363
+ warnings.push(`cannot tell ${whoOwns}: ${repository.call} answered in a shape this check does not read`);
2364
+ }
2365
+ }
2366
+ runActors = effectiveRunActors(listed, owner);
2367
+ const acts = 'start, steer, answer and review a run';
2368
+ if (runActors.kind === 'every-writer') {
2369
+ notes.push(`${RUN_ACTORS_VARIABLE} is \`${RUN_ACTORS_EVERY_WRITER}\`, so every collaborator with write access may ${acts}`);
2370
+ }
2371
+ else if (runActors.kind === 'listed') {
2372
+ notes.push(`${RUN_ACTORS_VARIABLE} admits ${nameList(runActors.logins)}, so only ${runActors.logins.length === 1 ? 'that login' : 'those logins'} may ${acts}`);
2373
+ }
2374
+ else if (runActors.kind === 'owner') {
2375
+ notes.push(`${RUN_ACTORS_VARIABLE} is unset, so only the repository owner, ${runActors.login}, may ${acts}`);
2376
+ }
2377
+ else if (runActors.owner !== undefined) {
2378
+ const { login, type } = runActors.owner;
2379
+ warnings.push(`${RUN_ACTORS_VARIABLE} is unset and ${login} is ${/^[aeiou]/i.test(type) ? 'an' : 'a'} ${type}, not a user, so no person may start, steer, answer or review a run: set it with \`gh variable set ${RUN_ACTORS_VARIABLE} --body <login,login>\`, or \`--body '${RUN_ACTORS_EVERY_WRITER}'\` for every writer`);
2380
+ }
2381
+ if (runActors.kind === 'every-writer' && secretNames?.has(OAUTH_TOKEN_SECRET) === true) {
2382
+ warnings.push(`${RUN_ACTORS_VARIABLE} is \`${RUN_ACTORS_EVERY_WRITER}\` while ${OAUTH_TOKEN_SECRET} is set, so every writer's runs spend one person's subscription, which its terms do not let them share: name the people in ${RUN_ACTORS_VARIABLE}, or use a Claude API organisation's key in ${API_KEY_SECRET} (docs/remote-execution.md, section 9)`);
2383
+ }
2384
+ if ((runActors.kind === 'listed' || runActors.kind === 'owner') && secretNames?.has(OAUTH_TOKEN_SECRET) === true) {
2385
+ const admitted = runActors;
2386
+ const collaborators = ask(['api', COLLABORATORS_ENDPOINT]);
2387
+ if (collaborators.answer === undefined)
2388
+ return fail(noSpawn);
2389
+ const beyond = `whether any writer is beyond ${RUN_ACTORS_VARIABLE}`;
2390
+ if (collaborators.answer.kind === 'unknown') {
2391
+ warnings.push(cannotTell(collaborators.call, collaborators.answer.why, beyond));
2392
+ }
2393
+ else if (collaborators.answer.kind === 'refused') {
2394
+ notes.push(`writers beyond ${RUN_ACTORS_VARIABLE} not checked: ${collaborators.call} needs push access (${collaborators.answer.why})`);
2395
+ }
2396
+ else {
2397
+ const writers = writersOf(collaborators.answer.stdout);
2398
+ if (writers === undefined) {
2399
+ warnings.push(`cannot tell ${beyond}: ${collaborators.call} answered in a shape this check does not read`);
2400
+ }
2401
+ else {
2402
+ const extra = writers.logins.filter((login) => !runActorAdmitted(admitted, login));
2403
+ if (extra.length > 0) {
2404
+ warnings.push(`${OAUTH_TOKEN_SECRET} is set and ${nameList(extra)} can write to the repository without being on ${RUN_ACTORS_VARIABLE}: the list stops them spending the subscription through the harness, but any writer can still read the secret by running an edited workflow from a branch; on a private repository a push ruleset on the workflow paths and the scripts they run closes that, and on a public one only withholding write access does (docs/remote-execution.md, section 9)`);
2405
+ }
2406
+ else if (writers.count === COLLABORATORS_PAGE_SIZE) {
2407
+ warnings.push(`cannot tell ${beyond}: ${collaborators.call} returned a full page of ${COLLABORATORS_PAGE_SIZE} collaborators with no writer beyond the list, and more may exist`);
2408
+ }
2409
+ }
2410
+ }
2411
+ }
2412
+ }
2192
2413
  let triggerKnown = '';
2193
2414
  if (forgeTriggerApplies(ctx.config)) {
2194
2415
  const trigger = ask(['workflow', 'view', WORKFLOW_TRIGGER_FILE]);
@@ -2250,7 +2471,8 @@ const REMOTE_GITHUB_CHECK = {
2250
2471
  notes.push(`${TRIGGER_ALLOWED_BOTS_VARIABLE} admits ${nameList(bots)}, each of which can start a run without a permission check`);
2251
2472
  }
2252
2473
  }
2253
- if (trigger.answer.kind === 'answered' && control.answer.kind === 'answered' && labelFound) {
2474
+ const forgeParsed = !unparsed.has(WORKFLOW_TRIGGER_PATH) && !unparsed.has(WORKFLOW_CONTROL_PATH);
2475
+ if (trigger.answer.kind === 'answered' && control.answer.kind === 'answered' && labelFound && forgeParsed) {
2254
2476
  triggerKnown = `; GitHub knows ${WORKFLOW_TRIGGER_FILE} and ${WORKFLOW_CONTROL_FILE}, and the label \`${labelName}\` exists`;
2255
2477
  }
2256
2478
  const prSetting = ask(['api', PR_SETTING_ENDPOINT]);
@@ -2307,7 +2529,12 @@ const REMOTE_GITHUB_CHECK = {
2307
2529
  * workflow not carried by `origin/<defaultBranch>`, because GitHub runs an `issues` or
2308
2530
  * `issue_comment` workflow only from its default branch — that last with
2309
2531
  * {@link REMOTE_EXECUTION_CHECK}'s push remedy and its two *not graded* notes, on the same reasoning.
2310
- * Each warn names every file it is about, so two absent files are one line naming both.
2532
+ * Each warn names every file it is about, so two absent files are one line naming both. A fourth,
2533
+ * joined to whichever of those fires or standing alone: a present forge workflow whose text does not
2534
+ * read {@link RUN_ACTORS_VARIABLE} ({@link carriesRunActors}), named in one warning with `init --force`
2535
+ * — these two carry no pin and move with the scripts, not with {@link upgradeWorkflowsCommand}. It is
2536
+ * local evidence because the workflows are what GitHub runs and only their text says whether the list
2537
+ * reaches the scripts; a read that throws is skipped.
2311
2538
  *
2312
2539
  * A value outside {@link FORGE_KINDS} is the config check's `fail`, and is not graded here.
2313
2540
  */
@@ -2348,9 +2575,22 @@ const FORGE_CHECK = {
2348
2575
  [WORKFLOW_CONTROL_PATH]: 'comments and reviews start nothing',
2349
2576
  };
2350
2577
  const consequence = (paths) => paths.map((path) => startsNothing[path]).join(', and ');
2578
+ const listless = forgeTriggerApplies(ctx.config)
2579
+ ? presentWorkflows.filter((path) => {
2580
+ try {
2581
+ return !carriesRunActors(readFileSync(join(root, ...path.split('/')), 'utf8'));
2582
+ }
2583
+ catch {
2584
+ return false;
2585
+ }
2586
+ })
2587
+ : [];
2588
+ const listlessWarning = listless.length === 0
2589
+ ? ''
2590
+ : `${nameList(listless)} ${listless.length === 1 ? 'was' : 'were'} written before \`${RUN_ACTORS_VARIABLE}\` and ${listless.length === 1 ? 'passes' : 'pass'} the scripts no list: with scripts written at the same time every writer may still start and command a run, and with the scripts re-rendered every start and command is held to an unset list — the repository owner alone, or nobody in an organisation-owned repository; \`${CLI} init --force\` replaces both workflows and the scripts after a .bak (docs/remote-execution.md, section 7, Upgrading)`;
2351
2591
  const absent = forgeWorkflows.filter((path) => !presentWorkflows.includes(path));
2352
2592
  if (absent.length > 0) {
2353
- return warn(`${on}, but ${nameList(absent)} ${isAre(absent)} absent, so ${consequence(absent)}: re-run \`${CLI} init\`, which writes ${absent.length === 1 ? 'it' : 'them'} create-if-absent`);
2593
+ return warn(`${on}, but ${nameList(absent)} ${isAre(absent)} absent, so ${consequence(absent)}: re-run \`${CLI} init\`, which writes ${absent.length === 1 ? 'it' : 'them'} create-if-absent${listless.length > 0 ? `; ${listlessWarning}` : ''}`);
2354
2594
  }
2355
2595
  const branch = ctx.config.defaultBranch;
2356
2596
  let carried;
@@ -2363,10 +2603,13 @@ const FORGE_CHECK = {
2363
2603
  else {
2364
2604
  const uncarried = forgeWorkflows.filter((path) => !pathAtRef(root, `origin/${branch}`, path));
2365
2605
  if (uncarried.length > 0) {
2366
- return warn(`${on}, but origin/${branch} does not carry ${nameList(uncarried)}, as this checkout last fetched it, and GitHub runs an issues or issue_comment workflow only from its default branch, so ${consequence(uncarried)} yet: commit ${uncarried.length === 1 ? 'it' : 'them'}, then run \`${WORKFLOW_SCOPE_COMMAND}\`, then \`${defaultBranchPushCommand(branch)}\`. ${WORKFLOW_SCOPE_REASON} ${defaultBranchPushReason(branch)}`);
2606
+ return warn(`${on}, but origin/${branch} does not carry ${nameList(uncarried)}, as this checkout last fetched it, and GitHub runs an issues or issue_comment workflow only from its default branch, so ${consequence(uncarried)} yet: commit ${uncarried.length === 1 ? 'it' : 'them'}, then run \`${WORKFLOW_SCOPE_COMMAND}\`, then \`${defaultBranchPushCommand(branch)}\`. ${WORKFLOW_SCOPE_REASON} ${defaultBranchPushReason(branch)}${listless.length > 0 ? ` ${listlessWarning}` : ''}`);
2367
2607
  }
2368
2608
  carried = ` and origin/${branch} carries them`;
2369
2609
  }
2610
+ if (listless.length > 0) {
2611
+ return warn(`${on}: ${WORKFLOW_TRIGGER_PATH} and ${WORKFLOW_CONTROL_PATH} are present${carried}; ${listlessWarning}`);
2612
+ }
2370
2613
  const asked = ctx.probeGithub
2371
2614
  ? `the ${REMOTE_GITHUB_CHECK.id} check above reports what GitHub says`
2372
2615
  : `\`${CLI} doctor --check-github\` asks GitHub`;