Skip to content

Make doc generation reproducible and Linux-only; unify "API diff" terminology - #4200

Merged
mattleibow merged 19 commits into
mainfrom
mattleibow/api-diff-windows-runner
Jun 22, 2026
Merged

Make doc generation reproducible and Linux-only; unify "API diff" terminology#4200
mattleibow merged 19 commits into
mainfrom
mattleibow/api-diff-windows-runner

Conversation

@mattleibow

@mattleibow mattleibow commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Summary

The repo generates its release documentation through three independent paths, and two of them produced different output depending on the build host or local NuGet-cache warmth. That is why the doc-bot's regenerated output kept "wobbling" between runs and was painful to reproduce locally. This PR makes all three paths deterministic, gives them one shared set of entry points that behave identically locally, in CI, and in Docker (Linux), unifies the confusing "changelog" → "API diff" terminology, and commits a freshly regenerated baseline to replace the drifted one from #4184.

Background: the three doc paths

Everything lives under scripts/infra/docs/ and is regenerated by the update-release-notes CI workflow. The three paths share one versioning model (versions.json):

Path Engine Produces Output location
1 — API diffs api-diff.cake + api-diff-tools.cake per-release-line, per-package API-change markdown (SkiaSharp.md, *.breaking.md), by diffing each published NuGet against its baseline documentation/docfx/releases/<line>/<pkg>/…
2 — Release notes generate-release-notes.py the human <line>.md release-note pages — a structured draft the CI AI-polish pass rewrites documentation/docfx/releases/<line>.md
3 — mdoc API docs docs.cake (mdoc) the mdoc XML API reference consumed by the API-docs site repo separate docs repo

The problems this PR fixes:

  • Paths 1 and 3 were non-deterministic — output depended on NuGet-cache state, filesystem enumeration order, or what was installed on the build host.
  • Entry points were scattered across the release-notes skill and scripts/infra/shared, so a local run never matched CI and was not reproducible.
  • Some steps were not Linux (mdoc ran on Windows/.NET), so "local Docker == CI" was not true.
  • "changelog" terminology collided with "release notes" and with GitHub's own "Full Changelog" compare links.

Path 1 — API diffs: now deterministic and correct

Self-dependency resolution. api-diff-tools.cake resolved a package's own previous version with a filesystem glob over the warm package cache, so the chosen baseline depended on what happened to be unpacked locally — a cold machine and a warm machine produced different diffs. Simply deleting the glob fixed determinism but regressed correctness: ApiInfo must resolve SkiaSharp.dll to see that the two infrastructure interfaces SKObject implements (ISKReferenceCounted, ISKSkipObjectRegistration) are internal and surface the public base System.IDisposable instead. With no SkiaSharp.dll on the search paths those internal names leaked into the public diff (8 files, e.g. SkiaSharp.Resources, the Vortice GR* types, Skottie/SceneGraph). The fix (AddSelfDep) stages exactly one build per self-dependency, chosen deterministically from each diff's own nuspec dependency closure — the same answer on every host and cache state, stable enough that one recent build resolves these long-lived infra types identically for every historical diff.

Exclude auto-generated resource designers. The platform build tooling compiles non-API helpers into the public "Views" assemblies — the Android aapt Resource/R.* tables and the Uno/WinUI GlobalStaticResources — and they leaked into the diff as enormous "New Type" blocks of nothing but resource-id constants (a single Forms designer is 2600+ lines). They are excluded at the source via Mono.ApiTools.NuGetDiff.IgnoreMemberRegex, anchored under SkiaSharp.Views. so real API lookalikes (SkiaSharp.Resources.*, SkiaSharp.GR*TextureResourceInfo) still diff normally.

Document the newest plain .NET TFM, not netstandard. GetPlatformDirectories returned the first cross-platform folder the filesystem enumerated (in practice netstandard2.0), hiding the #if NET members (Span overloads) and describing a surface no real app targets. It now ranks candidates — newest plain .NET (e.g. net10.0) → highest netstandard → portable → per-platform heads — so SkiaSharp is documented and diffed from net10.0. (This selection is shared with Path 3.) Ranking also removes the enumeration-order nondeterminism.

Proven: consecutive full runs are byte-identical; 0 internal-interface leaks (was 8); 3.116.0/SkiaSharp.Resources is byte-identical to the committed baseline again.

Path 2 — release notes: logic unchanged, now co-located

Path 2's engine logic is unchanged in this PR. It moves into scripts/infra/docs/ with the other two and keeps its structure-then-polish split: the Python script emits a structured draft and a CI AI pass polishes the prose. The raw draft is therefore intentionally non-deterministic (the AI rewrites it), which is why this PR does not commit raw note prose — see What changed vs main.

Path 3 — mdoc API docs: ref/ over lib/, on Mono/Linux

mdoc was fed the lib/ assemblies (the implementation surface). For SkiaSharp that includes ABI-compat members marked [Obsolete(..., error: true)] — e.g. the by-value SetMatrix(SKMatrix) next to SetMatrix(in SKMatrix). mdoc's member comparer ignores the by-ref marker, treats the pair as equal, and its sort is unstable, so the two members swapped places every run. Because mdoc runs in merge mode (the previous output is part of the next run's input), this showed up as a 2-cycle oscillation with no fixed point.

The C# compiler strips error-obsolete members from ref/ assemblies, so the ref/ surface is the canonical public API and is stable. docs.cake now prefers ref/ and falls back to lib/. Only SkiaSharp.nupkg ships ref/; every other package is lib-only and unaffected.

Proven: three consecutive runs produce the identical output hash; the obsolete by-value SetMatrix(SKMatrix) no longer appears in the docs. mdoc.exe is invoked via Mono so Path 3 runs on Linux. The managed GTK# reference assemblies it needs come from NuGet (fed to mdoc as --lib by the cake comparer), so no system GTK# install is required.


