Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/0-local-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,11 @@ jobs:

lint-renovate:
uses: $/.github/workflows/lint-renovate.yml

lint-docs:
uses: $/.github/workflows/lint-docs.yml
permissions:
contents: read # baseline for checkout
pull-requests: read # list the PR's changed files
security-events: write # SARIF upload to GHAS
issues: write # broken-links issue on the default branch
207 changes: 207 additions & 0 deletions .github/workflows/lint-docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
# SPDX-FileCopyrightText: © 2026 OpenCHAMI a Series of LF Projects, LLC
# SPDX-License-Identifier: MIT

name: lint-docs

# Reusable workflow that checks the caller's docs for broken links with
# lychee. Never fails on broken links; it reports them instead:
#
# - Pull requests that change docs: findings go to code scanning as SARIF
# (category lint-docs), so GitHub annotates the PR and closes each alert
# once a later commit fixes the link. Fork PRs can't upload, so they only
# get the job summary.
# - Default branch (push, schedule, or dispatch): one open issue with the
# report, created or updated each run, and closed once the links are fixed.
# The SARIF upload also runs, so PR alerts have a baseline to compare with.
#
# Checks lychee-action's default inputs (every .md, .html, and .rst file).
# Fails only when lychee itself errors (e.g. a bad config). Callers configure
# lychee with their own lychee.toml and .lycheeignore at the repository root.

on:
workflow_call:
inputs:
issue-title:
description: 'Title of the issue that tracks broken links on the default branch'
required: false
type: string
default: 'Docs link check found broken links'

permissions:
contents: read # baseline for checkout

