mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
4b1f2207b8
* ci for broken links + fix broken links * ci: replace expiring-PAT org membership gate with author_association The shared check-org-membership.yml reusable workflow authenticated to the GitHub API with the ORG_ACCESS_TOKEN PAT to confirm org membership. That PAT expired ~1 year after issuance, so the API could no longer see private org members and check-membership emitted is_member=false — silently skipping every auto-review, command-triggered review, /ai, /plan, and git-command job while still reporting success. Gate on the event payload's author_association (OWNER/MEMBER/COLLABORATOR) instead, which comes from the built-in GITHUB_TOKEN and never expires. The trusted internal bot and existing draft/fork/command guards are preserved; the workflow_call paths stay open as trusted upstream. Deletes the now-unused reusable workflow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
127 lines
4.5 KiB
JavaScript
127 lines
4.5 KiB
JavaScript
// Extracts every windmill.dev/docs link referenced in the frontend source and
|
|
// verifies none of them 404. Run: `node .github/scripts/check-docs-links.mjs`.
|
|
// Used by the check-docs-links GitHub workflow (release / manual trigger only).
|
|
|
|
import { readdir, readFile } from 'node:fs/promises'
|
|
import { join, extname } from 'node:path'
|
|
|
|
const ROOT = 'frontend/src'
|
|
const EXTS = new Set(['.ts', '.js', '.svelte', '.mjs', '.cjs'])
|
|
const DOCS_RE = /https?:\/\/(?:www\.)?windmill\.dev\/docs\/[^\s"'`)>\]}]*/g
|
|
// `const someBaseUrl = 'https://www.windmill.dev/docs/...'` used later as `${someBaseUrl}/foo`
|
|
const BASE_RE = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*['"`](https?:\/\/(?:www\.)?windmill\.dev\/docs\/[^'"`]+)['"`]/g
|
|
|
|
const CONCURRENCY = 24
|
|
const TIMEOUT_MS = 20000
|
|
const RETRIES = 2
|
|
|
|
async function walk(dir) {
|
|
const out = []
|
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
const p = join(dir, entry.name)
|
|
if (entry.isDirectory()) {
|
|
if (entry.name === 'node_modules' || entry.name === '.svelte-kit') continue
|
|
out.push(...(await walk(p)))
|
|
} else if (EXTS.has(extname(entry.name))) {
|
|
out.push(p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// url (no fragment) -> Set of source files it appears in
|
|
const urls = new Map()
|
|
const unresolved = []
|
|
|
|
function record(url, file) {
|
|
const clean = url
|
|
.replace(/\\.*$/, '') // cut at an escape sequence embedded in a string literal (e.g. \n)
|
|
.replace(/#.*$/, '') // drop anchor fragment — irrelevant to a 404 check
|
|
.replace(/[.,;:'")\]]+$/, '')
|
|
if (!clean) return
|
|
// A `{`/`${` means the URL is built from an unresolved template/interpolation var.
|
|
if (clean.includes('{')) {
|
|
unresolved.push(`${clean} (${file})`)
|
|
return
|
|
}
|
|
if (!urls.has(clean)) urls.set(clean, new Set())
|
|
urls.get(clean).add(file)
|
|
}
|
|
|
|
for (const file of await walk(ROOT)) {
|
|
let content = await readFile(file, 'utf8')
|
|
// Inline file-local base-url constants so `${base}/page` template literals resolve.
|
|
const bases = []
|
|
for (const m of content.matchAll(BASE_RE)) bases.push({ name: m[1], value: m[2], decl: m[0] })
|
|
for (const { name, value } of bases) {
|
|
content = content.replaceAll('${' + name + '}', value)
|
|
}
|
|
// Blank each base declaration so a prefix-only base (no index page of its own,
|
|
// e.g. .../app_configuration_settings) isn't checked as a standalone link.
|
|
// A genuinely bare `${base}` usage was already inlined above, so it's still covered.
|
|
for (const { decl } of bases) content = content.replace(decl, '')
|
|
for (const m of content.matchAll(DOCS_RE)) record(m[0], file)
|
|
}
|
|
|
|
const allUrls = [...urls.keys()].sort()
|
|
console.log(`Found ${allUrls.length} distinct docs links across ${ROOT}`)
|
|
if (unresolved.length) {
|
|
console.log(`\n⚠️ ${unresolved.length} link(s) built from an unrecognized base URL — skipped (register the base const so they can be checked):`)
|
|
for (const u of [...new Set(unresolved)].sort()) console.log(` ${u}`)
|
|
}
|
|
|
|
async function check(url) {
|
|
for (let attempt = 0; attempt <= RETRIES; attempt++) {
|
|
const ctrl = new AbortController()
|
|
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS)
|
|
try {
|
|
let res = await fetch(url, {
|
|
method: 'HEAD',
|
|
redirect: 'follow',
|
|
signal: ctrl.signal,
|
|
headers: { 'user-agent': 'windmill-docs-link-check' }
|
|
})
|
|
// Some hosts reject HEAD — fall back to GET.
|
|
if (res.status === 405 || res.status === 501) {
|
|
res = await fetch(url, {
|
|
method: 'GET',
|
|
redirect: 'follow',
|
|
signal: ctrl.signal,
|
|
headers: { 'user-agent': 'windmill-docs-link-check' }
|
|
})
|
|
}
|
|
clearTimeout(timer)
|
|
return { url, status: res.status, ok: res.status < 400 }
|
|
} catch (err) {
|
|
clearTimeout(timer)
|
|
if (attempt === RETRIES) return { url, status: 0, ok: false, error: String(err?.message || err) }
|
|
await new Promise((r) => setTimeout(r, 500 * (attempt + 1)))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Simple concurrency pool.
|
|
const results = []
|
|
let idx = 0
|
|
async function worker() {
|
|
while (idx < allUrls.length) {
|
|
const url = allUrls[idx++]
|
|
results.push(await check(url))
|
|
}
|
|
}
|
|
await Promise.all(Array.from({ length: CONCURRENCY }, worker))
|
|
|
|
const failures = results.filter((r) => !r.ok)
|
|
if (failures.length === 0) {
|
|
console.log(`\n✅ All ${allUrls.length} docs links are reachable.`)
|
|
process.exit(0)
|
|
}
|
|
|
|
console.log(`\n❌ ${failures.length} broken docs link(s):`)
|
|
for (const f of failures.sort((a, b) => a.url.localeCompare(b.url))) {
|
|
console.log(`\n ${f.url}`)
|
|
console.log(` status: ${f.error ? `error (${f.error})` : f.status}`)
|
|
for (const file of urls.get(f.url)) console.log(` ↳ ${file}`)
|
|
}
|
|
process.exit(1)
|