diff --git a/.github/workflows/build-and-stage.yml b/.github/workflows/build-and-stage.yml index 8be42a05..1e6bf263 100644 --- a/.github/workflows/build-and-stage.yml +++ b/.github/workflows/build-and-stage.yml @@ -25,7 +25,8 @@ jobs: name: Build and publish app runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - name: Git checkout + uses: actions/checkout@v6 with: persist-credentials: false diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml new file mode 100644 index 00000000..c4fb2746 --- /dev/null +++ b/.github/workflows/check-links.yml @@ -0,0 +1,66 @@ +name: Check external links + +on: + workflow_dispatch: + pull_request: + paths: + - '**.md' + - 'docs/**' + - '.github/workflows/check-links.yml' + - 'lychee.toml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + check-links: + name: Check external links in documentation + runs-on: ubuntu-latest + steps: + - name: Git checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + # To clear the lychee cache for a PR, replace `` with the PR number and run in pwsh: + # $ids = gh api repos/SteeltoeOSS/Documentation/actions/caches ` + # --paginate ` + # --jq '.actions_caches[] + # | select(.key | startswith("cache-lychee--")) + # | .id' + # $ids | ForEach-Object { + # gh api -X DELETE "repos/SteeltoeOSS/Documentation/actions/caches/$_" + # Write-Host "Deleted cache $_" + # } + - name: Restore lychee cache + uses: actions/cache@v4 + with: + path: .lycheecache + key: cache-lychee-${{ github.event.pull_request.number || 'manual' }}-${{ github.sha }} + restore-keys: cache-lychee-${{ github.event.pull_request.number || 'manual' }}- + + - name: Check external links with lychee + uses: lycheeverse/lychee-action@v2 + with: + args: --no-progress '**/*.md' + fail: false + failIfEmpty: true + jobSummary: false + + - name: Add link checking results to job summary + run: | + if [ -f ./lychee/out.md ]; then + sed 's/^# Summary$/# External link checking results/' ./lychee/out.md | tee -a "$GITHUB_STEP_SUMMARY" > ./lychee/results.md + fi + + - name: Comment on PR with link check results + if: ${{ github.event_name == 'pull_request' }} + uses: mshick/add-pr-comment@v2 + with: + message-id: external-links-check + message-path: ./lychee/results.md diff --git a/.gitignore b/.gitignore index 2bb691c8..6fd66bfb 100644 --- a/.gitignore +++ b/.gitignore @@ -459,3 +459,6 @@ src/Steeltoe.io/wwwroot/*xrefmap.yml # Temporary workaround until a proper DocFX build supporting .NET 10 is available. docfx-net10-binaries/ + +# lychee link-checker cache +.lycheecache diff --git a/build/README.md b/build/README.md index bcf86d17..63f6f4be 100644 --- a/build/README.md +++ b/build/README.md @@ -28,3 +28,14 @@ This process can take a while to complete, will display many warnings and may in 1. This command will produce many yaml files and place them in [docs](../docs), alongside the index pages for each API version 1. The script offers a parameter to target a single version (for example: `build-metadata.ps1 3`) 1. Run `docfx build` with [docfx-all.json](../docs/docfx-all.json), which will copy the html files into `wwwroot` of the Steeltoe.io project + +## Checking external links locally + +Check external links in markdown files with the same tools CI uses (no build required): + +```pwsh +pwsh build/check-external-links.ps1 +``` + +Requires [lychee](https://lychee.cli.rs/guides/getting-started/). +Settings are shared between CI and the local script via [`lychee.toml`](../lychee.toml) at the repo root. diff --git a/build/check-external-links.ps1 b/build/check-external-links.ps1 new file mode 100644 index 00000000..737b5b32 --- /dev/null +++ b/build/check-external-links.ps1 @@ -0,0 +1,32 @@ +#!/usr/bin/env pwsh +# Check external links in markdown files, matching what the check-links CI job does. +# Run from anywhere in the repo. Requires lychee: https://lychee.cli.rs/guides/getting-started/ + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (-not (Get-Command lychee -ErrorAction SilentlyContinue)) { + throw 'lychee not found. Install: cargo install lychee, scoop install lychee, or winget install lycheeverse.lychee' +} + +$baseDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = (Get-Item $baseDir).Parent.FullName +Push-Location $repoRoot + +try { + $mdFiles = Get-ChildItem -Path . -Filter '*.md' -Recurse -File + if ($mdFiles.Count -eq 0) { + throw 'No .md files found in the repository.' + } + + Write-Output "Checking external links in $($mdFiles.Count) markdown files ..." + + & lychee '**/*.md' + + if ($LASTEXITCODE -ne 0) { + throw "lychee exited with code $LASTEXITCODE" + } +} +finally { + Pop-Location +} diff --git a/lychee.toml b/lychee.toml new file mode 100644 index 00000000..577c97b7 --- /dev/null +++ b/lychee.toml @@ -0,0 +1,32 @@ +# Shared lychee config for CI and local script (build/check-external-links.ps1). + +verbose = "info" +cache = true +scheme = ["http", "https"] +require_https = true +include_fragments = true +retry_wait_time = 5 +root_dir = "." +host_concurrency = 5 +host_request_interval = "250ms" +exclude_loopback = true + +# Browser-like UA so sites that block bots (e.g. mysql.com) respond normally. +user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36" + +exclude = [ + # Placeholder hostnames in code samples. + "fortuneservice", + "\\.internal", + "consul-register-example", + # False positives: these sites generate anchor IDs via JS, so fragments + # aren't in the raw HTML. Track https://github.com/lycheeverse/lychee/issues/1729 + "https://learn\\.microsoft\\.com/.*#remarks", + "https://github\\.com/.*#", +] + +exclude_path = [ + # Vendored sources from API browser build. + "build.sources", + "build.docfx-net10-binaries", +]