Cross-cutting changes

One home, one set of entry points. The API-diff and release-notes engines lived under the release-notes skill and scripts/infra/shared; mdoc lived in scripts/infra/docs. They are all the same concern, so api-diff.cake, generate-release-notes.py, and api-diff-tools.cake move into scripts/infra/docs alongside docs.cake (git renames). The skill is now just SKILL.md plus a thin generate.sh that delegates here. local / CI / Docker now all call the same three entry points:

scripts/infra/docs/generate-{api-diffs,release-notes,api-docs}.sh

They are git-free (REPO_ROOT derived from the script dir) so they work for a worktree mounted inside Docker.

Linux-only via Docker. A reproducible image lives in scripts/infra/docs/docker (dotnet10 + mono + python3 + gh), so a local run in the container matches CI exactly.

CI: validate a feature branch before merge. The update-release-notes workflow always regenerated from main. A new workflow_dispatch source_branch input (default: main) threads through both "start from a clean tree" steps so a manual run can exercise a feature branch's doc-gen pipeline on CI before merging. The default keeps every existing trigger byte-for-byte identical; this mirrors the docs repo's skiasharp_branch input.

Terminology: "changelog" → "API diff". The repo used "changelog" for the machine-generated API-diff trees, which collided with "release notes" and with GitHub's own "Full Changelog" compare links. This unifies the vocabulary onto API diff: renamed generate-changelogs.shgenerate-api-diffs.sh and the dev spec release-notes-and-changelogs.mdrelease-notes-and-api-diffs.md; renamed Cake identifiers, the output/logs/api-diffs log path, and prose across docs / skill / workflow; recompiled the update-release-notes lock via gh aw compile. Intentionally kept: GitHub's literal [Full Changelog](compare/…) links (their own convention), the release-notes skill's legacy "changelog" trigger synonym, and unrelated third-party "changelog" vocabulary in other skills.

Developer docs. The documentation/dev/ guides that describe this system are added and restructured for readability: a new docs-overview.md map (what each engine, skill, and workflow does and where its output lives) and a re-laid-out release-notes-and-api-diffs.md spec — both gain a table of contents, and the spec's worst walls of text are broken into subheads, lists, and tables. The relayout is information-preserving: the spec's numbered headings (referenced ~120× from code comments and skills) are byte-identical to before, so no §N.M cross-reference breaks.


What changed vs main

76 files changed (+1498 / −1467). The script / workflow / doc changes above are the engine of the PR; the rest is the regenerated releases/ baseline replacing the drifted #4184 output. Measured against main, the releases/ tree changes are:

  • 23 historical API-diff files corrected by the determinism fixes (the self-dep and TFM-ranking changes re-resolve old diffs), plus TOC.yml and index.md.
  • The active development line advanced 4.148.04.150.0. git renders most of this as renames of 4.148.0/<pkg>/… to 4.150.0/<pkg>/… (a few files differ enough to show as add/delete). So 4.150.0/ is new here — it is not a pre-existing tree being "kept".
  • Two deliberate deletions are left in place to exercise CI's self-heal after merge (see Expected recreate PR): the 4.148.0/ and harfbuzzsharp/14.2.0/ API-diff trees. CI must regenerate exactly these after merge.
  • Release-note prose is deliberately not committed — it is owned by the CI AI-polish pass. Net of the regeneration, the only release-note page that is a true deletion vs main is 3.119.3.md; the newer note pages (4.147.0.md, 4.148.0.md, 4.150.0.md, 4.150.0-unreleased.md, harfbuzz 8.3.1.6.md / 14.2.0.md) never existed on main, so they simply do not appear yet.
  • One stale file pruned: 2.88.0/SkiaSharp.Views.Forms/SkiaSharp.Views.Forms.breaking.md — a leak the resource-exclusion fix means should no longer be generated.

Determinism evidence. A full regeneration in the Linux Docker harness reproduced the committed API-diff baseline byte-for-byte — 1204 of 1211 releases/ files identical, the 7 differing files being exactly the raw release-note prose pages the AI-polish pass owns. So Path 1 output is fully reproducible and main starts from a correct, stable tree.

Companion PR

mono/SkiaSharp-API-docs#147 flips the mdoc stub job to Linux to match. This PR must merge first — it adds scripts/infra/docs/generate-api-docs.sh, which that workflow calls.

Expected recreate PR after merge (hard record)

For an audit trail: once this PR merges, the update-release-notes workflow's recreate PR must touch exactly these 33 files under documentation/docfx/releases/ and nothing else (TOC.yml / index.md stay at zero diff).

26 API-diff files — regenerated byte-for-byte identical (the deleted 4.148.0/ + harfbuzzsharp/14.2.0/ trees, proven deterministic):

