From 36119da06bdf29d93efc3452ff423cea7c39a9d9 Mon Sep 17 00:00:00 2001 From: Moritz Hartmeier Date: Tue, 22 Sep 2026 18:03:57 +0200 Subject: [PATCH 1/3] fix(release): repoint a frozen version's API links at its own line `docusaurus docs:version` snapshots docs/ verbatim, so a newly frozen version inherits the CURRENT version's convention of linking the UNVERSIONED API reference. It is not the current version any more, and nothing in the bump corrected it. That breaks during a beta window. The frozen version is served at the site root while the beta owns /next/, and both link the same unversioned tree. Whichever publish sets PUBLISH_DOCS_FOR_LATEST last owns that tree, so readers of the RELEASED version silently get the beta's API reference. version-8.5.3 shipped in exactly that state -- docusaurus.config.ts records it as "210 files link docs.scandit.com/data-capture-sdk, 0 versioned". 7.6.14 and 6.28.11 link their own lines only because someone corrected them by hand afterwards, most recently in #443 for 7.6's c_api. minor_beta now rewrites the snapshot's links to its own line, both for data-capture-sdk/ and for stable/c_api/. docs/ is untouched: the current version should keep linking the unversioned tree. The rewrite refuses rather than guessing. Before touching anything it samples real links from the snapshot, applies the rewrite, and checks they resolve -- freezing a version with links to a 404 would be worse than the problem being fixed. Only the link kinds the snapshot actually uses are required, so a version that never mentions the linux C API is not blocked by a missing /X.Y/c_api/. SKIP_API_TARGET_CHECK=1 bypasses it when offline. The check probes the rewritten LINKS, not the constructed prefix. https://docs.scandit.com/8.6/data-capture-sdk/ is a 404 -- there is no index at that level -- while .../8.6/data-capture-sdk/ios/... resolves, so a prefix check would have blocked every bump. Links carrying a `${...}` template expression are skipped, since the framework segment is filled in at render time. Verified by simulating a freeze of the current docs against the live origin: 2,267 links across 214 files rewritten, nothing left pointing at the unversioned tree, docs/ unchanged, and the rewritten URLs return 200. A freeze of an unpublished line (9.9.0) is refused with the snapshot left untouched. Co-Authored-By: Claude Opus 5 --- scripts/update-version.py | 131 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/scripts/update-version.py b/scripts/update-version.py index 2a363b4a25..47ef1aa228 100755 --- a/scripts/update-version.py +++ b/scripts/update-version.py @@ -20,10 +20,13 @@ """ import json +import os import re import shutil import subprocess import sys +import urllib.error +import urllib.request from pathlib import Path from typing import Optional @@ -294,6 +297,131 @@ def update_cross_references(old_version: str, new_version: str) -> None: print(f" Updated {total_changes} references in {files_changed} files") +# The API-reference links a frozen version must own, as (source, target) pairs. +# `{line}` is the version's , which is what publish_platform() in +# data-capture-sdk publishes to. +API_REFERENCE_LINK_REWRITES = ( + ("docs.scandit.com/data-capture-sdk", "docs.scandit.com/{line}/data-capture-sdk"), + ("docs.scandit.com/stable/c_api", "docs.scandit.com/{line}/c_api"), +) + + +def api_reference_line(version: str) -> str: + """The line a version's API reference is published to.""" + match = re.match(r"^(\d+\.\d+)\.\d+", version) + if not match: + raise ValueError(f"cannot derive an API-reference line from {version!r}") + return match.group(1) + + +def sample_api_links(files: list, source: str, limit: int = 3) -> list: + """Distinct real link targets in `files` that use `source`. + + The rewritten links are what gets verified, not the constructed prefix: + https://docs.scandit.com/8.6/data-capture-sdk/ is a 404 because there is no + index at that level, while .../8.6/data-capture-sdk/ios/... resolves. A + prefix check would block every bump. + + Links carrying a `${...}` template expression are skipped - the framework + segment is filled in at render time, so there is no single URL to probe. + """ + pattern = re.compile(r"https://" + re.escape(source) + r"[^)\"'>` \n]*") + found: list[str] = [] + for file_path in files: + for match in pattern.findall(file_path.read_text()): + url = match.split("#", 1)[0] + if "${" in url or url in found: + continue + found.append(url) + if len(found) >= limit: + return found + return found + + +def api_target_published(url: str) -> bool: + """Whether `url` serves anything. + + A network failure raises rather than returning False: "we could not check" + must not be read as "it is missing", which would abort a bump for the wrong + reason. Set SKIP_API_TARGET_CHECK=1 to bypass entirely when offline. + """ + request = urllib.request.Request(url, method="HEAD") + try: + with urllib.request.urlopen(request, timeout=20) as response: + return 200 <= response.status < 400 + except urllib.error.HTTPError as error: + return 200 <= error.code < 400 + except urllib.error.URLError as error: + raise RuntimeError(f"could not reach {url}: {error}") from error + + +def rewrite_api_reference_links(version: str) -> None: + """Point a freshly frozen version's API-reference links at its own line. + + `docusaurus docs:version` snapshots docs/ verbatim, so the new snapshot + inherits the CURRENT version's convention of linking the UNVERSIONED API + reference. It is not the current version any more. + + That matters during a beta window: the frozen version is served at the site + root while the beta owns /next/, and both would link the same unversioned + tree. Whichever publish sets PUBLISH_DOCS_FOR_LATEST last owns that tree, so + readers of the released version silently get the beta's API reference. + + version-8.5.3 shipped in exactly that state - docusaurus.config.ts records + it as "210 files link docs.scandit.com/data-capture-sdk, 0 versioned" - + while 7.6.14 and 6.28.11 link their own lines because someone corrected + them by hand afterwards. + """ + line = api_reference_line(version) + docs_dir = Path(f"versioned_docs/version-{version}") + if not docs_dir.exists(): + raise FileNotFoundError(f"{docs_dir} does not exist") + + files = sorted( + set(docs_dir.rglob("*.md")) | set(docs_dir.rglob("*.mdx")) + ) + + # Only the links this snapshot actually uses have to resolve. A version that + # never mentions the linux C API must not be blocked by /X.Y/c_api/. + needed = [ + (source, target.format(line=line)) + for source, target in API_REFERENCE_LINK_REWRITES + if any(source in f.read_text() for f in files) + ] + if not needed: + print(" No API-reference links to rewrite") + return + + if os.environ.get("SKIP_API_TARGET_CHECK") != "1": + for source, target in needed: + for url in sample_api_links(files, source): + rewritten = url.replace(f"https://{source}", f"https://{target}") + if not api_target_published(rewritten): + raise RuntimeError( + f"{version} links the API reference, but {rewritten} does " + f"not resolve. Rewriting would freeze links to a 404. " + f"Publish that line first, or copy it from the newest " + f"patch folder as /7.6/c_api/ was. " + f"SKIP_API_TARGET_CHECK=1 bypasses this check." + ) + + total = 0 + touched = 0 + for file_path in files: + content = file_path.read_text() + original = content + for source, target in needed: + total += content.count(source) + content = content.replace(source, target) + if content != original: + file_path.write_text(content) + touched += 1 + + for _source, target in needed: + print(f" -> {target}") + print(f" Rewrote {total} API-reference links in {touched} files") + + def update_versioned_docs(old_version: str, new_version: str) -> None: print(f"Updating versioned docs from {old_version} to {new_version}...") @@ -453,6 +581,9 @@ def main() -> int: current_version = str(registry['current']) print(f"Detected: new minor beta ({current_version} → {new_version_string})") subprocess.run(["npm", "run", "docusaurus", "docs:version", current_version], check=True) + # The snapshot is no longer the current version, so it must stop + # linking the unversioned API reference - see the docstring. + rewrite_api_reference_links(current_version) update_config_for_minor_beta(config_path, current_version, new_version_string) print(f"✓ Updated from {current_version} to {new_version_string} (beta)") From b8477b485e91cefe7f4c0756f872ecf268aaedf0 Mon Sep 17 00:00:00 2001 From: Moritz Hartmeier Date: Tue, 22 Sep 2026 21:56:20 +0200 Subject: [PATCH 2/3] fix(release): drop the target check from the API-link rewrite The check verified that /data-capture-sdk/ resolved before rewriting a frozen snapshot's links to it. That is wrong in principle, not just weak: the bump runs well BEFORE the release, so the release notes and the snapshot can be reviewed while there is still time to change them. The API reference for that line is published by the release pipeline, which has not run yet -- so the check would have refused every bump. The links are correct by construction and unresolvable by definition at the moment they are written. Verifying them belongs after the release publishes, not in the bump. Removes sample_api_links(), api_target_published(), SKIP_API_TARGET_CHECK and the urllib import. The rewrite itself is unchanged and no longer touches the network. Re-verified offline: a simulated freeze of the current docs rewrites 2,267 links across 214 files, leaves nothing pointing at the unversioned tree, leaves docs/ unchanged, and a version whose line does not exist yet (9.9.0) is rewritten rather than refused, which is the whole point. Co-Authored-By: Claude Opus 5 --- scripts/update-version.py | 87 +++++++-------------------------------- 1 file changed, 16 insertions(+), 71 deletions(-) diff --git a/scripts/update-version.py b/scripts/update-version.py index 47ef1aa228..d2a13fb15b 100755 --- a/scripts/update-version.py +++ b/scripts/update-version.py @@ -20,13 +20,10 @@ """ import json -import os import re import shutil import subprocess import sys -import urllib.error -import urllib.request from pathlib import Path from typing import Optional @@ -314,47 +311,6 @@ def api_reference_line(version: str) -> str: return match.group(1) -def sample_api_links(files: list, source: str, limit: int = 3) -> list: - """Distinct real link targets in `files` that use `source`. - - The rewritten links are what gets verified, not the constructed prefix: - https://docs.scandit.com/8.6/data-capture-sdk/ is a 404 because there is no - index at that level, while .../8.6/data-capture-sdk/ios/... resolves. A - prefix check would block every bump. - - Links carrying a `${...}` template expression are skipped - the framework - segment is filled in at render time, so there is no single URL to probe. - """ - pattern = re.compile(r"https://" + re.escape(source) + r"[^)\"'>` \n]*") - found: list[str] = [] - for file_path in files: - for match in pattern.findall(file_path.read_text()): - url = match.split("#", 1)[0] - if "${" in url or url in found: - continue - found.append(url) - if len(found) >= limit: - return found - return found - - -def api_target_published(url: str) -> bool: - """Whether `url` serves anything. - - A network failure raises rather than returning False: "we could not check" - must not be read as "it is missing", which would abort a bump for the wrong - reason. Set SKIP_API_TARGET_CHECK=1 to bypass entirely when offline. - """ - request = urllib.request.Request(url, method="HEAD") - try: - with urllib.request.urlopen(request, timeout=20) as response: - return 200 <= response.status < 400 - except urllib.error.HTTPError as error: - return 200 <= error.code < 400 - except urllib.error.URLError as error: - raise RuntimeError(f"could not reach {url}: {error}") from error - - def rewrite_api_reference_links(version: str) -> None: """Point a freshly frozen version's API-reference links at its own line. @@ -371,53 +327,42 @@ def rewrite_api_reference_links(version: str) -> None: it as "210 files link docs.scandit.com/data-capture-sdk, 0 versioned" - while 7.6.14 and 6.28.11 link their own lines because someone corrected them by hand afterwards. + + NO CHECK THAT THE TARGET RESOLVES, deliberately. The bump runs well BEFORE + the release, so that people can review the snapshot and the release notes + while there is still time to change them. publish_platform() in + data-capture-sdk writes /data-capture-sdk/ and + /c_api/ from the release pipeline, which has not run yet, so + the links this writes are correct by construction and unresolvable by + definition at the moment they are written. Verifying them belongs after the + release publishes, not here. """ line = api_reference_line(version) docs_dir = Path(f"versioned_docs/version-{version}") if not docs_dir.exists(): raise FileNotFoundError(f"{docs_dir} does not exist") - files = sorted( - set(docs_dir.rglob("*.md")) | set(docs_dir.rglob("*.mdx")) - ) - - # Only the links this snapshot actually uses have to resolve. A version that - # never mentions the linux C API must not be blocked by /X.Y/c_api/. - needed = [ + rewrites = [ (source, target.format(line=line)) for source, target in API_REFERENCE_LINK_REWRITES - if any(source in f.read_text() for f in files) ] - if not needed: - print(" No API-reference links to rewrite") - return - - if os.environ.get("SKIP_API_TARGET_CHECK") != "1": - for source, target in needed: - for url in sample_api_links(files, source): - rewritten = url.replace(f"https://{source}", f"https://{target}") - if not api_target_published(rewritten): - raise RuntimeError( - f"{version} links the API reference, but {rewritten} does " - f"not resolve. Rewriting would freeze links to a 404. " - f"Publish that line first, or copy it from the newest " - f"patch folder as /7.6/c_api/ was. " - f"SKIP_API_TARGET_CHECK=1 bypasses this check." - ) total = 0 touched = 0 - for file_path in files: + for file_path in sorted(set(docs_dir.rglob("*.md")) | set(docs_dir.rglob("*.mdx"))): content = file_path.read_text() original = content - for source, target in needed: + for source, target in rewrites: total += content.count(source) content = content.replace(source, target) if content != original: file_path.write_text(content) touched += 1 - for _source, target in needed: + if total == 0: + print(" No API-reference links to rewrite") + return + for _source, target in rewrites: print(f" -> {target}") print(f" Rewrote {total} API-reference links in {touched} files") From 912677aba6bf7ce3bcb0b1a98c62ecd167a008f2 Mon Sep 17 00:00:00 2001 From: Moritz Hartmeier Date: Thu, 24 Sep 2026 14:36:36 +0200 Subject: [PATCH 3/3] fix(release): read and write the snapshot as UTF-8, and stop on a zero-match Two problems in the rewrite, both reported in review. read_text()/write_text() had no encoding, so they used the locale default. 57 of the ~676 files under docs/ are not decodable as cp1252, so on a Windows checkout this raised part-way through the walk -- AFTER `docs:version` had already written versioned_docs/, versioned_sidebars/ and versions.json, and with files earlier in sorted() order already rewritten. main()'s catch-all printed "Unexpected error", leaving a half-frozen repo that cannot simply be re-run, since the version now exists. Both calls are explicit UTF-8 now. A zero-match logged "No API-reference links to rewrite" and continued. That is the wrong direction: every version so far carries hundreds of these links, so zero means the convention moved and the patterns no longer match -- in which case the snapshot silently keeps linking the unversioned tree, which is the 8.5.3 bug this change exists to prevent. It now raises, naming the patterns that found nothing. Re-verified: a simulated freeze still rewrites 2,267 links across 214 files, runs clean under LC_ALL=C, and a snapshot with no API links raises instead of passing silently. Reported by @eugenia-scandit. Co-Authored-By: Claude Opus 5 --- scripts/update-version.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/scripts/update-version.py b/scripts/update-version.py index d2a13fb15b..469a3edf8f 100755 --- a/scripts/update-version.py +++ b/scripts/update-version.py @@ -347,21 +347,39 @@ def rewrite_api_reference_links(version: str) -> None: for source, target in API_REFERENCE_LINK_REWRITES ] + # encoding is explicit on both sides. read_text() with no encoding uses the + # locale default, and 57 of the ~676 files under docs/ are not decodable as + # cp1252 - so on a Windows checkout this raised mid-walk, AFTER + # `docs:version` had already written versioned_docs/, versioned_sidebars/ + # and versions.json, and with files earlier in sorted() order already + # rewritten. The catch-all in main() printed "Unexpected error" and left a + # half-frozen repo that cannot simply be re-run. total = 0 touched = 0 for file_path in sorted(set(docs_dir.rglob("*.md")) | set(docs_dir.rglob("*.mdx"))): - content = file_path.read_text() + content = file_path.read_text(encoding="utf-8") original = content for source, target in rewrites: total += content.count(source) content = content.replace(source, target) if content != original: - file_path.write_text(content) + file_path.write_text(content, encoding="utf-8") touched += 1 if total == 0: - print(" No API-reference links to rewrite") - return + # Stop rather than log and carry on. Every version so far carries + # hundreds of these links, so zero means the convention moved and these + # patterns no longer match - in which case the snapshot silently keeps + # linking the unversioned tree, which is exactly the 8.5.3 bug this + # exists to prevent. A version that genuinely has none needs this guard + # revisited, not bypassed. + raise RuntimeError( + f"{docs_dir} contains no API-reference links matching " + f"{[source for source, _ in rewrites]}. Either the snapshot is not " + f"what it should be, or the link convention changed and " + f"API_REFERENCE_LINK_REWRITES needs updating." + ) + for _source, target in rewrites: print(f" -> {target}") print(f" Rewrote {total} API-reference links in {touched} files")