@olegkoval/agent-skills 1.52.0 → 1.54.0
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.
- package/.github/copilot-instructions.md +1 -0
- package/.github/prompts/codexloop.prompt.md +31 -7
- package/.github/prompts/nas-actions-migration.prompt.md +74 -0
- package/.github/prompts/pr-description-writer.prompt.md +25 -2
- package/.kiro/steering/codexloop.md +31 -7
- package/.windsurf/rules/codexloop.md +31 -7
- package/README.md +1 -0
- package/adapters/claude/olko-github-pr/skills/codexloop/SKILL.md +31 -7
- package/adapters/claude/olko-github-pr/skills/lekker-review/SKILL.md +97 -29
- package/adapters/claude/olko-github-pr/skills/lekker-review/references/agents/deps.md +54 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/references/artifact-page.md +39 -14
- package/adapters/claude/olko-github-pr/skills/lekker-review/references/codex-workflow.md +1 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/references/github-post.md +3 -1
- package/adapters/claude/olko-github-pr/skills/lekker-review/references/output-format.md +11 -10
- package/adapters/claude/olko-github-pr/skills/lekker-review/references/pricing.json +12 -11
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/deps-only.mjs +95 -0
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +70 -6
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +136 -16
- package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/validate-lines.mjs +122 -0
- package/adapters/claude/olko-github-pr/skills/nas-actions-migration/SKILL.md +85 -0
- package/adapters/claude/olko-github-pr/skills/pr-description-writer/SKILL.md +26 -3
- package/adapters/codex/olko-github-pr/README.md +1 -0
- package/adapters/cursor/olko-github-pr/skills/codexloop/SKILL.md +31 -7
- package/adapters/cursor/olko-github-pr/skills/nas-actions-migration/SKILL.md +85 -0
- package/adapters/cursor/olko-github-pr/skills/pr-description-writer/SKILL.md +26 -3
- package/adapters/grok/olko-github-pr/skills/codexloop/SKILL.md +31 -7
- package/adapters/grok/olko-github-pr/skills/nas-actions-migration/SKILL.md +85 -0
- package/adapters/grok/olko-github-pr/skills/pr-description-writer/SKILL.md +26 -3
- package/catalog/skills.json +21 -0
- package/package.json +1 -1
- package/plugins/olko-apple-kit/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-creative/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-garmin-kit/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-git-tools/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-github-pr/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-github-pr/skills/codexloop/SKILL.md +31 -7
- package/plugins/olko-github-pr/skills/lekker-review/SKILL.md +97 -29
- package/plugins/olko-github-pr/skills/lekker-review/references/agents/deps.md +54 -0
- package/plugins/olko-github-pr/skills/lekker-review/references/artifact-page.md +39 -14
- package/plugins/olko-github-pr/skills/lekker-review/references/codex-workflow.md +1 -0
- package/plugins/olko-github-pr/skills/lekker-review/references/github-post.md +3 -1
- package/plugins/olko-github-pr/skills/lekker-review/references/output-format.md +11 -10
- package/plugins/olko-github-pr/skills/lekker-review/references/pricing.json +12 -11
- package/plugins/olko-github-pr/skills/lekker-review/scripts/deps-only.mjs +95 -0
- package/plugins/olko-github-pr/skills/lekker-review/scripts/selftest.mjs +70 -6
- package/plugins/olko-github-pr/skills/lekker-review/scripts/setup-worktree.sh +136 -16
- package/plugins/olko-github-pr/skills/lekker-review/scripts/validate-lines.mjs +122 -0
- package/plugins/olko-github-pr/skills/lekker-review/workflow.js +43 -23
- package/plugins/olko-github-pr/skills/nas-actions-migration/SKILL.md +84 -0
- package/plugins/olko-github-pr/skills/pr-description-writer/SKILL.md +26 -3
- package/plugins/olko-obsidian/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-product/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-reflection/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-release/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-skill-meta/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-web-ops/.claude-plugin/plugin.json +1 -1
- package/scripts/lib/catalog.mjs +1 -1
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// It lifts the real functions out of workflow.js by source extraction rather
|
|
13
13
|
// than importing, because workflow.js is written for the Workflow harness (top
|
|
14
14
|
// level `return`, an injected `args` global) and is not importable as a module.
|
|
15
|
-
import { readFileSync } from 'node:fs'
|
|
15
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
16
16
|
import { fileURLToPath } from 'node:url'
|
|
17
17
|
import { dirname, join } from 'node:path'
|
|
18
18
|
|
|
@@ -142,11 +142,12 @@ check('a custom house rule is a hard rule',
|
|
|
142
142
|
|
|
143
143
|
console.log('\nverification scope: depth controls breadth, never the trust bar')
|
|
144
144
|
const crit = { severity: 'critical' }, imp = { severity: 'important' }, obs = { severity: 'observation' }
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
check('critical and important are verified, observation is not',
|
|
149
|
-
[crit, imp, obs].map(f => shouldVerify(f)),
|
|
145
|
+
// Depth used to be able to switch verification off entirely, which meant a
|
|
146
|
+
// scan-depth review shipped unverified Criticals. Only `deps` opts out now (its
|
|
147
|
+
// single checklist agent reports advisory facts), see the deps depth section.
|
|
148
|
+
check('critical and important are verified at every non-deps depth, observation is not',
|
|
149
|
+
['scan', 'medium', 'deep', undefined].map(d => [crit, imp, obs].map(f => shouldVerify(f, d))),
|
|
150
|
+
Array(4).fill([true, true, false]))
|
|
150
151
|
check('a hard rule is NOT exempt from verification',
|
|
151
152
|
shouldVerify({ severity: 'critical', rule: 'TS-2', file: 'x.js', badCode: '', description: '', title: '' }), true)
|
|
152
153
|
check('a custom hard rule is NOT exempt either',
|
|
@@ -319,5 +320,68 @@ console.log('\ncloser picker: pool, exact rendering, fail-soft and --check')
|
|
|
319
320
|
check('github-post and artifact-page both use CLOSER', read('references/github-post.md').includes('CLOSER') && read('references/artifact-page.md').includes('CLOSER'), true)
|
|
320
321
|
}
|
|
321
322
|
|
|
323
|
+
console.log('\ndeps depth: routing, classifier, line validation')
|
|
324
|
+
{
|
|
325
|
+
const dimsSrc = (() => { try { return lift('dimensionsFor') } catch { return null } })()
|
|
326
|
+
check('workflow.js defines dimensionsFor', Boolean(dimsSrc), true)
|
|
327
|
+
if (dimsSrc) {
|
|
328
|
+
const dimensionsFor = new Function(dimsSrc + '\nreturn dimensionsFor')()
|
|
329
|
+
const M = { review: 'r', triage: 't' }
|
|
330
|
+
check('deps runs exactly one agent', dimensionsFor('deps', M).map(d => d.file), ['deps.md'])
|
|
331
|
+
check('scan still runs the triage pair', dimensionsFor('scan', M).length, 2)
|
|
332
|
+
check('medium still runs five specialists', dimensionsFor('medium', M).length, 5)
|
|
333
|
+
check('every dimension prompt file exists',
|
|
334
|
+
['deps', 'scan', 'medium'].flatMap(d => dimensionsFor(d, M)).every(d => existsSync(join(SKILL, 'references', 'agents', d.file))), true)
|
|
335
|
+
}
|
|
336
|
+
check('deps verifies nothing', [crit, imp, obs].map(f => shouldVerify(f, 'deps')), [false, false, false])
|
|
337
|
+
check('scan still verifies Criticals and Importants', [crit, imp, obs].map(f => shouldVerify(f, 'scan')), [true, true, false])
|
|
338
|
+
check('deps skips the prover', /depth !== 'scan' && depth !== 'deps'/.test(src), true)
|
|
339
|
+
|
|
340
|
+
const { classify } = await import(join(SKILL, 'scripts', 'deps-only.mjs'))
|
|
341
|
+
const pkg = o => JSON.stringify(o)
|
|
342
|
+
const files = {
|
|
343
|
+
'BASE:web/package.json': pkg({ name: 'web', scripts: { t: 'vitest' }, devDependencies: { vitest: '^3.2.0' }, engines: { node: '>=20' } }),
|
|
344
|
+
'HEAD:web/package.json': pkg({ name: 'web', scripts: { t: 'vitest' }, devDependencies: { vitest: '^3.2.4' }, overrides: { tinypool: '^2.1.2' }, engines: { node: '>=20' } }),
|
|
345
|
+
'BASE:eng/package.json': pkg({ name: 'eng', engines: { node: '>=20' } }),
|
|
346
|
+
'HEAD:eng/package.json': pkg({ name: 'eng', engines: { node: '>=24' } }),
|
|
347
|
+
'BASE:mix/package.json': pkg({ name: 'mix', scripts: { t: 'jest' }, dependencies: { a: '1' } }),
|
|
348
|
+
'HEAD:mix/package.json': pkg({ name: 'mix', scripts: { t: 'vitest' }, dependencies: { a: '2' } }),
|
|
349
|
+
}
|
|
350
|
+
const readAt = (rev, p) => files[`${rev}:${p}`] ?? null
|
|
351
|
+
const deps = classify(['package-lock.json', 'web/package.json', 'web/package-lock.json'], readAt)
|
|
352
|
+
check('lockfiles + dep keys -> deps', deps.kind, 'deps')
|
|
353
|
+
check('deps reports the changed dep keys', deps.packageJsons[0].depKeysChanged, ['devDependencies', 'overrides'])
|
|
354
|
+
check('deps reports each lockfile dir', deps.lockDirs, ['.', 'web'])
|
|
355
|
+
check('engines-only change -> deps', classify(['eng/package.json'], readAt).kind, 'deps')
|
|
356
|
+
check('a scripts change -> mixed', classify(['mix/package.json', 'package-lock.json'], readAt).kind, 'mixed')
|
|
357
|
+
check('a source file -> mixed', classify(['package-lock.json', 'web/src/a.ts'], readAt).kind, 'mixed')
|
|
358
|
+
check('an added package.json -> mixed', classify(['new/package.json'], readAt).kind, 'mixed')
|
|
359
|
+
|
|
360
|
+
const { validate, loadDedup } = await import(join(SKILL, 'scripts', 'validate-lines.mjs'))
|
|
361
|
+
const { mkdtempSync, writeFileSync: wf, mkdirSync } = await import('node:fs')
|
|
362
|
+
const { tmpdir } = await import('node:os')
|
|
363
|
+
const wt = mkdtempSync(join(tmpdir(), 'lekker-lines-'))
|
|
364
|
+
mkdirSync(join(wt, 'web'))
|
|
365
|
+
const body = Array.from({ length: 80 }, (_, i) => ` "pad${i}": "x",`)
|
|
366
|
+
body[69] = ' "tinypool": "^2.1.2"'
|
|
367
|
+
wf(join(wt, 'web', 'package.json'), body.join('\n'))
|
|
368
|
+
const dedupFn = loadDedup(target)
|
|
369
|
+
const f = (line, badCode, title = 'tinypool override forces a major') =>
|
|
370
|
+
({ file: 'web/package.json', line, severity: 'observation', title, description: '', badCode, fix: '' })
|
|
371
|
+
const r = validate([
|
|
372
|
+
f(398, '+ "tinypool": "^2.1.2"'),
|
|
373
|
+
f(70, '"tinypool": "^2.1.2"', 'tinypool override forces a major vitest does not declare'),
|
|
374
|
+
f(12, 'something that is not in the file at all', 'phantom'),
|
|
375
|
+
f(71, '"tinypool": "^2.1.2"', 'near enough'),
|
|
376
|
+
{ file: 'CI: checks / audit', line: 1, severity: 'observation', title: 'ci', description: '', badCode: 'x', fix: '' },
|
|
377
|
+
], wt, dedupFn)
|
|
378
|
+
check('diff line 398 is re-anchored to file line 70, then merged with its twin',
|
|
379
|
+
r.findings.filter(x => x.line === 70 && /tinypool/.test(x.title)).length, 1)
|
|
380
|
+
check('an unfindable badCode nulls the line', r.findings.find(x => x.title === 'phantom')?.line, null)
|
|
381
|
+
check('within +-3 lines is kept', r.findings.some(x => x.line === 71 || x.title === 'near enough'), true)
|
|
382
|
+
check('a non-file anchor is left alone', r.findings.find(x => x.title === 'ci')?.line, 1)
|
|
383
|
+
check('summary counts', [r.summary.remapped, r.summary.nulled, r.summary.unchecked], [1, 1, 1])
|
|
384
|
+
}
|
|
385
|
+
|
|
322
386
|
console.log(failed === 0 ? '\nall checks passed\n' : `\n${failed} check(s) FAILED\n`)
|
|
323
387
|
process.exit(failed === 0 ? 0 : 1)
|
|
@@ -134,7 +134,23 @@ if [[ -n "$LOCAL_REPO" ]]; then
|
|
|
134
134
|
exit 1
|
|
135
135
|
fi
|
|
136
136
|
else
|
|
137
|
-
|
|
137
|
+
# Fetch the base branch too: without it origin/<base> is whatever this clone
|
|
138
|
+
# last saw, and the merge-base drifts back far enough to list files the PR
|
|
139
|
+
# never touched (65 instead of 9 on one review). Explicit refspecs, because
|
|
140
|
+
# a --depth clone is single-branch and would not update either ref.
|
|
141
|
+
# LEKKER_BASE_BRANCH names the branch to fetch when LEKKER_BASE_REF is not
|
|
142
|
+
# origin/<branch>: a merged PR is diffed against <mergeCommit>^1, because its
|
|
143
|
+
# head is already inside the base branch and the merge-base would be the head.
|
|
144
|
+
FETCH_SPECS=("+refs/heads/${PR_BRANCH}:refs/remotes/origin/${PR_BRANCH}")
|
|
145
|
+
BASE_BRANCH="${LEKKER_BASE_BRANCH:-}"
|
|
146
|
+
if [[ -z "$BASE_BRANCH" && "${LEKKER_BASE_REF:-}" == origin/* ]]; then
|
|
147
|
+
BASE_BRANCH="${LEKKER_BASE_REF#origin/}"
|
|
148
|
+
fi
|
|
149
|
+
if [[ -n "$BASE_BRANCH" ]]; then
|
|
150
|
+
FETCH_SPECS+=("+refs/heads/${BASE_BRANCH}:refs/remotes/origin/${BASE_BRANCH}")
|
|
151
|
+
fi
|
|
152
|
+
git -C "$REPO_ROOT" fetch origin "${FETCH_SPECS[@]}" 2>/dev/null \
|
|
153
|
+
|| git -C "$REPO_ROOT" fetch origin "$PR_BRANCH" 2>/dev/null || true
|
|
138
154
|
|
|
139
155
|
if ! git -C "$REPO_ROOT" worktree add "$WORKTREE_PATH" "origin/$PR_BRANCH" 2>/dev/null; then
|
|
140
156
|
if ! git -C "$REPO_ROOT" worktree add "$WORKTREE_PATH" FETCH_HEAD 2>/dev/null; then
|
|
@@ -146,19 +162,9 @@ else
|
|
|
146
162
|
fi
|
|
147
163
|
|
|
148
164
|
# ---------------------------------------------------------------------------
|
|
149
|
-
# 3.
|
|
150
|
-
#
|
|
151
|
-
|
|
152
|
-
if [[ -d "$REPO_ROOT/node_modules" ]]; then
|
|
153
|
-
ln -s "$REPO_ROOT/node_modules" "$WORKTREE_PATH/node_modules"
|
|
154
|
-
NM_LINKED=true
|
|
155
|
-
else
|
|
156
|
-
NOTES="${NOTES}no-node_modules-static-checks-skipped;"
|
|
157
|
-
fi
|
|
158
|
-
|
|
159
|
-
# ---------------------------------------------------------------------------
|
|
160
|
-
# 3b. Determine changed files (to scope tsc/eslint to this PR's diff instead
|
|
161
|
-
# of the whole repo -- see scripts/changed-files.sh).
|
|
165
|
+
# 3. Determine changed files (to scope tsc/eslint to this PR's diff instead
|
|
166
|
+
# of the whole repo -- see scripts/changed-files.sh). Runs before the
|
|
167
|
+
# node_modules step, which needs to know whether a lockfile changed.
|
|
162
168
|
# ---------------------------------------------------------------------------
|
|
163
169
|
CHANGED_FILES_FILE="/tmp/lekker-changed-$$.txt"
|
|
164
170
|
CF_ERR_FILE="$(mktemp "${TMPDIR:-/tmp}/lekker-changed-err.XXXXXX")"
|
|
@@ -188,6 +194,100 @@ else
|
|
|
188
194
|
NOTES="${NOTES}static-checks-repo-wide-no-base-ref;"
|
|
189
195
|
fi
|
|
190
196
|
|
|
197
|
+
# The caller passes the PR's own file count (gh pr view) so a merge-base that
|
|
198
|
+
# still drifted is reported instead of silently widening the static checks.
|
|
199
|
+
if [[ -n "${LEKKER_EXPECTED_FILES:-}" ]] && [[ -f "$CHANGED_FILES_FILE" ]]; then
|
|
200
|
+
GOT_FILES="$(grep -c . "$CHANGED_FILES_FILE" 2>/dev/null || true)"
|
|
201
|
+
if [[ "${GOT_FILES:-0}" != "$LEKKER_EXPECTED_FILES" ]]; then
|
|
202
|
+
NOTES="${NOTES}changed-files-mismatch:${GOT_FILES:-0}-vs-${LEKKER_EXPECTED_FILES};"
|
|
203
|
+
fi
|
|
204
|
+
fi
|
|
205
|
+
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
# 3b. node_modules
|
|
208
|
+
# A PR that changes a lockfile gets a fresh `npm ci` in each changed
|
|
209
|
+
# lockfile's dir: the repo root's node_modules holds the OLD tree, so
|
|
210
|
+
# symlinking it would type-check and lint against dependencies the PR
|
|
211
|
+
# replaced. Every other PR symlinks the root's node_modules as before.
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
NM_LINKED=false
|
|
214
|
+
DEPS_INSTALL_FILE="/tmp/lekker-deps-install-$$.tsv"
|
|
215
|
+
printf '' > "$DEPS_INSTALL_FILE"
|
|
216
|
+
|
|
217
|
+
LOCK_DIRS=()
|
|
218
|
+
if [[ -s "$CHANGED_FILES_FILE" ]]; then
|
|
219
|
+
while IFS= read -r cf; do
|
|
220
|
+
case "$cf" in
|
|
221
|
+
package-lock.json|npm-shrinkwrap.json) LOCK_DIRS+=(".") ;;
|
|
222
|
+
*/package-lock.json|*/npm-shrinkwrap.json) LOCK_DIRS+=("${cf%/*}") ;;
|
|
223
|
+
yarn.lock|*/yarn.lock|pnpm-lock.yaml|*/pnpm-lock.yaml|bun.lock|*/bun.lock|bun.lockb|*/bun.lockb)
|
|
224
|
+
NOTES="${NOTES}deps-install-unsupported:${cf};" ;;
|
|
225
|
+
esac
|
|
226
|
+
done < "$CHANGED_FILES_FILE"
|
|
227
|
+
fi
|
|
228
|
+
|
|
229
|
+
# Portable timeout (macOS has no `timeout`). Exit 143 means it was killed.
|
|
230
|
+
run_with_timeout() {
|
|
231
|
+
local secs pid watcher rc
|
|
232
|
+
secs="$1"; shift
|
|
233
|
+
"$@" &
|
|
234
|
+
pid=$!
|
|
235
|
+
( sleep "$secs"; kill -TERM "$pid" 2>/dev/null ) &
|
|
236
|
+
watcher=$!
|
|
237
|
+
rc=0
|
|
238
|
+
wait "$pid" || rc=$?
|
|
239
|
+
kill "$watcher" 2>/dev/null || true
|
|
240
|
+
wait "$watcher" 2>/dev/null || true
|
|
241
|
+
return "$rc"
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
ROOT_LOCK_CHANGED=false
|
|
245
|
+
for d in "${LOCK_DIRS[@]+"${LOCK_DIRS[@]}"}"; do
|
|
246
|
+
[[ "$d" == "." ]] && ROOT_LOCK_CHANGED=true
|
|
247
|
+
done
|
|
248
|
+
|
|
249
|
+
if [[ ${#LOCK_DIRS[@]} -gt 0 ]] && [[ -n "${LEKKER_NO_INSTALL:-}" ]]; then
|
|
250
|
+
NOTES="${NOTES}deps-install-disabled;"
|
|
251
|
+
elif [[ ${#LOCK_DIRS[@]} -gt 0 ]]; then
|
|
252
|
+
for d in "${LOCK_DIRS[@]}"; do
|
|
253
|
+
log_file="/tmp/lekker-npm-ci-$$-${d//\//_}.log"
|
|
254
|
+
# --ignore-scripts: a dependency bump under review must not get to run
|
|
255
|
+
# its postinstall on the reviewer's machine.
|
|
256
|
+
rc=0
|
|
257
|
+
(cd "$WORKTREE_PATH/$d" && run_with_timeout 300 npm ci --ignore-scripts \
|
|
258
|
+
--no-audit --no-fund --prefer-offline) > "$log_file" 2>&1 || rc=$?
|
|
259
|
+
if [[ "$rc" == "0" ]]; then
|
|
260
|
+
status="ok"
|
|
261
|
+
[[ "$d" == "." ]] && NM_LINKED=true
|
|
262
|
+
elif [[ "$rc" == "143" ]]; then
|
|
263
|
+
status="timeout"
|
|
264
|
+
else
|
|
265
|
+
status="failed"
|
|
266
|
+
fi
|
|
267
|
+
printf '%s\t%s\t%s\n' "$d" "$status" "$log_file" >> "$DEPS_INSTALL_FILE"
|
|
268
|
+
[[ "$status" == "ok" ]] || NOTES="${NOTES}deps-install-${status}:${d};"
|
|
269
|
+
done
|
|
270
|
+
fi
|
|
271
|
+
|
|
272
|
+
if [[ "$ROOT_LOCK_CHANGED" == "false" ]]; then
|
|
273
|
+
if [[ -d "$REPO_ROOT/node_modules" ]]; then
|
|
274
|
+
ln -s "$REPO_ROOT/node_modules" "$WORKTREE_PATH/node_modules"
|
|
275
|
+
NM_LINKED=true
|
|
276
|
+
fi
|
|
277
|
+
fi
|
|
278
|
+
|
|
279
|
+
# Nested packages (web/, extensions/*) resolve imports from their own
|
|
280
|
+
# node_modules; without it tsc reports every import in them as TS2307.
|
|
281
|
+
while IFS= read -r pj; do
|
|
282
|
+
d="${pj#"$WORKTREE_PATH"/}"; d="${d%/package.json}"
|
|
283
|
+
[[ -e "$WORKTREE_PATH/$d/node_modules" ]] && continue
|
|
284
|
+
[[ -d "$REPO_ROOT/$d/node_modules" ]] || continue
|
|
285
|
+
case " ${LOCK_DIRS[*]+"${LOCK_DIRS[*]}"} " in *" $d "*) continue ;; esac
|
|
286
|
+
ln -s "$REPO_ROOT/$d/node_modules" "$WORKTREE_PATH/$d/node_modules"
|
|
287
|
+
done < <(find "$WORKTREE_PATH" -mindepth 2 -maxdepth 3 -name package.json \
|
|
288
|
+
-not -path '*/node_modules/*' 2>/dev/null)
|
|
289
|
+
[[ "$NM_LINKED" == "true" ]] || NOTES="${NOTES}no-node_modules-static-checks-skipped;"
|
|
290
|
+
|
|
191
291
|
# Lintable, still-on-disk changed files (a deleted file must not go to eslint).
|
|
192
292
|
ESLINT_TARGETS=()
|
|
193
293
|
if [[ -s "$CHANGED_FILES_FILE" ]]; then
|
|
@@ -327,6 +427,7 @@ python3 - \
|
|
|
327
427
|
"$TSC_CHANGED_FILE" \
|
|
328
428
|
"$TSC_COUNT_FILE" \
|
|
329
429
|
"$MERGE_BASE_VAL" \
|
|
430
|
+
"$DEPS_INSTALL_FILE" \
|
|
330
431
|
"$OUT_JSON" <<'PYEOF'
|
|
331
432
|
import json, sys, os
|
|
332
433
|
|
|
@@ -334,7 +435,7 @@ import json, sys, os
|
|
|
334
435
|
tsc_file, eslint_file, rules_file, delta_file, notes,
|
|
335
436
|
changed_files_file, base_ref_val, eslint_scope_file,
|
|
336
437
|
tsc_changed_file, tsc_count_file, merge_base_val,
|
|
337
|
-
out_json) = sys.argv[1:
|
|
438
|
+
deps_install_file, out_json) = sys.argv[1:18]
|
|
338
439
|
|
|
339
440
|
def read_file(path):
|
|
340
441
|
if not os.path.exists(path):
|
|
@@ -342,6 +443,23 @@ def read_file(path):
|
|
|
342
443
|
with open(path, "r", errors="replace") as f:
|
|
343
444
|
return f.read()
|
|
344
445
|
|
|
446
|
+
# One row per changed-lockfile dir: dir<TAB>status<TAB>log path. Empty = no
|
|
447
|
+
# lockfile changed (or install disabled), so the root node_modules was linked.
|
|
448
|
+
deps_install = []
|
|
449
|
+
for row in read_file(deps_install_file).splitlines():
|
|
450
|
+
parts = row.split("\t")
|
|
451
|
+
if len(parts) != 3:
|
|
452
|
+
continue
|
|
453
|
+
d, status, log = parts
|
|
454
|
+
entry = {"dir": d, "status": status}
|
|
455
|
+
if status != "ok":
|
|
456
|
+
entry["tail"] = "\n".join(read_file(log).splitlines()[-20:])
|
|
457
|
+
deps_install.append(entry)
|
|
458
|
+
try:
|
|
459
|
+
os.remove(log)
|
|
460
|
+
except OSError:
|
|
461
|
+
pass
|
|
462
|
+
|
|
345
463
|
changed_files = [l for l in read_file(changed_files_file).splitlines() if l.strip()]
|
|
346
464
|
|
|
347
465
|
eslint_scope = read_file(eslint_scope_file).strip() or "skipped"
|
|
@@ -367,10 +485,12 @@ data = {
|
|
|
367
485
|
"eslintScope": eslint_scope,
|
|
368
486
|
"tscChangedTail": read_file(tsc_changed_file),
|
|
369
487
|
"tscErrorCount": tsc_error_count,
|
|
488
|
+
"depsInstall": deps_install,
|
|
370
489
|
}
|
|
371
490
|
|
|
372
491
|
for p in (tsc_file, eslint_file, rules_file, changed_files_file,
|
|
373
|
-
eslint_scope_file, tsc_changed_file, tsc_count_file
|
|
492
|
+
eslint_scope_file, tsc_changed_file, tsc_count_file,
|
|
493
|
+
deps_install_file):
|
|
374
494
|
try:
|
|
375
495
|
os.remove(p)
|
|
376
496
|
except OSError:
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Checks every finding's `line` against the file at HEAD before synthesis.
|
|
3
|
+
// Reviewers sometimes cite a line number of pr.diff instead of the file (a
|
|
4
|
+
// 2026-10-15 finding said package.json:398 for code at line 70), and an
|
|
5
|
+
// inline GitHub comment on the wrong line is worse than none.
|
|
6
|
+
//
|
|
7
|
+
// node validate-lines.mjs <findings.json> <worktreePath>
|
|
8
|
+
//
|
|
9
|
+
// <findings.json> is an array or { findings: [...] }; it is rewritten in
|
|
10
|
+
// place, same shape. For each finding whose file exists in the worktree:
|
|
11
|
+
// - badCode's first non-blank line within +-3 of `line` -> kept
|
|
12
|
+
// - otherwise one unique match in the file -> re-anchored, lineRemapped: true
|
|
13
|
+
// - no match -> line: null (the finding becomes PR-level, not inline)
|
|
14
|
+
// - several matches -> kept, lineAmbiguous: true
|
|
15
|
+
// Then dedup (lifted from workflow.js) merges findings that now share an anchor.
|
|
16
|
+
// Prints a one-line JSON summary on stdout.
|
|
17
|
+
import { readFileSync, writeFileSync, existsSync, realpathSync } from 'node:fs'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
import { dirname, join, resolve, sep } from 'node:path'
|
|
20
|
+
|
|
21
|
+
const WINDOW = 3
|
|
22
|
+
const SKILL = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
23
|
+
|
|
24
|
+
function candidates(badCode) {
|
|
25
|
+
const first = String(badCode || '').split('\n').map(l => l.trim()).find(l => l.length > 0)
|
|
26
|
+
if (!first) return []
|
|
27
|
+
// Agents copy from the diff and sometimes keep its "+" marker.
|
|
28
|
+
const unmarked = first.replace(/^\+\s?/, '').trim()
|
|
29
|
+
return [...new Set([first, unmarked])].filter(Boolean)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function matches(fileLine, cands) {
|
|
33
|
+
const t = fileLine.trim()
|
|
34
|
+
return cands.some(c => t === c || (c.length >= 12 && t.includes(c)))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function anchor(finding, fileLines) {
|
|
38
|
+
const cands = candidates(finding.badCode)
|
|
39
|
+
if (cands.length === 0 || !Number.isInteger(finding.line)) return { finding, outcome: 'unchecked' }
|
|
40
|
+
const near = []
|
|
41
|
+
for (let n = Math.max(1, finding.line - WINDOW); n <= Math.min(fileLines.length, finding.line + WINDOW); n++) {
|
|
42
|
+
if (matches(fileLines[n - 1], cands)) near.push(n)
|
|
43
|
+
}
|
|
44
|
+
if (near.length > 0) return { finding, outcome: 'kept' }
|
|
45
|
+
const hits = []
|
|
46
|
+
fileLines.forEach((l, i) => { if (matches(l, cands)) hits.push(i + 1) })
|
|
47
|
+
if (hits.length === 1) {
|
|
48
|
+
return { finding: { ...finding, line: hits[0], originalLine: finding.line, lineRemapped: true }, outcome: 'remapped' }
|
|
49
|
+
}
|
|
50
|
+
if (hits.length === 0) {
|
|
51
|
+
return { finding: { ...finding, line: null, originalLine: finding.line }, outcome: 'nulled' }
|
|
52
|
+
}
|
|
53
|
+
return { finding: { ...finding, lineAmbiguous: true }, outcome: 'ambiguous' }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Same source-lift selftest.mjs uses: workflow.js runs inside the Workflow
|
|
57
|
+
// harness and cannot be imported, so its dedup is extracted by brace matching.
|
|
58
|
+
export function loadDedup(workflowPath = join(SKILL, 'workflow.js')) {
|
|
59
|
+
const src = readFileSync(workflowPath, 'utf8')
|
|
60
|
+
const lift = name => {
|
|
61
|
+
const i = src.indexOf(`function ${name}`)
|
|
62
|
+
if (i === -1) throw new Error(`function ${name} not found in workflow.js`)
|
|
63
|
+
let d = 0, j = i
|
|
64
|
+
for (;; j++) {
|
|
65
|
+
if (src[j] === '{') d++
|
|
66
|
+
else if (src[j] === '}') { d--; if (d === 0) break }
|
|
67
|
+
}
|
|
68
|
+
return src.slice(i, j + 1)
|
|
69
|
+
}
|
|
70
|
+
const liftConst = (name, fallback) => {
|
|
71
|
+
const m = new RegExp(`^const ${name} = .*$`, 'm').exec(src)
|
|
72
|
+
return m ? m[0] : fallback
|
|
73
|
+
}
|
|
74
|
+
const preamble = [
|
|
75
|
+
liftConst('HARD_RULES', 'const HARD_RULES = new Set()'),
|
|
76
|
+
liftConst('SAME_ISSUE_LINE_WINDOW', 'const SAME_ISSUE_LINE_WINDOW = 30'),
|
|
77
|
+
liftConst('SEVERITY_RANK', 'const SEVERITY_RANK = { critical: 3, important: 2, observation: 1, idiomatic: 0 }'),
|
|
78
|
+
...['titleTokens', 'sameIssue', 'nearbyLines', 'spanWithinWindow',
|
|
79
|
+
'isHardRule', 'longest', 'mergeFindings', 'dedup'].map(lift),
|
|
80
|
+
].join('\n')
|
|
81
|
+
return new Function(preamble + '\nreturn dedup')()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function validate(findings, worktree, dedup) {
|
|
85
|
+
const root = resolve(worktree)
|
|
86
|
+
const summary = { kept: 0, remapped: 0, nulled: 0, ambiguous: 0, unchecked: 0, merged: 0 }
|
|
87
|
+
const cache = new Map()
|
|
88
|
+
const out = findings.map(f => {
|
|
89
|
+
const path = resolve(root, String(f.file || ''))
|
|
90
|
+
if (!f.file || !path.startsWith(root + sep) || !existsSync(path)) {
|
|
91
|
+
summary.unchecked++
|
|
92
|
+
return f
|
|
93
|
+
}
|
|
94
|
+
if (!cache.has(path)) cache.set(path, readFileSync(path, 'utf8').split('\n'))
|
|
95
|
+
const { finding, outcome } = anchor(f, cache.get(path))
|
|
96
|
+
summary[outcome]++
|
|
97
|
+
return finding
|
|
98
|
+
})
|
|
99
|
+
// dedup compares line distances, so only anchored findings go through it.
|
|
100
|
+
const anchored = out.filter(f => Number.isInteger(f.line))
|
|
101
|
+
const rest = out.filter(f => !Number.isInteger(f.line))
|
|
102
|
+
const merged = summary.remapped > 0 && dedup ? dedup(anchored) : anchored
|
|
103
|
+
summary.merged = anchored.length - merged.length
|
|
104
|
+
return { findings: merged.concat(rest), summary }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isMain() {
|
|
108
|
+
try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)) } catch { return false }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (isMain()) {
|
|
112
|
+
const [file, worktree] = process.argv.slice(2)
|
|
113
|
+
if (!file || !worktree) {
|
|
114
|
+
console.error('usage: validate-lines.mjs <findings.json> <worktreePath>')
|
|
115
|
+
process.exit(2)
|
|
116
|
+
}
|
|
117
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'))
|
|
118
|
+
const list = Array.isArray(raw) ? raw : (raw.findings || [])
|
|
119
|
+
const { findings, summary } = validate(list, worktree, loadDedup())
|
|
120
|
+
writeFileSync(file, JSON.stringify(Array.isArray(raw) ? findings : { ...raw, findings }, null, 2))
|
|
121
|
+
console.log(JSON.stringify(summary))
|
|
122
|
+
}
|
|
@@ -280,13 +280,42 @@ function dedup(allFindings) {
|
|
|
280
280
|
return result
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
-
function shouldVerify(finding) {
|
|
284
|
-
//
|
|
285
|
-
//
|
|
283
|
+
function shouldVerify(finding, depth) {
|
|
284
|
+
// deps: one checklist agent over version bumps. Its findings are advisory
|
|
285
|
+
// facts (audit delta, install result, override vs declared range), not
|
|
286
|
+
// runtime-failure claims, so there is nothing for a verifier to refute.
|
|
287
|
+
if (depth === 'deps') {
|
|
288
|
+
return false
|
|
289
|
+
}
|
|
290
|
+
// Every other depth: review depth controls breadth, not the trust bar. Every finding
|
|
291
|
+
// that can affect the verdict must be checked. Hard rules take the verifier's
|
|
286
292
|
// rule-specific anchor/applicability path instead of its runtime challenges.
|
|
287
293
|
return finding.severity === 'critical' || finding.severity === 'important'
|
|
288
294
|
}
|
|
289
295
|
|
|
296
|
+
// Which reviewers run at each depth. `deps` is a PR whose only changes are
|
|
297
|
+
// lockfiles and the dependency keys of package.json: five specialists would
|
|
298
|
+
// produce a couple of notes that a single checklist agent covers, so it gets
|
|
299
|
+
// exactly one.
|
|
300
|
+
function dimensionsFor(depth, MODEL) {
|
|
301
|
+
if (depth === 'deps') {
|
|
302
|
+
return [{ key: 'deps', file: 'deps.md', model: MODEL.review }]
|
|
303
|
+
}
|
|
304
|
+
if (depth === 'scan') {
|
|
305
|
+
return [
|
|
306
|
+
{ key: 'triage-quality', file: 'triage-quality.md', model: MODEL.triage },
|
|
307
|
+
{ key: 'triage-logic', file: 'triage-logic.md', model: MODEL.triage },
|
|
308
|
+
]
|
|
309
|
+
}
|
|
310
|
+
return [
|
|
311
|
+
{ key: 'quality', file: 'quality.md', model: MODEL.review },
|
|
312
|
+
{ key: 'implementation', file: 'implementation.md', model: MODEL.review },
|
|
313
|
+
{ key: 'simplification', file: 'simplification.md', model: MODEL.review },
|
|
314
|
+
{ key: 'conventions', file: 'conventions.md', model: MODEL.review },
|
|
315
|
+
{ key: 'test-quality', file: 'test-quality.md', model: MODEL.review },
|
|
316
|
+
]
|
|
317
|
+
}
|
|
318
|
+
|
|
290
319
|
/**
|
|
291
320
|
* Does the finding carry enough of a composed boundary map to attempt a
|
|
292
321
|
* proof? Requires an entry point and a consumer, plus at least one of a
|
|
@@ -465,20 +494,8 @@ const budgetAtStart = budget.spent()
|
|
|
465
494
|
// Dimensions by depth
|
|
466
495
|
// -------------------------------------------------------------------------
|
|
467
496
|
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
const dimensions = isScan
|
|
471
|
-
? [
|
|
472
|
-
{ key: 'triage-quality', file: 'triage-quality.md', model: 'haiku' },
|
|
473
|
-
{ key: 'triage-logic', file: 'triage-logic.md', model: 'haiku' },
|
|
474
|
-
]
|
|
475
|
-
: [
|
|
476
|
-
{ key: 'quality', file: 'quality.md', model: 'sonnet' },
|
|
477
|
-
{ key: 'implementation', file: 'implementation.md', model: 'sonnet' },
|
|
478
|
-
{ key: 'simplification', file: 'simplification.md', model: 'sonnet' },
|
|
479
|
-
{ key: 'conventions', file: 'conventions.md', model: 'sonnet' },
|
|
480
|
-
{ key: 'test-quality', file: 'test-quality.md', model: 'sonnet' },
|
|
481
|
-
]
|
|
497
|
+
const MODEL = { review: 'sonnet', triage: 'haiku' }
|
|
498
|
+
const dimensions = dimensionsFor(depth, MODEL)
|
|
482
499
|
|
|
483
500
|
const wtDisplay = worktreePath || 'null (scan mode: diff only)'
|
|
484
501
|
|
|
@@ -497,6 +514,9 @@ const budgetAtStart = budget.spent()
|
|
|
497
514
|
+ ` (it is a REQUIRED property, so do not omit it even when there is nothing to report).`
|
|
498
515
|
+ ` If you found no issues, return {"findings": []}, never prose, never an empty object,`
|
|
499
516
|
+ ` never a "findings" key nested under anything else.`,
|
|
517
|
+
`LINE NUMBERS: each finding's "line" is the 1-based line in the file at HEAD,`
|
|
518
|
+
+ ` counted from the hunk header (@@ -a,b +c,d @@ starts at line c), never a line`
|
|
519
|
+
+ ` number of DIFF_FILE itself. Quote the code at that line verbatim in "badCode".`,
|
|
500
520
|
].join(' ')
|
|
501
521
|
|
|
502
522
|
if (prevSha) {
|
|
@@ -555,7 +575,7 @@ const budgetAtStart = budget.spent()
|
|
|
555
575
|
})
|
|
556
576
|
|
|
557
577
|
log(`${dim.key}: ${findings.length} findings, ${
|
|
558
|
-
findings.filter(function(f) { return shouldVerify(f) }).length
|
|
578
|
+
findings.filter(function(f) { return shouldVerify(f, depth) }).length
|
|
559
579
|
} to verify`)
|
|
560
580
|
|
|
561
581
|
return {
|
|
@@ -577,7 +597,7 @@ const budgetAtStart = budget.spent()
|
|
|
577
597
|
|
|
578
598
|
// A model verdict cannot replace the required production boundary map.
|
|
579
599
|
function enforceBoundary(finding) {
|
|
580
|
-
if (!shouldVerify(finding)) return finding
|
|
600
|
+
if (!shouldVerify(finding, depth)) return finding
|
|
581
601
|
const boundary = finding.boundary
|
|
582
602
|
const hasText = function(value) {
|
|
583
603
|
return typeof value === 'string' && value.trim().length > 0
|
|
@@ -599,10 +619,10 @@ const budgetAtStart = budget.spent()
|
|
|
599
619
|
async function verifyAll(findings) {
|
|
600
620
|
findings = findings.map(enforceBoundary)
|
|
601
621
|
const inScope = findings.filter(function(f) {
|
|
602
|
-
return shouldVerify(f)
|
|
622
|
+
return shouldVerify(f, depth)
|
|
603
623
|
})
|
|
604
624
|
const outOfScope = findings.filter(function(f) {
|
|
605
|
-
return !shouldVerify(f)
|
|
625
|
+
return !shouldVerify(f, depth)
|
|
606
626
|
})
|
|
607
627
|
|
|
608
628
|
if (inScope.length === 0) {
|
|
@@ -712,7 +732,7 @@ const budgetAtStart = budget.spent()
|
|
|
712
732
|
}))
|
|
713
733
|
|
|
714
734
|
log(`${deduped.length} unique finding(s) after dedup; verifying ${
|
|
715
|
-
deduped.filter(function(f) { return shouldVerify(f) }).length
|
|
735
|
+
deduped.filter(function(f) { return shouldVerify(f, depth) }).length
|
|
716
736
|
}`)
|
|
717
737
|
|
|
718
738
|
phase('Verify')
|
|
@@ -850,7 +870,7 @@ const budgetAtStart = budget.spent()
|
|
|
850
870
|
].join(' ')
|
|
851
871
|
}
|
|
852
872
|
|
|
853
|
-
if (worktreePath && depth !== 'scan') {
|
|
873
|
+
if (worktreePath && depth !== 'scan' && depth !== 'deps') {
|
|
854
874
|
const proveCandidates = finalFindings.filter(function(f) {
|
|
855
875
|
return shouldProve(f) && !isHardRule(f)
|
|
856
876
|
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: nas-actions-migration
|
|
3
|
+
description: Migrate GitHub Actions jobs to a repository-scoped NAS runner safely.
|
|
4
|
+
license: MIT
|
|
5
|
+
allowed-tools: Bash, Read, Write, Edit
|
|
6
|
+
compatibility: Codex, Claude Code, Cursor, GitHub Copilot, Windsurf, Kiro, and other Agent Skills compatible tools. Requires authenticated GitHub CLI, repository access, and NAS runner administration.
|
|
7
|
+
metadata:
|
|
8
|
+
targets: [_source-only]
|
|
9
|
+
author: Oleg Koval
|
|
10
|
+
tags:
|
|
11
|
+
- github-actions
|
|
12
|
+
- self-hosted-runner
|
|
13
|
+
- nas
|
|
14
|
+
- ci
|
|
15
|
+
- deployment
|
|
16
|
+
- migration
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
# NAS Actions Migration
|
|
20
|
+
|
|
21
|
+
Migrate eligible GitHub Actions jobs from hosted runners to a dedicated, repository-scoped NAS runner, following the proven Roazon and Funda Radar pattern. This workflow prepares repository changes and documents runner provisioning; it does not claim migration complete until jobs actually execute on the named NAS runner. Never put deployment credentials on a persistent self-hosted runner unless the user explicitly approves that trust boundary.
|
|
22
|
+
|
|
23
|
+
## When to Use
|
|
24
|
+
|
|
25
|
+
Use when asked to move Actions/CI jobs to a NAS, copy the Roazon/Funda Radar runner setup, or establish a repeatable NAS migration for another repository.
|
|
26
|
+
|
|
27
|
+
## Prerequisites
|
|
28
|
+
|
|
29
|
+
- Authenticated `gh` with access to the target repo and its Actions settings.
|
|
30
|
+
- SSH/admin access to the NAS and an approved secret manager for durable runner credentials.
|
|
31
|
+
- Read repository instructions and the target workflows before changing them.
|
|
32
|
+
- Inspect existing comparable repos; treat runner names, labels, commands, and service-specific workflow behavior as examples, not copy-ready values.
|
|
33
|
+
|
|
34
|
+
## Procedure
|
|
35
|
+
|
|
36
|
+
1. **Inventory the target.** Record every workflow, job, `runs-on`, trigger, fork behavior, required secrets, runtime/dependency setup, timeouts, deploy side effects, and current status. Determine the repository default branch and ensure the checkout is clean. Check existing repository runners and variables:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
gh api repos/OWNER/REPO/actions/runners --jq '.runners[] | {name,status,labels:[.labels[].name]}'
|
|
40
|
+
gh variable list --repo OWNER/REPO
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
2. **Inspect a known-good migration.** Compare the requested repository with the live `roazon` and `funda-radar` workflows and runner docs. Use a unique runner label for the target repo: repository-scoped runners cannot be shared across repositories. Never assume a label, runner container, or variable is already present.
|
|
44
|
+
|
|
45
|
+
3. **Classify jobs.** Move builds/tests/lint and other trusted, non-secret CI work first. Keep deploy jobs on hosted runners by default so deploy credentials never reach the NAS. Move additional jobs only after assessing credential exposure, untrusted code paths, network/data access, persistence, and resource requirements. Preserve existing required checks and non-blocking semantics.
|
|
46
|
+
|
|
47
|
+
4. **Route through an explicit opt-in variable.** Use a repository variable such as `CI_RUNNER=nas`, with a hosted-runner fallback until activation:
|
|
48
|
+
|
|
49
|
+
```yaml
|
|
50
|
+
runs-on: ${{ vars.CI_RUNNER == 'nas' && fromJSON('["self-hosted","linux","REPO-nas"]') || 'ubuntu-latest' }}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
For every job that can execute repository-controlled code on the NAS, add a fork guard:
|
|
54
|
+
|
|
55
|
+
```yaml
|
|
56
|
+
if: >-
|
|
57
|
+
github.event_name != 'pull_request'
|
|
58
|
+
|| github.event.pull_request.head.repo.full_name == github.repository
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Guard each job independently. Never rely on a downstream job's guard to protect an unguarded dependency. Add exact toolchain setup because runner images may not contain Node, Bun, browsers, Docker, or compatible system libraries.
|
|
62
|
+
|
|
63
|
+
5. **Document provisioning and rollback.** Provide the target repo's exact runner name, label, container/image, dedicated work directory, ephemeral/restart settings, required tool installation, safe secret storage, and health check. For persistent runners, use a narrowly scoped fine-grained PAT with repository Administration read/write in the NAS secret store; pass it as `ACCESS_TOKEN` only via a root-readable env file. Do not print, commit, message, or substitute a short-lived registration token for a restart-persistent credential. Never reuse another repo's `/_work` or token.
|
|
64
|
+
|
|
65
|
+
Document rollback as deleting the opt-in variable, which routes eligible jobs back to `ubuntu-latest`. State that this fallback still depends on GitHub Actions billing being available.
|
|
66
|
+
|
|
67
|
+
6. **Provision before activation.** Confirm the target repository runner is online and has `self-hosted`, `Linux`, and the exact repo label. If there is no approved NAS access/token, stop before registration and leave the variable unset. Never create a fake runner or assume an online runner in another repo is usable.
|
|
68
|
+
|
|
69
|
+
7. **Activate and exercise the real path.** Set the repository variable only after runner readiness. Dispatch each affected workflow on a trusted branch, then inspect run/job details and confirm every moved job reports the intended `runner_name`, expected runner labels, and passes. A green result on `ubuntu-latest`, a skipped job, or a successful deployment-provider status does not verify NAS execution.
|
|
70
|
+
|
|
71
|
+
8. **Recheck safety and report.** Review the final diff, validate workflow syntax, run any local lint/tests available, and inspect for unsafe fork access, missing setup, accidental secret exposure, and unintended deploy changes. Report changed workflows, moved and intentionally unmoved jobs, runner readiness, verification run URLs and runner names, rollback command, and blockers. Do not say “migrated” while registration or NAS execution remains unverified.
|
|
72
|
+
|
|
73
|
+
## Pitfalls
|
|
74
|
+
|
|
75
|
+
- GitHub repository runners are scoped to their repository unless deliberately configured as organization runners; the Roazon and Funda runners do not automatically serve a third repo.
|
|
76
|
+
- `CI_RUNNER=nas` is opt-in routing, not runner provisioning. Setting it before registration leaves jobs queued or failed.
|
|
77
|
+
- Self-hosted runners execute untrusted code with the runner host's access. Fork guards are mandatory, and persistent workspaces must be isolated.
|
|
78
|
+
- The current runner container may lack a runtime or compatible glibc. Test project-specific integration jobs on the exact image before declaring success.
|
|
79
|
+
- A billing-blocked hosted fallback cannot rescue a failed/offline NAS runner.
|
|
80
|
+
- Do not migrate deploy credentials or mutate production service settings as incidental cleanup.
|
|
81
|
+
|
|
82
|
+
## Verification
|
|
83
|
+
|
|
84
|
+
Migration is complete only when (a) the target runner is online with the expected repo-specific label, (b) every intended job's run metadata names that runner, (c) the required jobs pass on a trusted ref, (d) fork-origin jobs are skipped before reaching NAS, and (e) rollback to hosted routing is documented and can be applied. Otherwise report **prepared, not activated/verified** with the exact missing prerequisite.
|