mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-23 16:00:38 +00:00
ci: replace expiring-PAT org membership gate with author_association (#9957)
* 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>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
// 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)
|
||||
Reference in New Issue
Block a user