diff --git a/.github/workflows/translation-status.yml b/.github/workflows/translation-status.yml new file mode 100644 index 0000000..b205412 --- /dev/null +++ b/.github/workflows/translation-status.yml @@ -0,0 +1,164 @@ +# Reusable workflow for translated documentation repositories +# Called by: repo/.github/workflows/translation-status.yml +# +# See the translation-status.yml section of README.md for what it does, what a +# caller must provide, and the caller stanza to copy. +# +# The step order is deliberate. Everything that can fail the job runs after the +# comment is posted, so a red run always carries the explanation of why. +# +# What the gate enforces is that every translation carries the blob hash of the +# English page it was written against. It cannot read the translated text, so a +# commit that only rewrites the stamp turns it green and makes the staleness +# permanently invisible. That one is a reviewer's job, not this workflow's. + +name: Translation Status + +on: + workflow_call: + inputs: + runs-on: + description: 'Runner to use' + required: false + default: 'ubuntu-latest' + type: string + +# No permissions block: this workflow inherits the caller's token. Declaring one +# here would set the complete permission set for the job, so requesting +# pull-requests: write would abort a caller that grants only contents: read +# before a single step ran, reporting nothing but "a workflow file issue". The +# gate is the point of this workflow; the comment is best-effort. + +jobs: + # Renaming this job renames the status check in every consuming repository, + # which silently breaks any branch protection rule naming it. + translation-status: + runs-on: ${{ inputs.runs-on }} + timeout-minutes: 20 + steps: + # pull_request_target checks out the base branch, so the gate would grade + # main and pass on every pull request without examining its changes. The + # plausible route there is a maintainer reaching for it to make the + # comment work on fork pull requests, which it does not. + - name: Refuse an event this gate cannot judge + if: >- + github.event_name != 'pull_request' + && github.event_name != 'push' + && github.event_name != 'workflow_dispatch' + run: | + echo "Triggered by ${{ github.event_name }}." >&2 + echo "This workflow must see the pull request's own content." >&2 + echo "pull_request_target grades the base branch and always passes." >&2 + exit 1 + + - name: Checkout code + uses: actions/checkout@v4 + with: + # Full history: the report resolves each stamped blob to show what + # changed in English since a translation was written. A shallow + # clone has no such blob and degrades the diff to a hint. + fetch-depth: 0 + # Later steps run code this repository does not control at review + # time -- dependency build backends under uv sync, plugins under + # mkdocs build. Without this the job token stays readable by all of + # them in .git/config. Nothing here needs it after the clone. + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Install dependencies + # --locked, so a pyproject that has drifted from the lock file fails + # here instead of resolving to some other version of the checkers. The + # gate's meaning rests on their exit statuses. + run: uv sync --locked + + - name: Report translation status + run: | + # tee, not plain redirection: a report only in the job summary is + # invisible in the logs, which is where you look when it misbehaves. + # PIPESTATUS because the runner shell is bash -e without pipefail, so + # a crashing reporter would otherwise pass as tee's own exit 0. + set +e + uv run translation-status --format markdown --diff 2>&1 | tee report.md + reported=${PIPESTATUS[0]} + set -e + cat report.md >> "$GITHUB_STEP_SUMMARY" + exit "$reported" + + - name: Comment on the pull request + if: github.event_name == 'pull_request' + # A pull request from a fork gets a read-only token, and a docs + # contributor should get a review rather than a broken build. The + # comment is an explanation of the verdict, never the verdict itself. + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.number }} + REPO: ${{ github.repository }} + run: | + uv run translation-status --comment > comment.md + # Match on author as well as marker: anyone who can comment on the + # pull request could otherwise post the marker first and have every + # run patch their text instead. + # + # Assigned without a pipe so that a failed listing fails this step + # rather than reading as "no comment yet" -- that would turn one + # transient API error into a second, permanent comment. --paginate + # because on a long pull request the marker is not on the first page. + ids=$(gh api --paginate "repos/$REPO/issues/$PR/comments" \ + --jq '.[] + | select(.user.login == "github-actions[bot]") + | select(.body | contains("")) + | .id') + existing=$(printf '%s\n' "$ids" | head -1) + if [ -n "$existing" ]; then + gh api "repos/$REPO/issues/comments/$existing" \ + -X PATCH -F body=@comment.md --silent + echo "Updated comment $existing" + else + gh api "repos/$REPO/issues/$PR/comments" \ + -F body=@comment.md --silent + echo "Created comment" + fi + + - name: Build the site + run: uv run mkdocs build --strict + + - name: Check anchors + id: anchors + # Tolerated here so that a broken anchor does not hide a stale + # translation: both are content faults, and an author would rather fix + # them in one pass than in two. The last step turns it back into a + # failure. GitHub renders a tolerated failure as a green check, which + # is why that step repeats the listing rather than pointing here. + continue-on-error: true + run: | + # 2>&1 because the checker reports "could not run" on stderr. Without + # it that case reaches the summary and the failing step as an empty + # block, and a red run with no stated cause is the one thing this + # step order exists to prevent. + set +e + uv run check-anchors site 2>&1 | tee anchors.txt + broken=${PIPESTATUS[0]} + set -e + { + echo "" + echo "## Anchor check" + echo "" + echo '```' + cat anchors.txt + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + exit "$broken" + + - name: Gate on translation status + run: uv run translation-status --check + + - name: Fail on broken anchors + # always(), so that a failing gate does not skip this and leave the + # anchor breakage visible only on a step GitHub renders green. + if: always() && steps.anchors.outcome == 'failure' + run: | + cat anchors.txt >&2 + exit 1 diff --git a/README.md b/README.md index f1f34a6..fe32c63 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,20 @@ # Hat Labs Shared Workflows -Reusable GitHub Actions workflows for the pr-main-release strategy used across Hat Labs repositories. +Reusable GitHub Actions workflows for two classes of Hat Labs repository: those +that build Debian packages, and documentation sites. ## Overview -These workflows implement a standardized release process: +Most of these workflows implement a standardized release process for Debian +packages: 1. **PR** → Run tests 2. **Merge to main** → Build .deb, create pre-release, dispatch to APT unstable 3. **Publish release** → Dispatch to APT stable +`translation-status.yml` is the exception. It serves translated documentation +repositories and has nothing to do with packaging. + ## Workflows ### pr-checks.yml @@ -136,9 +141,84 @@ jobs: | `apt-repository` | `hatlabs/apt.hatlabs.fi` | APT repo to dispatch to | | `version-pattern` | `^v([0-9]+\.[0-9]+\.[0-9]+)\+([0-9]+)$` | Tag validation regex | -## Repository Requirements +### translation-status.yml + +For translated documentation repositories, not Debian packages. Reports which +translations are behind their English source, posts that report as a pull +request comment, builds the site, checks its anchors, and fails the run when any +translation is stale, missing, unstamped or orphaned. + +Copy the caller from `examples/docs-repo/.github/workflows/translation-status.yml`. +The stanza that selects this workflow is: -Each repository using these workflows must have: +```yaml +# The called workflow inherits this token. Omit pull-requests: write and the +# run still gates; only the comment is skipped. +permissions: + contents: read + pull-requests: write + +jobs: + translation-status: + uses: halos-org/shared-workflows/.github/workflows/translation-status.yml@main +``` + +**Inputs:** +| Input | Default | Description | +|-------|---------|-------------| +| `runs-on` | `ubuntu-latest` | Runner to use | + +**Requirements** — none of these is validated, so getting one wrong shows up as +a failing step rather than a clear message: +- `pyproject.toml` pins [halos-docs-tools](https://github.com/halos-org/docs-tools) + to a tag, and `uv.lock` is committed. The workflow runs `uv sync --locked`, so + the two must agree. +- `mkdocs` and `mkdocs-static-i18n` are project dependencies. The package brings + the checkers, not mkdocs. +- `mkdocs.yml` configures `mkdocs-static-i18n` with a `docs//` tree, and + leaves `site_dir` at its default — the anchor check reads `site`. +- The caller grants `pull-requests: write` if it wants the comment. + +**What it enforces.** Every translation carries the git blob hash of the English +page it was written against. The checker compares hashes; it cannot read the +translated text. A commit that only rewrites the stamp therefore turns the gate +green and makes that page's staleness permanently invisible — and the bot +comment prints the hash needed to do it, because that is also what an honest +update needs. Catching a stamp-only diff is a reviewer's job. + +**Making it a gate.** Nothing here makes the check required; that is a branch +protection rule in the consuming repository. The check is named ` / +translation-status`. Two things to know before enabling it: the example +deliberately carries no `paths` filter, because a required check that never runs +on a PR touching none of the filtered paths leaves that PR unmergeable forever; +and the gate reads the repository as it stood when the run started, so two +independently green PRs can merge into a stale `main`. Require branches to be up +to date before merging, or use a merge queue — which needs a `merge_group` +trigger the example does not have. + +The checkers come from the package, so the same commands run on a laptop before +you push. Glossaries and per-language rules stay in the documentation +repository. + +This workflow builds and runs pull request code, including code from forks, so +callers should leave it on GitHub-hosted runners and must not switch the trigger +to `pull_request_target`. + +A repository without translations has no use for this workflow — the status +checker needs the i18n configuration to know what to compare. Such a repository +consumes the package directly from its own build job instead, for example to run +`check-anchors` on the built site. + +`hatlabs` documentation repositories call this copy rather than the one in +`hatlabs/shared-workflows`. The one-org-per-copy rule exists to keep the APT +inputs straight, and this workflow has none; a single copy is deliberate. + +## Repository Requirements (Debian package workflows) + +Each repository using `pr-checks.yml`, `build-release.yml` or +`publish-stable.yml` must have the following. A documentation repository calling +`translation-status.yml` needs none of them — its requirements are listed in +that workflow's section above. ### 1. VERSION file diff --git a/examples/docs-repo/.github/workflows/translation-status.yml b/examples/docs-repo/.github/workflows/translation-status.yml new file mode 100644 index 0000000..7ddedbf --- /dev/null +++ b/examples/docs-repo/.github/workflows/translation-status.yml @@ -0,0 +1,28 @@ +name: Translation Status + +# No paths filter. The gate is a property of the whole repository, not of a +# diff, and a required check that never runs on a pull request touching none of +# the filtered paths leaves that pull request unmergeable forever. +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +# The called workflow inherits this token, so the comment needs +# pull-requests: write here. Omit it and the run still gates; only the comment +# is skipped. +permissions: + contents: read + pull-requests: write + +concurrency: + group: translation-status-${{ github.ref }} + # Pull requests only. On push, github.ref is refs/heads/main for every merge, + # so cancelling lets one merge kill the run checking the one before it -- and + # a cancelled run is grey, not red, so nobody is told. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + translation-status: + uses: halos-org/shared-workflows/.github/workflows/translation-status.yml@main