From 8da0c1eb03818b01706cc668edaf686e8544948e Mon Sep 17 00:00:00 2001 From: Siddhant Khare Date: Tue, 6 Oct 2026 22:25:36 +0530 Subject: [PATCH 1/5] ci: skip docs quality checks for unrelated changes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/mintlify-quality.yml | 12 ++ package.json | 1 + scripts/README.md | 29 +++- scripts/docs-check-scope.mjs | 119 +++++++++++++++++ scripts/docs-check-scope.test.mjs | 176 +++++++++++++++++++++++++ scripts/workflow-syntax.test.mjs | 71 ++++++++++ 6 files changed, 407 insertions(+), 1 deletion(-) create mode 100644 scripts/docs-check-scope.mjs create mode 100644 scripts/docs-check-scope.test.mjs create mode 100644 scripts/workflow-syntax.test.mjs diff --git a/.github/workflows/mintlify-quality.yml b/.github/workflows/mintlify-quality.yml index 45e9713d7e..78db5ca9c4 100644 --- a/.github/workflows/mintlify-quality.yml +++ b/.github/workflows/mintlify-quality.yml @@ -28,6 +28,7 @@ jobs: with: lfs: false persist-credentials: false + fetch-depth: 0 - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 @@ -40,5 +41,16 @@ jobs: - name: Install dependencies run: npm ci + - name: Validate workflows and docs-check scoping + run: npm run test:workflows + + - name: Determine documentation impact + id: docs-scope + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: node scripts/docs-check-scope.mjs + - name: Run repository quality checks + if: steps.docs-scope.outputs.docs_changed == 'true' run: npm run check:mintlify diff --git a/package.json b/package.json index 29a61df70b..d106669d5a 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "build:docusaurus": "docusaurus build", "build:config-page": "node scripts/update-config-page.mjs", "test:config-page": "node --test scripts/update-config-page.test.mjs", + "test:workflows": "node --test scripts/workflow-syntax.test.mjs scripts/docs-check-scope.test.mjs", "update:api-samples": "node scripts/update-api-samples.mjs", "report:api-samples": "node scripts/report-api-samples.mjs", "test:update-api-samples": "node --test scripts/update-api-samples.test.mjs scripts/report-api-samples.test.mjs", diff --git a/scripts/README.md b/scripts/README.md index 79ab8be41e..a4a468f494 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -17,6 +17,32 @@ workflows use it. `npm run check:mintlify`, run by the [quality workflow](../.github/workflows/mintlify-quality.yml), includes the root regression suites and freshness checks; it does not build or deploy edge infrastructure. +The quality workflow always runs `npm run test:workflows` on PRs to `main` or +`docs-next` and pushes to `docs-next`. This parses every workflow YAML file and +checks Bash/sh run-block syntax without executing the scripts. Other declared +shells are explicitly reported as skipped for shell validation; their YAML is +still checked. This is syntax validation, not a replacement for GitHub's workflow +schema, expression validation, or runtime checks. + +Docs-content checks run only when changed files affect documentation. +[`docs-check-scope.mjs`](./docs-check-scope.mjs) owns the scope: native/legacy docs, +explicit docs-maintenance scripts, docs fixtures, docs-specific workflows, +dependencies, build/formatting/Git source controls, and the website inputs covered +by docs navigation and site-boundary contracts. Unrelated website components, +assets, blog posts, and repository-maintenance scripts/files skip the full docs suite. +Add new docs inputs/helpers to the classifier and its regression cases. The +workflow still completes its existing check and records the decision in the run +summary; it does not use event-level path filters that could leave a required +check pending. + +PRs compare their merge base to the PR head; pushes compare the complete +before/after range. Deleted files and both sides of renames count, including +docs moved outside the docs directories. Manual runs and new-branch pushes run +the full docs suite. Invalid or unavailable comparison revisions fail explicitly +rather than silently skipping checks. Nightly/manual source updaters remain +independent of repository file changes, since upstream releases can change +without a docs commit. + | Files | Role and root commands | | --- | --- | | `prepare-agent-content.mjs`, `agent-content.mjs`, `validate-agent-content.mjs` | Prepare/check the curated root LLM index, website-only bundle, and Home/Project/Community Markdown: `npm run build:agent-content`, `npm run check:agent-content`. | @@ -124,7 +150,8 @@ It never approves or auto-merges PRs, and never force-pushes outside its reserve ## Regression tests -All nine `*.test.mjs` files remain wired into package commands: +All root `*.test.mjs` files remain wired into package commands: +- `npm run test:workflows`: all workflow YAML/Bash syntax, docs-impact scoping, change ranges, renames/deletions, and CI gates. - `npm run test:site-boundary`: agent content, site boundary, sitemap, and legacy API routes. - `npm run test:config-page`: configuration generation, including preservation/error cases. - `npm run test:update-api-samples`: offline source updates, no-change/incompatibility/transport handling, no partial incompatible writes, issue deduplication, and workflow gates. diff --git a/scripts/docs-check-scope.mjs b/scripts/docs-check-scope.mjs new file mode 100644 index 0000000000..2ef6afa898 --- /dev/null +++ b/scripts/docs-check-scope.mjs @@ -0,0 +1,119 @@ +import { execFileSync } from 'node:child_process'; +import { appendFile } from 'node:fs/promises'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const repositoryRoot = fileURLToPath(new URL('../', import.meta.url)); +const docsDirectories = [ + 'docs-site/', + 'docs/', + 'mintlify-native/', + 'tests/fixtures/mintlify/', + 'tests/fixtures/configuration-generator/', + 'src/components/Docs/', + 'src/components/SwaggerUI/', + 'patches/', +]; +const docsScripts = new Set([ + 'agent-content', + 'clean-agent-markdown', + 'deployment-verification', + 'docs-check-scope', + 'generate-legacy-api-routes', + 'legacy-api-routes', + 'native-deployment-fingerprint', + 'native-routes', + 'prepare-agent-content', + 'prepare-site-sitemap', + 'report-api-samples', + 'site-boundary', + 'site-sitemap', + 'update-api-samples', + 'update-config-page', + 'validate-agent-content', + 'validate-site-boundary', + 'verify-docs-deployment', +].flatMap((name) => [`scripts/${name}.mjs`, `scripts/${name}.test.mjs`])); +const docsInputs = new Set([ + '.gitattributes', + '.gitignore', + '.prettierrc', + '.prettierignore', + '.github/workflows/mintlify-quality.yml', + '.github/workflows/update-docs.yml', + '.github/workflows/update-api-samples.yml', + 'package.json', + 'package-lock.json', + 'tsconfig.json', + 'docusaurus.config.js', + 'scripts/README.md', + // Website inputs covered by documentation navigation and site-boundary contracts. + 'src/data/legacy-api-routes.json', + 'src/utils/legacy-api-redirect.mjs', + 'src/pages/api/service.tsx', + 'src/pages/community.mdx', + 'src/features/LandingPage/QuickStartSection/index.tsx', + 'src/theme/Root.tsx', +]); + +export function isDocsRelated(path) { + return docsInputs.has(path) || docsScripts.has(path) + || docsDirectories.some((directory) => path === directory.slice(0, -1) || path.startsWith(directory)); +} + +function requireSha(value, name) { + if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(value ?? '') || /^0+$/.test(value)) { + throw new Error(`${name} must be a nonzero Git commit SHA`); + } +} + +export function docsCheckScope({ eventName, baseSha, headSha, cwd = repositoryRoot }) { + if (eventName === 'workflow_dispatch') { + return { docsChanged: true, files: [], reason: 'Manual run: checking all documentation.' }; + } + if (eventName !== 'pull_request' && eventName !== 'push') { + throw new Error(`Unsupported docs-check event: ${eventName ?? '(missing)'}`); + } + requireSha(headSha, 'HEAD_SHA'); + if (eventName === 'push' && /^(?:0{40}|0{64})$/.test(baseSha ?? '')) { + return { docsChanged: true, files: [], reason: 'New branch: checking all documentation.' }; + } + requireSha(baseSha, 'BASE_SHA'); + const range = `${baseSha}${eventName === 'pull_request' ? '...' : '..'}${headSha}`; + const changed = execFileSync('git', ['diff', '--no-renames', '--name-only', '-z', range, '--'], { + cwd, + encoding: 'utf8', + maxBuffer: 8 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }).split('\0').filter(Boolean); + const files = changed.filter(isDocsRelated); + return { + docsChanged: files.length > 0, + files, + reason: files.length > 0 + ? `${files.length} documentation-related file(s) changed: running docs-quality checks.` + : 'No documentation-related changes: skipping docs-quality checks. Workflow syntax validation remains enabled.', + }; +} + +async function main() { + const result = docsCheckScope({ + eventName: process.env.GITHUB_EVENT_NAME, + baseSha: process.env.BASE_SHA, + headSha: process.env.HEAD_SHA, + }); + console.log(result.reason); + for (const file of result.files) console.log(` ${JSON.stringify(file)}`); + if (process.env.GITHUB_OUTPUT) { + await appendFile(process.env.GITHUB_OUTPUT, `docs_changed=${result.docsChanged}\n`); + } + if (process.env.GITHUB_STEP_SUMMARY) { + await appendFile(process.env.GITHUB_STEP_SUMMARY, `## Documentation checks\n\n${result.reason}\n`); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(`::error::Cannot determine documentation impact: ${error.message}`); + process.exitCode = 1; + }); +} diff --git a/scripts/docs-check-scope.test.mjs b/scripts/docs-check-scope.test.mjs new file mode 100644 index 0000000000..f91c1f0306 --- /dev/null +++ b/scripts/docs-check-scope.test.mjs @@ -0,0 +1,176 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { parse as parseYaml } from 'yaml'; +import { docsCheckScope, isDocsRelated } from './docs-check-scope.mjs'; + +const script = fileURLToPath(new URL('./docs-check-scope.mjs', import.meta.url)); + +test('includes documentation sources, tooling, fixtures, dependencies, and boundary inputs', () => { + for (const path of [ + 'docs-site', 'docs', 'mintlify-native', + 'docs-site/getting-started.mdx', 'docs-site/images/example.svg', 'docs/content/intro.mdx', + 'mintlify-native/docs.json', 'scripts/update-config-page.mjs', 'scripts/README.md', + 'scripts/docs-check-scope.mjs', 'scripts/docs-check-scope.test.mjs', 'scripts/site-sitemap.test.mjs', + 'tests/fixtures/mintlify/core.json', 'tests/fixtures/configuration-generator/schema.json', + 'src/components/Docs/Link/index.ts', 'src/components/SwaggerUI/index.tsx', 'patches/dependency.patch', + '.gitattributes', '.gitignore', '.prettierrc', '.prettierignore', 'package.json', 'package-lock.json', + 'tsconfig.json', 'docusaurus.config.js', 'src/data/legacy-api-routes.json', 'src/utils/legacy-api-redirect.mjs', + 'src/pages/api/service.tsx', 'src/pages/community.mdx', 'src/theme/Root.tsx', + 'src/features/LandingPage/QuickStartSection/index.tsx', + '.github/workflows/mintlify-quality.yml', '.github/workflows/update-docs.yml', + '.github/workflows/update-api-samples.yml', + ]) { + assert.equal(isDocsRelated(path), true, path); + } +}); + +test('excludes all unrelated website, blog, asset, and repository-maintenance changes', () => { + for (const path of [ + 'src/components/AdoptersCarousel/index.tsx', 'static/img/adopters/supabase.svg', + 'src/features/LandingPage/HeroSection/index.tsx', 'src/css/custom.css', + 'src/pages/project.mdx', 'src/components/icons/index.ts', 'blog/news.md', 'blog/authors.yml', + 'README.md', 'LICENSE', '.github/CODEOWNERS', '.github/dependabot.yaml', + '.github/workflows/scorecard.yml', '.github/workflows/deploy.yml', 'eslint.config.js', + 'scripts/workflow-syntax.test.mjs', 'scripts/repository-maintenance.mjs', + 'docs-site-backup/example.mdx', 'scripts-backup/update.mjs', + ]) { + assert.equal(isDocsRelated(path), false, path); + } +}); + +test('manual and initial-branch runs check all docs; invalid inputs fail instead of skipping', () => { + assert.equal(docsCheckScope({ eventName: 'workflow_dispatch' }).docsChanged, true); + assert.equal(docsCheckScope({ + eventName: 'push', baseSha: '0'.repeat(40), headSha: 'a'.repeat(40), + }).docsChanged, true); + for (const options of [ + {}, + { eventName: 'schedule' }, + { eventName: 'pull_request', baseSha: 'a'.repeat(40) }, + { eventName: 'push', baseSha: 'missing', headSha: 'a'.repeat(40) }, + { eventName: 'pull_request', baseSha: '0'.repeat(40), headSha: 'a'.repeat(40) }, + { eventName: 'push', baseSha: 'a'.repeat(40), headSha: 'HEAD; exit 0' }, + ]) { + assert.throws(() => docsCheckScope(options)); + } +}); + +async function repository(t) { + const cwd = await mkdtemp(join(tmpdir(), 'openfga-docs-scope-')); + t.after(() => rm(cwd, { recursive: true, force: true })); + const git = (...args) => { + const result = spawnSync('git', [ + '-c', 'user.name=Docs Scope Test', '-c', 'user.email=docs-scope@example.com', + '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', ...args, + ], { cwd, encoding: 'utf8' }); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); + return result.stdout.trim(); + }; + const write = async (path, content) => { + await mkdir(dirname(join(cwd, path)), { recursive: true }); + await writeFile(join(cwd, path), content); + }; + const commit = () => { + git('add', '-A'); + git('commit', '--quiet', '-m', 'Fixture change'); + return git('rev-parse', 'HEAD'); + }; + git('init', '--quiet'); + await write('docs-site/intro.mdx', 'Original docs\n'); + await write('blog/news.md', 'Original blog\n'); + const base = commit(); + return { cwd, git, write, commit, base }; +} + +test('uses PR merge-base scope, the complete push range, and both sides of renames', async (t) => { + const { cwd, git, write, commit, base } = await repository(t); + const scope = (eventName, baseSha, headSha) => docsCheckScope({ eventName, baseSha, headSha, cwd }); + await write('blog/news.md', 'Unrelated update\n'); + const marketing = commit(); + assert.equal(scope('pull_request', base, marketing).docsChanged, false); + assert.equal(scope('push', base, marketing).docsChanged, false); + + await write('docs-site/intro.mdx', 'Updated docs\n'); + const docs = commit(); + await write('blog/news.md', 'Another unrelated update\n'); + const latest = commit(); + assert.equal(scope('push', base, latest).docsChanged, true, 'Earlier commits in a push must count'); + assert.equal(scope('push', docs, latest).docsChanged, false); + + await rename(join(cwd, 'docs-site/intro.mdx'), join(cwd, 'blog/renamed.mdx')); + const movedOut = commit(); + assert.deepEqual(scope('push', latest, movedOut).files, ['docs-site/intro.mdx']); + await rename(join(cwd, 'blog/renamed.mdx'), join(cwd, 'docs-site/intro.mdx')); + const movedIn = commit(); + assert.deepEqual(scope('push', movedOut, movedIn).files, ['docs-site/intro.mdx']); + await rm(join(cwd, 'docs-site/intro.mdx')); + const deleted = commit(); + assert.deepEqual(scope('push', movedIn, deleted).files, ['docs-site/intro.mdx']); + await write('docs-site/name with\nnewline.mdx', 'New docs\n'); + const unusual = commit(); + assert.deepEqual(scope('push', deleted, unusual).files, ['docs-site/name with\nnewline.mdx']); + + git('checkout', '--quiet', '--detach', base); + await write('docs-site/base-only.mdx', 'Docs added only to the target branch\n'); + const advancedBase = commit(); + assert.equal(scope('pull_request', advancedBase, marketing).docsChanged, false, + 'Docs changes only on the target branch must not count as PR changes'); + assert.throws(() => scope('push', 'f'.repeat(40), marketing), /git diff/); +}); + +test('CLI publishes an explicit skip or full-run decision, and errors produce no success output', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'openfga-docs-scope-output-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const output = join(directory, 'output'); + const summary = join(directory, 'summary'); + const root = fileURLToPath(new URL('../', import.meta.url)); + const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }); + assert.equal(head.status, 0, head.stderr); + const run = (env) => spawnSync(process.execPath, [script], { + env: { ...process.env, GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary, ...env }, + encoding: 'utf8', + }); + const skipped = run({ GITHUB_EVENT_NAME: 'push', BASE_SHA: head.stdout.trim(), HEAD_SHA: head.stdout.trim() }); + assert.equal(skipped.status, 0, skipped.stderr); + assert.equal(await readFile(output, 'utf8'), 'docs_changed=false\n'); + assert.match(await readFile(summary, 'utf8'), /No documentation-related changes/); + assert.match(skipped.stdout, /Workflow syntax validation remains enabled/); + + const manual = run({ GITHUB_EVENT_NAME: 'workflow_dispatch' }); + assert.equal(manual.status, 0, manual.stderr); + const before = await readFile(output, 'utf8'); + assert.equal(before, 'docs_changed=false\ndocs_changed=true\n'); + const failed = run({ GITHUB_EVENT_NAME: 'push', BASE_SHA: '', HEAD_SHA: head.stdout.trim() }); + assert.notEqual(failed.status, 0); + assert.match(failed.stderr, /::error::Cannot determine documentation impact.*BASE_SHA/); + assert.equal(await readFile(output, 'utf8'), before); +}); + +test('CI validates every PR before conditionally running docs checks without event-level path filters', async () => { + const workflow = parseYaml(await readFile(new URL('../.github/workflows/mintlify-quality.yml', import.meta.url), 'utf8')); + assert.deepEqual(workflow.on.pull_request.branches, ['main', 'docs-next']); + for (const event of [workflow.on.pull_request, workflow.on.push]) { + assert.equal(event.paths, undefined); + assert.equal(event['paths-ignore'], undefined); + } + assert.ok(Object.hasOwn(workflow.on, 'workflow_dispatch')); + const job = workflow.jobs['mintlify-quality']; + assert.equal(job.name, 'Check repository-owned Mintlify content'); + const steps = job.steps; + assert.equal(steps[0].with['fetch-depth'], 0); + const syntax = steps.findIndex((step) => step.run === 'npm run test:workflows'); + const scope = steps.findIndex((step) => step.id === 'docs-scope'); + const quality = steps.findIndex((step) => step.run === 'npm run check:mintlify'); + assert.ok(syntax > 0 && syntax < scope && scope < quality); + assert.equal(steps[syntax].if, undefined); + assert.equal(steps[syntax]['continue-on-error'], undefined); + assert.equal(steps[scope].env.BASE_SHA, '${{ github.event.pull_request.base.sha || github.event.before }}'); + assert.equal(steps[scope].env.HEAD_SHA, '${{ github.event.pull_request.head.sha || github.sha }}'); + assert.equal(steps[quality].if, "steps.docs-scope.outputs.docs_changed == 'true'"); +}); diff --git a/scripts/workflow-syntax.test.mjs b/scripts/workflow-syntax.test.mjs new file mode 100644 index 0000000000..58fd6765d9 --- /dev/null +++ b/scripts/workflow-syntax.test.mjs @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { readdir, readFile } from 'node:fs/promises'; +import test from 'node:test'; +import { parse as parseYaml } from 'yaml'; + +function checkShell(source, shell) { + assert.equal(typeof source, 'string', 'A workflow run block must be a string'); + const result = spawnSync(shell, ['-n'], { input: source, encoding: 'utf8' }); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, '', 'Shell syntax warnings must not be ignored'); +} + +test('rejects the unindented heredoc YAML that caused run 37493362190', () => { + assert.throws(() => parseYaml([ + 'jobs:', + ' update-docs:', + ' steps:', + ' - run: |', + ' pr_body="$(cat <<\'BODY\'', + 'This automated PR updates the generated documentation.', + 'BODY', + ' )"', + ].join('\n'))); +}); + +test('rejects duplicate workflow YAML keys', () => { + assert.throws(() => parseYaml('jobs:\n checks:\n steps: []\n steps: []\n'), /unique/); +}); + +test('checks shell syntax without executing commands and accepts correctly indented heredocs', () => { + checkShell('exit 99\n', 'bash'); + const workflow = parseYaml([ + 'jobs:', + ' checks:', + ' steps:', + ' - run: |', + ' cat <<\'BODY\'', + ' Documentation update.', + ' BODY', + ].join('\n')); + checkShell(workflow.jobs.checks.steps[0].run, 'bash'); +}); + +test('rejects broken Bash blocks and non-string run blocks', () => { + assert.throws(() => checkShell('if true; then\n echo missing-fi\n', 'bash')); + assert.throws(() => checkShell({ echo: 'not a script' }, 'bash'), /must be a string/); +}); + +const directory = new URL('../.github/workflows/', import.meta.url); +const files = (await readdir(directory)).filter((file) => /\.ya?ml$/.test(file)).sort(); +assert.ok(files.length > 0, 'No GitHub Actions workflows found'); + +for (const file of files) { + test(`${file}: workflow YAML and shell syntax`, async (t) => { + const workflow = parseYaml(await readFile(new URL(file, directory), 'utf8')); + assert.ok(workflow?.jobs && typeof workflow.jobs === 'object', 'Workflow must define jobs'); + for (const [jobId, job] of Object.entries(workflow.jobs)) { + for (const [index, step] of (job.steps ?? []).entries()) { + if (!Object.hasOwn(step, 'run')) continue; + const declared = step.shell ?? job.defaults?.run?.shell ?? workflow.defaults?.run?.shell; + const shell = declared?.split(/\s+/)[0] + ?? (JSON.stringify(job['runs-on']).includes('windows') ? 'pwsh' : job.container ? 'sh' : 'bash'); + await t.test(`${jobId}: ${step.name ?? `step ${index + 1}`}`, { + skip: !['bash', 'sh'].includes(shell) && `No shell-syntax validator for ${shell}; YAML is still validated`, + }, () => checkShell(step.run, shell)); + } + } + }); +} From 7074a6d3e6c9621616223989a830e447747adccc Mon Sep 17 00:00:00 2001 From: Siddhant Khare Date: Tue, 6 Oct 2026 22:42:39 +0530 Subject: [PATCH 2/5] ci: simplify docs checks with declarative path filters Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/mintlify-quality.yml | 53 ++++++-- package.json | 2 +- scripts/README.md | 34 +++-- scripts/docs-check-scope.mjs | 119 ----------------- scripts/docs-check-scope.test.mjs | 176 ------------------------- scripts/workflow-syntax.test.mjs | 83 ++++++++++++ 6 files changed, 143 insertions(+), 324 deletions(-) delete mode 100644 scripts/docs-check-scope.mjs delete mode 100644 scripts/docs-check-scope.test.mjs diff --git a/.github/workflows/mintlify-quality.yml b/.github/workflows/mintlify-quality.yml index 78db5ca9c4..19c1c5db37 100644 --- a/.github/workflows/mintlify-quality.yml +++ b/.github/workflows/mintlify-quality.yml @@ -12,6 +12,7 @@ on: permissions: contents: read + pull-requests: read concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -28,7 +29,6 @@ jobs: with: lfs: false persist-credentials: false - fetch-depth: 0 - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 @@ -41,16 +41,51 @@ jobs: - name: Install dependencies run: npm ci - - name: Validate workflows and docs-check scoping + - name: Validate workflow syntax and docs filters run: npm run test:workflows - - name: Determine documentation impact - id: docs-scope - env: - BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} - HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} - run: node scripts/docs-check-scope.mjs + - name: Detect documentation changes + id: changes + # Larger PRs bypass filtering because GitHub lists at most 3,000 changed files. + if: >- + github.event_name != 'workflow_dispatch' && + github.event.before != '0000000000000000000000000000000000000000' && + (github.event_name != 'pull_request' || github.event.pull_request.changed_files < 3000) + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 + with: + base: ${{ github.event.before }} + filters: | + docs: + - '{docs-site,docs,mintlify-native,patches}{,/**}' + - 'tests/fixtures/{mintlify,configuration-generator}/**' + - 'src/components/{Docs,SwaggerUI}{,/**}' + - '{package.json,package-lock.json,tsconfig.json,docusaurus.config.js}' + - '{.gitattributes,.gitignore,.prettierrc,.prettierignore}' + - '.github/workflows/{mintlify-quality,update-docs,update-api-samples}.yml' + - 'scripts/README.md' + - 'scripts/{agent-content,clean-agent-markdown,prepare-agent-content,validate-agent-content}{,.test}.mjs' + - 'scripts/{deployment-verification,native-deployment-fingerprint,native-routes,verify-docs-deployment}{,.test}.mjs' + - 'scripts/{generate-legacy-api-routes,legacy-api-routes,report-api-samples,update-api-samples}{,.test}.mjs' + - 'scripts/{prepare-site-sitemap,site-boundary,site-sitemap,validate-site-boundary,update-config-page}{,.test}.mjs' + - 'src/data/legacy-api-routes.json' + - 'src/utils/legacy-api-redirect.mjs' + - 'src/pages/{api/service.tsx,community.mdx}' + - 'src/features/LandingPage/QuickStartSection/index.tsx' + - 'src/theme/Root.tsx' + - deleted: + - 'src/pages/index.tsx' + - 'src/pages/project.mdx' + - 'blog/ignore-duplicate-writes-announcement.md' - name: Run repository quality checks - if: steps.docs-scope.outputs.docs_changed == 'true' + if: >- + github.event_name == 'workflow_dispatch' || + github.event.before == '0000000000000000000000000000000000000000' || + github.event.pull_request.changed_files >= 3000 || + steps.changes.outputs.docs == 'true' run: npm run check:mintlify + + - name: Report skipped docs checks + if: steps.changes.outputs.docs == 'false' + run: | + echo "No docs-related changes: skipped the docs-quality suite. Workflow syntax validation still ran." >> "$GITHUB_STEP_SUMMARY" diff --git a/package.json b/package.json index d106669d5a..0216b5f633 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "build:docusaurus": "docusaurus build", "build:config-page": "node scripts/update-config-page.mjs", "test:config-page": "node --test scripts/update-config-page.test.mjs", - "test:workflows": "node --test scripts/workflow-syntax.test.mjs scripts/docs-check-scope.test.mjs", + "test:workflows": "node --test scripts/workflow-syntax.test.mjs", "update:api-samples": "node scripts/update-api-samples.mjs", "report:api-samples": "node scripts/report-api-samples.mjs", "test:update-api-samples": "node --test scripts/update-api-samples.test.mjs scripts/report-api-samples.test.mjs", diff --git a/scripts/README.md b/scripts/README.md index a4a468f494..4f6c9c9e9e 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -24,24 +24,20 @@ shells are explicitly reported as skipped for shell validation; their YAML is still checked. This is syntax validation, not a replacement for GitHub's workflow schema, expression validation, or runtime checks. -Docs-content checks run only when changed files affect documentation. -[`docs-check-scope.mjs`](./docs-check-scope.mjs) owns the scope: native/legacy docs, -explicit docs-maintenance scripts, docs fixtures, docs-specific workflows, -dependencies, build/formatting/Git source controls, and the website inputs covered -by docs navigation and site-boundary contracts. Unrelated website components, -assets, blog posts, and repository-maintenance scripts/files skip the full docs suite. -Add new docs inputs/helpers to the classifier and its regression cases. The -workflow still completes its existing check and records the decision in the run -summary; it does not use event-level path filters that could leave a required -check pending. - -PRs compare their merge base to the PR head; pushes compare the complete -before/after range. Deleted files and both sides of renames count, including -docs moved outside the docs directories. Manual runs and new-branch pushes run -the full docs suite. Invalid or unavailable comparison revisions fail explicitly -rather than silently skipping checks. Nightly/manual source updaters remain -independent of repository file changes, since upstream releases can change -without a docs commit. +The quality workflow uses a pinned `dorny/paths-filter` action and a step-level +`if` to run docs-content checks only for docs sources and supporting tooling, +fixtures, dependencies, configuration, and navigation/boundary inputs. The +filters live directly in the workflow; add new docs inputs there and in the +filter regression cases. Unrelated changes get an explicit skip summary while +the existing required check completes, without event-level path filters. + +Deleting or moving the required Home/Project pages or the preserved duplicate-write +announcement triggers the boundary checks; ordinary content edits still skip. +Both sides of renames count. PRs use GitHub's changed-file list; pushes compare +the complete before/after range. Manual runs, new-branch pushes, and PRs with +3,000 or more changed files run the full suite (GitHub's PR-files API is capped +at 3,000 files). Nightly/manual source updaters remain independent of repository +file changes, since upstream releases can change without a docs commit. | Files | Role and root commands | | --- | --- | @@ -151,7 +147,7 @@ It never approves or auto-merges PRs, and never force-pushes outside its reserve ## Regression tests All root `*.test.mjs` files remain wired into package commands: -- `npm run test:workflows`: all workflow YAML/Bash syntax, docs-impact scoping, change ranges, renames/deletions, and CI gates. +- `npm run test:workflows`: all workflow YAML/Bash syntax, declared docs-path filters, required-file deletion/move rules, and CI gates. - `npm run test:site-boundary`: agent content, site boundary, sitemap, and legacy API routes. - `npm run test:config-page`: configuration generation, including preservation/error cases. - `npm run test:update-api-samples`: offline source updates, no-change/incompatibility/transport handling, no partial incompatible writes, issue deduplication, and workflow gates. diff --git a/scripts/docs-check-scope.mjs b/scripts/docs-check-scope.mjs deleted file mode 100644 index 2ef6afa898..0000000000 --- a/scripts/docs-check-scope.mjs +++ /dev/null @@ -1,119 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { appendFile } from 'node:fs/promises'; -import { fileURLToPath, pathToFileURL } from 'node:url'; - -const repositoryRoot = fileURLToPath(new URL('../', import.meta.url)); -const docsDirectories = [ - 'docs-site/', - 'docs/', - 'mintlify-native/', - 'tests/fixtures/mintlify/', - 'tests/fixtures/configuration-generator/', - 'src/components/Docs/', - 'src/components/SwaggerUI/', - 'patches/', -]; -const docsScripts = new Set([ - 'agent-content', - 'clean-agent-markdown', - 'deployment-verification', - 'docs-check-scope', - 'generate-legacy-api-routes', - 'legacy-api-routes', - 'native-deployment-fingerprint', - 'native-routes', - 'prepare-agent-content', - 'prepare-site-sitemap', - 'report-api-samples', - 'site-boundary', - 'site-sitemap', - 'update-api-samples', - 'update-config-page', - 'validate-agent-content', - 'validate-site-boundary', - 'verify-docs-deployment', -].flatMap((name) => [`scripts/${name}.mjs`, `scripts/${name}.test.mjs`])); -const docsInputs = new Set([ - '.gitattributes', - '.gitignore', - '.prettierrc', - '.prettierignore', - '.github/workflows/mintlify-quality.yml', - '.github/workflows/update-docs.yml', - '.github/workflows/update-api-samples.yml', - 'package.json', - 'package-lock.json', - 'tsconfig.json', - 'docusaurus.config.js', - 'scripts/README.md', - // Website inputs covered by documentation navigation and site-boundary contracts. - 'src/data/legacy-api-routes.json', - 'src/utils/legacy-api-redirect.mjs', - 'src/pages/api/service.tsx', - 'src/pages/community.mdx', - 'src/features/LandingPage/QuickStartSection/index.tsx', - 'src/theme/Root.tsx', -]); - -export function isDocsRelated(path) { - return docsInputs.has(path) || docsScripts.has(path) - || docsDirectories.some((directory) => path === directory.slice(0, -1) || path.startsWith(directory)); -} - -function requireSha(value, name) { - if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(value ?? '') || /^0+$/.test(value)) { - throw new Error(`${name} must be a nonzero Git commit SHA`); - } -} - -export function docsCheckScope({ eventName, baseSha, headSha, cwd = repositoryRoot }) { - if (eventName === 'workflow_dispatch') { - return { docsChanged: true, files: [], reason: 'Manual run: checking all documentation.' }; - } - if (eventName !== 'pull_request' && eventName !== 'push') { - throw new Error(`Unsupported docs-check event: ${eventName ?? '(missing)'}`); - } - requireSha(headSha, 'HEAD_SHA'); - if (eventName === 'push' && /^(?:0{40}|0{64})$/.test(baseSha ?? '')) { - return { docsChanged: true, files: [], reason: 'New branch: checking all documentation.' }; - } - requireSha(baseSha, 'BASE_SHA'); - const range = `${baseSha}${eventName === 'pull_request' ? '...' : '..'}${headSha}`; - const changed = execFileSync('git', ['diff', '--no-renames', '--name-only', '-z', range, '--'], { - cwd, - encoding: 'utf8', - maxBuffer: 8 * 1024 * 1024, - stdio: ['ignore', 'pipe', 'pipe'], - }).split('\0').filter(Boolean); - const files = changed.filter(isDocsRelated); - return { - docsChanged: files.length > 0, - files, - reason: files.length > 0 - ? `${files.length} documentation-related file(s) changed: running docs-quality checks.` - : 'No documentation-related changes: skipping docs-quality checks. Workflow syntax validation remains enabled.', - }; -} - -async function main() { - const result = docsCheckScope({ - eventName: process.env.GITHUB_EVENT_NAME, - baseSha: process.env.BASE_SHA, - headSha: process.env.HEAD_SHA, - }); - console.log(result.reason); - for (const file of result.files) console.log(` ${JSON.stringify(file)}`); - if (process.env.GITHUB_OUTPUT) { - await appendFile(process.env.GITHUB_OUTPUT, `docs_changed=${result.docsChanged}\n`); - } - if (process.env.GITHUB_STEP_SUMMARY) { - await appendFile(process.env.GITHUB_STEP_SUMMARY, `## Documentation checks\n\n${result.reason}\n`); - } -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - main().catch((error) => { - console.error(`::error::Cannot determine documentation impact: ${error.message}`); - process.exitCode = 1; - }); -} diff --git a/scripts/docs-check-scope.test.mjs b/scripts/docs-check-scope.test.mjs deleted file mode 100644 index f91c1f0306..0000000000 --- a/scripts/docs-check-scope.test.mjs +++ /dev/null @@ -1,176 +0,0 @@ -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import test from 'node:test'; -import { fileURLToPath } from 'node:url'; -import { parse as parseYaml } from 'yaml'; -import { docsCheckScope, isDocsRelated } from './docs-check-scope.mjs'; - -const script = fileURLToPath(new URL('./docs-check-scope.mjs', import.meta.url)); - -test('includes documentation sources, tooling, fixtures, dependencies, and boundary inputs', () => { - for (const path of [ - 'docs-site', 'docs', 'mintlify-native', - 'docs-site/getting-started.mdx', 'docs-site/images/example.svg', 'docs/content/intro.mdx', - 'mintlify-native/docs.json', 'scripts/update-config-page.mjs', 'scripts/README.md', - 'scripts/docs-check-scope.mjs', 'scripts/docs-check-scope.test.mjs', 'scripts/site-sitemap.test.mjs', - 'tests/fixtures/mintlify/core.json', 'tests/fixtures/configuration-generator/schema.json', - 'src/components/Docs/Link/index.ts', 'src/components/SwaggerUI/index.tsx', 'patches/dependency.patch', - '.gitattributes', '.gitignore', '.prettierrc', '.prettierignore', 'package.json', 'package-lock.json', - 'tsconfig.json', 'docusaurus.config.js', 'src/data/legacy-api-routes.json', 'src/utils/legacy-api-redirect.mjs', - 'src/pages/api/service.tsx', 'src/pages/community.mdx', 'src/theme/Root.tsx', - 'src/features/LandingPage/QuickStartSection/index.tsx', - '.github/workflows/mintlify-quality.yml', '.github/workflows/update-docs.yml', - '.github/workflows/update-api-samples.yml', - ]) { - assert.equal(isDocsRelated(path), true, path); - } -}); - -test('excludes all unrelated website, blog, asset, and repository-maintenance changes', () => { - for (const path of [ - 'src/components/AdoptersCarousel/index.tsx', 'static/img/adopters/supabase.svg', - 'src/features/LandingPage/HeroSection/index.tsx', 'src/css/custom.css', - 'src/pages/project.mdx', 'src/components/icons/index.ts', 'blog/news.md', 'blog/authors.yml', - 'README.md', 'LICENSE', '.github/CODEOWNERS', '.github/dependabot.yaml', - '.github/workflows/scorecard.yml', '.github/workflows/deploy.yml', 'eslint.config.js', - 'scripts/workflow-syntax.test.mjs', 'scripts/repository-maintenance.mjs', - 'docs-site-backup/example.mdx', 'scripts-backup/update.mjs', - ]) { - assert.equal(isDocsRelated(path), false, path); - } -}); - -test('manual and initial-branch runs check all docs; invalid inputs fail instead of skipping', () => { - assert.equal(docsCheckScope({ eventName: 'workflow_dispatch' }).docsChanged, true); - assert.equal(docsCheckScope({ - eventName: 'push', baseSha: '0'.repeat(40), headSha: 'a'.repeat(40), - }).docsChanged, true); - for (const options of [ - {}, - { eventName: 'schedule' }, - { eventName: 'pull_request', baseSha: 'a'.repeat(40) }, - { eventName: 'push', baseSha: 'missing', headSha: 'a'.repeat(40) }, - { eventName: 'pull_request', baseSha: '0'.repeat(40), headSha: 'a'.repeat(40) }, - { eventName: 'push', baseSha: 'a'.repeat(40), headSha: 'HEAD; exit 0' }, - ]) { - assert.throws(() => docsCheckScope(options)); - } -}); - -async function repository(t) { - const cwd = await mkdtemp(join(tmpdir(), 'openfga-docs-scope-')); - t.after(() => rm(cwd, { recursive: true, force: true })); - const git = (...args) => { - const result = spawnSync('git', [ - '-c', 'user.name=Docs Scope Test', '-c', 'user.email=docs-scope@example.com', - '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null', ...args, - ], { cwd, encoding: 'utf8' }); - assert.ifError(result.error); - assert.equal(result.status, 0, result.stderr); - return result.stdout.trim(); - }; - const write = async (path, content) => { - await mkdir(dirname(join(cwd, path)), { recursive: true }); - await writeFile(join(cwd, path), content); - }; - const commit = () => { - git('add', '-A'); - git('commit', '--quiet', '-m', 'Fixture change'); - return git('rev-parse', 'HEAD'); - }; - git('init', '--quiet'); - await write('docs-site/intro.mdx', 'Original docs\n'); - await write('blog/news.md', 'Original blog\n'); - const base = commit(); - return { cwd, git, write, commit, base }; -} - -test('uses PR merge-base scope, the complete push range, and both sides of renames', async (t) => { - const { cwd, git, write, commit, base } = await repository(t); - const scope = (eventName, baseSha, headSha) => docsCheckScope({ eventName, baseSha, headSha, cwd }); - await write('blog/news.md', 'Unrelated update\n'); - const marketing = commit(); - assert.equal(scope('pull_request', base, marketing).docsChanged, false); - assert.equal(scope('push', base, marketing).docsChanged, false); - - await write('docs-site/intro.mdx', 'Updated docs\n'); - const docs = commit(); - await write('blog/news.md', 'Another unrelated update\n'); - const latest = commit(); - assert.equal(scope('push', base, latest).docsChanged, true, 'Earlier commits in a push must count'); - assert.equal(scope('push', docs, latest).docsChanged, false); - - await rename(join(cwd, 'docs-site/intro.mdx'), join(cwd, 'blog/renamed.mdx')); - const movedOut = commit(); - assert.deepEqual(scope('push', latest, movedOut).files, ['docs-site/intro.mdx']); - await rename(join(cwd, 'blog/renamed.mdx'), join(cwd, 'docs-site/intro.mdx')); - const movedIn = commit(); - assert.deepEqual(scope('push', movedOut, movedIn).files, ['docs-site/intro.mdx']); - await rm(join(cwd, 'docs-site/intro.mdx')); - const deleted = commit(); - assert.deepEqual(scope('push', movedIn, deleted).files, ['docs-site/intro.mdx']); - await write('docs-site/name with\nnewline.mdx', 'New docs\n'); - const unusual = commit(); - assert.deepEqual(scope('push', deleted, unusual).files, ['docs-site/name with\nnewline.mdx']); - - git('checkout', '--quiet', '--detach', base); - await write('docs-site/base-only.mdx', 'Docs added only to the target branch\n'); - const advancedBase = commit(); - assert.equal(scope('pull_request', advancedBase, marketing).docsChanged, false, - 'Docs changes only on the target branch must not count as PR changes'); - assert.throws(() => scope('push', 'f'.repeat(40), marketing), /git diff/); -}); - -test('CLI publishes an explicit skip or full-run decision, and errors produce no success output', async (t) => { - const directory = await mkdtemp(join(tmpdir(), 'openfga-docs-scope-output-')); - t.after(() => rm(directory, { recursive: true, force: true })); - const output = join(directory, 'output'); - const summary = join(directory, 'summary'); - const root = fileURLToPath(new URL('../', import.meta.url)); - const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }); - assert.equal(head.status, 0, head.stderr); - const run = (env) => spawnSync(process.execPath, [script], { - env: { ...process.env, GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary, ...env }, - encoding: 'utf8', - }); - const skipped = run({ GITHUB_EVENT_NAME: 'push', BASE_SHA: head.stdout.trim(), HEAD_SHA: head.stdout.trim() }); - assert.equal(skipped.status, 0, skipped.stderr); - assert.equal(await readFile(output, 'utf8'), 'docs_changed=false\n'); - assert.match(await readFile(summary, 'utf8'), /No documentation-related changes/); - assert.match(skipped.stdout, /Workflow syntax validation remains enabled/); - - const manual = run({ GITHUB_EVENT_NAME: 'workflow_dispatch' }); - assert.equal(manual.status, 0, manual.stderr); - const before = await readFile(output, 'utf8'); - assert.equal(before, 'docs_changed=false\ndocs_changed=true\n'); - const failed = run({ GITHUB_EVENT_NAME: 'push', BASE_SHA: '', HEAD_SHA: head.stdout.trim() }); - assert.notEqual(failed.status, 0); - assert.match(failed.stderr, /::error::Cannot determine documentation impact.*BASE_SHA/); - assert.equal(await readFile(output, 'utf8'), before); -}); - -test('CI validates every PR before conditionally running docs checks without event-level path filters', async () => { - const workflow = parseYaml(await readFile(new URL('../.github/workflows/mintlify-quality.yml', import.meta.url), 'utf8')); - assert.deepEqual(workflow.on.pull_request.branches, ['main', 'docs-next']); - for (const event of [workflow.on.pull_request, workflow.on.push]) { - assert.equal(event.paths, undefined); - assert.equal(event['paths-ignore'], undefined); - } - assert.ok(Object.hasOwn(workflow.on, 'workflow_dispatch')); - const job = workflow.jobs['mintlify-quality']; - assert.equal(job.name, 'Check repository-owned Mintlify content'); - const steps = job.steps; - assert.equal(steps[0].with['fetch-depth'], 0); - const syntax = steps.findIndex((step) => step.run === 'npm run test:workflows'); - const scope = steps.findIndex((step) => step.id === 'docs-scope'); - const quality = steps.findIndex((step) => step.run === 'npm run check:mintlify'); - assert.ok(syntax > 0 && syntax < scope && scope < quality); - assert.equal(steps[syntax].if, undefined); - assert.equal(steps[syntax]['continue-on-error'], undefined); - assert.equal(steps[scope].env.BASE_SHA, '${{ github.event.pull_request.base.sha || github.event.before }}'); - assert.equal(steps[scope].env.HEAD_SHA, '${{ github.event.pull_request.head.sha || github.sha }}'); - assert.equal(steps[quality].if, "steps.docs-scope.outputs.docs_changed == 'true'"); -}); diff --git a/scripts/workflow-syntax.test.mjs b/scripts/workflow-syntax.test.mjs index 58fd6765d9..819580b321 100644 --- a/scripts/workflow-syntax.test.mjs +++ b/scripts/workflow-syntax.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { readdir, readFile } from 'node:fs/promises'; import test from 'node:test'; +import picomatch from 'picomatch'; import { parse as parseYaml } from 'yaml'; function checkShell(source, shell) { @@ -48,6 +49,88 @@ test('rejects broken Bash blocks and non-string run blocks', () => { assert.throws(() => checkShell({ echo: 'not a script' }, 'bash'), /must be a string/); }); +const quality = parseYaml(await readFile(new URL('../.github/workflows/mintlify-quality.yml', import.meta.url), 'utf8')); +const job = quality.jobs['mintlify-quality']; +const changes = job.steps.find((step) => step.id === 'changes'); +const rules = parseYaml(changes.with.filters).docs.flatMap((rule) => typeof rule === 'string' + ? [{ match: picomatch(rule, { dot: true }) }] + : Object.entries(rule).map(([status, patterns]) => ({ + status, + match: picomatch(patterns, { dot: true }), + }))); +const matchesDocs = (file, status) => rules.some((rule) => + (!rule.status || rule.status === status) && rule.match(file)); + +test('workflow filters include docs sources and supporting inputs', () => { + for (const file of [ + 'docs-site', 'docs-site/page.mdx', 'docs-site/images/example.svg', 'docs/content/intro.mdx', + 'mintlify-native/docs.json', 'patches/example.patch', 'src/components/Docs/Link/index.ts', + 'src/components/SwaggerUI', 'tests/fixtures/mintlify/core.json', + 'tests/fixtures/configuration-generator/schema.json', 'scripts/README.md', + 'scripts/update-config-page.mjs', 'scripts/site-boundary.test.mjs', + 'scripts/native-deployment-fingerprint.mjs', 'scripts/report-api-samples.test.mjs', + 'package.json', 'package-lock.json', 'tsconfig.json', 'docusaurus.config.js', + '.gitattributes', '.gitignore', '.prettierrc', '.prettierignore', + '.github/workflows/mintlify-quality.yml', '.github/workflows/update-docs.yml', + '.github/workflows/update-api-samples.yml', 'src/data/legacy-api-routes.json', + 'src/utils/legacy-api-redirect.mjs', 'src/pages/api/service.tsx', 'src/pages/community.mdx', + 'src/features/LandingPage/QuickStartSection/index.tsx', 'src/theme/Root.tsx', + ]) { + for (const status of ['added', 'modified', 'deleted']) assert.equal(matchesDocs(file, status), true, file); + } +}); + +test('workflow filters exclude unrelated website, blog, asset, and maintenance changes', () => { + for (const file of [ + 'src/components/AdoptersCarousel/index.tsx', 'static/img/adopters/supabase.svg', + 'src/features/LandingPage/HeroSection/index.tsx', 'src/css/custom.css', 'src/components/icons/index.ts', + 'blog/news.md', 'blog/authors.yml', 'README.md', 'LICENSE', '.github/CODEOWNERS', + '.github/dependabot.yaml', '.github/workflows/scorecard.yml', '.github/workflows/deploy.yml', + 'eslint.config.js', 'scripts/workflow-syntax.test.mjs', 'scripts/repository-maintenance.mjs', + 'docs-site-backup/example.mdx', 'scripts-backup/update.mjs', + ]) { + for (const status of ['added', 'modified', 'deleted']) assert.equal(matchesDocs(file, status), false, file); + } +}); + +test('required website files trigger docs checks for deletions and moves, not ordinary edits', () => { + for (const file of [ + 'src/pages/index.tsx', 'src/pages/project.mdx', 'blog/ignore-duplicate-writes-announcement.md', + ]) { + assert.equal(matchesDocs(file, 'added'), false, file); + assert.equal(matchesDocs(file, 'modified'), false, file); + assert.equal(matchesDocs(file, 'deleted'), true, file); + const moved = [{ file, status: 'deleted' }, { file: `archive/${file}`, status: 'added' }]; + assert.equal(matchesDocs(moved[1].file, moved[1].status), false); + assert.ok(moved.some((entry) => matchesDocs(entry.file, entry.status)), `Move-out must count: ${file}`); + } +}); + +test('CI always validates syntax and keeps required status while gating docs checks', () => { + assert.deepEqual(quality.on.pull_request.branches, ['main', 'docs-next']); + assert.ok(Object.hasOwn(quality.on, 'workflow_dispatch')); + for (const event of [quality.on.pull_request, quality.on.push]) { + assert.equal(event.paths, undefined); + assert.equal(event['paths-ignore'], undefined); + } + assert.equal(quality.permissions['pull-requests'], 'read'); + assert.equal(job.name, 'Check repository-owned Mintlify content'); + assert.match(changes.uses, /^dorny\/paths-filter@[a-f0-9]{40}$/); + assert.equal(changes.with.base, '${{ github.event.before }}'); + const syntax = job.steps.findIndex((step) => step.run === 'npm run test:workflows'); + const detection = job.steps.indexOf(changes); + const full = job.steps.findIndex((step) => step.run === 'npm run check:mintlify'); + assert.ok(syntax > 0 && syntax < detection && detection < full); + assert.equal(job.steps[syntax].if, undefined); + assert.equal(job.steps[syntax]['continue-on-error'], undefined); + assert.equal(changes.if, + "github.event_name != 'workflow_dispatch' && github.event.before != '0000000000000000000000000000000000000000' && (github.event_name != 'pull_request' || github.event.pull_request.changed_files < 3000)"); + assert.equal(job.steps[full].if, + "github.event_name == 'workflow_dispatch' || github.event.before == '0000000000000000000000000000000000000000' || github.event.pull_request.changed_files >= 3000 || steps.changes.outputs.docs == 'true'"); + assert.ok(job.steps.some((step) => step.if === "steps.changes.outputs.docs == 'false'" + && step.run.includes('GITHUB_STEP_SUMMARY'))); +}); + const directory = new URL('../.github/workflows/', import.meta.url); const files = (await readdir(directory)).filter((file) => /\.ya?ml$/.test(file)).sort(); assert.ok(files.length > 0, 'No GitHub Actions workflows found'); From 664e765c6e367a13ab171e1d11ecffffb4f6898a Mon Sep 17 00:00:00 2001 From: Siddhant Khare Date: Tue, 6 Oct 2026 22:55:32 +0530 Subject: [PATCH 3/5] ci: use native paths for docs quality checks Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/mintlify-quality.yml | 98 ++++++++++++++------------ .github/workflows/test-deploy.yml | 5 ++ scripts/README.md | 39 +++++----- scripts/workflow-syntax.test.mjs | 97 +++++++++++++------------ 4 files changed, 124 insertions(+), 115 deletions(-) diff --git a/.github/workflows/mintlify-quality.yml b/.github/workflows/mintlify-quality.yml index 19c1c5db37..47e2d3bf19 100644 --- a/.github/workflows/mintlify-quality.yml +++ b/.github/workflows/mintlify-quality.yml @@ -5,14 +5,64 @@ on: branches: - main - docs-next + paths: &docs-paths + - 'docs-site' + - 'docs-site/**' + - 'docs' + - 'docs/**' + - 'mintlify-native' + - 'mintlify-native/**' + - 'patches' + - 'patches/**' + - 'tests/fixtures/mintlify/**' + - 'tests/fixtures/configuration-generator/**' + - 'src/components/Docs' + - 'src/components/Docs/**' + - 'src/components/SwaggerUI' + - 'src/components/SwaggerUI/**' + - 'scripts/README.md' + - 'scripts/agent-content*.mjs' + - 'scripts/clean-agent-markdown.mjs' + - 'scripts/deployment-verification*.mjs' + - 'scripts/generate-legacy-api-routes.mjs' + - 'scripts/legacy-api-routes.test.mjs' + - 'scripts/native-deployment-fingerprint*.mjs' + - 'scripts/native-routes*.mjs' + - 'scripts/prepare-agent-content.mjs' + - 'scripts/prepare-site-sitemap.mjs' + - 'scripts/report-api-samples*.mjs' + - 'scripts/site-boundary*.mjs' + - 'scripts/site-sitemap*.mjs' + - 'scripts/update-api-samples*.mjs' + - 'scripts/update-config-page*.mjs' + - 'scripts/validate-agent-content.mjs' + - 'scripts/validate-site-boundary.mjs' + - 'scripts/verify-docs-deployment.mjs' + - '.gitattributes' + - '.gitignore' + - '.prettierrc' + - '.prettierignore' + - 'package.json' + - 'package-lock.json' + - 'tsconfig.json' + - 'docusaurus.config.js' + - '.github/workflows/mintlify-quality.yml' + - '.github/workflows/update-docs.yml' + - '.github/workflows/update-api-samples.yml' + - 'src/data/legacy-api-routes.json' + - 'src/utils/legacy-api-redirect.mjs' + - 'src/pages/api/service.tsx' + - 'src/pages/community.mdx' + - 'src/features/LandingPage/QuickStartSection/index.tsx' + - 'src/theme/Root.tsx' push: branches: - docs-next + paths: *docs-paths workflow_dispatch: permissions: contents: read - pull-requests: read concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -41,51 +91,5 @@ jobs: - name: Install dependencies run: npm ci - - name: Validate workflow syntax and docs filters - run: npm run test:workflows - - - name: Detect documentation changes - id: changes - # Larger PRs bypass filtering because GitHub lists at most 3,000 changed files. - if: >- - github.event_name != 'workflow_dispatch' && - github.event.before != '0000000000000000000000000000000000000000' && - (github.event_name != 'pull_request' || github.event.pull_request.changed_files < 3000) - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 - with: - base: ${{ github.event.before }} - filters: | - docs: - - '{docs-site,docs,mintlify-native,patches}{,/**}' - - 'tests/fixtures/{mintlify,configuration-generator}/**' - - 'src/components/{Docs,SwaggerUI}{,/**}' - - '{package.json,package-lock.json,tsconfig.json,docusaurus.config.js}' - - '{.gitattributes,.gitignore,.prettierrc,.prettierignore}' - - '.github/workflows/{mintlify-quality,update-docs,update-api-samples}.yml' - - 'scripts/README.md' - - 'scripts/{agent-content,clean-agent-markdown,prepare-agent-content,validate-agent-content}{,.test}.mjs' - - 'scripts/{deployment-verification,native-deployment-fingerprint,native-routes,verify-docs-deployment}{,.test}.mjs' - - 'scripts/{generate-legacy-api-routes,legacy-api-routes,report-api-samples,update-api-samples}{,.test}.mjs' - - 'scripts/{prepare-site-sitemap,site-boundary,site-sitemap,validate-site-boundary,update-config-page}{,.test}.mjs' - - 'src/data/legacy-api-routes.json' - - 'src/utils/legacy-api-redirect.mjs' - - 'src/pages/{api/service.tsx,community.mdx}' - - 'src/features/LandingPage/QuickStartSection/index.tsx' - - 'src/theme/Root.tsx' - - deleted: - - 'src/pages/index.tsx' - - 'src/pages/project.mdx' - - 'blog/ignore-duplicate-writes-announcement.md' - - name: Run repository quality checks - if: >- - github.event_name == 'workflow_dispatch' || - github.event.before == '0000000000000000000000000000000000000000' || - github.event.pull_request.changed_files >= 3000 || - steps.changes.outputs.docs == 'true' run: npm run check:mintlify - - - name: Report skipped docs checks - if: steps.changes.outputs.docs == 'false' - run: | - echo "No docs-related changes: skipped the docs-quality suite. Workflow syntax validation still ran." >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/test-deploy.yml b/.github/workflows/test-deploy.yml index 94833d1422..2e4fa21f4b 100644 --- a/.github/workflows/test-deploy.yml +++ b/.github/workflows/test-deploy.yml @@ -27,5 +27,10 @@ jobs: - name: Install dependencies run: npm ci + - name: Validate workflows and preserved site files + run: | + npm run test:workflows + node --test --test-name-pattern='repository retains' scripts/site-boundary.test.mjs + - name: Test build website run: npm run build diff --git a/scripts/README.md b/scripts/README.md index 4f6c9c9e9e..47e22a630c 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -17,27 +17,22 @@ workflows use it. `npm run check:mintlify`, run by the [quality workflow](../.github/workflows/mintlify-quality.yml), includes the root regression suites and freshness checks; it does not build or deploy edge infrastructure. -The quality workflow always runs `npm run test:workflows` on PRs to `main` or -`docs-next` and pushes to `docs-next`. This parses every workflow YAML file and -checks Bash/sh run-block syntax without executing the scripts. Other declared -shells are explicitly reported as skipped for shell validation; their YAML is -still checked. This is syntax validation, not a replacement for GitHub's workflow -schema, expression validation, or runtime checks. - -The quality workflow uses a pinned `dorny/paths-filter` action and a step-level -`if` to run docs-content checks only for docs sources and supporting tooling, -fixtures, dependencies, configuration, and navigation/boundary inputs. The -filters live directly in the workflow; add new docs inputs there and in the -filter regression cases. Unrelated changes get an explicit skip summary while -the existing required check completes, without event-level path filters. - -Deleting or moving the required Home/Project pages or the preserved duplicate-write -announcement triggers the boundary checks; ordinary content edits still skip. -Both sides of renames count. PRs use GitHub's changed-file list; pushes compare -the complete before/after range. Manual runs, new-branch pushes, and PRs with -3,000 or more changed files run the full suite (GitHub's PR-files API is capped -at 3,000 files). Nightly/manual source updaters remain independent of repository -file changes, since upstream releases can change without a docs commit. +The quality workflow uses GitHub's native `paths` filters for PRs and pushes. +Only docs sources and supporting tooling, fixtures, dependencies, configuration, +and navigation/boundary inputs trigger it. The shared path list lives directly +in the workflow; update it and its regression cases when adding docs inputs. +Manual runs remain unfiltered. GitHub's native diff limits apply; use a manual +run for unusually large changes. Keep this path-filtered status optional, since +GitHub leaves skipped workflow checks pending. + +The always-running [deployment workflow](../.github/workflows/test-deploy.yml) +runs `npm run test:workflows` on every PR to `main` or `docs-next`. This parses all +workflow YAML and checks Bash/sh syntax without executing the scripts; other +declared shells are explicitly skipped for shell validation, not YAML validation. +It also runs the existing file-preservation contract, so deleting or moving +required website files cannot bypass the check when docs quality is skipped. +Ordinary website content edits do not trigger the full docs suite. +Nightly/manual source updaters remain unchanged. | Files | Role and root commands | | --- | --- | @@ -147,7 +142,7 @@ It never approves or auto-merges PRs, and never force-pushes outside its reserve ## Regression tests All root `*.test.mjs` files remain wired into package commands: -- `npm run test:workflows`: all workflow YAML/Bash syntax, declared docs-path filters, required-file deletion/move rules, and CI gates. +- `npm run test:workflows`: all workflow YAML/Bash syntax, native docs-path filters, and unconditional PR syntax/preservation gates. - `npm run test:site-boundary`: agent content, site boundary, sitemap, and legacy API routes. - `npm run test:config-page`: configuration generation, including preservation/error cases. - `npm run test:update-api-samples`: offline source updates, no-change/incompatibility/transport handling, no partial incompatible writes, issue deduplication, and workflow gates. diff --git a/scripts/workflow-syntax.test.mjs b/scripts/workflow-syntax.test.mjs index 819580b321..f64b00e77e 100644 --- a/scripts/workflow-syntax.test.mjs +++ b/scripts/workflow-syntax.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { readdir, readFile } from 'node:fs/promises'; import test from 'node:test'; +import { fileURLToPath } from 'node:url'; import picomatch from 'picomatch'; import { parse as parseYaml } from 'yaml'; @@ -50,18 +51,9 @@ test('rejects broken Bash blocks and non-string run blocks', () => { }); const quality = parseYaml(await readFile(new URL('../.github/workflows/mintlify-quality.yml', import.meta.url), 'utf8')); -const job = quality.jobs['mintlify-quality']; -const changes = job.steps.find((step) => step.id === 'changes'); -const rules = parseYaml(changes.with.filters).docs.flatMap((rule) => typeof rule === 'string' - ? [{ match: picomatch(rule, { dot: true }) }] - : Object.entries(rule).map(([status, patterns]) => ({ - status, - match: picomatch(patterns, { dot: true }), - }))); -const matchesDocs = (file, status) => rules.some((rule) => - (!rule.status || rule.status === status) && rule.match(file)); +const matchesDocs = picomatch(quality.on.pull_request.paths, { dot: true }); -test('workflow filters include docs sources and supporting inputs', () => { +test('native paths include docs sources and supporting inputs', () => { for (const file of [ 'docs-site', 'docs-site/page.mdx', 'docs-site/images/example.svg', 'docs/content/intro.mdx', 'mintlify-native/docs.json', 'patches/example.patch', 'src/components/Docs/Link/index.ts', @@ -76,11 +68,11 @@ test('workflow filters include docs sources and supporting inputs', () => { 'src/utils/legacy-api-redirect.mjs', 'src/pages/api/service.tsx', 'src/pages/community.mdx', 'src/features/LandingPage/QuickStartSection/index.tsx', 'src/theme/Root.tsx', ]) { - for (const status of ['added', 'modified', 'deleted']) assert.equal(matchesDocs(file, status), true, file); + assert.equal(matchesDocs(file), true, file); } }); -test('workflow filters exclude unrelated website, blog, asset, and maintenance changes', () => { +test('native paths exclude unrelated website, blog, asset, and maintenance changes', () => { for (const file of [ 'src/components/AdoptersCarousel/index.tsx', 'static/img/adopters/supabase.svg', 'src/features/LandingPage/HeroSection/index.tsx', 'src/css/custom.css', 'src/components/icons/index.ts', @@ -88,47 +80,60 @@ test('workflow filters exclude unrelated website, blog, asset, and maintenance c '.github/dependabot.yaml', '.github/workflows/scorecard.yml', '.github/workflows/deploy.yml', 'eslint.config.js', 'scripts/workflow-syntax.test.mjs', 'scripts/repository-maintenance.mjs', 'docs-site-backup/example.mdx', 'scripts-backup/update.mjs', - ]) { - for (const status of ['added', 'modified', 'deleted']) assert.equal(matchesDocs(file, status), false, file); - } -}); - -test('required website files trigger docs checks for deletions and moves, not ordinary edits', () => { - for (const file of [ 'src/pages/index.tsx', 'src/pages/project.mdx', 'blog/ignore-duplicate-writes-announcement.md', ]) { - assert.equal(matchesDocs(file, 'added'), false, file); - assert.equal(matchesDocs(file, 'modified'), false, file); - assert.equal(matchesDocs(file, 'deleted'), true, file); - const moved = [{ file, status: 'deleted' }, { file: `archive/${file}`, status: 'added' }]; - assert.equal(matchesDocs(moved[1].file, moved[1].status), false); - assert.ok(moved.some((entry) => matchesDocs(entry.file, entry.status)), `Move-out must count: ${file}`); + assert.equal(matchesDocs(file), false, file); } }); -test('CI always validates syntax and keeps required status while gating docs checks', () => { +test('docs quality uses native paths with an unrestricted manual trigger', () => { assert.deepEqual(quality.on.pull_request.branches, ['main', 'docs-next']); + assert.deepEqual(quality.on.push.paths, quality.on.pull_request.paths); assert.ok(Object.hasOwn(quality.on, 'workflow_dispatch')); - for (const event of [quality.on.pull_request, quality.on.push]) { - assert.equal(event.paths, undefined); - assert.equal(event['paths-ignore'], undefined); + const job = quality.jobs['mintlify-quality']; + assert.equal(job.if, undefined); + assert.equal(job.steps.find((step) => step.run === 'npm run check:mintlify').if, undefined); + assert.ok(job.steps.every((step) => !step.uses?.startsWith('dorny/paths-filter@'))); +}); + +test('every PR validates syntax and required-file preservation even when docs quality is skipped', async () => { + const deployment = parseYaml(await readFile(new URL('../.github/workflows/test-deploy.yml', import.meta.url), 'utf8')); + assert.deepEqual(deployment.on.pull_request.branches, ['main', 'docs-next']); + assert.equal(deployment.on.pull_request.paths, undefined); + assert.equal(deployment.on.pull_request['paths-ignore'], undefined); + const job = deployment.jobs['test-deploy']; + assert.equal(job.name, 'Test deployment'); + const validation = job.steps.findIndex((step) => step.run?.includes('npm run test:workflows')); + const build = job.steps.findIndex((step) => step.run === 'npm run build'); + assert.ok(validation > 0 && validation < build); + assert.equal(job.steps[validation].if, undefined); + assert.equal(job.steps[validation]['continue-on-error'], undefined); + assert.match(job.steps[validation].run, + /node --test --test-name-pattern='repository retains' scripts\/site-boundary\.test\.mjs/); +}); + +test('the preservation contract rejects deleted or moved required website files', () => { + const env = { ...process.env }; + // Run a fresh test runner, not another instance of the parent test worker. + delete env.NODE_TEST_CONTEXT; + for (const file of [ + 'src/pages/index.tsx', 'src/pages/project.mdx', 'blog/ignore-duplicate-writes-announcement.md', + ]) { + const missing = new URL(`../${file}`, import.meta.url).href; + const mock = `import fs from 'node:fs'; + import { syncBuiltinESMExports } from 'node:module'; + const exists = fs.existsSync; + fs.existsSync = (path) => String(path) === ${JSON.stringify(missing)} ? false : exists(path); + syncBuiltinESMExports();`; + const result = spawnSync(process.execPath, [ + '--import', `data:text/javascript,${encodeURIComponent(mock)}`, + '--test', '--test-name-pattern=repository retains', + fileURLToPath(new URL('./site-boundary.test.mjs', import.meta.url)), + ], { env, encoding: 'utf8', timeout: 20_000 }); + assert.ifError(result.error); + assert.notEqual(result.status, 0, result.stdout + result.stderr); + assert.ok((result.stdout + result.stderr).includes(`${file} must remain available`), result.stdout + result.stderr); } - assert.equal(quality.permissions['pull-requests'], 'read'); - assert.equal(job.name, 'Check repository-owned Mintlify content'); - assert.match(changes.uses, /^dorny\/paths-filter@[a-f0-9]{40}$/); - assert.equal(changes.with.base, '${{ github.event.before }}'); - const syntax = job.steps.findIndex((step) => step.run === 'npm run test:workflows'); - const detection = job.steps.indexOf(changes); - const full = job.steps.findIndex((step) => step.run === 'npm run check:mintlify'); - assert.ok(syntax > 0 && syntax < detection && detection < full); - assert.equal(job.steps[syntax].if, undefined); - assert.equal(job.steps[syntax]['continue-on-error'], undefined); - assert.equal(changes.if, - "github.event_name != 'workflow_dispatch' && github.event.before != '0000000000000000000000000000000000000000' && (github.event_name != 'pull_request' || github.event.pull_request.changed_files < 3000)"); - assert.equal(job.steps[full].if, - "github.event_name == 'workflow_dispatch' || github.event.before == '0000000000000000000000000000000000000000' || github.event.pull_request.changed_files >= 3000 || steps.changes.outputs.docs == 'true'"); - assert.ok(job.steps.some((step) => step.if === "steps.changes.outputs.docs == 'false'" - && step.run.includes('GITHUB_STEP_SUMMARY'))); }); const directory = new URL('../.github/workflows/', import.meta.url); From b49b5190ba3a17966da08e6711f86663fee33c05 Mon Sep 17 00:00:00 2001 From: Siddhant Khare Date: Tue, 6 Oct 2026 23:03:01 +0530 Subject: [PATCH 4/5] ci: simplify docs filters to directory-wide paths Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/mintlify-quality.yml | 37 +++----------------------- scripts/README.md | 10 ++++--- scripts/workflow-syntax.test.mjs | 33 ++++++++++++++++++----- 3 files changed, 37 insertions(+), 43 deletions(-) diff --git a/.github/workflows/mintlify-quality.yml b/.github/workflows/mintlify-quality.yml index 47e2d3bf19..50b460d56e 100644 --- a/.github/workflows/mintlify-quality.yml +++ b/.github/workflows/mintlify-quality.yml @@ -14,30 +14,9 @@ on: - 'mintlify-native/**' - 'patches' - 'patches/**' - - 'tests/fixtures/mintlify/**' - - 'tests/fixtures/configuration-generator/**' - - 'src/components/Docs' - - 'src/components/Docs/**' - - 'src/components/SwaggerUI' - - 'src/components/SwaggerUI/**' - - 'scripts/README.md' - - 'scripts/agent-content*.mjs' - - 'scripts/clean-agent-markdown.mjs' - - 'scripts/deployment-verification*.mjs' - - 'scripts/generate-legacy-api-routes.mjs' - - 'scripts/legacy-api-routes.test.mjs' - - 'scripts/native-deployment-fingerprint*.mjs' - - 'scripts/native-routes*.mjs' - - 'scripts/prepare-agent-content.mjs' - - 'scripts/prepare-site-sitemap.mjs' - - 'scripts/report-api-samples*.mjs' - - 'scripts/site-boundary*.mjs' - - 'scripts/site-sitemap*.mjs' - - 'scripts/update-api-samples*.mjs' - - 'scripts/update-config-page*.mjs' - - 'scripts/validate-agent-content.mjs' - - 'scripts/validate-site-boundary.mjs' - - 'scripts/verify-docs-deployment.mjs' + - 'tests/**' + - 'src/**' + - 'scripts/**' - '.gitattributes' - '.gitignore' - '.prettierrc' @@ -46,15 +25,7 @@ on: - 'package-lock.json' - 'tsconfig.json' - 'docusaurus.config.js' - - '.github/workflows/mintlify-quality.yml' - - '.github/workflows/update-docs.yml' - - '.github/workflows/update-api-samples.yml' - - 'src/data/legacy-api-routes.json' - - 'src/utils/legacy-api-redirect.mjs' - - 'src/pages/api/service.tsx' - - 'src/pages/community.mdx' - - 'src/features/LandingPage/QuickStartSection/index.tsx' - - 'src/theme/Root.tsx' + - '.github/workflows/**' push: branches: - docs-next diff --git a/scripts/README.md b/scripts/README.md index 47e22a630c..686f874396 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -18,9 +18,11 @@ workflows use it. `npm run check:mintlify`, run by the regression suites and freshness checks; it does not build or deploy edge infrastructure. The quality workflow uses GitHub's native `paths` filters for PRs and pushes. -Only docs sources and supporting tooling, fixtures, dependencies, configuration, -and navigation/boundary inputs trigger it. The shared path list lives directly -in the workflow; update it and its regression cases when adding docs inputs. +Directory-wide patterns cover docs sources, `patches/`, `src/`, `scripts/`, +`tests/`, and `.github/workflows/`, alongside the listed root dependencies and +configuration. This intentionally includes non-docs edits within those directories +so new inputs are covered without maintaining per-file lists. The shared path +list lives directly in the workflow. Manual runs remain unfiltered. GitHub's native diff limits apply; use a manual run for unusually large changes. Keep this path-filtered status optional, since GitHub leaves skipped workflow checks pending. @@ -31,7 +33,7 @@ workflow YAML and checks Bash/sh syntax without executing the scripts; other declared shells are explicitly skipped for shell validation, not YAML validation. It also runs the existing file-preservation contract, so deleting or moving required website files cannot bypass the check when docs quality is skipped. -Ordinary website content edits do not trigger the full docs suite. +Blog and static-asset-only changes do not trigger the full docs suite. Nightly/manual source updaters remain unchanged. | Files | Role and root commands | diff --git a/scripts/workflow-syntax.test.mjs b/scripts/workflow-syntax.test.mjs index f64b00e77e..ddc1d76c84 100644 --- a/scripts/workflow-syntax.test.mjs +++ b/scripts/workflow-syntax.test.mjs @@ -72,15 +72,36 @@ test('native paths include docs sources and supporting inputs', () => { } }); -test('native paths exclude unrelated website, blog, asset, and maintenance changes', () => { +test('native paths cover entire supporting directories instead of individual files', () => { + for (const directory of ['src', 'scripts', 'tests', '.github/workflows']) { + assert.deepEqual( + quality.on.pull_request.paths.filter((path) => path.startsWith(`${directory}/`)), + [`${directory}/**`], + ); + for (const file of [ + `${directory}/new-file.mjs`, `${directory}/nested/new-file.mjs`, `${directory}/.hidden/new-file.mjs`, + ]) { + assert.equal(matchesDocs(file), true, file); + } + } for (const file of [ - 'src/components/AdoptersCarousel/index.tsx', 'static/img/adopters/supabase.svg', - 'src/features/LandingPage/HeroSection/index.tsx', 'src/css/custom.css', 'src/components/icons/index.ts', + 'src/components/AdoptersCarousel/index.tsx', 'src/features/LandingPage/HeroSection/index.tsx', + 'src/css/custom.css', 'src/components/icons/index.ts', 'src/pages/index.tsx', 'src/pages/project.mdx', + 'scripts/workflow-syntax.test.mjs', 'scripts/repository-maintenance.mjs', + 'tests/new-suite/example.test.mjs', '.github/workflows/scorecard.yml', '.github/workflows/deploy.yml', + ]) { + assert.equal(matchesDocs(file), true, file); + } +}); + +test('native paths exclude blog, static assets, and unrelated root configuration', () => { + for (const file of [ + 'static/img/adopters/supabase.svg', 'blog/news.md', 'blog/authors.yml', 'README.md', 'LICENSE', '.github/CODEOWNERS', - '.github/dependabot.yaml', '.github/workflows/scorecard.yml', '.github/workflows/deploy.yml', - 'eslint.config.js', 'scripts/workflow-syntax.test.mjs', 'scripts/repository-maintenance.mjs', + '.github/dependabot.yaml', 'eslint.config.js', 'docs-site-backup/example.mdx', 'scripts-backup/update.mjs', - 'src/pages/index.tsx', 'src/pages/project.mdx', 'blog/ignore-duplicate-writes-announcement.md', + 'src-backup/components/Docs/index.ts', 'tests-backup/example.test.mjs', + '.github/workflows-backup/example.yml', 'blog/ignore-duplicate-writes-announcement.md', ]) { assert.equal(matchesDocs(file), false, file); } From bbe8badd4b389a93c3d9dbbf8fcdb2047d25f7ba Mon Sep 17 00:00:00 2001 From: Siddhant Khare Date: Wed, 14 Oct 2026 10:44:31 +0530 Subject: [PATCH 5/5] ci: remove custom workflow validation tests Keep native docs path filters and run the existing site-preservation test on every PR. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/test-deploy.yml | 6 +- package.json | 1 - scripts/README.md | 11 +- scripts/workflow-syntax.test.mjs | 180 ------------------------------ 4 files changed, 6 insertions(+), 192 deletions(-) delete mode 100644 scripts/workflow-syntax.test.mjs diff --git a/.github/workflows/test-deploy.yml b/.github/workflows/test-deploy.yml index 2e4fa21f4b..d3e2e8c2c7 100644 --- a/.github/workflows/test-deploy.yml +++ b/.github/workflows/test-deploy.yml @@ -27,10 +27,8 @@ jobs: - name: Install dependencies run: npm ci - - name: Validate workflows and preserved site files - run: | - npm run test:workflows - node --test --test-name-pattern='repository retains' scripts/site-boundary.test.mjs + - name: Check preserved site files + run: node --test --test-name-pattern='repository retains' scripts/site-boundary.test.mjs - name: Test build website run: npm run build diff --git a/package.json b/package.json index 7723ab46a8..eea2ba4eae 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,6 @@ "build:docusaurus": "docusaurus build", "build:config-page": "node scripts/update-config-page.mjs", "test:config-page": "node --test scripts/update-config-page.test.mjs", - "test:workflows": "node --test scripts/workflow-syntax.test.mjs", "update:api-samples": "node scripts/update-api-samples.mjs", "report:api-samples": "node scripts/report-api-samples.mjs", "test:update-api-samples": "node --test scripts/update-api-samples.test.mjs scripts/report-api-samples.test.mjs", diff --git a/scripts/README.md b/scripts/README.md index e863a29631..763efa891c 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -28,11 +28,9 @@ run for unusually large changes. Keep this path-filtered status optional, since GitHub leaves skipped workflow checks pending. The always-running [deployment workflow](../.github/workflows/test-deploy.yml) -runs `npm run test:workflows` on every PR to `main` or `docs-next`. This parses all -workflow YAML and checks Bash/sh syntax without executing the scripts; other -declared shells are explicitly skipped for shell validation, not YAML validation. -It also runs the existing file-preservation contract, so deleting or moving -required website files cannot bypass the check when docs quality is skipped. +runs the existing file-preservation test from `site-boundary.test.mjs` on every +PR to `main` or `docs-next`, so deleting or moving required website files cannot +bypass the check when docs quality is skipped. Blog and static-asset-only changes do not trigger the full docs suite. Nightly/manual source updaters remain unchanged. @@ -151,8 +149,7 @@ It never approves or auto-merges PRs, and never force-pushes outside its reserve ## Regression tests -All root `*.test.mjs` files remain wired into package commands: -- `npm run test:workflows`: all workflow YAML/Bash syntax, native docs-path filters, and unconditional PR syntax/preservation gates. +All nine `*.test.mjs` files remain wired into package commands: - `npm run test:site-boundary`: agent content, site boundary, sitemap, and legacy API routes. - `npm run test:config-page`: configuration generation, including preservation/error cases. - `npm run test:update-api-samples`: offline source updates, no-change/incompatibility/transport handling, no partial incompatible writes, issue deduplication, and workflow gates. diff --git a/scripts/workflow-syntax.test.mjs b/scripts/workflow-syntax.test.mjs deleted file mode 100644 index ddc1d76c84..0000000000 --- a/scripts/workflow-syntax.test.mjs +++ /dev/null @@ -1,180 +0,0 @@ -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { readdir, readFile } from 'node:fs/promises'; -import test from 'node:test'; -import { fileURLToPath } from 'node:url'; -import picomatch from 'picomatch'; -import { parse as parseYaml } from 'yaml'; - -function checkShell(source, shell) { - assert.equal(typeof source, 'string', 'A workflow run block must be a string'); - const result = spawnSync(shell, ['-n'], { input: source, encoding: 'utf8' }); - assert.ifError(result.error); - assert.equal(result.status, 0, result.stderr); - assert.equal(result.stderr, '', 'Shell syntax warnings must not be ignored'); -} - -test('rejects the unindented heredoc YAML that caused run 37493362190', () => { - assert.throws(() => parseYaml([ - 'jobs:', - ' update-docs:', - ' steps:', - ' - run: |', - ' pr_body="$(cat <<\'BODY\'', - 'This automated PR updates the generated documentation.', - 'BODY', - ' )"', - ].join('\n'))); -}); - -test('rejects duplicate workflow YAML keys', () => { - assert.throws(() => parseYaml('jobs:\n checks:\n steps: []\n steps: []\n'), /unique/); -}); - -test('checks shell syntax without executing commands and accepts correctly indented heredocs', () => { - checkShell('exit 99\n', 'bash'); - const workflow = parseYaml([ - 'jobs:', - ' checks:', - ' steps:', - ' - run: |', - ' cat <<\'BODY\'', - ' Documentation update.', - ' BODY', - ].join('\n')); - checkShell(workflow.jobs.checks.steps[0].run, 'bash'); -}); - -test('rejects broken Bash blocks and non-string run blocks', () => { - assert.throws(() => checkShell('if true; then\n echo missing-fi\n', 'bash')); - assert.throws(() => checkShell({ echo: 'not a script' }, 'bash'), /must be a string/); -}); - -const quality = parseYaml(await readFile(new URL('../.github/workflows/mintlify-quality.yml', import.meta.url), 'utf8')); -const matchesDocs = picomatch(quality.on.pull_request.paths, { dot: true }); - -test('native paths include docs sources and supporting inputs', () => { - for (const file of [ - 'docs-site', 'docs-site/page.mdx', 'docs-site/images/example.svg', 'docs/content/intro.mdx', - 'mintlify-native/docs.json', 'patches/example.patch', 'src/components/Docs/Link/index.ts', - 'src/components/SwaggerUI', 'tests/fixtures/mintlify/core.json', - 'tests/fixtures/configuration-generator/schema.json', 'scripts/README.md', - 'scripts/update-config-page.mjs', 'scripts/site-boundary.test.mjs', - 'scripts/native-deployment-fingerprint.mjs', 'scripts/report-api-samples.test.mjs', - 'package.json', 'package-lock.json', 'tsconfig.json', 'docusaurus.config.js', - '.gitattributes', '.gitignore', '.prettierrc', '.prettierignore', - '.github/workflows/mintlify-quality.yml', '.github/workflows/update-docs.yml', - '.github/workflows/update-api-samples.yml', 'src/data/legacy-api-routes.json', - 'src/utils/legacy-api-redirect.mjs', 'src/pages/api/service.tsx', 'src/pages/community.mdx', - 'src/features/LandingPage/QuickStartSection/index.tsx', 'src/theme/Root.tsx', - ]) { - assert.equal(matchesDocs(file), true, file); - } -}); - -test('native paths cover entire supporting directories instead of individual files', () => { - for (const directory of ['src', 'scripts', 'tests', '.github/workflows']) { - assert.deepEqual( - quality.on.pull_request.paths.filter((path) => path.startsWith(`${directory}/`)), - [`${directory}/**`], - ); - for (const file of [ - `${directory}/new-file.mjs`, `${directory}/nested/new-file.mjs`, `${directory}/.hidden/new-file.mjs`, - ]) { - assert.equal(matchesDocs(file), true, file); - } - } - for (const file of [ - 'src/components/AdoptersCarousel/index.tsx', 'src/features/LandingPage/HeroSection/index.tsx', - 'src/css/custom.css', 'src/components/icons/index.ts', 'src/pages/index.tsx', 'src/pages/project.mdx', - 'scripts/workflow-syntax.test.mjs', 'scripts/repository-maintenance.mjs', - 'tests/new-suite/example.test.mjs', '.github/workflows/scorecard.yml', '.github/workflows/deploy.yml', - ]) { - assert.equal(matchesDocs(file), true, file); - } -}); - -test('native paths exclude blog, static assets, and unrelated root configuration', () => { - for (const file of [ - 'static/img/adopters/supabase.svg', - 'blog/news.md', 'blog/authors.yml', 'README.md', 'LICENSE', '.github/CODEOWNERS', - '.github/dependabot.yaml', 'eslint.config.js', - 'docs-site-backup/example.mdx', 'scripts-backup/update.mjs', - 'src-backup/components/Docs/index.ts', 'tests-backup/example.test.mjs', - '.github/workflows-backup/example.yml', 'blog/ignore-duplicate-writes-announcement.md', - ]) { - assert.equal(matchesDocs(file), false, file); - } -}); - -test('docs quality uses native paths with an unrestricted manual trigger', () => { - assert.deepEqual(quality.on.pull_request.branches, ['main', 'docs-next']); - assert.deepEqual(quality.on.push.paths, quality.on.pull_request.paths); - assert.ok(Object.hasOwn(quality.on, 'workflow_dispatch')); - const job = quality.jobs['mintlify-quality']; - assert.equal(job.if, undefined); - assert.equal(job.steps.find((step) => step.run === 'npm run check:mintlify').if, undefined); - assert.ok(job.steps.every((step) => !step.uses?.startsWith('dorny/paths-filter@'))); -}); - -test('every PR validates syntax and required-file preservation even when docs quality is skipped', async () => { - const deployment = parseYaml(await readFile(new URL('../.github/workflows/test-deploy.yml', import.meta.url), 'utf8')); - assert.deepEqual(deployment.on.pull_request.branches, ['main', 'docs-next']); - assert.equal(deployment.on.pull_request.paths, undefined); - assert.equal(deployment.on.pull_request['paths-ignore'], undefined); - const job = deployment.jobs['test-deploy']; - assert.equal(job.name, 'Test deployment'); - const validation = job.steps.findIndex((step) => step.run?.includes('npm run test:workflows')); - const build = job.steps.findIndex((step) => step.run === 'npm run build'); - assert.ok(validation > 0 && validation < build); - assert.equal(job.steps[validation].if, undefined); - assert.equal(job.steps[validation]['continue-on-error'], undefined); - assert.match(job.steps[validation].run, - /node --test --test-name-pattern='repository retains' scripts\/site-boundary\.test\.mjs/); -}); - -test('the preservation contract rejects deleted or moved required website files', () => { - const env = { ...process.env }; - // Run a fresh test runner, not another instance of the parent test worker. - delete env.NODE_TEST_CONTEXT; - for (const file of [ - 'src/pages/index.tsx', 'src/pages/project.mdx', 'blog/ignore-duplicate-writes-announcement.md', - ]) { - const missing = new URL(`../${file}`, import.meta.url).href; - const mock = `import fs from 'node:fs'; - import { syncBuiltinESMExports } from 'node:module'; - const exists = fs.existsSync; - fs.existsSync = (path) => String(path) === ${JSON.stringify(missing)} ? false : exists(path); - syncBuiltinESMExports();`; - const result = spawnSync(process.execPath, [ - '--import', `data:text/javascript,${encodeURIComponent(mock)}`, - '--test', '--test-name-pattern=repository retains', - fileURLToPath(new URL('./site-boundary.test.mjs', import.meta.url)), - ], { env, encoding: 'utf8', timeout: 20_000 }); - assert.ifError(result.error); - assert.notEqual(result.status, 0, result.stdout + result.stderr); - assert.ok((result.stdout + result.stderr).includes(`${file} must remain available`), result.stdout + result.stderr); - } -}); - -const directory = new URL('../.github/workflows/', import.meta.url); -const files = (await readdir(directory)).filter((file) => /\.ya?ml$/.test(file)).sort(); -assert.ok(files.length > 0, 'No GitHub Actions workflows found'); - -for (const file of files) { - test(`${file}: workflow YAML and shell syntax`, async (t) => { - const workflow = parseYaml(await readFile(new URL(file, directory), 'utf8')); - assert.ok(workflow?.jobs && typeof workflow.jobs === 'object', 'Workflow must define jobs'); - for (const [jobId, job] of Object.entries(workflow.jobs)) { - for (const [index, step] of (job.steps ?? []).entries()) { - if (!Object.hasOwn(step, 'run')) continue; - const declared = step.shell ?? job.defaults?.run?.shell ?? workflow.defaults?.run?.shell; - const shell = declared?.split(/\s+/)[0] - ?? (JSON.stringify(job['runs-on']).includes('windows') ? 'pwsh' : job.container ? 'sh' : 'bash'); - await t.test(`${jobId}: ${step.name ?? `step ${index + 1}`}`, { - skip: !['bash', 'sh'].includes(shell) && `No shell-syntax validator for ${shell}; YAML is still validated`, - }, () => checkShell(step.run, shell)); - } - } - }); -}