Skip to content

Commit c02450a

Browse files
committed
feat(check): add external-tools-release-tags-resolve gate
For every tool entry with release:"asset"|"archive" and a github: repository, probe `gh release view <tag> --repo <owner>/<repo>` to confirm the pinned tag exists as a real GitHub release. Schema shape validation (external-tools-are-valid) accepted any string for version/tag; a fabricated tag only surfaced as an asset-download failure mid-build, far from the edit that caused it. Network-gated: skips cleanly when gh is absent or unauthenticated so offline development and CI without a gh token are unaffected. Probes both the stored tag and v<tag> as a fallback when the stored value lacks a v prefix, matching the convention used by most GitHub tools. Wired into check.mts as a separate step after the fast schema check. --dry-run mode prints probe outcomes without failing, supporting self-verification.
1 parent 7494133 commit c02450a

2 files changed

Lines changed: 364 additions & 0 deletions

File tree

scripts/fleet/check.mts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,17 @@ const steps: Array<() => boolean> = [
100100
// incident: a drifted tool entry left an INLINED_* env var empty and hung a
101101
// pre-commit test run.
102102
() => run('node', ['scripts/fleet/check/external-tools-are-valid.mts']),
103+
// Every tool entry with release:"asset"|"archive" and a github: repository
104+
// must reference a tag that exists as a real GitHub release. Schema shape
105+
// checks accept any string for version/tag; a fabricated tag only surfaces
106+
// at runtime as an asset-download failure. Network-gated: skips cleanly
107+
// when gh is absent or unauthenticated so offline dev and CI without gh
108+
// are not broken. Runs after the fast schema check (above) because a
109+
// malformed file fails the schema check before reaching here.
110+
() =>
111+
run('node', [
112+
'scripts/fleet/check/external-tools-release-tags-resolve.mts',
113+
]),
103114
// researching-recency SKILL.md must quote the engine's output markers
104115
// verbatim (badge, evidence envelope, footer fences) so the model's
105116
// pass-through/synthesis instructions match what the engine emits.
Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
// Fleet check — every tool entry with release:"asset"|"archive" and a
2+
// github: repository references a tag that exists as a real GitHub release.
3+
//
4+
// Schema shape (external-tools-are-valid.mts) catches renamed fields and wrong
5+
// types at the data layer, but it does NOT verify that the pinned version
6+
// actually shipped — a developer can type any string for `version`/`tag` and
7+
// the schema accepts it. The first signal of a fabricated tag is then an
8+
// asset-download failure deep in the build, far from the edit that caused it.
9+
//
10+
// This check closes that gap: for every `release:"asset"|"archive"` entry that
11+
// declares a `repository: "github:<owner>/<repo>"` it runs
12+
// `gh release view <tag> --repo <owner>/<repo> --json tagName` and fails if the
13+
// release is not found. Tag derivation mirrors the build scripts: the `tag`
14+
// field takes precedence; when absent, `version` is used; when the stored tag
15+
// has no `v` prefix, the check also probes `v<tag>` as a fallback (many tools
16+
// tag as `vX.Y.Z` but document the version without the prefix).
17+
//
18+
// Network-gated: the check skips gracefully when:
19+
// - `gh` is not on PATH,
20+
// - `gh auth status` fails (unauthenticated),
21+
// - the `gh release view` call returns a network error.
22+
// In each skip case the script logs the reason and exits 0 so offline
23+
// development and CI environments without a gh token are not broken.
24+
//
25+
// Wire-up: called as a separate step in scripts/fleet/check.mts AFTER the
26+
// fast schema check (external-tools-are-valid.mts). Keeping the two steps
27+
// separate preserves the offline-safe property of the schema check.
28+
//
29+
// --dry-run: print every tool→tag probe (as SKIP/FOUND/NOT FOUND) without
30+
// actually failing the check. Useful for verifying the script's own logic.
31+
//
32+
// Usage:
33+
// node scripts/fleet/check/external-tools-release-tags-resolve.mts
34+
// node scripts/fleet/check/external-tools-release-tags-resolve.mts --dry-run
35+
// node scripts/fleet/check/external-tools-release-tags-resolve.mts --quiet
36+
37+
import { existsSync, readFileSync } from 'node:fs'
38+
import path from 'node:path'
39+
import process from 'node:process'
40+
import { fileURLToPath } from 'node:url'
41+
42+
import { errorMessage } from '@socketsecurity/lib-stable/errors'
43+
import { globSync } from '@socketsecurity/lib-stable/globs/match'
44+
import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default'
45+
import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child'
46+
47+
import { findToolFiles } from './external-tools-are-valid.mts'
48+
import { REPO_ROOT } from '../paths.mts'
49+
50+
const logger = getDefaultLogger()
51+
52+
export interface TagProbe {
53+
readonly file: string
54+
readonly toolName: string
55+
readonly ownerRepo: string
56+
readonly tag: string
57+
readonly resolvedTag: string | undefined
58+
readonly found: boolean
59+
readonly skipped: boolean
60+
readonly skipReason: string | undefined
61+
}
62+
63+
export interface ReleaseCheckResult {
64+
readonly probes: TagProbe[]
65+
readonly skippedAll: boolean
66+
readonly skipReason: string | undefined
67+
}
68+
69+
/**
70+
* Derive the GitHub release tag to probe for a tool entry. The `tag` field
71+
* takes precedence; when absent, `version` is used. Returns undefined when
72+
* neither field is set (the entry lacks enough data to check).
73+
*/
74+
export function deriveTag(tool: Record<string, unknown>): string | undefined {
75+
const tag = tool['tag']
76+
if (typeof tag === 'string' && tag.length > 0) {
77+
return tag
78+
}
79+
const version = tool['version']
80+
if (typeof version === 'string' && version.length > 0) {
81+
return version
82+
}
83+
return undefined
84+
}
85+
86+
/**
87+
* Parse "github:<owner>/<repo>" repository strings. Returns the "<owner>/<repo>"
88+
* portion or undefined when the value is not a github: reference.
89+
*/
90+
export function parseGithubRepo(repository: unknown): string | undefined {
91+
if (typeof repository !== 'string') {
92+
return undefined
93+
}
94+
if (!repository.startsWith('github:')) {
95+
return undefined
96+
}
97+
const ownerRepo = repository.slice('github:'.length)
98+
if (!ownerRepo.includes('/')) {
99+
return undefined
100+
}
101+
return ownerRepo
102+
}
103+
104+
/**
105+
* Check whether gh is available and authenticated. Returns undefined when
106+
* everything is fine, or a skip-reason string when the gate should be skipped.
107+
*/
108+
export function checkGhAvailable(): string | undefined {
109+
const which = spawnSync('which', ['gh'], {
110+
stdio: ['ignore', 'pipe', 'ignore'],
111+
})
112+
if (which.status !== 0) {
113+
return 'gh not found on PATH — install the GitHub CLI to enable release-tag probing'
114+
}
115+
const auth = spawnSync('gh', ['auth', 'status'], {
116+
stdio: ['ignore', 'pipe', 'pipe'],
117+
})
118+
if (auth.status !== 0) {
119+
return 'gh is not authenticated (gh auth status failed) — run `gh auth login` to enable release-tag probing'
120+
}
121+
return undefined
122+
}
123+
124+
/**
125+
* Probe a single GitHub release tag. Returns the resolved tag name on success,
126+
* or undefined when the release is not found. Throws on a non-network gh
127+
* error so the caller can decide to skip.
128+
*/
129+
export function probeReleaseTag(
130+
ownerRepo: string,
131+
tag: string,
132+
): string | undefined {
133+
const r = spawnSync(
134+
'gh',
135+
['release', 'view', tag, '--repo', ownerRepo, '--json', 'tagName'],
136+
{ stdio: ['ignore', 'pipe', 'pipe'] },
137+
)
138+
if (r.status === 0) {
139+
try {
140+
const parsed = JSON.parse(String(r.stdout)) as unknown
141+
if (
142+
typeof parsed === 'object' &&
143+
parsed !== null &&
144+
'tagName' in parsed &&
145+
typeof (parsed as Record<string, unknown>)['tagName'] === 'string'
146+
) {
147+
return (parsed as Record<string, string>)['tagName']
148+
}
149+
} catch {}
150+
return tag
151+
}
152+
const stderr = String(r.stderr ?? '')
153+
if (
154+
stderr.includes('release not found') ||
155+
stderr.includes('Could not find release') ||
156+
stderr.includes('HTTP 404')
157+
) {
158+
return undefined
159+
}
160+
if (
161+
stderr.includes('network') ||
162+
stderr.includes('dial tcp') ||
163+
stderr.includes('no such host') ||
164+
stderr.includes('connection refused') ||
165+
stderr.includes('timeout') ||
166+
r.status == null
167+
) {
168+
throw new Error(`network error probing ${ownerRepo}@${tag}: ${stderr}`)
169+
}
170+
return undefined
171+
}
172+
173+
/**
174+
* Scan all tool-data files under repoRoot and probe each qualifying entry.
175+
*/
176+
export function checkReleases(repoRoot: string): ReleaseCheckResult {
177+
const ghSkip = checkGhAvailable()
178+
if (ghSkip) {
179+
return { probes: [], skippedAll: true, skipReason: ghSkip }
180+
}
181+
182+
const files = findToolFiles(repoRoot)
183+
const probes: TagProbe[] = []
184+
185+
for (let i = 0, { length } = files; i < length; i += 1) {
186+
const relPath = files[i]!
187+
const abs = path.join(repoRoot, relPath)
188+
if (!existsSync(abs)) {
189+
continue
190+
}
191+
let raw: unknown
192+
try {
193+
raw = JSON.parse(readFileSync(abs, 'utf8'))
194+
} catch {
195+
continue
196+
}
197+
if (typeof raw !== 'object' || raw === null) {
198+
continue
199+
}
200+
const config = raw as Record<string, unknown>
201+
const tools = config['tools']
202+
if (typeof tools !== 'object' || tools === null) {
203+
continue
204+
}
205+
const toolsMap = tools as Record<string, unknown>
206+
for (const [toolName, toolValue] of Object.entries(toolsMap)) {
207+
if (typeof toolValue !== 'object' || toolValue === null) {
208+
continue
209+
}
210+
const tool = toolValue as Record<string, unknown>
211+
const release = tool['release']
212+
if (release !== 'archive' && release !== 'asset') {
213+
continue
214+
}
215+
const ownerRepo = parseGithubRepo(tool['repository'])
216+
if (!ownerRepo) {
217+
continue
218+
}
219+
const storedTag = deriveTag(tool)
220+
if (!storedTag) {
221+
continue
222+
}
223+
let resolvedTag: string | undefined
224+
let found = false
225+
let skipped = false
226+
let skipReason: string | undefined
227+
228+
try {
229+
resolvedTag = probeReleaseTag(ownerRepo, storedTag)
230+
if (resolvedTag !== undefined) {
231+
found = true
232+
} else if (!storedTag.startsWith('v')) {
233+
const withV = `v${storedTag}`
234+
resolvedTag = probeReleaseTag(ownerRepo, withV)
235+
if (resolvedTag !== undefined) {
236+
found = true
237+
}
238+
}
239+
} catch (e) {
240+
skipped = true
241+
skipReason = `network error — ${errorMessage(e)}`
242+
found = false
243+
}
244+
245+
probes.push({
246+
file: relPath,
247+
toolName,
248+
ownerRepo,
249+
tag: storedTag,
250+
resolvedTag,
251+
found,
252+
skipped,
253+
skipReason,
254+
})
255+
}
256+
}
257+
258+
return { probes, skippedAll: false, skipReason: undefined }
259+
}
260+
261+
function main(): void {
262+
const quiet = process.argv.includes('--quiet')
263+
const dryRun = process.argv.includes('--dry-run')
264+
265+
const result = checkReleases(REPO_ROOT)
266+
267+
if (result.skippedAll) {
268+
if (!quiet) {
269+
logger.log(
270+
`[check-external-tools-release-tags-resolve] skipped: ${result.skipReason}`,
271+
)
272+
}
273+
return
274+
}
275+
276+
if (result.probes.length === 0) {
277+
if (!quiet) {
278+
logger.success(
279+
'[check-external-tools-release-tags-resolve] no github-release tool entries found.',
280+
)
281+
}
282+
return
283+
}
284+
285+
const failures: TagProbe[] = []
286+
const networkSkips: TagProbe[] = []
287+
288+
for (let i = 0, { length } = result.probes; i < length; i += 1) {
289+
const p = result.probes[i]!
290+
if (p.skipped) {
291+
networkSkips.push(p)
292+
if (!quiet) {
293+
logger.log(
294+
` ⚠ ${p.file}#${p.toolName} (${p.ownerRepo}@${p.tag}): ${p.skipReason}`,
295+
)
296+
}
297+
} else if (dryRun) {
298+
const status = p.found
299+
? `FOUND as ${p.resolvedTag}`
300+
: `NOT FOUND (tried ${p.tag}${!p.tag.startsWith('v') ? ` and v${p.tag}` : ''})`
301+
logger.log(
302+
` ${p.found ? '✓' : '✗'} ${p.file}#${p.toolName} (${p.ownerRepo}@${p.tag}): ${status}`,
303+
)
304+
} else if (!p.found) {
305+
failures.push(p)
306+
}
307+
}
308+
309+
if (networkSkips.length > 0 && !quiet) {
310+
logger.log(
311+
`[check-external-tools-release-tags-resolve] ${networkSkips.length} probe(s) skipped due to network errors.`,
312+
)
313+
}
314+
315+
if (dryRun) {
316+
if (!quiet) {
317+
logger.log(
318+
`[check-external-tools-release-tags-resolve] dry-run complete: ${result.probes.length} probe(s).`,
319+
)
320+
}
321+
return
322+
}
323+
324+
if (failures.length > 0) {
325+
logger.fail(
326+
'[check-external-tools-release-tags-resolve] tool entries reference tags with no GitHub release:',
327+
)
328+
for (let i = 0, { length } = failures; i < length; i += 1) {
329+
const f = failures[i]!
330+
const tried = !f.tag.startsWith('v')
331+
? `${f.tag} and v${f.tag}`
332+
: f.tag
333+
logger.error(
334+
` ✗ ${f.file}#${f.toolName}: ${f.ownerRepo} tag ${tried} — no release found`,
335+
)
336+
}
337+
logger.error(
338+
' Each entry with release:"asset"|"archive" and repository:"github:<owner>/<repo>" must reference a real GitHub release tag. Update the version/tag field to a tag that exists, or add the tag field explicitly.',
339+
)
340+
process.exitCode = 1
341+
return
342+
}
343+
344+
if (!quiet) {
345+
logger.success(
346+
`[check-external-tools-release-tags-resolve] all ${result.probes.length} release-tag probe(s) resolved.`,
347+
)
348+
}
349+
}
350+
351+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
352+
main()
353+
}

0 commit comments

Comments
 (0)