jobs:
lint-docs:
runs-on: ubuntu-slim
timeout-minutes: 15
permissions:
contents: read # baseline for checkout
pull-requests: read # list the PR's changed files
security-events: write # SARIF upload to GHAS
outputs:
report-issue: ${{ steps.mode.outputs.report-issue }}
broken: ${{ steps.report.outputs.broken }}
env:
GH_TOKEN: ${{ github.token }}
steps:
- name: Decide what to report
id: mode
env:
EVENT: ${{ github.event_name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
check=true
report_issue=false
if [[ "$EVENT" == pull_request || "$EVENT" == pull_request_target ]]; then
# Not piped into grep -q: its early exit fails gh with SIGPIPE.
files=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" --jq '.[].filename')
if ! grep -qiE '\.(md|markdown|html?|rst)$' <<< "$files"; then
echo "The PR changes no docs; nothing to check."
check=false
fi
elif [[ "$GITHUB_REF_NAME" == "$(gh api "repos/${GITHUB_REPOSITORY}" --jq .default_branch)" ]]; then
report_issue=true
fi
echo "check=${check}" >> "$GITHUB_OUTPUT"
echo "report-issue=${report_issue}" >> "$GITHUB_OUTPUT"

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
if: ${{ steps.mode.outputs.check == 'true' }}
with:
persist-credentials: false

- name: Check links
id: lychee
if: ${{ steps.mode.outputs.check == 'true' }}
uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0
with:
format: json
output: ${{ runner.temp }}/lychee.json
fail: false # broken links are reported, not failed on
failIfEmpty: false
jobSummary: false # markdown only; the report below writes the summary
checkbox: false # markdown only

- name: Build reports
id: report
if: ${{ steps.mode.outputs.check == 'true' }}
env:
LYCHEE_EXIT: ${{ steps.lychee.outputs.exit_code }}
BLOB_URL: ${{ github.server_url }}/${{ github.repository }}/blob/${{ github.sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
# 0: all links fine, 2: broken links, else lychee failed. Usage errors
# also exit 2, but leave no JSON report.
if [[ "$LYCHEE_EXIT" != 0 && "$LYCHEE_EXIT" != 2 ]] || [[ ! -s "${RUNNER_TEMP}/lychee.json" ]]; then
echo "::error::lychee failed (exit ${LYCHEE_EXIT})."
exit 1
fi
# One finding per broken link, with local file URLs made repo-relative.
jq --arg root "file://${GITHUB_WORKSPACE}/" '
[ (.error_map // {} | to_entries[] | .value[] + {file: .key, kind: "error"}),
(.timeout_map // {} | to_entries[] | .value[] + {file: .key, kind: "timeout"}) ]
| map({file, kind, line: (.span.line // 1), column: (.span.column // 1),
url: (.url | ltrimstr($root)), status: .status.text})
' "${RUNNER_TEMP}/lychee.json" > "${RUNNER_TEMP}/findings.json"

jq '{
version: "2.1.0",
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
runs: [{
tool: {driver: {
name: "lychee",
informationUri: "https://github.com/lycheeverse/lychee",
rules: [
{id: "broken-link", shortDescription: {text: "Broken link"},
help: {text: "The link target is missing or returned an error. Fix or remove the link, or exclude it in .lycheeignore or lychee.toml."},
defaultConfiguration: {level: "error"}},
{id: "link-timeout", shortDescription: {text: "Link timed out"},
help: {text: "The link target did not respond in time. It may be down, or slow to answer."},
defaultConfiguration: {level: "warning"}}
]}},
results: map({
ruleId: (if .kind == "error" then "broken-link" else "link-timeout" end),
level: (if .kind == "error" then "error" else "warning" end),
message: {text: "\(.url): \(.status)"},
locations: [{physicalLocation: {
artifactLocation: {uri: .file},
region: {startLine: .line, startColumn: .column}}}]
})
}]
}' "${RUNNER_TEMP}/findings.json" > "${RUNNER_TEMP}/lychee.sarif"

jq -r --arg blob "$BLOB_URL" --arg run "$RUN_URL" '
if length == 0 then "No broken links found." else
"Found \(map(select(.kind == "error")) | length) broken links and \(map(select(.kind == "timeout")) | length) timeouts ([run](\($run))).",
(group_by(.file)[] | "", "### `\(.[0].file)`", "",
(sort_by(.line)[] | "- [line \(.line)](\($blob)/\(.file)?plain=1#L\(.line)): \(.url) (\(.status))"))
end
' "${RUNNER_TEMP}/findings.json" > "${RUNNER_TEMP}/report.md"
cat "${RUNNER_TEMP}/report.md" >> "$GITHUB_STEP_SUMMARY"

# Timeouts alone don't open an issue: they are usually transient.
echo "broken=$(jq 'any(.kind == "error")' "${RUNNER_TEMP}/findings.json")" >> "$GITHUB_OUTPUT"

- name: Upload SARIF to GHAS
# Fork PRs get a read-only token, so the upload would fail.
if: >-
steps.mode.outputs.check == 'true'
&& (github.event.pull_request.head.repo.full_name == github.repository
|| github.event_name != 'pull_request')
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
sarif_file: ${{ runner.temp }}/lychee.sarif
category: lint-docs

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ steps.mode.outputs.report-issue == 'true' }}
with:
name: lint-docs-report
path: ${{ runner.temp }}/report.md
retention-days: 1

report-issue:
needs: lint-docs
if: ${{ needs.lint-docs.outputs.report-issue == 'true' }}
runs-on: ubuntu-slim
timeout-minutes: 5
permissions:
issues: write # create, update, or close the broken-links issue
# Runs one at a time, so two runs can't both create the issue.
concurrency:
group: lint-docs-issue-${{ github.repository }}
cancel-in-progress: false
env:
GH_TOKEN: ${{ github.token }}
TITLE: ${{ inputs.issue-title }}
BROKEN: ${{ needs.lint-docs.outputs.broken }}
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: lint-docs-report
path: ${{ runner.temp }}/lint-docs

- name: Create, update, or close the issue
env:
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
report="${RUNNER_TEMP}/lint-docs/report.md"
issue=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --limit 1000 --json number,title |
jq -r '[.[] | select(.title == env.TITLE)][0].number // empty')
if [[ "$BROKEN" == true ]]; then
if [[ -n "$issue" ]]; then
gh issue edit "$issue" --repo "$GITHUB_REPOSITORY" --body-file "$report"
echo "Updated issue #${issue}."
else
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body-file "$report"
fi
elif [[ -n "$issue" ]]; then
gh issue close "$issue" --repo "$GITHUB_REPOSITORY" \
--comment "No broken links found as of ${GITHUB_SHA} ([run](${RUN_URL}))."
echo "Closed issue #${issue}."
else
echo "No broken links and no open issue."
fi
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Reusable GitHub Actions for CI/CD.
- [`.github/workflows/stale.yml`](docs/workflows.md#stale): Reusable workflow that marks and closes inactive issues and PRs (org-default policy)
- [`.github/workflows/lint-codegen-fabrica.yml`](docs/workflows.md#lint-codegen-fabrica): Reusable workflow that fails when a Fabrica project's committed generated code is out of date
- [`.github/workflows/lint-renovate.yml`](docs/workflows.md#lint-renovate): Reusable workflow that validates the caller's Renovate config
- [`.github/workflows/lint-docs.yml`](docs/workflows.md#lint-docs): Reusable workflow that checks docs for broken links (lychee), reporting on PRs and in an issue
- [`.github/workflows/lint-makefile-common.yml`](docs/workflows.md#lint-makefile-common): Reusable workflow that fails when a repo's vendored `Makefile.common` differs from the latest release
- [`Makefile.common`](docs/makefile-common.md): Shared Makefile targets that OpenCHAMI repos vendor and include

Expand Down
31 changes: 31 additions & 0 deletions docs/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,37 @@ jobs:
# config-file: renovate.json
```

### lint-docs
Checks the caller's docs for broken links with [lychee](https://github.com/lycheeverse/lychee). It reports broken links rather than failing:

- **Pull requests** that change docs (`.md`, `.html`, `.rst`): findings go to code scanning (category `lint-docs`), so the PR is annotated like `lint-ci`'s zizmor findings, and each alert closes once a later commit fixes the link. Broken links fail the code scanning check, timeouts only warn. PRs that change no docs skip the check. Fork PRs can't upload, so they only get the job summary. Private repos need GitHub Code Security for the upload; public repos need no setup.
- **Default branch** (push, schedule, or dispatch): keeps one open issue (`issue-title`) with the report, created or updated on each run and closed once the links are fixed. Timeouts alone don't open it.

Checks every `.md`, `.html`, and `.rst` file (lychee-action's defaults). Configure lychee with a [`lychee.toml`](https://lychee.cli.rs/guides/config/) at the repository root, e.g. `exclude_path` to skip files; see [Excluding links](https://lychee.cli.rs/recipes/excluding-links/) for `.lycheeignore`. The job fails only when lychee itself errors. A schedule catches external links that break between pushes.

**Usage:**
```yaml
name: Lint Docs
on:
pull_request:
push:
branches: [main]
schedule:
- cron: '0 6 * * 1' # catch external links that rot

jobs:
lint-docs:
uses: OpenCHAMI/github-actions/.github/workflows/lint-docs.yml@v4.0
permissions:
contents: read # baseline for checkout
pull-requests: read # list the PR's changed files
security-events: write # SARIF upload to GHAS
issues: write # broken-links issue on the default branch
# Optional overrides:
# with:
# issue-title: Docs link check found broken links
```

### lint-makefile-common
Fails when the repo's vendored `Makefile.common` differs from the copy in the latest release of this repository (read at the release's commit SHA), and prints the diff. Takes no inputs. Make it a required check only if drift should block merging.

Expand Down
Loading