From cbeaf74f6b2aea9e831abf886312d91cfd0ad03a Mon Sep 17 00:00:00 2001 From: Mike Odnis Date: Tue, 22 Sep 2026 04:54:45 -0400 Subject: [PATCH] fix(api-docs/dotnet): read package versions from MSBuild, not a csproj regex The .NET template scraped / out of the .csproj files to label each package in the generated API index. That only works for repos that commit a literal version string. A repo that derives its version from git tags carries no such literal on purpose -- a literal in the tree is something an inbound sync can revert -- so the scrape fell through to its "unknown" default and the index rendered `vunknown` for every package. Ask MSBuild instead, which is authoritative for both shapes: it reports the literal where one exists and the tag-derived value where it does not. The .csproj scan is kept behind it as a fallback for when the query is unavailable (no -getProperty before SDK 8.0.200) or the project cannot be evaluated, so nothing regresses for repos that still use a literal. Two details that the obvious version of this fix gets wrong: - `-getProperty:Version` on its own only *evaluates* the project, and a tag-derived version is computed inside a target. A bare query returns the SDK's implicit 1.0.0. The query runs `-t:GetAssemblyVersion`, a stock SDK target whose job is to settle the version. It compiles nothing -- the solution is already built by an earlier step -- and costs ~0.5s per project, ~3s for the six. - An MSBuild answer of exactly 1.0.0 is indistinguishable from the SDK default, so it is treated as inconclusive and the literal scan still runs. That preserves the existing multi-candidate behaviour, where a version lives only in the packaging variant of a duplicated .csproj. Also set fetch-depth: 0 on the source checkout. Tag-derived versioning reads the tag graph at build time, and under the default shallow, tagless checkout it does not fail -- it silently settles on 0.0.0-alpha.. Without this the index would render `v0.0.0-alpha.0` instead of `vunknown`, which is worse: a plausible-looking wrong version. The existing persist-credentials: false is unchanged. Verified against a full-history clone of the .NET SDK repo: the old logic yields "unknown" for all six packages, the new logic yields the real MinVer version (0.6.1-alpha.0.42 at HEAD, 0.7.0 with a release tag applied), and the same tree at depth 1 yields 0.0.0-alpha.0. A literal-version repo returns its literal unchanged under both the old and new logic. --- .../source-repo-templates/api-docs.dotnet.yml | 97 ++++++++++++++++--- 1 file changed, 86 insertions(+), 11 deletions(-) diff --git a/automation/source-repo-templates/api-docs.dotnet.yml b/automation/source-repo-templates/api-docs.dotnet.yml index da7da875..5841c32b 100644 --- a/automation/source-repo-templates/api-docs.dotnet.yml +++ b/automation/source-repo-templates/api-docs.dotnet.yml @@ -90,6 +90,15 @@ jobs: with: ref: ${{ inputs.ref || github.ref }} persist-credentials: false + # Full history AND tags. Repos that derive their package + # version from git tags (MinVer et al.) read the tag graph + # at build time. Under the default shallow, tagless + # checkout they do not fail -- they silently settle on a + # 0.0.0-alpha. placeholder, which would then be + # published as the version of every documented package. + # Verified: the same tree at depth 1 resolves 0.0.0-alpha.0 + # where full history resolves the real tag version. + fetch-depth: 0 - name: Setup .NET uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0 @@ -181,20 +190,80 @@ jobs: # .mdx). Users navigate to per-package pages via the URL bar # or future programmatic _pages.json nav splice. # - # Version next to each project is read from / - # in the .csproj. Falls back to "unknown" so - # missing tags don't break the doc build. + # Version next to each project comes from MSBuild, which is + # the only source that is right for every repo this template + # is synced into. Scraping / out of + # the .csproj -- what this step used to do -- only works for + # repos that commit a literal version string. Repos that + # derive the version from git tags (MinVer et al.) carry no + # such literal by design, precisely because a literal in the + # tree is something an inbound sync can revert, so the scrape + # returned "unknown" for every package there. + # + # Asking MSBuild covers both: it reports the literal where + # one exists and the tag-derived value where it does not. The + # .csproj scan is kept as a fallback for the case where the + # MSBuild query is unavailable (an SDK older than 8.0.200 has + # no -getProperty) or the project cannot be evaluated. run: | python3 - <<'PY' > "$OUTPUT_DIR/README.mdx" import os import pathlib import re + import subprocess ref_name = os.environ.get("DOCS_REF_NAME", "main") repo = os.environ.get("GITHUB_REPOSITORY", "") output_dir = pathlib.Path(os.environ["OUTPUT_DIR"]) projects = (os.environ.get("PUBLIC_PROJECTS") or "").split() + # The version the SDK implies when the project sets none. It + # is reported like any real version, so it cannot be told + # apart from a deliberate 1.0.0 by its value alone. Treat it + # as inconclusive and keep looking; if some .csproj really + # does declare 1.0.0, the literal scan below returns it. + SDK_DEFAULT_VERSION = "1.0.0" + VERSION_RE = re.compile(r"\A\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*\Z") + + def msbuild_version(csproj: pathlib.Path) -> str: + # -getProperty alone only evaluates the project, and a + # tag-derived version is computed inside a target, not at + # evaluation -- so a bare query returns the SDK default. + # GetAssemblyVersion is a stock SDK target whose job is to + # settle the version, and it is one of the targets MinVer + # hooks. It compiles nothing: the solution was already + # built above, so this costs well under a second per + # project. + try: + proc = subprocess.run( + [ + "dotnet", "msbuild", str(csproj), + "-t:GetAssemblyVersion", + "-getProperty:Version", + "-p:Configuration=Release", + "-nologo", + ], + capture_output=True, + text=True, + timeout=180, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return "" + if proc.returncode != 0: + return "" + lines = [ln.strip() for ln in proc.stdout.splitlines() if ln.strip()] + value = lines[-1] if lines else "" + return value if VERSION_RE.match(value) else "" + + def literal_version(csproj: pathlib.Path) -> str: + text = csproj.read_text(encoding="utf-8") + for tag in ("Version", "VersionPrefix"): + m = re.search(rf"<{tag}>([^<]+)", text) + if m: + return m.group(1).strip() + return "" + def read_version(proj: str) -> str: # .csproj files are usually /.csproj # but DefaultDocumentation expects the assembly name as the @@ -205,15 +274,21 @@ jobs: # variant has . Scan all candidates and return # the first one that yields a real version, so we don't # show "unknown" just because the first match happens - # to be the bare assembly project. - candidates = list(pathlib.Path(".").rglob(f"{proj}.csproj")) + # to be the bare assembly project. Sorted for a stable + # answer across runners. + candidates = sorted(pathlib.Path(".").rglob(f"{proj}.csproj")) + sdk_default = "" + for cand in candidates: + value = msbuild_version(cand) + if value and value != SDK_DEFAULT_VERSION: + return value + if value: + sdk_default = value for cand in candidates: - text = cand.read_text(encoding="utf-8") - for tag in ("Version", "VersionPrefix"): - m = re.search(rf"<{tag}>([^<]+)", text) - if m: - return m.group(1).strip() - return "unknown" + value = literal_version(cand) + if value: + return value + return sdk_default or "unknown" print("# ResQ .NET SDK") print()