diff --git a/.github/GOVERNANCE.md b/.github/GOVERNANCE.md index 0f3c26f2a5..677f2e0b6a 100644 --- a/.github/GOVERNANCE.md +++ b/.github/GOVERNANCE.md @@ -66,13 +66,49 @@ AI-assisted PRs follow the same rules as any other. The author, not the agent, i ## What CI checks -On every PR: the site builds, frontmatter namespaces and permalinks are present and unique, and the PR title matches the convention. A broken build or a duplicate permalink blocks the merge. - -On every PR, informational only: a **webkit adoption report** runs the design-system ESLint rules over the UI and writes the result to the run Summary — how many `webkit/*` violations there are, which rules, which files, and what the check did *not* look at. It never blocks the merge. It exists so the distance between this codebase and the design system is a number someone can watch, instead of something noticed in review. - -Weekly: a link check crawls the built site for broken internal links and opens an issue when it finds them. - -Everything else in this document is a convention that reviewers uphold, which is how most of it will always work. +Three checks have to be green to merge, and each one is its own workflow. + +**CI gate** is the aggregator. A `changes` job reads the diff and decides which of the jobs below +run, so a PR that only touches `.mdx` never pays for the platform checks. The gate passes when +every job it covers either succeeded or was cleanly skipped, which is what lets a job be added or +switched off without editing branch protection. + +- The site builds, and frontmatter namespaces and permalinks are present and unique. A broken + build or a duplicate permalink blocks the merge. +- The navigation tree validates. A broken entry there takes down every page at once, so this one + blocks outright. +- ESLint, Prettier and Stylelint run **over the files the PR changed**. Over the whole tree they + report debt nobody in a given PR created; scoped to the diff they mean what you touch, you leave + clean. +- Dependencies are audited and the diff is scanned for verified secrets. This repository is + public: a token pasted into an example goes into the history and does not come out. + +**Internal links** builds the site and checks every internal link in it. + +**Design system adoption** runs the design-system ESLint rules over the UI, writes the result to +the run Summary — how many `webkit/*` violations there are, which rules, which files, and what the +check did *not* look at — and then fails on any violation at all. The report comes first and always, +so the number reaches you whatever the check decides; the check itself tolerates nothing. + +### Checks that ratchet + +Four checks carry more debt than any one PR can clear, so they are frozen at a baseline in +`ci/baselines.json` and fail only on a number that **grows**: high/critical vulnerabilities, type +errors from `astro check`, translations whose slug does not match the English page, and broken +internal links (most of those are links to `www.azion.com` pages that live in the site repository, +not here). The current numbers are in `ci/baselines.json`; this document does not repeat them, +because a number copied into prose goes stale the first time someone fixes something. + +Design-system adoption is deliberately **not** one of them: its baseline is zero, so every +violation fails the check. + +The count can fall and the baseline is then stale, which is reported and never punished; re-snapshot +with `pnpm ci:ratchet --update`. A ratchet whose command stops producing a number **fails** +rather than passing, because a check that silently measures nothing is worse than no check at all — +which is exactly what the link checker did for as long as it existed. + +Everything else in this document is a convention that reviewers uphold, which is how most of it will +always work. ## Review expectations diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 0000000000..4fa0dbfd31 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,27 @@ +name: Setup +description: pnpm + Node from .nvmrc + a frozen install. The preamble every job repeats. + +inputs: + install: + description: Run `pnpm install --frozen-lockfile`. Set to 'false' for a job that only needs the checkout. + required: false + default: 'true' + +runs: + using: composite + steps: + - name: Set up pnpm + # No `version:` on purpose — it resolves from `packageManager` in package.json, + # so the lockfile and CI can never drift apart. + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + + - name: Set up Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + cache: pnpm + + - name: Install dependencies + if: inputs.install == 'true' + shell: bash + run: pnpm install --frozen-lockfile diff --git a/.github/workflows/azion-deploy.yml b/.github/workflows/azion-deploy.yml deleted file mode 100644 index 66e9022479..0000000000 --- a/.github/workflows/azion-deploy.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Deploy Application using Azion CLI - -on: - workflow_dispatch: - inputs: - branch: - required: true - type: choice - default: main - options: - - main - -jobs: - deploy: - name: Deploy - runs-on: ubuntu-latest - - permissions: - contents: write - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Use Node.js 20.x - uses: actions/setup-node@v4 - with: - node-version: 20 - - # If you want to change package manager, change the command below and add the necessary configurations - - name: Install dependencies - run: npm install - - - name: Install Azion CLI - run: | - curl -o azionlinux https://downloads.azion.com/linux/x86_64/azion - sudo mv azionlinux /usr/bin/azion - sudo chmod u+x /usr/bin/azion - - - name: CLI version - run: azion --version - - # Configure a personal token in your github secrets - # You may create a personal token by running 'azion create personal-token' - - name: Configure token - run: | - azion -t ${{ secrets.AZION_PERSONAL_TOKEN }} - azion whoami - - - name: Azion Build - run: | - azion build - - # You may add the --sync flag to sync local and remote resources - - name: Azion Deploy - run: | - azion deploy --local --debug - - - name: Commit Azion files - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add azion/azion.json azion.config.* || true - # Commit only if there are staged changes - if git diff --cached --quiet; then - echo "No Azion changes to commit. Skipping push." - else - git commit -m "chore: update azion files" - # Rebase in case remote has new commits, then push - git pull --rebase origin ${{ inputs.branch }} - git push - fi diff --git a/.github/workflows/ci-gate.yml b/.github/workflows/ci-gate.yml new file mode 100644 index 0000000000..a1803bd69a --- /dev/null +++ b/.github/workflows/ci-gate.yml @@ -0,0 +1,229 @@ +name: CI gate + +# The PR gate, in the shape of aziontech/webkit's governance.yml: a `changes` job with path +# filters, every job conditioned on one of its outputs, and one aggregator at the end. +# +# `CI gate` aggregates the jobs in this file: it passes when every one succeeded or was +# cleanly skipped, so a job can be added or switched off without touching branch +# protection. +# +# Two checks are deliberately NOT in here — `internal-links.yml` and +# `design-system-adoption.yml` are standalone workflows with their own triggers and their +# own status checks, so branch protection needs all three names. Internal links pays for +# that independence by building the site itself; see the note in its own file. +# +# This workflow never deploys. Deploys live in their own workflows, with their own secrets +# and the opposite concurrency (a deploy is never cancelled mid-flight). + +on: + pull_request: + branches: + - main + # The new-docs work lands here first, so PRs to it get the same gate. + - release/new-azion-docs + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + changes: + name: Detect changes + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + content: ${{ steps.filter.outputs.content }} + platform: ${{ steps.filter.outputs.platform }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 + id: filter + with: + filters: | + # Touching the gate itself, or any config it enforces, re-runs everything — + # otherwise a PR that breaks the gate is the one PR the gate never sees. + gate: &gate + - '.github/workflows/ci-gate.yml' + - '.github/actions/setup/**' + - 'ci/baselines.json' + - 'scripts/ci/**' + - 'eslint.config.mjs' + - '.prettierrc*' + - '.prettierignore' + - '.stylelintrc*' + - '.nvmrc' + content: + - 'src/content/**' + - 'src/i18n/**' + - 'src/nav/**' + - 'cicd/massive-redirect/**' + - *gate + platform: + - 'src/**' + - 'plugins/**' + - 'integrations/**' + - 'scripts/**' + - 'astro.config.ts' + - 'tsconfig.json' + - 'postcss.config.js' + - 'package.json' + - 'pnpm-lock.yaml' + - *gate + + security: + name: Security scans + needs: changes + if: needs.changes.outputs.platform == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: ./.github/actions/setup + + # Ratchet. 33 high and 1 critical are already in the tree — the repository has never + # run an audit, so blocking outright would mean fixing all 34 before any PR could + # merge. The critical one is astro < 7.2.8 (RCE through AVIF image optimization); + # bumping Astro is its own change, not a side effect of wiring up CI. + - name: Audit dependencies (ratchet) + run: pnpm ci:ratchet audit + + - name: Secret detection + uses: trufflesecurity/trufflehog@20652fbbdefffcdaa493a5bf57ab2ac6b1db715b # main + with: + path: ./ + base: ${{ github.event.pull_request.base.sha }} + head: ${{ github.event.pull_request.head.sha }} + extra_args: --only-verified + + content: + name: Content integrity + needs: changes + if: ${{ needs.changes.outputs.content == 'true' || needs.changes.outputs.platform == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/setup + + # Blocking: the navigation tree is the one structure a broken entry takes down for + # every page at once. + - name: Navigation tree + run: pnpm lint:navcheck + + # Ratchet. 745 translations already carry a slug that does not match the English + # page; this fails only on one this branch adds. + - name: Translation slugs (ratchet) + run: pnpm ci:ratchet slugcheck + + code-quality: + name: Code quality + needs: changes + if: needs.changes.outputs.platform == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + - uses: ./.github/actions/setup + + # Every check below carries `if: always()`, so one failure does not hide the other + # three. Splitting these into separate jobs bought exactly that, and cost a second + # checkout and install (~24 s of runner) to buy it. + # + # ESLint, Prettier and Stylelint run over the changed files only. Over the whole tree + # ESLint reports 97 errors across 46 files — debt that is no branch's fault, and not + # worth reformatting the repository to clear. Scoping to the diff is the ratchet: + # what you touch, you leave clean. + - name: Collect changed files + id: diff + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + git diff --name-only --diff-filter=ACMR "$BASE_SHA" "$HEAD_SHA" > changed.txt + grep -E '\.(js|mjs|cjs|ts|tsx|vue|astro)$' changed.txt > changed-code.txt || true + grep -E '\.(css|scss|vue|astro)$' changed.txt > changed-style.txt || true + grep -E '\.(js|mjs|cjs|ts|tsx|vue|astro|json|md|mdx|yml|yaml|css|scss)$' changed.txt > changed-fmt.txt || true + echo "code=$(wc -l < changed-code.txt)" >> "$GITHUB_OUTPUT" + echo "style=$(wc -l < changed-style.txt)" >> "$GITHUB_OUTPUT" + echo "fmt=$(wc -l < changed-fmt.txt)" >> "$GITHUB_OUTPUT" + + - name: ESLint (zero warnings on changed files) + if: ${{ always() && steps.diff.outcome == 'success' && steps.diff.outputs.code != '0' }} + run: tr '\n' '\0' < changed-code.txt | xargs -0 pnpm exec eslint --max-warnings 0 + + - name: Prettier (changed files) + if: ${{ always() && steps.diff.outcome == 'success' && steps.diff.outputs.fmt != '0' }} + run: tr '\n' '\0' < changed-fmt.txt | xargs -0 pnpm exec prettier --check + + - name: Stylelint (changed files) + if: ${{ always() && steps.diff.outcome == 'success' && steps.diff.outputs.style != '0' }} + run: tr '\n' '\0' < changed-style.txt | xargs -0 pnpm exec stylelint + + # Ratchet, for the same reason as the slugs: the type errors already here are not + # this branch's. This is the class of bug that reaches production silently — DocFrame + # rendered an empty slot with a build reporting zero errors. + - name: astro check (ratchet) + if: always() + run: pnpm ci:ratchet astro-check + + build: + name: Build + needs: changes + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/setup + + # `build:local` ends in test:frontmatter, so namespaces and unique permalinks are + # asserted here. + - name: Build and validate frontmatter + run: pnpm build:local + env: + NODE_OPTIONS: --max-old-space-size=8120 + + + ci-gate: + name: CI gate + needs: [changes, security, content, code-quality, build] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # `skipped` counts as OK, so a job the path filter did not select — or one switched + # off — never blocks a merge. + - name: Every job passed or was skipped + run: | + declare -A results=( + [changes]="${{ needs.changes.result }}" + [security]="${{ needs.security.result }}" + [content]="${{ needs.content.result }}" + [code-quality]="${{ needs['code-quality'].result }}" + [build]="${{ needs.build.result }}" + ) + failed=0 + for job in "${!results[@]}"; do + result="${results[$job]}" + if [[ "$result" != "success" && "$result" != "skipped" ]]; then + echo "FAIL $job: $result" + failed=1 + else + echo "ok $job: $result" + fi + done + if [[ $failed -eq 1 ]]; then + echo "The CI gate did not pass." + exit 1 + fi + echo "CI gate passed." diff --git a/.github/workflows/design-system-adoption.yml b/.github/workflows/design-system-adoption.yml new file mode 100644 index 0000000000..143f5eeb79 --- /dev/null +++ b/.github/workflows/design-system-adoption.yml @@ -0,0 +1,58 @@ +name: Design system adoption + +# Standalone: its own trigger, its own concurrency, its own check. +# +# The number is reported to the Summary and then enforced with no tolerance: the baseline +# is zero, so the 72 violations currently in the tree fail this check until they are fixed. +# +# It needs nothing from the rest of the pipeline — no build, no artifact — so being +# independent costs it nothing. This is also the seam with the design system: the +# four-stage gate (wiring · canary · adoption ratchet · style) being built on +# chore/webkit-gate replaces the body of this workflow, and eventually the whole file +# becomes one `uses:` of @aziontech/webkit's own reusable workflow. + +on: + pull_request: + branches: + - main + - release/new-azion-docs + types: [opened, synchronize, reopened] + # Deliberately no `paths:` filter. This is a required check, and a workflow whose path + # filter does not match never runs — so it never reports, and branch protection waits + # on it forever. A check that is cheap (36 s) and always reports beats a check that is + # sometimes free and sometimes deadlocks the merge. + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + design-system-adoption: + name: Design system adoption + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/setup + + # The report goes to the Summary first, unconditionally, so the number reaches the + # reader whatever the ratchet below decides. + - name: Adoption report + if: always() + continue-on-error: true + run: | + pnpm lint:webkit + pnpm --silent report:webkit-adoption >> "$GITHUB_STEP_SUMMARY" + + # Blocking, with the baseline at zero: no violation is tolerated. This is red today — + # 72 violations across 30 files, adoption 65% — and stays red until they are fixed. + # + # That is the point. The step used to run with `continue-on-error` and reported green + # over all 72, which is the same failure mode as the link checker that said "no link + # issues" because it parsed zero pages: a check nobody can trust. A ratchet at 72 + # would have kept the merge unblocked, and was rejected for the same reason. + - name: No design-system violations + run: pnpm ci:ratchet webkit-adoption diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 555730b1db..e4f63b6d96 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -3,10 +3,19 @@ on: push: branches: - dev + +concurrency: + group: deploy-dev + cancel-in-progress: false + +permissions: + contents: read + jobs: deploy: name: Development publising runs-on: ubuntu-latest + timeout-minutes: 30 steps: - name: CHECKOUT project uses: actions/checkout@v4 diff --git a/.github/workflows/internal-links.yml b/.github/workflows/internal-links.yml new file mode 100644 index 0000000000..ab82c7934d --- /dev/null +++ b/.github/workflows/internal-links.yml @@ -0,0 +1,50 @@ +name: Internal links + +# Standalone: its own trigger, its own concurrency, its own check. +# +# Being independent has a price, and it is worth naming. This workflow builds the site +# itself — ~2 min — because a workflow with its own trigger runs in its own run, and +# artifacts do not cross runs. While it was called from `ci-gate.yml` it downloaded the +# `dist` the gate had already produced and cost 9 s instead. +# +# What independence buys: it can be re-run on its own, it cannot be held up by an +# unrelated job, and it can be moved or switched off without editing the gate. It runs in +# parallel with the gate, so the extra build costs runner minutes, not wall-clock. +# +# OG image generation is skipped: the checker reads HTML and sitemaps, never the images. + +on: + pull_request: + branches: + - main + - release/new-azion-docs + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + internal-links: + name: Internal links + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: ./.github/actions/setup + + - name: Build the site + run: pnpm build:local + env: + NODE_OPTIONS: --max-old-space-size=8120 + SKIP_OG: 'true' + + # Ratchet. The checker had been pointed at the wrong base URL since the Astro fork, + # so it parsed zero pages and always said "no link issues". With the URL fixed it + # reports real numbers, and most of them are links to www.azion.com pages that live + # in the site repo, not here — which is a configuration job, not this branch's. + - name: Link check (ratchet) + run: pnpm ci:ratchet linkcheck diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml deleted file mode 100644 index 9f2d182ec7..0000000000 --- a/.github/workflows/pr-checks.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: PR checks -on: - pull_request: - branches: - - main - # The new-docs work lands here first, so PRs to it need the same checks. - - release/new-azion-docs - types: [opened, synchronize, reopened] - -concurrency: - group: pr-checks-${{ github.event.pull_request.number }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - validate: - name: Build and frontmatter - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up pnpm - uses: pnpm/action-setup@v4 - - - name: Set up Node - uses: actions/setup-node@v4 - with: - node-version: 22.23.2 - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Validate navigation - run: pnpm lint:navcheck - - - name: Build and validate frontmatter - run: pnpm build:local - env: - NODE_OPTIONS: --max-old-space-size=8120 - - # Design-system adoption: measured, never enforced. The report lands in the run - # Summary; `if: always()` so it still appears when a step above fails, and - # `continue-on-error` so a red number never blocks the PR. - - name: Webkit adoption report - if: always() - continue-on-error: true - run: | - pnpm lint:webkit - pnpm --silent report:webkit-adoption >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml deleted file mode 100644 index d7edc74737..0000000000 --- a/.github/workflows/pr-title.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: PR title -on: - pull_request: - types: [opened, edited, reopened, synchronize] - -concurrency: - group: pr-title-${{ github.event.pull_request.number }} - cancel-in-progress: true - -permissions: - pull-requests: read - -jobs: - title: - name: Conventional title, no ticket codes - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Check type(scope) format - uses: amannn/action-semantic-pull-request@v5 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - types: | - feat - fix - docs - i18n - refactor - chore - requireScope: true - # Imperative summaries start lowercase. This is what rejects - # "Enhance documentation on..." and "Update en.json". - subjectPattern: ^(?![A-Z]).+$ - subjectPatternError: > - The summary "{subject}" must start with a lowercase letter and read as an - imperative, for example "clarify Bot Manager scoring rules". - - - name: Reject ticket codes in the title - env: - PR_TITLE: ${{ github.event.pull_request.title }} - run: | - if printf '%s' "$PR_TITLE" | grep -qE '^\[?[A-Z]{2,}-[0-9]+\]?|\[[A-Z]{2,}-[0-9]+\]'; then - echo "::error::Ticket codes do not belong in PR titles. Put the ticket in the branch name (EDU-1234-short-slug) and the Related issue field in the PR body." - exit 1 - fi diff --git a/.github/workflows/prod.yml b/.github/workflows/prod.yml index 4681b179a4..c4a8a94eba 100644 --- a/.github/workflows/prod.yml +++ b/.github/workflows/prod.yml @@ -3,19 +3,22 @@ on: push: branches: - main - workflow_dispatch: - inputs: - branch: - required: true - type: choice - default: main - options: - - main + workflow_dispatch: + +# A deploy is never cancelled mid-flight: two merges landing close together would leave +# `gsutil rsync -d` racing over the same bucket. +concurrency: + group: deploy-production + cancel-in-progress: false + +permissions: + contents: read jobs: deploy: name: Production publising runs-on: ubuntu-latest + timeout-minutes: 45 steps: - name: CHECKOUT project uses: actions/checkout@v4 @@ -42,8 +45,13 @@ jobs: pnpm run build:prod - name: UPDATE Algolia data + # Through `env:`, not argv — a secret on the command line is readable in the + # runner's process list. + env: + ALGOLIA_APP: ${{ secrets.ALGOLIA_CONF_APP }} + ALGOLIA_API: ${{ secrets.ALGOLIA_CONF_API }} run: | - pnpm exec tsx cicd/algolia-reindex.ts app=${{ secrets.ALGOLIA_CONF_APP }} api=${{ secrets.ALGOLIA_CONF_API }} + pnpm exec tsx cicd/algolia-reindex.ts "app=$ALGOLIA_APP" "api=$ALGOLIA_API" - name: 'AUTH Google Cloud' uses: 'google-github-actions/auth@v2' diff --git a/.github/workflows/stage.yml b/.github/workflows/stage.yml index 26f3b19ee0..7c160b0a8c 100644 --- a/.github/workflows/stage.yml +++ b/.github/workflows/stage.yml @@ -3,10 +3,19 @@ on: push: branches: - stage + +concurrency: + group: deploy-stage + cancel-in-progress: false + +permissions: + contents: read + jobs: deploy: name: Development publising runs-on: ubuntu-latest + timeout-minutes: 30 steps: - name: CHECKOUT project uses: actions/checkout@v4 diff --git a/.github/workflows/weekly-linkcheck.yml b/.github/workflows/weekly-linkcheck.yml deleted file mode 100644 index a7ce717aaf..0000000000 --- a/.github/workflows/weekly-linkcheck.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: Weekly link check -on: - schedule: - - cron: '0 6 * * 1' # Mondays 06:00 UTC, 03:00 GMT-3 - workflow_dispatch: - -permissions: - contents: read - issues: write - -jobs: - linkcheck: - name: Internal links - runs-on: ubuntu-latest - timeout-minutes: 45 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up pnpm - uses: pnpm/action-setup@v4 - - - name: Set up Node - uses: actions/setup-node@v4 - with: - node-version: 20.13.1 - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Check internal links - id: linkcheck - run: | - pnpm build:local - pnpm exec tsm --require=./scripts/lib/filter-warnings.cjs ./scripts/lint-linkcheck.ts - env: - SKIP_OG: 'true' - NODE_OPTIONS: --max-old-space-size=8120 - - - name: File an issue on breakage - if: steps.linkcheck.conclusion == 'failure' - uses: actions/github-script@v7 - with: - script: | - const label = 'broken-links'; - const existing = await github.rest.issues.listForRepo({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - labels: label, - }); - const run = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - const open = existing.data.filter((i) => !i.pull_request); - if (open.length > 0) { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: open[0].number, - body: `Still failing as of the latest run: ${run}`, - }); - return; - } - await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: 'Weekly link check is failing', - labels: [label], - body: [ - 'The weekly internal link check failed.', - '', - `Run: ${run}`, - '', - 'This issue stays open and collects a comment per failing run. Close it once the run is green.', - ].join('\n'), - }); diff --git a/ci/baselines.json b/ci/baselines.json new file mode 100644 index 0000000000..c8a68a9f15 --- /dev/null +++ b/ci/baselines.json @@ -0,0 +1,32 @@ +{ + "audit": { + "command": "node scripts/ci/audit-count.mjs", + "pattern": "high\\+critical: (\\d+)", + "baseline": 32, + "label": "vulnerabilidades high/critical nas dependências" + }, + "astro-check": { + "command": "pnpm -s check", + "pattern": "-\\s+(\\d+)\\s+errors", + "baseline": 32, + "label": "erros de tipo" + }, + "slugcheck": { + "command": "pnpm -s lint:slugcheck", + "pattern": "Found (\\d+) translations with mismatched slugs", + "baseline": 744, + "label": "traduções com slug divergente do inglês" + }, + "linkcheck": { + "command": "pnpm -s lint:linkcheck:nobuild", + "pattern": "Found (?:(\\d+)|no) link issues", + "baseline": 26516, + "label": "links quebrados no build" + }, + "webkit-adoption": { + "command": "pnpm -s lint:webkit && pnpm -s report:webkit-adoption", + "pattern": "\\| Violations \\| \\*\\*(\\d+)\\*\\* \\|", + "baseline": 0, + "label": "violações das regras do design system" + } +} diff --git a/package.json b/package.json index 272b07da39..821ebcc01e 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,9 @@ "lint:a11y:local": "pa11y-ci --sitemap 'http://localhost:3000/sitemap.xml' --sitemap-find 'https://www.azion.com' --sitemap-replace 'http://localhost:3000'", "lint:a11y:remote": "pa11y-ci --sitemap 'https://wwww.azion.com/sitemap.xml'", "lint:linkcheck": "SKIP_OG=true pnpm run build && tsm --require=./scripts/lib/filter-warnings.cjs ./scripts/lint-linkcheck.ts", - "lint:linkcheck:nobuild": "tsm --require=./scripts/lib/filter-warnings.cjs ./scripts/lint-linkcheck.ts", + "lint:linkcheck:nobuild": "NODE_OPTIONS=--max-old-space-size=8120 tsm --require=./scripts/lib/filter-warnings.cjs ./scripts/lint-linkcheck.ts", "lint:slugcheck": "node ./scripts/lint-slugcheck.mjs", + "ci:ratchet": "node scripts/ci/ratchet.mjs", "lint:navcheck": "tsm --require=./scripts/lib/filter-warnings.cjs ./scripts/lint-navcheck.ts", "nav:url-map": "tsm --require=./scripts/lib/filter-warnings.cjs ./scripts/nav/url-map.ts", "nav:migrate": "tsm --require=./scripts/lib/filter-warnings.cjs ./scripts/nav/migrate-permalinks.ts", diff --git a/scripts/ci/audit-count.mjs b/scripts/ci/audit-count.mjs new file mode 100644 index 0000000000..5b831432a4 --- /dev/null +++ b/scripts/ci/audit-count.mjs @@ -0,0 +1,30 @@ +#!/usr/bin/env node +// Counts high + critical advisories from `pnpm audit --json` and prints one line the +// ratchet can read. Parsing the JSON metadata beats a regex over the table: the table +// omits severities with a count of zero, so a pattern for "critical" would stop matching +// on the day the last critical is fixed — and a ratchet that stops matching fails. + +import { execSync } from 'node:child_process'; + +let raw; +try { + raw = execSync('pnpm audit --json', { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); +} catch (error) { + // `pnpm audit` exits non-zero when it finds anything; the report is still on stdout. + raw = `${error.stdout ?? ''}`; +} + +let counts; +try { + counts = JSON.parse(raw).metadata.vulnerabilities; +} catch { + console.error('Could not parse `pnpm audit --json`.'); + console.error(raw.slice(0, 2000)); + process.exit(1); +} + +const blocking = (counts.high ?? 0) + (counts.critical ?? 0); +const other = (counts.moderate ?? 0) + (counts.low ?? 0) + (counts.info ?? 0); + +console.log(`high+critical: ${blocking}`); +console.log(`(moderate/low/info: ${other})`); diff --git a/scripts/ci/ratchet.mjs b/scripts/ci/ratchet.mjs new file mode 100644 index 0000000000..04379072ca --- /dev/null +++ b/scripts/ci/ratchet.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +// A count ratchet for checks whose debt is too large to fix before turning them on. +// +// Each check runs a command, extracts one number from its output, and compares that number +// with the frozen baseline in ci/baselines.json. The number may go down (the baseline is +// then stale, which is reported, never punished) but never up. It is the same idea as the +// webkit adoption baseline, applied to tools that have no baseline mechanism of their own. +// +// node scripts/ci/ratchet.mjs check +// node scripts/ci/ratchet.mjs --update re-snapshot the baseline +// node scripts/ci/ratchet.mjs --all check every entry + +import { execSync } from 'node:child_process'; +import { readFileSync, writeFileSync, appendFileSync } from 'node:fs'; + +const BASELINES = 'ci/baselines.json'; + +function run(command) { + try { + return strip( + execSync(command, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 256 * 1024 * 1024, + }) + ); + } catch (error) { + // These commands exit non-zero precisely because they found something. The output, + // not the exit code, is the measurement. + return strip(`${error.stdout ?? ''}${error.stderr ?? ''}`); + } +} + +// astro check and the link checker colour their output; the counts we match sit between +// escape sequences. +function strip(text) { + // eslint-disable-next-line no-control-regex + return text.replace(/\u001b\[[0-9;]*m/g, ''); +} + +function summary(line) { + if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${line}\n`); +} + +function check(name, entry, update) { + const output = run(entry.command); + const match = output.match(new RegExp(entry.pattern)); + + if (!match) { + // A check that stops producing its own number is not "clean" — it is broken, and + // silently passing would be the worst outcome. `lint:linkcheck` spent its whole life + // matching a base URL that appears in no sitemap and reporting zero issues. + console.error(`FAIL ${name}: could not read a count from the output of \`${entry.command}\`.`); + console.error(output.split('\n').slice(-25).join('\n')); + return false; + } + + // A pattern may match a "none found" phrasing with no digits — that is a real zero. + // `count > undefined` is false, so a missing or mistyped baseline would silently + // disable the check instead of failing it. + if (typeof entry.baseline !== 'number' || !Number.isFinite(entry.baseline)) { + console.error(`FAIL ${name}: baseline is not a number (${JSON.stringify(entry.baseline)}).`); + return false; + } + + const count = match[1] === undefined ? 0 : Number(match[1]); + + if (update) { + entry.baseline = count; + console.log(`ok ${name}: baseline set to ${count}`); + return true; + } + + if (count > entry.baseline) { + // A baseline of zero is not frozen debt, it is "none tolerated" — saying the branch + // "adds" all of them would be a lie. + console.error( + entry.baseline === 0 + ? `FAIL ${name}: ${count} ${entry.label}. The baseline is zero — none are tolerated.` + : `FAIL ${name}: ${count} ${entry.label} — the baseline is ${ + entry.baseline + }, so this branch adds ${count - entry.baseline}.` + ); + console.error(output.split('\n').slice(-40).join('\n')); + summary(`| ${name} | **${count}** | ${entry.baseline} | +${count - entry.baseline} |`); + return false; + } + + const delta = entry.baseline - count; + console.log( + `ok ${name}: ${count} ${entry.label} (baseline ${entry.baseline}${ + delta ? `, ${delta} fewer` : '' + })` + ); + summary(`| ${name} | ${count} | ${entry.baseline} | ${delta ? `−${delta}` : '—'} |`); + return true; +} + +const args = process.argv.slice(2); +const update = args.includes('--update'); +const names = args.filter((a) => !a.startsWith('--')); +const baselines = JSON.parse(readFileSync(BASELINES, 'utf8')); +const selected = names.length ? names : Object.keys(baselines); + +for (const name of selected) { + if (!baselines[name]) { + console.error(`FAIL unknown check "${name}". Known: ${Object.keys(baselines).join(', ')}`); + process.exit(1); + } +} + +summary('| check | agora | baseline | delta |'); +summary('|---|---|---|---|'); + +let failed = false; +for (const name of selected) { + if (!check(name, baselines[name], update)) failed = true; +} + +if (update) { + writeFileSync(BASELINES, `${JSON.stringify(baselines, null, 2)}\n`); + console.log(`\n${BASELINES} updated.`); +} + +process.exit(failed ? 1 : 0); diff --git a/scripts/lint-linkcheck.ts b/scripts/lint-linkcheck.ts index 6dc8fa42ac..a9762fb2cd 100644 --- a/scripts/lint-linkcheck.ts +++ b/scripts/lint-linkcheck.ts @@ -1,3 +1,4 @@ +import { SITE_URL } from '../src/consts'; import { LinkCheckerState } from './lib/linkcheck/base/base'; import type { LinkCheckerOptions } from './lib/linkcheck/base/base'; import { CanonicalUrl } from './lib/linkcheck/checks/canonical-url'; @@ -27,6 +28,16 @@ class LinkChecker { const pagePathnames = getPagePathnamesFromSitemap(options); + // A checker that parsed no pages is broken, not clean — and it would print + // "Found no link issues. Great job!", which is exactly how this tool spent its whole + // life green while matching a base URL that appears in no sitemap. + if (pagePathnames.length === 0) { + throw new Error( + `No pages found in the sitemaps under ${options.buildOutputDir} for base URL ${options.baseUrl}. ` + + 'Either the build output is missing, or the base URL does not match the entries.' + ); + } + const allPages = parsePages(pagePathnames, options); const linkIssues = findLinkIssues(allPages, options, state); @@ -51,7 +62,11 @@ class LinkChecker { } const linkChecker = new LinkChecker({ - baseUrl: 'https://docs.astro.build', + // The sitemap is generated by Astro from `site`, which is SITE_URL — so the base URL the + // checker matches against has to be the same one, per environment. It was still the + // upstream 'https://docs.astro.build' inherited from the Astro fork, which matches no + // in our sitemap: every run parsed zero pages and reported "no link issues". + baseUrl: SITE_URL, buildOutputDir: './dist', pageSourceDir: './src/content/docs', checks: [ diff --git a/scripts/webkit-adoption-report.mjs b/scripts/webkit-adoption-report.mjs index 49a333e42d..3706fc64cf 100644 --- a/scripts/webkit-adoption-report.mjs +++ b/scripts/webkit-adoption-report.mjs @@ -41,7 +41,7 @@ function parseArgs(argv) { const format = formatIndex === -1 ? 'markdown' : args[formatIndex + 1]; if (!file) { process.stderr.write( - 'usage: node scripts/webkit-adoption-report.mjs [--format markdown|json]\n', + 'usage: node scripts/webkit-adoption-report.mjs [--format markdown|json]\n' ); process.exit(1); } @@ -95,11 +95,20 @@ function collect(results, cwd) { } } + // Seeing no UI files at all means the lint never reached the components — a stale or + // empty report, a changed layout, a crashed ESLint swallowed by `|| true`. Reporting + // "100%, zero violations" from that is the same lie the link checker used to tell. + if (uiFiles.size === 0) { + throw new Error( + 'No UI files appear in the ESLint report. The lint did not reach the components; ' + + 're-run `pnpm lint:webkit` and check that it produced findings.' + ); + } + const dirtyUiFiles = [...byFile.keys()].filter((f) => UI_EXTENSIONS.has(extensionOf(f))); // Same shape as the architecture report in azion-console-kit: share of files that are // clean, not of violations. One file with 20 findings weighs the same as one with 1. - const score = - uiFiles.size === 0 ? 100 : Math.round((1 - dirtyUiFiles.length / uiFiles.size) * 100); + const score = Math.round((1 - dirtyUiFiles.length / uiFiles.size) * 100); return { total, @@ -133,7 +142,7 @@ function renderMarkdown(report, catalog) { push( '> **The webkit catalog could not be resolved, so 8 of the 12 rules silently did nothing.** ' + 'This report is not a clean bill of health — install `@aziontech/webkit` or set ' + - '`WEBKIT_CATALOG_PATH`, then run it again.', + '`WEBKIT_CATALOG_PATH`, then run it again.' ); push(); } @@ -146,7 +155,9 @@ function renderMarkdown(report, catalog) { push(`| Violations | **${report.total}** |`); push(`| Files affected | ${report.filesAffected} |`); push( - `| UI files clean | ${report.uiFilesClean} of ${report.uiFilesTotal} — **${report.score}%** (${statusFor(report.score)}) |`, + `| UI files clean | ${report.uiFilesClean} of ${report.uiFilesTotal} — **${ + report.score + }%** (${statusFor(report.score)}) |` ); push(); @@ -186,18 +197,19 @@ function renderMarkdown(report, catalog) { : 'none'; push(`- Violations found in: ${extensions}.`); push( - `- The adoption score counts ${[...UI_EXTENSIONS].map((e) => `\`.${e}\``).join(' and ')} files only — ` + - 'those are the ones the design system governs.', + `- The adoption score counts ${[...UI_EXTENSIONS] + .map((e) => `\`.${e}\``) + .join(' and ')} files only — ` + 'those are the ones the design system governs.' ); push( - '- `no-style-override` does **not** run on `.astro`: it needs vue-eslint-parser\'s template ' + + "- `no-style-override` does **not** run on `.astro`: it needs vue-eslint-parser's template " + 'visitor, which astro-eslint-parser does not provide. Restyled webkit components inside ' + - 'Astro files are invisible here.', + 'Astro files are invisible here.' ); push( '- Raw HTML where a webkit component exists (`