documentation/docfx/releases/4.148.0/SkiaSharp.Direct3D.Vortice/SkiaSharp.Direct3D.Vortice.md
documentation/docfx/releases/4.148.0/SkiaSharp.HarfBuzz/SkiaSharp.HarfBuzz.md
documentation/docfx/releases/4.148.0/SkiaSharp.Resources/SkiaSharp.Resources.md
documentation/docfx/releases/4.148.0/SkiaSharp.SceneGraph/SkiaSharp.SceneGraph.md
documentation/docfx/releases/4.148.0/SkiaSharp.Skottie/SkiaSharp.Skottie.md
documentation/docfx/releases/4.148.0/SkiaSharp.Views.Blazor/SkiaSharp.Views.Blazor.md
documentation/docfx/releases/4.148.0/SkiaSharp.Views.Desktop.Common/SkiaSharp.Views.Desktop.Common.md
documentation/docfx/releases/4.148.0/SkiaSharp.Views.Gtk3/SkiaSharp.Views.Gtk3.md
documentation/docfx/releases/4.148.0/SkiaSharp.Views.Gtk4/SkiaSharp.Views.Gtk4.md
documentation/docfx/releases/4.148.0/SkiaSharp.Views.Maui.Controls/SkiaSharp.Views.Maui.Controls.md
documentation/docfx/releases/4.148.0/SkiaSharp.Views.Maui.Core/SkiaSharp.Views.Maui.Core.md
documentation/docfx/releases/4.148.0/SkiaSharp.Views.Uno.WinUI/SkiaSharp.Views.Windows.md
documentation/docfx/releases/4.148.0/SkiaSharp.Views.WPF/SkiaSharp.Views.WPF.md
documentation/docfx/releases/4.148.0/SkiaSharp.Views.WinUI/SkiaSharp.Views.Windows.md
documentation/docfx/releases/4.148.0/SkiaSharp.Views.WindowsForms/SkiaSharp.Views.WindowsForms.md
documentation/docfx/releases/4.148.0/SkiaSharp.Views/SkiaSharp.Views.Android.md
documentation/docfx/releases/4.148.0/SkiaSharp.Views/SkiaSharp.Views.Mac.md
documentation/docfx/releases/4.148.0/SkiaSharp.Views/SkiaSharp.Views.Tizen.md
documentation/docfx/releases/4.148.0/SkiaSharp.Views/SkiaSharp.Views.iOS.md
documentation/docfx/releases/4.148.0/SkiaSharp.Views/SkiaSharp.Views.tvOS.md
documentation/docfx/releases/4.148.0/SkiaSharp.Vulkan.SharpVk/SkiaSharp.Vulkan.SharpVk.md
documentation/docfx/releases/4.148.0/SkiaSharp/SkiaSharp.breaking.md
documentation/docfx/releases/4.148.0/SkiaSharp/SkiaSharp.md
documentation/docfx/releases/4.148.0/index.md
documentation/docfx/releases/harfbuzzsharp/14.2.0/HarfBuzzSharp/HarfBuzzSharp.md
documentation/docfx/releases/harfbuzzsharp/14.2.0/index.md

7 release-note pages — generated and AI-polished (content will differ from the raw generator output; that is expected):

documentation/docfx/releases/3.119.3.md
documentation/docfx/releases/4.147.0.md
documentation/docfx/releases/4.148.0.md
documentation/docfx/releases/4.150.0.md
documentation/docfx/releases/4.150.0-unreleased.md
documentation/docfx/releases/harfbuzzsharp/8.3.1.6.md
documentation/docfx/releases/harfbuzzsharp/14.2.0.md

Red flags (would mean the pipeline is not yet deterministic): any file other than these 33 appearing; any of the 26 API-diff files coming back with a content diff; or TOC.yml / index.md showing changes.

…ism bugs

Context: documentation/dev/release-notes-and-changelogs.md

The doc-generation pipeline has three "paths": API changelogs (api-diff),
release notes, and mdoc XML API docs. Two of them produced different output
depending on the host or the warmth of the local caches, which is why the
bot's regenerated docs kept "wobbling" between runs and why the output was
hard to reproduce locally. This reworks the pipeline so all three paths are
deterministic, share one set of entry points, and run entirely on Linux
(local Docker == CI).

~~ Path 1 (API changelogs): host/cache-independent ~~

api-diff-tools.cake resolved a package's own previous version with a
filesystem glob over the warm package cache. The set of matches — and thus
the chosen baseline — depended on what happened to be unpacked locally, so a
cold machine and a warm machine produced different changelogs. Removed the
self-dependency glob; the baseline is now derived deterministically.

Proven: cold vs warm runs are byte-identical across all 1205 output files.

~~ Path 3 (mdoc API docs): document ref/ assemblies, not lib/, and run on Mono ~~

mdoc was fed the lib/ assemblies (the implementation surface). For SkiaSharp
that includes ABI-compat members marked [Obsolete(..., error: true)] — e.g.
the by-value SetMatrix(SKMatrix) that sits next to SetMatrix(in SKMatrix).
mdoc's member comparer ignores the by-ref marker, treats the pair as equal,
and its sort is unstable, so the two members swapped places on every run.
Because mdoc runs in merge mode (the previous output is part of the next
run's input), this showed up as a 2-cycle oscillation with no fixed point.

The C# compiler strips error-obsolete members from ref/ assemblies, so the
ref/ surface is the canonical public API and is stable. docs.cake now
prefers ref/ and falls back to lib/. Only SkiaSharp.nupkg ships ref/; every
other package is lib-only and is unaffected.

Proven: three consecutive runs produce the identical output hash (92f6c0ec);
the obsolete by-value SetMatrix(SKMatrix) no longer appears in the docs.

mdoc.exe is also now invoked via Mono so Path 3 runs on Linux. The managed
GTK# reference assemblies it needs come from NuGet (fed to mdoc as --lib by
the cake comparer), so no system GTK# install is required.

~~ Co-locate the three engines + single source of truth ~~

