diff --git a/.github/workflows/docs-tests.yaml b/.github/workflows/docs-tests.yaml index 90ad325583..62fa7b6ec9 100644 --- a/.github/workflows/docs-tests.yaml +++ b/.github/workflows/docs-tests.yaml @@ -16,6 +16,185 @@ jobs: docs/**/*.md cookbook/**/*.md + recheck: + name: recheck + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + contents: read + pull-requests: write + env: + REDOCLY_TELEMETRY: off + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 24 + cache: npm + - name: Install dependencies + run: npm ci + - name: Pack and install the CLI + run: | + npm run pack:prepare + npm i -g ./redocly-cli.tgz + redocly --version + + # Diff against the live merge-base of the PR head and the base branch. + # `github.sha` is the auto-merge commit on the current base tip, so a + # diff against the stale base sha would list files other PRs changed. + - name: Changed Markdown files + id: diff + run: | + git fetch origin ${{ github.base_ref }} --quiet + git diff --name-only origin/${{ github.base_ref }}...${{ github.event.pull_request.head.sha }} \ + | grep -E '^(docs|cookbook)/.*\.md$' > changed.txt || true + # A deleted file makes the command fail, so keep only files that still exist. + while read -r file; do [ -f "$file" ] && echo "$file"; done < changed.txt > docs-changed.txt + echo "Changed Markdown files: $(wc -l < docs-changed.txt)" + echo "count=$(wc -l < docs-changed.txt)" >> "$GITHUB_OUTPUT" + + # Annotates the changed files, every severity, and writes the summary the + # comment below reads. It never gates: the full-tree step does. + - name: Annotate changed files + id: recheck + if: steps.diff.outputs.count != '0' + continue-on-error: true + run: | + exit_code=0 + xargs redocly recheck --config docs/redocly.yaml --format=github-actions --summary json --summary-path recheck-summary.json < docs-changed.txt || exit_code=$? + echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT" + exit $exit_code + + # Runs over the whole tree, not just changed files, because deleting a + # file or renaming a heading breaks links in files a PR never touched. + # Errors gate; warnings and info stay visible in the summary comment. + # --max-problems lists errors first, so the cap only trims the backlog + # of warnings and info from unchanged files. + - name: Full docs tree must stay error-free + run: redocly recheck --config docs/redocly.yaml docs cookbook --format=github-actions --max-problems 50 + + # Scores every changed file on this PR and on the merge-base so the + # comment can show the readability change per file. Both sides run from + # detached worktrees: the workspace is the auto-merge commit, so scoring + # it would blend base-branch edits into the "This PR" column. Both read + # the workspace config, because the merge-base may not have one. + - name: Readability of changed files + if: steps.diff.outputs.count != '0' + continue-on-error: true + run: | + score() { + git worktree add --detach "$1" "$2" + ( + cd "$1" + while read -r file; do [ -f "$file" ] && echo "$file"; done < "$GITHUB_WORKSPACE/docs-changed.txt" \ + | xargs redocly recheck --config "$GITHUB_WORKSPACE/docs/redocly.yaml" --readability --format=json \ + > "$GITHUB_WORKSPACE/$3" + ) || true + } + score /tmp/head-tree ${{ github.event.pull_request.head.sha }} readability-head.json + score /tmp/base-tree "$(git merge-base origin/${{ github.base_ref }} ${{ github.event.pull_request.head.sha }})" readability-base.json + + # Posts one comment per PR and updates it on every run. A PR from a fork + # has a read-only token, so a failed comment does not fail the job. + - name: Summary comment + if: always() && steps.diff.outcome == 'success' + continue-on-error: true + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const fs = require('fs'); + const changedCount = Number('${{ steps.diff.outputs.count }}'); + const exitCode = '${{ steps.recheck.outputs.exit_code }}'; + const runUrl = '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'; + + let summary = null; + try { + if (fs.existsSync('recheck-summary.json')) summary = JSON.parse(fs.readFileSync('recheck-summary.json', 'utf8')); + } catch (error) { + console.log('Failed to read summary file:', error.message); + } + + const lines = ['## πŸ“ Recheck Summary', '']; + if (changedCount === 0) { + lines.push('ℹ️ **No Markdown files changed** under `docs/` or `cookbook/`.'); + } else if (!summary) { + lines.push('πŸ”§ **Recheck failed to run** on the changed files.'); + lines.push(''); + lines.push(`Check the [workflow logs](${runUrl}) for details.`); + } else { + const { totalErrors = 0, totalWarnings = 0, totalInfo = 0, filesScanned = 0, breakdown = {} } = summary; + const statusEmoji = totalErrors > 0 ? '❌' : totalWarnings > 0 ? '⚠️' : 'βœ…'; + const statusMessage = totalErrors > 0 ? 'Errors found' : totalWarnings > 0 ? 'Warnings found' : 'All good!'; + lines.push(`${statusEmoji} **${statusMessage}** β€’ Scanned ${filesScanned} changed file(s)`); + lines.push(''); + if (totalErrors > 0 || totalWarnings > 0 || totalInfo > 0) { + lines.push(`- **${totalErrors}** error(s)`); + lines.push(`- **${totalWarnings}** warning(s)`); + lines.push(`- **${totalInfo}** info`); + lines.push(''); + lines.push('### πŸ“‹ Findings by rule'); + lines.push(''); + for (const [rule, counts] of Object.entries(breakdown)) { + const icons = [counts.errors > 0 && 'πŸ”΄', counts.warnings > 0 && '🟑', counts.info > 0 && 'πŸ”΅'].filter(Boolean).join(' '); + lines.push(`${icons} **${rule.replace('recheck/', '')}**: ${counts.errors} errors, ${counts.warnings} warnings, ${counts.info} info`); + } + lines.push(''); + lines.push('Errors fail the full-tree check. Warnings and info are a worklist and do not block the PR.'); + } else { + lines.push('πŸŽ‰ No findings in the changed files.'); + } + } + + // Readability of changed files, head versus merge-base. The table shows + // the Automated Readability Index: a grade level, so lower is easier. + const parseScores = (path) => { + const scores = {}; + if (!fs.existsSync(path)) return scores; + try { + for (const row of JSON.parse(fs.readFileSync(path, 'utf8')).files ?? []) { + if (row.automatedReadabilityIndex !== null) scores[row.file] = row.automatedReadabilityIndex; + } + } catch (error) { + console.log(`Could not parse ${path}:`, error.message); + } + return scores; + }; + const headScores = parseScores('readability-head.json'); + const baseScores = parseScores('readability-base.json'); + const rows = Object.keys(headScores).sort().map((file) => { + const before = baseScores[file]; + const after = headScores[file]; + const change = before === undefined + ? 'new file' + : Math.abs(after - before) < 0.05 + ? 'Β±0' + : after < before + ? `🟒 ${(after - before).toFixed(1)} easier` + : `πŸ”΄ +${(after - before).toFixed(1)} harder`; + return `| ${file} | ${before === undefined ? 'β€”' : before.toFixed(1)} | ${after.toFixed(1)} | ${change} |`; + }); + if (rows.length > 0) { + lines.push(''); + lines.push('### πŸ“– Readability of changed files'); + lines.push(''); + lines.push('Automated Readability Index (ARI): a U.S. grade level. Lower is easier to read.'); + lines.push(''); + lines.push('| File | Base | This PR | Change |'); + lines.push('|---|---:|---:|---:|'); + const MAX_ROWS = 20; + lines.push(...rows.slice(0, MAX_ROWS)); + if (rows.length > MAX_ROWS) lines.push(`| …and ${rows.length - MAX_ROWS} more files | | | |`); + } + + const tag = ''; + const { owner, repo, number } = context.issue; + const body = `${tag}\n${lines.join('\n')}`; + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: number, per_page: 100 }); + const previous = comments.find((comment) => comment.body && comment.body.includes(tag)); + if (previous) await github.rest.issues.updateComment({ owner, repo, comment_id: previous.id, body }); + else await github.rest.issues.createComment({ owner, repo, issue_number: number, body }); + vale: name: vale action runs-on: blacksmith-4vcpu-ubuntu-2404 diff --git a/cookbook/custom-plugins/sorting/README.md b/cookbook/custom-plugins/sorting/README.md index e905c80005..9652f3cf61 100644 --- a/cookbook/custom-plugins/sorting/README.md +++ b/cookbook/custom-plugins/sorting/README.md @@ -107,7 +107,7 @@ Your command will look something like the following example: redocly lint openapi.yaml ``` -If your OpenAPI doesn't fulfil the criteria in the configured rules, the details of the warnings/errors are shown in the output. +If your OpenAPI doesn't fulfill the criteria in the configured rules, the details of the warnings/errors are shown in the output. Adjust the rule configuration or severity levels to meet your needs, and let us know if there's some other rules you'd like to see included. diff --git a/cookbook/rulesets/security/README.md b/cookbook/rulesets/security/README.md index 1f34c77c92..a5965c30e5 100644 --- a/cookbook/rulesets/security/README.md +++ b/cookbook/rulesets/security/README.md @@ -192,4 +192,4 @@ Pick a limit that's generous for the size of the data that you expect, but small ## References - [OWASP top ten](https://owasp.org/Top10/) -- Published linting rulesets from [Spectral](https://blog.stoplight.io/spectral-owasp-api-2023-security-ruleset) and [Vacuum](https://quobix.com/vacuum/rules/owasp/) +- Published linting rulesets from [Spectral](https://github.com/stoplightio/spectral-owasp-ruleset) and [Vacuum](https://quobix.com/vacuum/rules/owasp/) diff --git a/docs/redocly.yaml b/docs/redocly.yaml new file mode 100644 index 0000000000..7da6cb8de7 --- /dev/null +++ b/docs/redocly.yaml @@ -0,0 +1,317 @@ +# Markdown linting for docs/ and cookbook/, run by `redocly recheck` in CI +# (.github/workflows/docs-tests.yaml). This file lives here, not in the +# repository root, because a root redocly.yaml changes the CLI package's unit +# tests. The rules are ported from the Redocly monorepo's recheck.yaml, with +# severities set from a measurement of this tree on 2026-10-05: a rule at zero +# findings gates at error, a rule with a backlog reports at warn until the +# backlog is cleared, then steps up to error. +extends: + - recheck/markdown + - recheck/markdoc + - recheck/plain-language + - recheck/technical-english + - recheck/inclusive-language + +recheck: + markdoc: true + rules: + # -- Ported from .markdownlint.yaml so the two tools agree -- + recheck/line-length: off # MD013; readable-line-length below governs + recheck/ul-style: off # MD004; bullet-style-dash below governs + recheck/ol-prefix: off # MD029 + recheck/list-marker-space: off # MD030 + recheck/no-inline-html: off # MD033 + recheck/no-bare-urls: off # MD034 + recheck/no-emphasis-as-heading: off # MD036 + recheck/first-line-h1: off # MD041 + recheck/code-block-style: off # MD046 + recheck/blanks-around-headings: off # blank_lines + recheck/blanks-around-fences: off # blank_lines + recheck/blanks-around-lists: off # blank_lines + recheck/no-multiple-blanks: off # blank_lines + # MD024 siblings_only: no-duplicate-headings below runs the same assertion + # with respectSections, so duplicates report once. + recheck/no-duplicate-heading: off + # no-broken-fragment-links below runs the same assertion across files. + recheck/link-fragments: off + recheck/single-h1: # MD025 front_matter_title: '' + severity: error + assertions: + single-h1: { frontMatterTitle: '' } + recheck/no-trailing-punctuation: # MD026 + severity: error + assertions: + no-trailing-punctuation: { punctuation: '.,;:γ€‚οΌŒοΌ›:' } + + # -- recheck/plain-language: the word-choice and structure rules at zero + # findings gate at error. excess-intensifiers (36) and + # paragraph-sentence-count (9) stay at the preset's warn until cleared. -- + plain-language/filler-phrases: error + plain-language/an-amount-of: error + plain-language/complex-words: error + plain-language/redundant-pairs: error + plain-language/double-negative-phrases: error + plain-language/jargon-terms: error + plain-language/double-negative-patterns: error + plain-language/paragraph-max-words: error + + # -- recheck/technical-english stays at the preset defaults: sentence-length + # (446) and paragraph-length (18) at warn as the visible worklist, + # passive-voice (1207) at info. -- + + recheck/us-spelling: + severity: error + message: 'Use the US spelling "%s" instead of British "%s".' + link: https://docs.microsoft.com/en-us/style-guide/word-choice/use-us-spelling-avoid-non-english-words + assertions: + swap: + ignoreCase: true + wordBoundary: true + pairs: + aeon: eon + aeroplane: airplane + ageing: aging + aluminium: aluminum + anaemia: anemia + anaesthesia: anesthesia + analyse: analyze + annexe: annex + apologise: apologize + behaviour: behavior + busses: buses + calibre: caliber + cancelled: canceled + cancellation: cancelation + catalogue: catalog + categorise: categorize + categorised: categorized + categorises: categorizes + categorising: categorizing + centre: center + cheque: check + civilisation: civilization + civilise: civilize + colour: color + cosy: cozy + cypher: cipher + dependant: dependent + defence: defense + distil: distill + draught: draft + encyclopaedia: encyclopedia + enquiry: inquiry + enrol: enroll + enrolment: enrollment + enthral: enthrall + expiry: expiration + favourite: favorite + fibre: fiber + fillet: filet + flavour: flavor + furore: furor + fulfil: fulfill + gaol: jail + humour: humor + honour: honor + initialled: initialed + initialling: initialing + instil: instill + jewellery: jewelry + labelling: labeling + labelled: labeled + labour: labor + libellous: libelous + licence: license + likeable: likable + liveable: livable + lustre: luster + manoeuvre: maneuver + marvellous: marvelous + matt: matte + meagre: meager + metre: meter + modelling: modeling + moustache: mustache + neighbour: neighbor + normalise: normalize + offence: offense + organise: organize + organisation: organization + orientated: oriented + paralyse: paralyze + plough: plow + pretence: pretense + programme: program + pyjamas: pajamas + rateable: ratable + realise: realize + recognise: recognize + reconnoitre: reconnoiter + rumour: rumor + sabre: saber + saleable: salable + saltpetre: saltpeter + sceptic: skeptic + sepulchre: sepulcher + signalling: signaling + sizeable: sizable + skilful: skillful + sombre: somber + smoulder: smolder + speciality: specialty + spectre: specter + splendour: splendor + standardise: standardize + standardised: standardized + sulphur: sulfur + theatre: theater + travelled: traveled + traveller: traveler + travelling: traveling + unshakeable: unshakable + wilful: willful + yoghurt: yogurt + exceptions: + lines: + # Swap-pair examples in the recheck docs show British spellings on purpose. + - 'colour: color' + - 'color: colour' + - 'behaviour: behavior' + - 'behavior: behaviour' + - 'organise: organize' + + recheck/inclusion-gender-culture: + severity: error + message: 'Use inclusive language. Consider "%s" instead of "%s".' + assertions: + swap: + keysAreRegex: true + wordBoundary: true + ignoreCase: true + pairs: + he: they + his: their + she: they + hers: their + 'blacklist(?:ed|ing|s)?': blocklist + 'whitelist(?:ed|ing|s)?': allowlist + master: primary + slave: replica + he/she: they + s/he: they + exceptions: + lines: + # 'master' as a git branch segment inside URLs we do not control. + - '/master/' + + # Four headings still start with a gerund; warn until they are reworded. + recheck/no-gerund-headings: + severity: warn + scope: heading + message: 'Do not start headings with a gerund.' + assertions: + pattern: + ignoreCase: true + tokens: + - "^\\w*ing\\b" + exceptions: + lines: + - Breaking + - Contributing + - Monitoring + - Naming + - Routing + - Settings + - String + - Testing + - Training + - Troubleshooting + + recheck/oxford-comma: + severity: error + scope: sentence + message: "Use the Oxford comma in '%s'." + assertions: + pattern: + tokens: + - '(?:[^\s,]+,){1,} \w+ (?:and|or) \w+[.?!]' + + # 22 lines over the limit; warn until they are split. + recheck/readable-line-length: + severity: warn + message: 'Keep lines under %s characters.' + assertions: + line-length: + lineLength: 400 + codeBlocks: false + tables: false + + recheck/no-trailing-spaces: + severity: error + message: 'Remove trailing spaces.' + assertions: + no-trailing-spaces: + codeBlocks: false + + recheck/bullet-style-dash: + severity: error + message: "Use '-' for unordered list bullets." + assertions: + ul-style: + style: dash + + # 628 findings; warn until the docs are rewrapped one sentence per line. + recheck/semantic-line-breaks: + severity: warn + message: 'Use semantic line breaks (%s mode).' + assertions: + semantic-line-breaks: + mode: sentence + ignoreCodeBlocks: true + ignoreTables: true + + recheck/no-hard-tabs: + severity: error + message: 'Use spaces instead of tabs.' + assertions: + no-hard-tabs: + codeBlocks: false + spacesPerTab: 2 + + recheck/no-duplicate-headings: + severity: error + message: 'Duplicate heading "%s".' + assertions: + no-duplicate-heading: + caseSensitive: false + ignoreCommonHeadings: false + respectSections: true + + recheck/no-broken-fragment-links: + severity: error + message: 'Broken link fragment: %s' + assertions: + link-fragments: + crossFile: true + + # Three images are over the limit; warn until they are compressed. + recheck/optimize-images: + severity: warn + message: 'Image too large: %s' + assertions: + max-image-size: + maxSizeKB: 100 + extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'] + + # On 2026-10-05, three pages are over 15 minutes at 200 words per minute: + # both changelogs and guides/use-generated-client.md. Warn until they are split. + recheck/page-reading-time: + severity: warn + message: 'Page %s is %s minutes; at most 15 expected. Split it, or move reference material to its own page.' + appliesTo: + - 'docs/**' + - 'cookbook/**' + assertions: + metric: + formula: reading-time + max: 15