Skip to content
Merged
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
134 changes: 132 additions & 2 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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), `contents: read` for `docs-check.yml`, 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 `pr-changes.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.

Expand All @@ -20,6 +20,9 @@ Reusable workflows share whole pipelines (the caller's job is `uses:` one of the
| [`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 |
| [`pr-changes.yml`](pr-changes.yml) | Classifies a pull request: documentation only, or full validation; for the PR workflow below |

The required check of a PR workflow is the composite action [`actions/required-gate`](../../actions/README.md#required-check-of-a-pr-workflow).

## Which workflows to use

Expand Down Expand Up @@ -153,7 +156,134 @@ 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
## PR workflow and the required check

A repository that adopts it runs its heavy validation once per pull request
revision, and not again on the push of the branch or on the merge to `main`:

| Event | What runs |
|---|---|
| Push to a feature branch | Nothing |
| Pull request opened, synchronized, reopened | `pr.yml`: Docs and Policy always; CI, interop and the other heavy jobs unless the pull request is documentation only; then `OT Fabric Required Checks` |
| Push to `main` (the merge) | `docs.yml` only (docsync and policy, about a minute) |
| Schedule, `workflow_dispatch` | Unchanged: nightly, self-interop; `ci.yml` and `interop.yml` can also be dispatched on any branch |
| Release | Unchanged: the release workflows test the release commit in full |

One workflow, `pr.yml`, runs on every pull request with no path filter, and
its last job is the only required check. The heavy workflows keep their jobs
and become reusable (`on: workflow_call`), so nothing is copied:

```yaml
# .github/workflows/pr.yml
name: PR
on:
pull_request:

permissions:
contents: read

# A new revision cancels the run of the previous one; other workflows
# (release, nightly, dispatch) are in other groups.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
changes:
uses: otfabric/.github/.github/workflows/pr-changes.yml@v3.7.0

docs:
uses: ./.github/workflows/docs.yml
secrets: inherit

ci:
needs: changes
if: needs.changes.outputs.full == 'true'
permissions:
contents: read
id-token: write
uses: ./.github/workflows/ci.yml
secrets: inherit

required:
name: OT Fabric Required Checks
needs: [changes, docs, ci]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: otfabric/.github/actions/required-gate@v3.7.0
with:
needs: ${{ toJSON(needs) }}
required: changes docs
required-for-code: ci
```

The rules the shape depends on:

- **No path filter on `pr.yml`.** A required check whose workflow a filter
skipped never reports, and the pull request waits for it forever.
- **The gate has `if: always()`.** It runs when a job failed or the run was
cancelled, and fails then. A skipped job counts as passed for a required
check, so the gate must never be skipped; `!cancelled()` would skip it.
- **The gate needs every other job**, and lists each in `required` (must
succeed on every pull request) or `required-for-code` (must succeed unless
the pull request is documentation only, and then must be skipped). A job
in `needs` that is in neither list, or a listed job missing from `needs`,
fails the gate, so a job cannot be added without an expectation.
- **The gate job is defined in `pr.yml` itself**, so the check is named
`OT Fabric Required Checks` with no prefix. A gate inside a called
workflow would report as `<caller job> / <job>`.
- **The heavy jobs depend on `changes`** and run when `full` is `"true"`. If
`changes` fails they are skipped and the gate fails: rerun the failed jobs.
- **Keep the triggers** to the defaults (`opened`, `synchronize`,
`reopened`). `edited` would rerun everything on a title change. A pull
request whose base is changed to `main` gets a run on its next push, or
close and reopen it.

`pr-changes.yml` classifies the files the pull request's merge commit
changes, compared with its first parent: exactly the tree the jobs test.
Documentation only means every changed file is an added or modified regular
`*.md` file, not in a dot directory, `testdata/` or `vendor/`, not
`AGENTS.md`, `CLAUDE.md` or `GEMINI.md`, and not matched by `code-paths`.
A deletion, a rename, a symlink, any other file, a run that is not a pull
request, or a checkout it cannot verify gives `full: "true"`. List Markdown
that a test or generator reads in `code-paths` (go-iec61850's
`readme_test.go` reads `README.md`):

```yaml
changes:
uses: otfabric/.github/.github/workflows/pr-changes.yml@v3.7.0
with:
code-paths: |
README.md
docs/generated/*.md
```

Patterns are `fnmatch` patterns on the whole path: `*` also matches `/`, so
`*.md` matches every Markdown file and `README.md` only the root one.

### Migrating a repository

1. Make each heavy workflow reusable: replace its `push` and `pull_request`
triggers with `workflow_call` and `workflow_dispatch`. Its jobs, inputs
and pins stay as they are.
2. `docs.yml`: keep `push: branches: [main]`, replace `pull_request` with
`workflow_call`.
3. Add `pr.yml` as above, with one job per reusable workflow and one entry
for each in the gate. Grant each job the permissions its workflow
requests (`id-token: write` for go-ci, `packages: read` for GHCR pulls).
4. Open the pull request. It runs `pr.yml` from the branch: check that
`OT Fabric Required Checks` reports, and that its summary lists every job.
5. Only then require the check, by the exact name, in a ruleset (strict:
the branch must be up to date with `main`). Never require a job of a
called workflow or of a workflow with a path filter.

Rollback: restore the previous triggers of the heavy workflows and delete
`pr.yml`; first remove the required check from the ruleset, or every open
pull request waits for it.

## Markdown-only changes (repositories without a PR workflow)

A push or pull request that changes only Markdown needs no build, tests or
interop run. The heavy workflows of a repository skip it:
Expand Down
188 changes: 188 additions & 0 deletions .github/workflows/pr-changes.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
#
# PR Changes — Reusable Workflow
#
# Classifies a pull request for the PR orchestration workflow of a repository:
# documentation only, or everything else. The heavy jobs of the caller (Go CI,
# interop, generated code) run when `full` is "true"; the documentation and
# policy jobs run on every pull request.
#
# Unknown means full. A file counts as documentation only when it is an added
# or modified regular Markdown file outside dot directories, testdata/ and
# vendor/, is not agent instructions (AGENTS.md, CLAUDE.md), and matches no
# pattern in code-paths. Anything else, a deletion or a rename, a run that is
# not a pull request, and a checkout that is not the pull request's merge
# commit give full: "true". The step never fails on a classification problem;
# it reports the reason instead.
#
# The changed files are those of the merge commit GitHub tests (its first
# parent is the base branch), so they are exactly what the heavy jobs check
# out: no API pagination, no file limit, no token scope beyond contents: read.
#
# Jobs:
# classify — "Classify changes": outputs full and reason, a table in the
# job summary.
#
# Caller example (in the repository's PR workflow):
#
# jobs:
# changes:
# uses: otfabric/.github/.github/workflows/pr-changes.yml@v3
# with:
# # Markdown that a test or generator reads counts as code.
# code-paths: |
# README.md

name: PR Changes

on:
workflow_call:
inputs:
code-paths:
description: >-
Newline-separated fnmatch patterns, matched against the whole path
(`*` also matches `/`). A Markdown file that matches counts as code:
a README whose snippets a test checks, a generated document.
Lines starting with # are comments.
required: false
type: string
default: ""
outputs:
full:
description: '"true": run every check. "false": documentation only.'
value: ${{ jobs.classify.outputs.full }}
reason:
description: "One line: why the pull request was classified so."
value: ${{ jobs.classify.outputs.reason }}

permissions:
contents: read

jobs:
classify:
name: Classify changes
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
full: ${{ steps.classify.outputs.full }}
reason: ${{ steps.classify.outputs.reason }}

steps:
# The merge commit and both its parents. Nothing from it is executed.
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 2
persist-credentials: false

- name: Classify changed files
id: classify
shell: python3 -I {0}
env:
EVENT: ${{ github.event_name }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
CODE_PATHS: ${{ inputs.code-paths }}
run: |
import fnmatch
import os
import subprocess
import sys

AGENT_FILES = {"agents.md", "claude.md", "claude.local.md", "gemini.md"}
SUMMARY_ROWS = 200


def printable(text):
# A file name can hold a newline; it must not reach GITHUB_OUTPUT
# or a workflow command as a line of its own.
return "".join(ch if ch.isprintable() else "?" for ch in text)


def finish(full, reason, rows=()):
reason = printable(reason)
with open(os.environ["GITHUB_OUTPUT"], "a") as out:
out.write(f"reason={reason}\n")
out.write(f"full={'true' if full else 'false'}\n")
verdict = "full validation" if full else "documentation only"
print(f"::notice title=PR changes::{verdict}: {reason}")
summary = os.environ.get("GITHUB_STEP_SUMMARY")
if summary:
with open(summary, "a") as s:
s.write(f"### PR changes: {verdict}\n\n{reason}\n\n")
if rows:
s.write("| File | Status | Counts as |\n|---|---|---|\n")
for path, status, why in rows[:SUMMARY_ROWS]:
shown = printable(path).replace("|", "\\|").replace("`", "'")
s.write(f"| `{shown}` | {status} | {'code: ' + why if why else 'documentation'} |\n")
if len(rows) > SUMMARY_ROWS:
s.write(f"\n{len(rows) - SUMMARY_ROWS} more files not shown.\n")
sys.exit(0)


def git(*args):
return subprocess.run(["git", *args], capture_output=True, check=True).stdout


def code_reason(path, status, old_mode, new_mode, patterns):
"""None when the file is documentation only, else why it is code."""
if status not in ("A", "M"):
return f"status {status}: only added or modified Markdown is documentation"
if new_mode != "100644" or (status == "M" and old_mode != "100644"):
return "not a regular file"
if not path.endswith(".md"):
return "not Markdown"
parts = path.split("/")
if any(p.startswith(".") for p in parts):
return "hidden, or in a dot directory"
if "testdata" in parts or "vendor" in parts:
return "fixture or vendored file"
if parts[-1].lower() in AGENT_FILES:
return "agent instructions"
for p in patterns:
if fnmatch.fnmatchcase(path, p):
return f"matches code-paths {p}"
return None


try:
event = os.environ.get("EVENT", "")
if event != "pull_request":
finish(True, f"a {event or 'unknown'} event has no pull request to classify")
head = os.environ.get("HEAD_SHA", "")
patterns = [
line.strip()
for line in os.environ.get("CODE_PATHS", "").splitlines()
if line.strip() and not line.strip().startswith("#")
]
commits = git("rev-list", "--parents", "-n", "1", "HEAD").decode().split()
if len(commits) != 3:
finish(True, f"HEAD has {len(commits) - 1} parents, not the two of a pull request merge commit")
if not head or commits[2] != head:
finish(True, f"the second parent of HEAD is not the pull request head {head or '(unknown)'}")
raw = git("diff", "--raw", "--no-renames", "--no-abbrev", "-z", commits[1], commits[0])
fields = raw.split(b"\0")
if fields and fields[-1] == b"":
fields.pop()
if len(fields) % 2:
finish(True, "the output of git diff could not be parsed")
rows = []
for meta, path in zip(fields[0::2], fields[1::2]):
m = meta.decode().lstrip(":").split()
if len(m) != 5:
finish(True, "the output of git diff could not be parsed")
old_mode, new_mode, status = m[0], m[1], m[4]
name = path.decode("utf-8", "replace")
rows.append((name, status, code_reason(name, status, old_mode, new_mode, patterns)))
if not rows:
finish(True, "the merge commit changes no files")
code = [r for r in rows if r[2]]
if code:
path, _, why = code[0]
finish(True, f"{len(code)} of {len(rows)} changed files are not documentation, e.g. {path} ({why})", rows)
finish(False, f"all {len(rows)} changed files are documentation", rows)
except SystemExit:
raise
except Exception as e: # unknown means full
finish(True, f"classification failed ({type(e).__name__}: {e})")
Loading
Loading