diff --git a/.github/workflows/fern-docs.yml b/.github/workflows/fern-docs.yml index b71494499c37..01f63818f64e 100644 --- a/.github/workflows/fern-docs.yml +++ b/.github/workflows/fern-docs.yml @@ -137,18 +137,41 @@ jobs: python-version: '3.13' - name: Generate API references - # The Python/Rust reference pages are publish-time artifacts: - # gitignored in the source tree and generated here against the exact - # ref being published, so source PRs never carry (or review) the - # regenerated MDX. Generation must precede the pages-dev sync below, - # which rsyncs whatever sits under docs/fern/pages/. The Kubernetes - # reference regenerates from a docs-owned source file and stays - # committed, so it keeps the freshness check. + id: api_refs + # Python/Rust references are publish-time artifacts: generated here + # against the ref being published, never committed. Generation must + # precede the pages-dev sync below, which rsyncs docs/fern/pages/. + # + # The Kubernetes reference stays committed, but this path REGENERATES + # it rather than gating on it. As `--check` the step failed, which + # skipped the sync and publish steps entirely, so a main left stale by + # merge skew silently froze the live site. Drift is still fatal, just + # after the site is current: the probe records it and the gate at the + # end of this job fails the run. run: | + set -euo pipefail python3 -m pip install 'griffe==2.1.0' python3 source-checkout/docs/fern/scripts/gen_python_api.py python3 source-checkout/docs/fern/scripts/gen_rust_api.py - python3 source-checkout/docs/fern/scripts/gen_kubernetes_api.py --check + if python3 source-checkout/docs/fern/scripts/gen_kubernetes_api.py --check >/dev/null 2>&1; then + echo "kubernetes_drift=false" >> "$GITHUB_OUTPUT" + else + echo "kubernetes_drift=true" >> "$GITHUB_OUTPUT" + fi + k8s_page=source-checkout/docs/fern/pages/reference/kubernetes-api/full-api-reference.mdx + committed_bytes=$(wc -c < "$k8s_page") + python3 source-checkout/docs/fern/scripts/gen_kubernetes_api.py + regenerated_bytes=$(wc -c < "$k8s_page") + # Publishing through drift is the point of this step, but the + # regenerated page is what the sync below rsyncs, so a generator that + # exits 0 while emitting a gutted reference would reach the live site + # and only be reported afterwards. Ordinary skew moves this page by a + # few hundred bytes; losing a fifth of it means generation itself is + # what is wrong, so stop before anything is synced or published. + if [ "$regenerated_bytes" -lt "$(( committed_bytes * 4 / 5 ))" ]; then + echo "::error file=docs/fern/pages/reference/kubernetes-api/full-api-reference.mdx::Regenerated Kubernetes API reference is $regenerated_bytes bytes against $committed_bytes committed, a loss of more than a fifth. Refusing to sync or publish. Check gen_kubernetes_api.py and its source, docs/fern/pages/reference/kubernetes-api/api-reference-k8s.md." + exit 1 + fi - name: Checkout docs-website branch uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -664,6 +687,25 @@ jobs: if: steps.ctx.outputs.is_main == 'true' run: python3 source-checkout/docs/fern/scripts/check_published_styles.py + - name: Fail if the committed Kubernetes API reference was stale + # Deliberately last, and deliberately not gated on is_main: the preview + # path should report drift too. Whether the site actually published is + # a separate question - Publish Docs needs both is_main and has_changes + # - so the message reports which of the two happened rather than + # asserting a publish. `!cancelled()` keeps the drift annotation on runs + # where an earlier step already failed; a bare `if:` carries an implicit + # success() and would drop the report on exactly those runs. + if: ${{ !cancelled() && steps.api_refs.outputs.kubernetes_drift == 'true' }} + run: | + set -euo pipefail + if [ "${{ steps.ctx.outputs.is_main }}" = "true" ] && [ "${{ steps.changes.outputs.has_changes }}" = "true" ]; then + outcome="The site published the regenerated output, so docs are current, but the committed page differs from the reviewed bytes." + else + outcome="Nothing was published on this run, so the live site is unchanged." + fi + echo "::error file=docs/fern/pages/reference/kubernetes-api/full-api-reference.mdx::Committed Kubernetes API reference does not match its source. $outcome Fix with: python3 docs/fern/scripts/gen_kubernetes_api.py" + exit 1 + ############################################################################# # VERSION RELEASE - Run on new version tags (vX.Y.Z) ############################################################################# diff --git a/docs/fern/scripts/tests/test_api_reference_regressions.py b/docs/fern/scripts/tests/test_api_reference_regressions.py index 9d46ea506ff4..75190e4ba005 100644 --- a/docs/fern/scripts/tests/test_api_reference_regressions.py +++ b/docs/fern/scripts/tests/test_api_reference_regressions.py @@ -571,6 +571,28 @@ def test_pre_merge_gates_every_api_generator_input() -> None: assert "steps.filter.outputs.api_docs_any_modified" in action +K8S_DRIFT_GATE_STEP = "Fail if the committed Kubernetes API reference was stale" + + +def _publish_step_run(step_name: str) -> str: + """Return a fern-docs.yml step's `run` body, parsed rather than grepped. + + Substring-matching the whole workflow cannot tell a real command from the + same text inside a comment, so an assertion can pass on prose while the + command it guards has changed underneath it. + """ + document = yaml.safe_load( + (REPO_ROOT / ".github" / "workflows" / "fern-docs.yml").read_text( + encoding="utf-8" + ) + ) + for job in document["jobs"].values(): + for step in job.get("steps", []): + if step.get("name") == step_name: + return step.get("run", "") + raise AssertionError(f"fern-docs.yml has no step named {step_name!r}") + + def test_pre_merge_runs_all_api_generators_hermetically() -> None: workflow = (REPO_ROOT / ".github" / "workflows" / "pre-merge.yml").read_text( encoding="utf-8" @@ -598,9 +620,52 @@ def test_pre_merge_runs_all_api_generators_hermetically() -> None: assert "gen_kubernetes_api.py --check" in workflow # The publish and preview paths must GENERATE the pages before syncing # them to the docs-website branch (dev sync and version snapshots both). + # Asserted against the parsed `run` body, not the file text: the step's + # comment names these same commands, so a whole-file substring check would + # pass on prose after the command itself changed. + generate = _publish_step_run("Generate API references") for generator in ("python", "rust"): - assert f"gen_{generator}_api.py --check" not in publish - assert "gen_kubernetes_api.py --check" in publish + assert f"gen_{generator}_api.py --check" not in generate + # Kubernetes output stays committed, but publish REGENERATES it rather than + # gating on it. As `--check` this step failed, which skipped the sync and + # publish steps below, so a main left stale by merge skew silently froze the + # live site. The probe records drift and the gate below fails the run after + # the site is current. + assert re.search(r"gen_kubernetes_api\.py$", generate, re.MULTILINE) + assert "gen_kubernetes_api.py --check" in generate + assert "kubernetes_drift=true" in generate + gate = _publish_step_run(K8S_DRIFT_GATE_STEP) + assert "exit 1" in gate + # Wiring and polarity, not only shape. `steps.` for an id that does + # not exist renders empty, so dropping or renaming `id: api_refs`, or + # inverting either the probe or the gate, leaves the gate permanently + # inert while every assertion above still passes. Nothing else in the repo + # catches a dangling step reference: no hook or workflow runs actionlint. + fern_docs = yaml.safe_load( + (REPO_ROOT / ".github" / "workflows" / "fern-docs.yml").read_text( + encoding="utf-8" + ) + ) + by_name = { + step.get("name"): step + for step in fern_docs["jobs"]["preview-or-publish-docs"]["steps"] + } + assert by_name[K8S_DRIFT_GATE_STEP]["if"] == ( + "${{ !cancelled() && steps." + f"{by_name['Generate API references']['id']}" + ".outputs.kubernetes_drift == 'true' }}" + ) + assert re.search( + r"--check[^\n]*then\n\s*echo \"kubernetes_drift=false\"[^\n]*\n" + r"\s*else\n\s*echo \"kubernetes_drift=true\"", + generate, + ) + # The regenerated page is rsynced before the gate runs, so write mode needs + # its own floor: a generator that exits 0 on a gutted page must not publish. + assert "regenerated_bytes" in generate + assert "committed_bytes * 4 / 5" in generate + # The gate is only honest if it runs after the site has been published. + assert publish.index("Publish Docs") < publish.index(K8S_DRIFT_GATE_STEP) assert "Generate API references" in publish assert "Generate API references at the tag" in publish # fern check validates nav paths, so the fern-check job must materialize