The api-diff and release-notes engines lived under the release-notes skill
and scripts/infra/shared; mdoc lived in scripts/infra/docs. They are all the
same concern, so api-diff.cake, generate-release-notes.py, and
api-diff-tools.cake move into scripts/infra/docs alongside docs.cake (git
renames; #load/ROOT paths updated to match). The skill is now just SKILL.md
plus a thin generate.sh that delegates here.

local/CI/Docker now all call the same three entry points:
  scripts/infra/docs/generate-{changelogs,release-notes,api-docs}.sh
They are git-free (REPO_ROOT derived from the script dir) so they work for a
worktree mounted inside Docker. A reproducible image lives in
scripts/infra/docs/docker (dotnet10 + mono + python3 + gh).

~~ Generated content intentionally excluded ~~

The changelog / release-note / API-doc *content* is deliberately not in this
PR. With the determinism fixes in place, CI regenerates it from scratch on
merge, which both keeps this PR reviewable and exercises the pipeline
end-to-end. The companion mono/SkiaSharp-API-docs PR flips the mdoc stub job
to Linux to match.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

📦 Try the packages from this PR

Warning

Do not run these scripts without first reviewing the code in this PR.

Step 1 — Download the packages

bash / macOS / Linux:

curl -fsSL https://raw.githubusercontent.com/mono/SkiaSharp/main/scripts/get-skiasharp-pr.sh | bash -s -- 4200

PowerShell / Windows:

iex "& { $(irm https://raw.githubusercontent.com/mono/SkiaSharp/main/scripts/get-skiasharp-pr.ps1) } 4200"

Step 2 — Add the local NuGet source

dotnet nuget add source ~/.skiasharp/hives/pr-4200/packages --name skiasharp-pr-4200
More options
Option Description
--successful-only / -SuccessfulOnly Only use successful builds
--force / -Force Overwrite previously downloaded packages
--list / -List List available artifacts without downloading
--build-id ID / -BuildId ID Download from a specific build

Or download manually from Azure Pipelines — look for the nuget artifact on the build for this PR.

Remove the source when you're done:

dotnet nuget remove source skiasharp-pr-4200

mattleibow and others added 2 commits June 20, 2026 01:07
The documentation pipeline has three distinct outputs, but two of them
shared confusingly close names: "release notes" and "API changelogs".
In conversation the word "changelog" kept colliding with "release notes",
and it also clashes with GitHub's own "Full Changelog" compare links that
appear *inside* the generated release-notes pages. Settle on three
unambiguous terms — release notes, API docs, API diffs — and rename our
own "changelog" vocabulary to "API diff" everywhere it refers to the
api-diff engine.

Scope of the rename:

  * Files: generate-changelogs.sh -> generate-api-diffs.sh;
    release-notes-and-changelogs.md -> release-notes-and-api-diffs.md
    (the behavior spec). All cross-references updated.
  * Cake identifiers in api-diff.cake: changelogVersion -> apiDiffVersion,
    CopyChangelogs -> CopyApiDiffs, changelogPath -> apiDiffPath,
    changelogOutputPath -> apiDiffOutputPath; log dir
    output/logs/changelogs -> output/logs/api-diffs.
  * Prose/docs/workflow: AGENTS.md, dev docs, docs-overview, run.sh help
    and subcommand (changelogs -> api-diffs), generate.sh, the
    update-release-notes workflow markdown, and the Python engine's
    "API-changelog engine" -> "API-diff engine" banner.
  * Recompiled update-release-notes.lock.yml via `gh aw compile` so the
    embedded prompt body and workflow display name match the edited
    markdown (lock is generated; never hand-edited).

Deliberate retentions (NOT misses):

  * GitHub's literal "[Full Changelog](compare/...)" link text is GitHub's
    release convention, not our term — preserved in the Python engine and
    SKILL.md examples, and in the generated docfx/releases pages.
  * SKILL.md keeps "changelog" listed as a legacy trigger synonym so the
    skill still fires when a user types the old word.
  * Generated documentation/docfx/releases/**.md is untouched (CI
    regenerates it) and unrelated skills (skia-analyst,
    native-dependency-update, security-audit, release-publish) keep their
    own third-party "changelog" vocabulary.
  * gh-aw's protected_files list contains the literal filename
    "CHANGELOG.md" — boilerplate, left as-is.

Validation: `python3 -m py_compile` on the engine passes; `dotnet cake
--target=docs-api-diff --dryrun` compiles every #load'd script and
resolves the target; the recompiled lock contains no stray "changelog"
beyond the protected_files boilerplate.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
After the changelog→API-diff rename, reviewed every changed doc-gen file
for terminology consistency, comment quality, and — the main goal —
comments that describe the FINAL state rather than the chain of edits we
made to get there. Most "no longer exists/generated" comments are correct
runtime descriptions (stale-moniker/stub pruning) and were left alone;
the fixes below target the handful that narrated the migration itself or
slipped during the bulk rename.

  * generate-api-diffs.sh: the rename turned the `run.sh api-diffs`
    subcommand into prose "api diffs" (a space). Restore the hyphenated
    command token so the doc matches the actual subcommand.

  * Windows→Linux narrative (generate-api-docs.sh, Dockerfile, docs.cake):
    replaced "runs the docs step on windows-latest today" / "now invokes
    mdoc under mono" / "no longer needs a Windows runner" with the final
    state — mdoc.exe runs under mono on non-Windows hosts, so Path 3 runs
    on Linux (auto-api-docs-writer is Linux + mono). The old wording was
    soon-stale and contradicted docs-overview.md, which already documents
    that workflow as Linux + mono.

  * Dropped temporal markers that no longer add anything: "used to live
    here" (docs.cake header), "ships ref/ today" (docs.cake), "no longer
    bundled here" (writing-docs.md), "no longer Windows-only"
    (docs-overview.md).

  * run.sh: the `help` sed range (2,34) stopped at the "Environment:"
    header, hiding COLD / IMAGE / ALLOW_NO_TOKEN. Extended to 2,37 so the
    documented env vars actually print.

Validation: `bash -n` on the edited scripts; `run.sh help` now lists the
Environment vars; `dotnet cake --target=update-docs --dryrun` compiles
docs.cake; the changelog-residual set is unchanged (only the intentional
GitHub "Full Changelog" retentions and the SKILL.md legacy trigger).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mattleibow mattleibow changed the title Make doc generation reproducible and Linux-only; fix two non-determinism bugs Make doc generation reproducible and Linux-only; unify "API diff" terminology Jun 19, 2026
@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

📖 Documentation Preview

The documentation for this PR has been deployed and is available at:

🔗 View Staging Site
🔗 View Staging Docs
🔗 View Staging Gallery (Blazor)
🔗 View Staging Gallery (Uno Platform)
🔗 View Staging SkiaFiddle

This preview will be updated automatically when you push new commits to this PR.


This comment is automatically updated by the documentation staging workflow.

mattleibow and others added 2 commits June 20, 2026 01:52
The update-release-notes pipeline (API diffs + release notes) always
regenerates from main: both the prepare job and the agent pre-step reset
to origin/main before running the generators. That's correct for
push/schedule/tag runs — release notes target main, and a push to
release/* is a content *source*, not the PR base — but it means a manual
dispatch can only ever exercise main's scripts, so there was no way to
validate a feature branch's doc-gen pipeline on CI before merging it.

Add a workflow_dispatch `source_branch` input (default: main) and thread
it through both "Start from a clean … tree" steps via SOURCE_BRANCH. The
default keeps every existing trigger byte-for-byte identical; only a
manual run can point it at a feature branch to generate from that
branch's tree (its scripts + content). This mirrors the docs repo's
auto-api-docs-writer `skiasharp_branch` input.

Threading it through the agent pre-step too keeps the Prepare patch
applying cleanly, since both jobs then check out the same source.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
GetPlatformDirectories is the shared helper that both the API-diff engine
and the mdoc API-doc generator use to pick which assembly inside a NuGet
package represents its public surface. When a package shipped a
cross-platform (BCL) build it returned the *first* such folder the
filesystem happened to enumerate, which in practice was netstandard2.0 —
so the docs and diffs described the netstandard surface. That surface is
the lowest common denominator: it hides the members SkiaSharp only exposes
under `#if NET` (Span overloads and friends), and netstandard is not what
real apps target anymore.

Rank the cross-platform candidates instead of taking the first match:
prefer the newest plain .NET build (e.g. net10.0), then the highest
netstandard, then portable, and only fall back to per-platform heads when
no cross-platform build exists. SkiaSharp's TFMCurrent is net10.0, so the
core packages are now documented and diffed from net10.0 — the most
complete, real-world surface.

Selecting by explicit rank rather than enumeration order also removes a
latent nondeterminism: the chosen TFM no longer depends on the host's
directory ordering. The regex `^net\d+\.\d+$` matches modern .NET only —
the required dot excludes net4x desktop and the anchors exclude platform
heads like net10.0-ios, which continue to flow through the per-platform
branch unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
mattleibow and others added 2 commits June 20, 2026 03:27
The platform build tooling compiles non-API helper classes directly into
SkiaSharp's public "Views" assemblies, and they leak into the generated
api-diff markdown as enormous "New Type" blocks of nothing but resource-id
constants:

  - the Android "aapt" resource designer <RootNamespace>.Resource and its
    nested R.* tables (Resource.Drawable, Resource.Id, Resource.Styleable, ...)
    in the Android builds of SkiaSharp.Views.Android / .Forms / .Maui.*;
  - the Uno/WinUI XAML GlobalStaticResources class in the UWP/WinUI builds of
    SkiaSharp.Views.UWP / .Windows / .Uno.

A single Forms designer is 2600+ lines, none of it real public API, and the
exact contents vary with the platform tooling and the TFM that happens to be
selected on each side of the diff. A real-package diff of SkiaSharp.Views.Forms
2.80.0 -> 2.88.0 carries 12 designer entries (Resource plus 11 nested
Resource.* tables) that swamp the six genuine type changes.

Exclude them at the source via Mono.ApiTools.NuGetDiff's IgnoreMemberRegex,
which feeds the formatted-markdown diff's IgnoreAdded/IgnoreNew/IgnoreRemoved
sets so a match drops the whole type block (including nested members) at
generation time. This is cleaner than post-processing the markdown, and unlike
a ".Resource" suffix heuristic it also catches the dot-nested R.* tables and
GlobalStaticResources.

The two patterns are anchored under "SkiaSharp.Views." and require
Resource/GlobalStaticResources to be a whole trailing name segment, so real API
lookalikes outside that namespace are untouched: SkiaSharp.Resources.* and
SkiaSharp.GR*TextureResourceInfo still diff normally. Verified on real packages:
the 12 designer entries disappear while all six genuine SkiaSharp.Views.Forms
type changes (Extensions, SKCanvasViewRenderer(Base), SKGLViewRenderer(Base),
SKPaintSurfaceEventArgs) are preserved.

Only the api-diff path is affected; the mdoc engine (scripts/infra/docs/docs.cake)
already filters the designer itself and consumes this comparer solely for its
SearchPaths.

Co-authored-by: Matthew Leibowitz <mattleibow@live.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The previous determinism pass (f4dde60) removed the self-dependency
glob from CreateNuGetDiffAsync because it was a genuine source of
host/cache nondeterminism: it added EVERY cached version of SkiaSharp,
HarfBuzzSharp and SkiaSharp.Views.Maui.Core to comparer.SearchPaths, and
since Mono.Cecil binds by simple assembly name the winning build depended
on filesystem-enumeration order and cache warmth.

But removing it outright regressed correctness. ApiInfo needs to resolve
the referenced SkiaSharp assembly to see the accessibility of the types a
public type inherits/implements. SkObject implements the two internal
infrastructure interfaces ISKReferenceCounted and ISKSkipObjectRegistration
(binding/SkiaSharp/SKObject.cs:450,472). When SkiaSharp.dll is on the
search paths, ApiInfo sees they are `internal`, drops them, and surfaces
the public base System.IDisposable instead. With no SkiaSharp.dll to
resolve, those internal names LEAK into the public markdown diff as bogus
base-interface entries.

A full historical run leaked them into 8 files across the lib-only
packages whose types derive from SKObject — e.g.
3.116.0/SkiaSharp.Resources/SkiaSharp.Resources.md listed
"ISKReferenceCounted, ISKSkipObjectRegistration" where origin/main
correctly shows "System.IDisposable"; the Vortice GR* types and the
Skottie/SceneGraph trees were affected too.

Restore resolution without reintroducing the nondeterminism: stage
exactly ONE build per self-dependency, chosen as the latest STABLE
release on nuget.org via NuGetVersions.GetLatestAsync. That is the same
answer on every host and every cache state, and because these infra types
are long-lived and stable a single recent build resolves them identically
for every historical diff.

Proven in the Linux Docker harness:

  * leak gone — 0 occurrences of either interface across all 1205 output
    files (was 8); 3.116.0/SkiaSharp.Resources is byte-identical to
    origin/main again, and GRVorticeD3D* / Maui.Controls now show the
    correct System.IDisposable / ISKGLView surface;
  * deterministic — two consecutive full runs are byte-identical
    (manifest hash c2089aa0, delta hash a1362be8).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
mattleibow and others added 12 commits June 20, 2026 18:00
Context: independent review of #4200 found a wording contradiction.

The §5.2 "Deterministic resolution" narrative and §7.2 invariant 9 still
claimed *every* assembly reference is satisfied by a version "pinned" in
`scripts/VERSIONS.txt` via `AddDep`/`AddPackageDir`. That stopped being true
once SkiaSharp's own inter-package self-references moved to `AddSelfDep`
(commit ddc2798), which resolves the latest stable on nuget.org rather than a
VERSIONS.txt pin. The mechanism is still host- and cache-independent — the
property invariant 9 actually guarantees — but it is not "pinned", so the spec
contradicted the code an independent reviewer was auditing it against.

Reconcile the prose with the implementation: describe third-party deps as
pinned in VERSIONS.txt and SkiaSharp's own references as resolved to the latest
stable via `AddSelfDep`, and reword invariant 9 from "pinned dependency" to
"real, host-independent dependency" covering both paths. Also fold in why
self-deps must resolve at all (SKObject's internal ISKReferenceCounted /
ISKSkipObjectRegistration interfaces leak into the public diff otherwise), which
the §5.2 text previously left implicit.

Documentation-only; no generated output changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Context: PR #4200
Spec: documentation/dev/release-notes-and-api-diffs.md §5.2, invariant 9

The historical API-diff generator resolves a public type's inherited
interfaces from whatever SkiaSharp/HarfBuzzSharp build is on Mono.Cecil's
search path (it binds by simple assembly name). If our own inter-package
references aren't resolved, SKObject's `internal` ISKReferenceCounted and
ISKSkipObjectRegistration leak into the public diff as bogus base
interfaces. The previous fix (AddSelfDep) put a single global build — the
latest stable version on nuget.org — on the path for every diff. That
removed the leak but mixed eras: a 1.x diff was described using today's
3.x assembly and could show inherited members the historical type never
had. It also can't be made correct by reusing the package's own version,
because the self-dependency family is not one version line — e.g.
SkiaSharp.HarfBuzz 2.88.7 was built against HarfBuzzSharp 7.3.0.1, not
2.88.7 — so the package version tells you nothing about its deps.

Stage self-dependencies per-diff instead, at the exact version pinned in
the package-under-diff's own .nuspec (the version it was actually built
against), then unstage them before the next line so nothing leaks across
versions. This is contemporaneous, deterministic, and host/cache-
independent (nuspec content is frozen), and it generalises to every
self-dependency uniformly via the one source that records their versions.

  * StageSelfDepsFromNuspecAsync / UnstageSearchPaths / IsSelfDependency /
    GetSelfDepLibDirectory in api-diff-tools.cake; AddSelfDep removed.
  * Wired into both diff call sites in api-diff.cake (current CI gate and
    the authoritative docs-api-diff-past loop) with try/finally unstage.
  * NativeAssets.* dependencies are excluded — they ship only native
    binaries (no managed types to resolve), are the largest packages to
    fetch, and a missing preview native build would otherwise abort the
    whole run. This alone cut a full run's download from ~2.3GB to ~265MB.
  * A self-dependency version that can't be fetched is logged and skipped,
    not fatal.

Validated by a full authoritative run (00:18:06): 0 errors, 0 internal-
interface leaks across all 60 version folders (1.x through 4.x), a 1.55.0
SkiaSharp.Views diff that correctly resolves SKObject to the era's public
System.IDisposable, and byte-identical output across two independent runs
(idempotent). docs.cake (mdoc) is unaffected — it self-stages from
locally-built frameworks.

Co-authored-by: Matthew Leibowitz <mattleibow@live.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Regenerate the entire committed documentation/docfx/releases/ api-diff
tree from scratch using the current generation scripts (nuspec-driven
per-diff self-dependency staging + the Resource/GlobalStaticResources
member exclusions) so the committed tree is a faithful, reproducible
output of today's scripts rather than the stale output last written by
#4184 (old, pre-self-dep-fix scripts).

The committed tree had drifted from what the scripts now produce: every
subsequent run legitimately rewrote ~24 files (self-dep version pinning,
contemporaneous inherited-type resolution, and the new 4.150.0 dev line),
which made it impossible to tell an intended change from baseline drift.
Committing a clean full regeneration establishes the authoritative
baseline so future runs (local or CI) produce only the diff for whatever
actually changed.

Generated by: scripts/infra/docs/generate-api-diffs.sh
(docs-api-diff-past), run in the docs Docker image, exit 0, no errors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Run the full Prepare pipeline exactly as the "Update Release Notes & API
Diffs" workflow does — `.agents/skills/release-notes/scripts/generate.sh`
(Path 1 Cake api diffs, then Path 2 Python release notes --all) — in the
docs Docker image. Path 1 reproduced the api-diff tree committed in the
previous commit with zero diff (deterministic), confirming the api-diff
baseline is reproducible; this commit adds the Path 2 release-notes output
that the api-diff-only commit was missing.

New release-notes pages for the lines that had none committed yet
(4.147.0, 4.148.0, 4.150.0 + 4.150.0-unreleased, HarfBuzzSharp 14.2.0 and
8.3.1.6) plus the regenerated TOC.yml / index.md that link them. Together
with the api-diff baseline this is the complete, reproducible output of
today's generation scripts.

Generated by: .agents/skills/release-notes/scripts/generate.sh
(docs-api-diff-past + generate-release-notes.py --all), docs Docker image.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…I recreate

Remove the api-diff trees and release-notes pages for three lines so that
merging this branch lets the "Update Release Notes & API Diffs" workflow
regenerate exactly these — and only these — proving the pipeline recreates
deleted output deterministically and that a steady-state run is a no-op.

Deleted (51 files), api diff dir + notes page for each:
  - documentation/docfx/releases/3.119.3/ + 3.119.3.md
  - documentation/docfx/releases/4.148.0/ + 4.148.0.md
  - documentation/docfx/releases/harfbuzzsharp/14.2.0/ + 14.2.0.md

The top-level TOC.yml and index.md are deliberately left intact: Path 2
regenerates them every run, so they already match what CI will produce
(zero diff there). The recreate PR should therefore contain only these 51
files reappearing, making the round-trip easy to verify.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Remove the generated-but-unpolished release-notes prose pages for the 4.x
line (4.147.0, 4.150.0, 4.150.0-unreleased) so the "Update Release Notes &
API Diffs" workflow regenerates them and the AI polish step rewrites the
prose. The raw generator output is a placeholder; the human-readable notes
come from the polish pass that only runs in CI, so committing the unpolished
pages here would just be overwritten.

Only the top-level prose pages are removed. The deterministic api-diff trees
(4.147.0/ and 4.150.0/) are kept — they need no polishing and already match
what CI produces. 4.148.0's prose page was already removed in the previous
commit as part of the recreate-validation set.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…sh via CI

Same rationale as the 4.x prose pages: the raw generated 8.3.1.6 notes are a
placeholder that the CI AI-polish pass rewrites, so remove the page and let the
"Update Release Notes & API Diffs" workflow regenerate and polish it. Only the
prose page is removed; no api-diff tree exists for this version to keep.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Re-running the full Prepare pipeline (.agents/skills/release-notes/scripts/
generate.sh: Cake docs-api-diff-past + generate-release-notes.py --all) in
the docs Docker image recreated every file previously deleted on this
branch. A byte-for-byte hash comparison against the prior baseline showed
1204/1211 files identical; only the 7 raw release-notes prose pages differ
run-to-run, confirming the api-diff generation is fully deterministic.

This commit re-adds the regenerated api-diff trees (3.119.3, 4.148.0,
HarfBuzzSharp 14.2.0) and the raw notes so the branch holds the complete,
current generator output before we re-stage the intended deletions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…polish

Remove the 7 raw release-notes prose pages flagged by the generator in
output/files-to-polish.txt. These are the only files that differ between
two identical generate.sh runs, because the raw generator output is a
placeholder that the CI AI-polish pass rewrites. Committing the unpolished
text here would just be overwritten, so let the "Update Release Notes &
API Diffs" workflow regenerate and polish them instead.

Removed:
  - 3.119.3.md, 4.147.0.md, 4.148.0.md, 4.150.0.md, 4.150.0-unreleased.md
  - harfbuzzsharp/8.3.1.6.md, harfbuzzsharp/14.2.0.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…CI recreate

Remove the api-diff trees for 4.148.0 and HarfBuzzSharp 14.2.0 so that
merging this branch lets the "Update Release Notes & API Diffs" workflow
regenerate exactly these — and only these — proving the pipeline recreates
deleted api-diff output deterministically (the byte-identical re-run already
demonstrated this locally for the full tree).

Deleted (26 files):
  - documentation/docfx/releases/4.148.0/ (24)
  - documentation/docfx/releases/harfbuzzsharp/14.2.0/ (2)

The 3.119.3 api-diff tree and the top-level TOC.yml / index.md are left
intact; CI regenerates the nav at zero diff, so the recreate PR should show
only these api-diff files reappearing alongside the regenerated, AI-polished
release notes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Drop the two API-diff "local-iteration" knobs from the doc-generation
engine: --nugetDiffMinVersion (NUGET_DIFF_MIN_VERSION) and
--useOutputNugets (NUGET_DIFF_USE_OUTPUT_NUGETS).

Neither was part of the documented contract. The behavior spec
(documentation/dev/release-notes-and-api-diffs.md §5) describes the
api-diff engine as always rebuilding "the complete set authoritatively"
— which is exactly the unfloored full run CI already used. The flags
only ever existed as undocumented code knobs:

  * nugetDiffMinVersion was wired (IsLineBelowMin + a per-line skip, a
    partial-run guard on the index/co-release writes, and floor checks
    in ClearOwnedApiDiffFolders) but only NARROWED which lines a local
    run regenerated; it never changed committed output.
  * useOutputNugets was fully vestigial — defined and documented in
    shared.cake and generate-api-diffs.sh, but never read by any .cake
    code path. The "diff freshly built unpublished packages" behavior
    lives in the separate docs-api-diff ("current") target (spec §5.3),
    which gates on FileExists(output/nugets/...) and never consulted
    this flag.

Removing them leaves docs-api-diff-past doing the single authoritative
full walk, matching the spec and CI. No documentation references either
flag, so nothing else needs updating. Can be reintroduced later if a
real local-iteration speed knob is needed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The two developer docs in `documentation/dev/` had grown into long
unbroken paragraphs and oversized bullet blocks that were hard to scan,
and neither had a table of contents. This restructures both for
readability **without changing any information** — no facts, paths, rules,
or `§N.M` cross-references were added, removed, or reworded in meaning.

Constraint: the spec's numbered headings (`## N.` / `### N.M`) are
referenced ~120 times from code comments and skills
(`api-diff.cake`, `api-diff-tools.cake`, `generate-release-notes.py`,
the release-notes skill scripts), so renumbering would break those
references. Verified the numbered-heading set is byte-identical to the
pre-edit baseline, all new subheads are unnumbered `####`, and every
code `§N.M` reference still resolves.

docs-overview.md (the entry-point map):
  * add a "Start here — what are you trying to do?" chooser table and a
    clickable Contents TOC
  * convert the CI key-points and local/Docker bullet blocks to tables

release-notes-and-api-diffs.md (the 820-line behavior spec):
  * add a Contents TOC (plain `§`-text, non-clickable, to avoid fragile
    auto-anchors on backtick/em-dash headings)
  * break the worst walls of text into unnumbered `####` subheads, lists,
    and tables — §1.5 (two families), §3.5/§3.6 (ownership, co-release
    map), §4.4 (API-diff link rule), §5.2 (deterministic reference
    resolution), §5.4 (package cache), §7.2 invariant 9

Reviewed by GPT-5.5 for information loss; the three clauses it flagged in
docs-overview.md (a dropped "editing API docs" item, an overstated deps
hint, a "lives in" → "runs in" wording shift) were restored.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mattleibow
mattleibow merged commit eb251ab into main Jun 22, 2026
3 of 4 checks passed
@mattleibow
mattleibow deleted the mattleibow/api-diff-windows-runner branch June 22, 2026 20:07
@github-actions

Copy link
Copy Markdown
Contributor

The backport to release/4.148.x failed:

The process '/usr/bin/git' failed with exit code 1

To backport manually, run these commands in your terminal:

# Fetch latest updates from GitHub
git fetch
# Create a new working tree
git worktree add .worktrees/backport-release/4.148.x release/4.148.x
# Navigate to the new working tree
cd .worktrees/backport-release/4.148.x
# Create a new branch
git switch --create backport/pr-4200-to-release/4.148.x
# Cherry-pick the merged commit of this pull request and resolve the conflicts
git cherry-pick -x --mainline 1 eb251ab24c96a32418b39db3c327b836d5c268f3
# Push it to GitHub
git push --set-upstream origin backport/pr-4200-to-release/4.148.x
# Go back to the original working tree
cd ../..
# Delete the working tree
git worktree remove .worktrees/backport-release/4.148.x

Then, create a pull request where the base branch is release/4.148.x and the compare/head branch is backport/pr-4200-to-release/4.148.x.

mattleibow added a commit to mono/SkiaSharp-API-docs that referenced this pull request Jun 23, 2026
[CI] Run API-docs stub regeneration on Linux via Mono (#147)

Two related changes to the auto-api-docs-writer workflow: flip the
regenerate-stubs job to Linux, and stop the writer from running on pull
requests.

~~ Move regenerate-stubs from Windows to Linux ~~

This job was the last non-Linux job across the SkiaSharp + SkiaSharp-API-docs
doc pipeline; with it flipped, the entire two-repo pipeline runs on Linux,
matching the local Docker image and the SkiaSharp-side CI.

The job only used Windows because mdoc.exe is a .NET Framework tool, but that
requirement no longer holds:

  * mdoc.exe runs fine under Mono, and SkiaSharp's docs.cake already invokes it
    via `mono`.
  * The managed GTK# reference assemblies mdoc needs come from NuGet via the
    cake comparer (passed as --lib paths), so no system GTK# install is
    required — the Windows GTK# 2 MSI download/install was redundant.

So Mono (mono-complete via apt) is the only added dependency. The job now calls
the shared scripts/infra/docs/generate-api-docs.sh entry point instead of
`dotnet cake --target=update-docs`, uses global.json for the SDK and Linux-style
nuget cache paths, and drops the now-redundant `dotnet tool restore` and
docs-download-output steps.

Proven locally end-to-end on Linux (Docker, dotnet10 + mono-complete) against
this repo's stub tree: it correctly pruned the orphaned net6-only type files
(ActionHelper, FloatFloatActionHelper) and added the newer [Nullable]
attributes, and two consecutive runs produced byte-identical output
(hash ec15190a) — so the job is both correct and idempotent on Linux.

Prerequisite: the companion mono/SkiaSharp#4200, which adds
scripts/infra/docs/generate-api-docs.sh, has merged to main, so the checked-out
SkiaSharp tree now has the script.

~~ Drop the pull_request trigger ~~

The writer is a full agentic workflow: it regenerates stubs, fills placeholders
with AI, and opens a PR via gh-aw safe-outputs. When a PR edits this workflow,
the writer fires on that PR and then fails, because safe-outputs refuses to
create a PR that touches protected workflow files
(protect_top_level_dot_folders: true) — surfacing as a red safe_outputs check on
this very PR. Removing the pull_request trigger stops the writer running on PRs;
push:[main] still validates workflow edits after they land, and
workflow_dispatch + schedule are unaffected.

Recompiled .lock.yml via `gh aw compile` for both commits.

Supersedes #144, which gh-aw's recreate_ref:true auto-closed when a run was
dispatched on its branch; moved to the dedicated ci/linux-mdoc-regenerate-stubs
branch so the agentic placeholder-fill PRs reusing dev/linux-mdoc-regenerate-stubs
(e.g. #145) can't collide with it.

Co-authored-by: Matthew Leibowitz <mattleibow@live.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mattleibow mattleibow added this to the 4.150.0-preview.2 milestone Jun 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant