diff --git a/.github/workflows/README.md b/.github/workflows/README.md index ad8a9d8..e5a291a 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -4,7 +4,7 @@ Shared [reusable workflows](https://docs.github.com/en/actions/sharing-automatio The `OTFABRIC_READ_TOKEN` secret gives access to private `github.com/otfabric/*` modules. In `go-ci.yml` it is needed when a module has private dependencies, and by the policy job when `.otfabric/policy.yaml` exists. The token must be able to read `otfabric/engineering`. -**Permissions.** The organisation's default `GITHUB_TOKEN` is read-only, and a caller must grant at least what a called workflow requests, or the run fails at startup with no job started. Grant `contents: read` and `id-token: write` for `go-ci.yml` (Codecov OIDC), and `contents: write` for the release workflows. +**Permissions.** The organisation's default `GITHUB_TOKEN` is read-only, and a caller must grant at least what a called workflow requests, or the run fails at startup with no job started. Grant `contents: read` and `id-token: write` for `go-ci.yml` (Codecov OIDC), `contents: read` for `docs-check.yml`, and `contents: write` for the release workflows. **Versions.** `v3` turns on govulncheck, tests `cmd/`, uses atomic coverage and starts the matrix at the go directive. `v2` and `v2.1.0` were moved to the same commit by the maintainer on 2026-10-30, so they have these defaults too. @@ -19,6 +19,7 @@ Reusable workflows share whole pipelines (the caller's job is `uses:` one of the | [`go-ci.yml`](go-ci.yml) | Lint, static analysis, coverage, and multi-version test matrix | | [`go-package-release.yml`](go-package-release.yml) | Semver-tagged release for Go libraries / packages | | [`go-binary-release.yml`](go-binary-release.yml) | Cross-compiled binary release with SHA-256 checksums | +| [`docs-check.yml`](docs-check.yml) | [docsync](https://github.com/otfabric/docsync) and otfabric-policy, on every push, Markdown-only ones included | ## Which workflows to use @@ -148,6 +149,56 @@ Both release workflows support an optional `RELEASE.md` file in the repo root. W Sections are delimited by `---`. Only the section matching the tag being released is extracted. +## Markdown-only changes + +A push or pull request that changes only Markdown needs no build, tests or +interop run. The heavy workflows of a repository skip it: + +```yaml +on: + push: + branches: [main] + paths-ignore: ['**/*.md'] + pull_request: + paths-ignore: ['**/*.md'] +``` + +A workflow that checks a generated Markdown file (for example `OBJECTS.md`) +lists it instead: `paths: ['**', '!**/*.md', 'OBJECTS.md']`. Tag pushes and +`workflow_dispatch` are never filtered. + +The documents are still checked: `docs-check.yml` runs on every push and pull +request, with no path filter, so the Docs and Policy checks always report. A +branch protection rule must require those, never a job of a workflow that a +path filter can skip: a skipped required workflow leaves the check pending. + +```yaml +# .github/workflows/docs.yml +name: Docs +on: + push: + branches: [main] + pull_request: +permissions: + contents: read +jobs: + docs: + uses: otfabric/.github/.github/workflows/docs-check.yml@v3 + secrets: inherit +``` + +| Input | Default | Description | +|-------|---------|-------------| +| `module-dir` | `.` | Directory of the Go module docsync checks | +| `docsync-version` | `v0.1.0` | Tag of `otfabric/docsync` to run | +| `mode` | `enforce` | `warn` turns findings into warnings; a docsync error fails in both modes | +| `run-policy` | `true` | Run `otfabric-policy`, as in `go-ci.yml` | +| `policy-version` | `v1.3.0` | Tag of `otfabric/engineering` whose `otfabric-policy` to run | +| `timeout-minutes` | `15` | Job timeout | + +With the Policy job here, a repository that calls both can set +`run-policy: false` in `go-ci.yml`. + ## Inputs reference Each workflow's inputs are fully documented with descriptions inside the YAML files themselves. Key inputs worth highlighting: diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml new file mode 100644 index 0000000..270a823 --- /dev/null +++ b/.github/workflows/docs-check.yml @@ -0,0 +1,237 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +# +# Docs Check — Reusable Workflow +# +# Checks that a Go module's documents say what its code does, with +# otfabric/docsync, and runs otfabric-policy. It is cheap, so it runs on every +# push and pull request, including those that change Markdown only, which the +# heavy workflows skip with paths-ignore. That makes its jobs the ones a +# branch protection rule can require. +# +# Jobs: +# docsync — docsync check in module-dir, annotations and a summary. +# policy — Optional (run-policy), otfabric-policy on repositories with +# .otfabric/policy.yaml, as in go-ci.yml. +# +# Caller example (.github/workflows/docs.yml): +# +# name: Docs +# on: +# push: +# branches: [main] +# pull_request: +# permissions: +# contents: read +# jobs: +# docs: +# uses: otfabric/.github/.github/workflows/docs-check.yml@v3 +# secrets: inherit + +name: Docs Check + +on: + workflow_call: + inputs: + module-dir: + description: "Directory of the Go module to check." + required: false + type: string + default: "." + docsync-version: + description: "Tag of otfabric/docsync to run." + required: false + type: string + default: "v0.1.0" + mode: + description: >- + enforce: findings fail the job. warn: findings are annotations and + the job passes. A docsync error (exit code 2 or more) fails the job + in both modes. + required: false + type: string + default: "enforce" + timeout-minutes: + description: "Job timeout in minutes." + required: false + type: number + default: 15 + run-policy: + description: >- + Run otfabric-policy on the repository root, in its own job, when + .otfabric/policy.yaml exists. Needs OTFABRIC_READ_TOKEN. + required: false + type: boolean + default: true + policy-version: + description: "Tag of otfabric/engineering whose otfabric-policy to run." + required: false + type: string + default: "v1.3.0" + secrets: + OTFABRIC_READ_TOKEN: + description: >- + PAT with read access to private github.com/otfabric/* modules. + docsync type-checks the documents' Go blocks against the module, so + it loads the module's dependencies; the policy job fetches + otfabric-policy. Forks and Dependabot never have it. + required: false + +permissions: + contents: read + +env: + GOPRIVATE: github.com/otfabric/* + +concurrency: + group: docs-check-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ inputs.module-dir }} + cancel-in-progress: true + +jobs: + docsync: + name: Docs + runs-on: ubuntu-latest + timeout-minutes: ${{ inputs.timeout-minutes }} + permissions: + contents: read + defaults: + run: + working-directory: ${{ inputs.module-dir }} + + steps: + # The tags and history: DS-SEC compares SECURITY.md with the latest + # tag, DS-REL compares RELEASE.md with the commit dates. + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + + # docsync needs Go 1.26; the module's go directive only sets a floor, + # and the type check of the documents runs on the toolchain it is given. + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version: stable + cache: true + cache-dependency-path: | + ${{ inputs.module-dir }}/go.sum + + - name: Configure private module access + shell: bash + env: + TOKEN: ${{ secrets.OTFABRIC_READ_TOKEN }} + run: | + if [ -n "${TOKEN}" ]; then + git config --global url."https://x-access-token:${TOKEN}@github.com/otfabric/".insteadOf "https://github.com/otfabric/" + fi + + # Installed, not run with go run, which turns every exit code into 1. + - name: Install docsync + shell: bash + env: + GOWORK: "off" + VERSION: ${{ inputs.docsync-version }} + run: GOBIN="$RUNNER_TEMP/bin" go install "github.com/otfabric/docsync@${VERSION}" + + # One run gives the annotations and the summary, so the module is loaded + # once. Exit code 1 means findings, 2 and above an error of docsync or + # of the configuration. + - name: docsync + shell: bash + env: + GOWORK: "off" + VERSION: ${{ inputs.docsync-version }} + MODE: ${{ inputs.mode }} + run: | + set +e -uo pipefail + case "$MODE" in enforce|warn) ;; *) echo "::error::mode must be enforce or warn, not '$MODE'"; exit 2 ;; esac + "$RUNNER_TEMP/bin/docsync" check --format github . > "$RUNNER_TEMP/docsync.txt" + code=$? + if [ "$MODE" = warn ]; then + sed 's/^::error /::warning /' "$RUNNER_TEMP/docsync.txt" + else + cat "$RUNNER_TEMP/docsync.txt" + fi + count=$(grep -c '^::error ' "$RUNNER_TEMP/docsync.txt" || true) + { + echo "### docsync ${VERSION}" + echo + if [ "$code" -eq 0 ]; then + echo "The documents agree with the code." + elif [ "$code" -eq 1 ]; then + echo "$count findings (mode: $MODE). Run \`go run github.com/otfabric/docsync@${VERSION} check .\` locally for the list." + else + echo "docsync failed with exit code $code; see the log." + fi + } >> "$GITHUB_STEP_SUMMARY" + if [ "$code" -eq 1 ] && [ "$MODE" = warn ]; then + exit 0 + fi + exit "$code" + + # As in go-ci.yml: the check is about the repository, so it runs at the + # root. A secret cannot be read in a job-level if, so a step decides. + policy: + name: Policy + if: inputs.run-policy + runs-on: ubuntu-latest + timeout-minutes: ${{ inputs.timeout-minutes }} + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Decide whether the policy check runs + id: gate + shell: bash + env: + TOKEN: ${{ secrets.OTFABRIC_READ_TOKEN }} + EVENT: ${{ github.event_name }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + REPO: ${{ github.repository }} + ACTOR: ${{ github.actor }} + run: | + set -euo pipefail + run=false + if [ ! -f .otfabric/policy.yaml ]; then + echo "::notice::otfabric-policy skipped: no .otfabric/policy.yaml" + elif [ "$EVENT" = pull_request ] && [ "$HEAD_REPO" != "$REPO" ]; then + echo "::notice::otfabric-policy skipped: pull request from a fork has no secrets" + elif [ -n "$TOKEN" ]; then + run=true + elif [ "$ACTOR" = 'dependabot[bot]' ]; then + echo "::notice::otfabric-policy skipped: Dependabot runs have no Actions secrets" + else + echo "::error::OTFABRIC_READ_TOKEN is empty, so otfabric-policy cannot be fetched. Pass it with 'secrets: inherit', or set run-policy: false." + exit 1 + fi + echo "run=$run" >> "$GITHUB_OUTPUT" + + - name: Set up Go + if: steps.gate.outputs.run == 'true' + uses: actions/setup-go@v7 + with: + go-version: stable + cache: false + + - name: Configure private module access + if: steps.gate.outputs.run == 'true' + shell: bash + env: + TOKEN: ${{ secrets.OTFABRIC_READ_TOKEN }} + run: git config --global url."https://x-access-token:${TOKEN}@github.com/otfabric/engineering".insteadOf "https://github.com/otfabric/engineering" + + - name: otfabric-policy + if: steps.gate.outputs.run == 'true' + shell: bash + env: + GOWORK: "off" + GOPRIVATE: github.com/otfabric/engineering + VERSION: ${{ inputs.policy-version }} + run: | + set -euo pipefail + go run "github.com/otfabric/engineering/cmd/otfabric-policy@${VERSION}" check -format markdown . | tee -a "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/selftest.yml b/.github/workflows/selftest.yml index 5e3f120..1dbe327 100644 --- a/.github/workflows/selftest.yml +++ b/.github/workflows/selftest.yml @@ -62,6 +62,25 @@ jobs: run-govulncheck: false run-policy: false + # docs-check.yml: a module whose documents agree with its code, enforced, + # with the policy job on. + docs-check-pass: + uses: ./.github/workflows/docs-check.yml + with: + module-dir: testdata/docs-check/pass + mode: enforce + policy-version: v1.2.0 + secrets: + OTFABRIC_READ_TOKEN: ${{ secrets.OTFABRIC_READ_TOKEN }} + + # A module with a finding passes in warn mode. + docs-check-warn: + uses: ./.github/workflows/docs-check.yml + with: + module-dir: testdata/docs-check/fail + mode: warn + run-policy: false + matrix-floor: name: Matrix floor cases runs-on: ubuntu-latest @@ -112,6 +131,18 @@ jobs: - uses: actions/checkout@v7 - run: scripts/selftest/policy.sh + docs-check-modes: + name: Docs check cases + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version: stable + cache: false + - run: scripts/selftest/docs-check.sh + release-notes: name: Release notes extraction runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index bfb5e59..43d21b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## What this is -The organisation's shared GitHub configuration: the reusable workflows `go-ci.yml`, `go-package-release.yml` and `go-binary-release.yml`, the composite actions for the `*-interop` repositories, the organisation profile page, and the selftest of the workflows. There is no product and no Go module of its own; the Go code under `testdata/` is fixtures. Every OT Fabric repository depends on this one, so a mistake here reaches all of them. Profile: **infrastructure**. +The organisation's shared GitHub configuration: the reusable workflows `go-ci.yml`, `docs-check.yml`, `go-package-release.yml` and `go-binary-release.yml`, the composite actions for the `*-interop` repositories, the organisation profile page, and the selftest of the workflows. There is no product and no Go module of its own; the Go code under `testdata/` is fixtures. Every OT Fabric repository depends on this one, so a mistake here reaches all of them. Profile: **infrastructure**. ## Organisation standards @@ -34,6 +34,7 @@ This repository follows **otfabric/engineering policy 1.2**, profile **infrastru | anything | `go run ./cmd/otfabric-policy check ` from otfabric/engineering | | `go-ci.yml` policy steps | `scripts/selftest/policy.sh` | | release notes extraction | `scripts/selftest/release-notes.sh` | +| `docs-check.yml` docsync step | `scripts/selftest/docs-check.sh` | | a workflow | the Selftest workflow, on the pull request | There is no Makefile. diff --git a/scripts/selftest/docs-check.sh b/scripts/selftest/docs-check.sh new file mode 100755 index 0000000..1544c39 --- /dev/null +++ b/scripts/selftest/docs-check.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Cases of the docsync step of docs-check.yml, run on the fixtures with the +# exact step script. The reusable workflow cannot be called with +# continue-on-error, so the failing case runs here. +set -euo pipefail + +root=$(cd "$(dirname "$0")/../.." && pwd) +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +python3 "$root/scripts/selftest/extract-step.py" "$root/.github/workflows/docs-check.yml" docsync > "$work/step.sh" +version=$(sed -nE '/docsync-version:/,/default:/s/^ *default: "(v[^"]+)"$/\1/p' "$root/.github/workflows/docs-check.yml") +mkdir -p "$work/bin" +GOBIN="$work/bin" GOWORK=off go install "github.com/otfabric/docsync@${version}" + +fail=0 +check() { # fixture mode want + local got=0 + (cd "$root/testdata/docs-check/$1" && RUNNER_TEMP="$work" GITHUB_STEP_SUMMARY="$work/summary" VERSION="$version" MODE="$2" GOWORK=off bash -e "$work/step.sh") > "$work/out" 2>&1 || got=$? + if [ "$got" = "$3" ]; then + echo "ok $1 $2: exit $got" + else + echo "FAIL $1 $2: exit $got, want $3"; cat "$work/out"; fail=1 + fi +} + +check pass enforce 0 +check fail enforce 1 +check fail warn 0 +check pass bogus 2 +grep -q '^::warning ' "$work/out" && { echo "FAIL a bad mode printed findings"; fail=1; } +exit "$fail" diff --git a/testdata/docs-check/fail/API.md b/testdata/docs-check/fail/API.md new file mode 100644 index 0000000..9eb5531 --- /dev/null +++ b/testdata/docs-check/fail/API.md @@ -0,0 +1,3 @@ +# API + +`Hello(name string) string` returns a greeting; `greet.Goodbye` does not exist. diff --git a/testdata/docs-check/fail/go.mod b/testdata/docs-check/fail/go.mod new file mode 100644 index 0000000..3925dc6 --- /dev/null +++ b/testdata/docs-check/fail/go.mod @@ -0,0 +1,3 @@ +module example.com/fail + +go 1.23 diff --git a/testdata/docs-check/fail/greet.go b/testdata/docs-check/fail/greet.go new file mode 100644 index 0000000..d92b9a1 --- /dev/null +++ b/testdata/docs-check/fail/greet.go @@ -0,0 +1,6 @@ +// Package greet is the fixture of the docs-check selftest: its documents +// name a function it does not have, so docsync reports a finding. +package greet + +// Hello returns a greeting for name. +func Hello(name string) string { return "hello, " + name } diff --git a/testdata/docs-check/pass/API.md b/testdata/docs-check/pass/API.md new file mode 100644 index 0000000..fd1fd17 --- /dev/null +++ b/testdata/docs-check/pass/API.md @@ -0,0 +1,10 @@ +# API + +| Function | Returns | +|---|---| +| `Hello(name string) string` | A greeting for name | + +```go +msg := greet.Hello("world") +_ = msg +``` diff --git a/testdata/docs-check/pass/go.mod b/testdata/docs-check/pass/go.mod new file mode 100644 index 0000000..06587dc --- /dev/null +++ b/testdata/docs-check/pass/go.mod @@ -0,0 +1,3 @@ +module example.com/pass + +go 1.23 diff --git a/testdata/docs-check/pass/greet.go b/testdata/docs-check/pass/greet.go new file mode 100644 index 0000000..9abec48 --- /dev/null +++ b/testdata/docs-check/pass/greet.go @@ -0,0 +1,6 @@ +// Package greet is the fixture of the docs-check selftest: its documents +// agree with its code. +package greet + +// Hello returns a greeting for name. +func Hello(name string) string { return "hello, " + name }