This is an automated email from the ASF dual-hosted git repository.

imbajin pushed a commit to branch cx-oink-1-migration
in repository https://gitbox.apache.org/repos/asf/hugegraph-doc.git

commit 1c6bc5b58bad650244c081648ce34a3ba3b94772
Author: imbajin <[email protected]>
AuthorDate: Mon Aug 31 07:04:54 2026 +0800

    feat(site): add versioned OINK builds
    
    - resolve latest, 1.7, and 1.5 to exact commit SHAs
    - build isolated production and staging artifacts
    - validate URL, language, metadata, and license contracts
    - aggregate reviewed outputs before staging or production publish
---
 .github/workflows/hugo.yml |  212 +++++-
 dist/url-contract.json     |   36 +
 layouts/404.html           |   10 -
 scripts/test_versioning.py |  208 ++++++
 scripts/versioning.py      | 1721 ++++++++++++++++++++++++++++++++++++++++++++
 versions.json              |   30 +
 6 files changed, 2169 insertions(+), 48 deletions(-)

diff --git a/.github/workflows/hugo.yml b/.github/workflows/hugo.yml
index 638825c29..bad8ca95f 100644
--- a/.github/workflows/hugo.yml
+++ b/.github/workflows/hugo.yml
@@ -15,18 +15,69 @@ env:
   HUGO_CACHEDIR: /tmp/hugo_cache
 
 jobs:
-  # Keep this job id aligned with the required `deploy` status in .asf.yaml.
-  # Publishing is added only after the aggregate artifact is complete.
-  deploy:
+  prepare:
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+    outputs:
+      versions: ${{ steps.matrix.outputs.versions }}
+    steps:
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+        with:
+          fetch-depth: 0
+          persist-credentials: false
+
+      - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # 
v6
+        with:
+          python-version: "3.13"
+
+      - name: Validate source and version tooling
+        run: |
+          bash dist/validate-links.sh
+          python3 -m unittest discover -s scripts -p 'test_*.py' -v
+
+      - name: Resolve immutable version matrix
+        id: matrix
+        env:
+          LATEST_SHA: ${{ github.sha }}
+        run: |
+          python3 scripts/versioning.py prepare \
+            --latest-sha "$LATEST_SHA" \
+            --output resolved-versions.json
+          echo "versions=$(jq -c '.include' resolved-versions.json)" >> 
"$GITHUB_OUTPUT"
+
+      - name: Upload resolved version manifest
+        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a 
# v7
+        with:
+          name: resolved-versions-${{ github.sha }}
+          path: resolved-versions.json
+          if-no-files-found: error
+
+  build:
+    needs: prepare
     runs-on: ubuntu-latest
     permissions:
       contents: read
+    strategy:
+      fail-fast: false
+      matrix:
+        version: ${{ fromJSON(needs.prepare.outputs.versions) }}
+        site:
+          - name: production
+            origin: https://hugegraph.apache.org/
+          - name: staging
+            origin: https://hugegraph-oink.staged.apache.org/
+    name: Build ${{ matrix.site.name }} / ${{ matrix.version.id }}
     steps:
       - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
         with:
           fetch-depth: 0
           persist-credentials: false
 
+      - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # 
v6
+        with:
+          python-version: "3.13"
+
       - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
         with:
           go-version-file: go.mod
@@ -42,47 +93,132 @@ jobs:
         uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
         with:
           path: ${{ env.HUGO_CACHEDIR }}
-          key: ${{ runner.os }}-hugo-${{ env.HUGO_VERSION }}-${{ 
hashFiles('go.sum') }}
+          key: ${{ runner.os }}-hugo-${{ env.HUGO_VERSION }}-${{ 
matrix.version.id }}-${{ hashFiles('go.sum') }}
           restore-keys: |
-            ${{ runner.os }}-hugo-${{ env.HUGO_VERSION }}-
+            ${{ runner.os }}-hugo-${{ env.HUGO_VERSION }}-${{ 
matrix.version.id }}-
 
       - name: Verify pinned OINK module
         run: test "$(hugo mod graph)" = "github.com/apache/hugegraph-doc 
github.com/pgsty/[email protected]"
 
-      - name: Check source links
-        run: bash dist/validate-links.sh
-
-      - name: Build strict production artifact
-        run: >-
-          hugo --cleanDestinationDir --gc --minify
-          --environment production
-          --printPathWarnings --printI18nWarnings
-          --panicOnWarning --logLevel info
-
-      - name: Validate production artifact
-        run: python3 dist/validate-site-output.py public 
https://hugegraph.apache.org/
-
-      - name: Build and validate staging URL shape
-        run: >-
-          hugo --cleanDestinationDir --gc --minify
-          --destination public-staging
-          --baseURL https://hugegraph-oink.staged.apache.org/
-          --environment production
-          --printPathWarnings --printI18nWarnings
-          --panicOnWarning --logLevel info
-
-      - name: Validate staging artifact
-        run: >-
-          python3 dist/validate-site-output.py public-staging
-          https://hugegraph-oink.staged.apache.org/
-
-      - name: Add ASF deployment metadata
-        run: cp .asf.yaml public/.asf.yaml
-
-      - name: Upload site artifact
+      - name: Build isolated version artifact
+        env:
+          OINK_PYTHON: python3
+        run: |
+          python3 scripts/versioning.py build \
+            --version "${{ matrix.version.id }}" \
+            --sha "${{ matrix.version.sha }}" \
+            --site-origin "${{ matrix.site.origin }}" \
+            --output "${RUNNER_TEMP}/version-public"
+          python3 scripts/versioning.py validate \
+            --version "${{ matrix.version.id }}" \
+            --sha "${{ matrix.version.sha }}" \
+            --site-origin "${{ matrix.site.origin }}" \
+            --artifact "${RUNNER_TEMP}/version-public"
+
+      - name: Upload isolated version artifact
+        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a 
# v7
+        with:
+          name: ${{ matrix.site.name }}-${{ matrix.version.id }}
+          path: ${{ runner.temp }}/version-public
+          include-hidden-files: true
+          if-no-files-found: error
+
+  # Keep this job id aligned with the required `deploy` status in .asf.yaml.
+  # It produces deployable artifacts but does not publish untrusted PR code.
+  deploy:
+    if: always()
+    needs: [prepare, build]
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+    steps:
+      - name: Require every version build to succeed
+        env:
+          PREPARE_RESULT: ${{ needs.prepare.result }}
+          BUILD_RESULT: ${{ needs.build.result }}
+        run: |
+          test "$PREPARE_RESULT" = success
+          test "$BUILD_RESULT" = success
+
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+        with:
+          fetch-depth: 0
+          persist-credentials: false
+
+      - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # 
v6
+        with:
+          python-version: "3.13"
+
+      - name: Download resolved version manifest
+        uses: 
actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+        with:
+          name: resolved-versions-${{ github.sha }}
+          path: resolved
+
+      - name: Download production versions
+        uses: 
actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+        with:
+          pattern: production-*
+          path: version-artifacts
+
+      - name: Download staging versions
+        uses: 
actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+        with:
+          pattern: staging-*
+          path: version-artifacts
+
+      - name: Aggregate production and staging sites
+        run: |
+          python3 scripts/versioning.py aggregate \
+            --artifacts version-artifacts \
+            --artifact-prefix production- \
+            --resolved-manifest resolved/resolved-versions.json \
+            --site-origin https://hugegraph.apache.org/ \
+            --output "${RUNNER_TEMP}/public-production"
+          python3 scripts/versioning.py aggregate \
+            --artifacts version-artifacts \
+            --artifact-prefix staging- \
+            --resolved-manifest resolved/resolved-versions.json \
+            --site-origin https://hugegraph-oink.staged.apache.org/ \
+            --asf-profile oink \
+            --asf-whoami asf-staging-oink \
+            --output "${RUNNER_TEMP}/public-staging"
+
+      - name: Upload production aggregate
+        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a 
# v7
+        with:
+          name: hugegraph-site-production-${{ github.sha }}
+          path: ${{ runner.temp }}/public-production
+          include-hidden-files: true
+          if-no-files-found: error
+
+      - name: Upload ASF staging aggregate
         uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a 
# v7
         with:
-          name: hugegraph-site-${{ github.sha }}
-          path: public
+          name: hugegraph-site-staging-${{ github.sha }}
+          path: ${{ runner.temp }}/public-staging
           include-hidden-files: true
           if-no-files-found: error
+
+  publish:
+    if: github.event_name == 'push' && github.ref == 'refs/heads/master'
+    needs: deploy
+    runs-on: ubuntu-latest
+    permissions:
+      contents: write
+    steps:
+      - name: Download reviewed production aggregate
+        uses: 
actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
+        with:
+          name: hugegraph-site-production-${{ github.sha }}
+          path: public-production
+
+      - name: Publish clean aggregate to asf-site
+        uses: 
peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0
+        with:
+          github_token: ${{ secrets.GITHUB_TOKEN }}
+          publish_dir: ./public-production
+          publish_branch: asf-site
+          keep_files: false
+          force_orphan: false
+          commit_message: ${{ github.event.head_commit.message }}
diff --git a/dist/url-contract.json b/dist/url-contract.json
new file mode 100644
index 000000000..b7b568d13
--- /dev/null
+++ b/dist/url-contract.json
@@ -0,0 +1,36 @@
+{
+  "schemaVersion": 1,
+  "baseline": {
+    "origin": "https://hugegraph.apache.org/";,
+    "verifiedAt": "2026-08-31",
+    "defaultStatus": 200,
+    "stagingOrigin": "https://hugegraph-oink.staged.apache.org/";,
+    "stagingStatusBeforePublish": 404
+  },
+  "routes": [
+    {"path": "/", "versions": ["latest", "1.7", "1.5"], "file": "index.html", 
"contains": ["Apache HugeGraph"]},
+    {"path": "/cn/", "versions": ["latest", "1.7", "1.5"], "file": 
"cn/index.html", "contains": ["Apache HugeGraph"]},
+    {"path": "/docs/", "versions": ["latest", "1.7", "1.5"], "file": 
"docs/index.html", "contains": ["Documentation"]},
+    {"path": "/cn/docs/", "versions": ["latest", "1.7", "1.5"], "file": 
"cn/docs/index.html", "contains": ["Documentation"]},
+    {"path": "/blog/", "versions": ["latest"], "file": "blog/index.html", 
"contains": ["Blog"]},
+    {"path": "/cn/blog/", "versions": ["latest"], "file": 
"cn/blog/index.html", "contains": ["Blog"]},
+    {"path": "/about/", "versions": ["latest"], "file": "about/index.html", 
"contains": ["Apache HugeGraph"]},
+    {"path": "/cn/about/", "versions": ["latest"], "file": 
"cn/about/index.html", "contains": ["Apache HugeGraph"]},
+    {"path": "/community/", "versions": ["latest"], "file": 
"community/index.html", "contains": ["Community"]},
+    {"path": "/cn/community/", "versions": ["latest"], "file": 
"cn/community/index.html", "contains": ["社区"]},
+    {"path": "/docs/download/download/", "versions": ["latest", "1.7", "1.5"], 
"file": "docs/download/download/index.html", "contains": ["Download"]},
+    {"path": "/cn/docs/download/download/", "versions": ["latest", "1.7", 
"1.5"], "file": "cn/docs/download/download/index.html", "contains": ["下载"]},
+    {"path": "/docs/quickstart/hugegraph/hugegraph-server/", "versions": 
["latest", "1.7", "1.5"], "file": 
"docs/quickstart/hugegraph/hugegraph-server/index.html", "contains": 
["HugeGraph-Server"]},
+    {"path": "/cn/docs/quickstart/hugegraph/hugegraph-server/", "versions": 
["latest", "1.7", "1.5"], "file": 
"cn/docs/quickstart/hugegraph/hugegraph-server/index.html", "contains": 
["HugeGraph-Server"]},
+    {"path": "/docs/config/config-guide/", "versions": ["latest", "1.7", 
"1.5"], "file": "docs/config/config-guide/index.html", "contains": 
["GremlinServer"]},
+    {"path": "/cn/docs/config/config-guide/", "versions": ["latest", "1.7", 
"1.5"], "file": "cn/docs/config/config-guide/index.html", "contains": 
["GremlinServer"]},
+    {"path": "/docs/clients/restful-api/graphs/", "versions": ["latest", 
"1.7", "1.5"], "file": "docs/clients/restful-api/graphs/index.html", 
"contains": ["REST"]},
+    {"path": "/cn/docs/clients/restful-api/graphs/", "versions": ["latest", 
"1.7", "1.5"], "file": "cn/docs/clients/restful-api/graphs/index.html", 
"contains": ["REST"]},
+    {"path": "/docs/guides/architectural/", "versions": ["latest", "1.7", 
"1.5"], "file": "docs/guides/architectural/index.html", "contains": 
["HugeGraph"]},
+    {"path": "/cn/docs/guides/architectural/", "versions": ["latest", "1.7", 
"1.5"], "file": "cn/docs/guides/architectural/index.html", "contains": 
["HugeGraph"]},
+    {"path": "/404.html", "versions": ["latest", "1.7", "1.5"], "file": 
"404.html", "contains": ["404"]},
+    {"path": "/cn/404.html", "versions": ["latest", "1.7", "1.5"], "file": 
"cn/404.html", "contains": ["404"]},
+    {"path": "/client-go/", "versions": ["latest"], "file": 
"client-go/index.html", "contains": ["go-import", "go-source"]},
+    {"path": "/icons/logo.svg", "versions": ["latest", "1.7", "1.5"], "file": 
"icons/logo.svg", "contains": ["<svg"], "baselineStatus": 404, 
"migrationStatus": 200}
+  ]
+}
diff --git a/layouts/404.html b/layouts/404.html
deleted file mode 100644
index 378b73675..000000000
--- a/layouts/404.html
+++ /dev/null
@@ -1,10 +0,0 @@
-{{ define "main"}}
-    <main id="main">
-      <div>
-       <h1 id="title">Not found</h1>
-       <p>Oops! This page doesn't exist. Try going back to our <a href="{{ "/" 
| relURL }}">home page</a>.</p>
-
-       <p>You can learn how to make a 404 page like this in <a 
href="https://gohugo.io/templates/404/";>Custom 404 Pages</a>.</p>      
-      </div>
-    </main>
-{{ end }}
diff --git a/scripts/test_versioning.py b/scripts/test_versioning.py
new file mode 100644
index 000000000..9d5d2fb85
--- /dev/null
+++ b/scripts/test_versioning.py
@@ -0,0 +1,208 @@
+#!/usr/bin/env python3
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0.
+
+import argparse
+import json
+import tempfile
+import unittest
+from pathlib import Path
+
+import versioning
+
+
+ORIGIN = "https://hugegraph.apache.org/";
+STAGING_ORIGIN = "https://hugegraph-oink.staged.apache.org/";
+PUBLISH_PATH = "versions/1.7"
+ALLOWED_PATHS = {"/docs", "/versions/1.7/docs", "/versions/1.5/docs"}
+
+
+def rewrite(value: str) -> str:
+    return versioning.rewrite_internal_url(
+        value,
+        origin=ORIGIN,
+        publish_path=PUBLISH_PATH,
+        allowed_paths=ALLOWED_PATHS,
+    )
+
+
+class VersionUrlTest(unittest.TestCase):
+    def test_scopes_root_relative_urls(self) -> None:
+        self.assertEqual(rewrite("/docs/"), "/versions/1.7/docs/")
+        self.assertEqual(
+            rewrite("/cn/docs/config/?mode=all#backend"),
+            "/versions/1.7/cn/docs/config/?mode=all#backend",
+        )
+
+    def test_preserves_exact_absolute_version_destinations(self) -> None:
+        self.assertEqual(rewrite("https://hugegraph.apache.org/docs";), 
f"{ORIGIN}docs")
+        self.assertEqual(
+            rewrite("https://hugegraph.apache.org/versions/1.5/docs/";),
+            "https://hugegraph.apache.org/versions/1.5/docs/";,
+        )
+
+    def test_rewrites_production_origin_for_staging(self) -> None:
+        self.assertEqual(
+            versioning.rewrite_internal_url(
+                "https://hugegraph.apache.org/docs/config/";,
+                origin=STAGING_ORIGIN,
+                publish_path=PUBLISH_PATH,
+                allowed_paths=ALLOWED_PATHS,
+            ),
+            
"https://hugegraph-oink.staged.apache.org/versions/1.7/docs/config/";,
+        )
+        self.assertEqual(
+            versioning.rewrite_internal_url(
+                "https://hugegraph.apache.org/blog/";,
+                origin=STAGING_ORIGIN,
+                publish_path="",
+                allowed_paths=ALLOWED_PATHS,
+            ),
+            "https://hugegraph-oink.staged.apache.org/blog/";,
+        )
+
+    def test_rejects_non_selector_cross_version_url(self) -> None:
+        with self.assertRaises(SystemExit):
+            rewrite("/versions/1.5/docs/config/")
+
+    def test_maps_known_historical_routes(self) -> None:
+        self.assertEqual(
+            
rewrite("https://hugegraph.apache.org/versions/1.7/docs/introduction/";),
+            
"https://hugegraph.apache.org/versions/1.7/docs/introduction/readme/";,
+        )
+        self.assertEqual(
+            rewrite("/docs/quickstart/hugegraph-loader#usage"),
+            "/versions/1.7/docs/quickstart/toolchain/hugegraph-loader/#usage",
+        )
+
+    def test_markdown_rewrite_skips_fenced_code(self) -> None:
+        source = (
+            "[Docs](/docs/)\n"
+            "```html\n"
+            '<a href="/docs/">example</a>\n'
+            "```\n"
+            '<a href="/blog/">Blog</a>\n'
+        )
+        rendered, count = versioning.rewrite_text_urls(source, rewrite, 
markdown=True)
+        self.assertEqual(count, 2)
+        self.assertIn("[Docs](/versions/1.7/docs/)", rendered)
+        self.assertIn('<a href="/docs/">example</a>', rendered)
+        self.assertIn('<a href="https://hugegraph.apache.org/blog/";>Blog</a>', 
rendered)
+
+    def test_rejects_artifact_from_unexpected_sha(self) -> None:
+        expected = {
+            "id": "1.7",
+            "name": "1.7",
+            "ref": "release-1.7.0",
+            "publishPath": "versions/1.7",
+            "archived": True,
+            "githubBranch": "release-1.7.0",
+            "sha": "a" * 40,
+        }
+        actual = dict(expected)
+        actual["sha"] = "b" * 40
+        with self.assertRaises(SystemExit):
+            versioning.require_metadata_matches(expected, actual, 
Path(".version.json"))
+
+    def test_validate_command_rejects_artifact_sha_drift(self) -> None:
+        with tempfile.TemporaryDirectory() as temp_name:
+            artifact = Path(temp_name) / "artifact"
+            artifact.mkdir()
+            entry = json.loads(
+                (versioning.ROOT / "versions.json").read_text(encoding="utf-8")
+            )["versions"][0]
+            metadata = dict(entry)
+            metadata.update({"sha": "b" * 40, "baseURL": ORIGIN})
+            (artifact / ".version.json").write_text(
+                json.dumps(metadata), encoding="utf-8"
+            )
+            args = argparse.Namespace(
+                manifest=versioning.ROOT / "versions.json",
+                version="latest",
+                sha="a" * 40,
+                site_origin=ORIGIN,
+                artifact=artifact,
+            )
+            with self.assertRaises(SystemExit):
+                versioning.validate_artifact(args)
+
+    def test_rejects_active_and_ambiguous_url_schemes(self) -> None:
+        for value in (
+            "javascript:alert(1)",
+            "data:text/html,test",
+            "file:///etc/passwd",
+            "ftp://example.org/file";,
+            "//example.org/path",
+        ):
+            with self.subTest(value=value), self.assertRaises(SystemExit):
+                versioning.require_safe_url_scheme(value, "fixture.html")
+        
self.assertFalse(versioning.require_safe_url_scheme("mailto:[email protected]";, "x"))
+        self.assertFalse(versioning.require_safe_url_scheme("tel:+1", "x"))
+        self.assertTrue(versioning.require_safe_url_scheme("/docs/", "x"))
+
+    def test_rejects_resolved_manifest_drift(self) -> None:
+        with tempfile.TemporaryDirectory() as temp_name:
+            manifest = json.loads(
+                (versioning.ROOT / "versions.json").read_text(encoding="utf-8")
+            )
+            for entry in manifest["versions"]:
+                entry["sha"] = "a" * 40
+            manifest["versions"][1]["name"] = "unexpected"
+            path = Path(temp_name) / "resolved.json"
+            path.write_text(json.dumps(manifest), encoding="utf-8")
+            with self.assertRaises(SystemExit):
+                versioning.load_resolved_manifest(path)
+
+    def test_aggregate_rejects_metadata_sha_before_copy(self) -> None:
+        with tempfile.TemporaryDirectory() as temp_name:
+            temp = Path(temp_name)
+            manifest = json.loads(
+                (versioning.ROOT / "versions.json").read_text(encoding="utf-8")
+            )
+            for entry in manifest["versions"]:
+                entry["sha"] = "a" * 40
+            resolved = temp / "resolved.json"
+            resolved.write_text(json.dumps(manifest), encoding="utf-8")
+            latest = temp / "artifacts/latest"
+            latest.mkdir(parents=True)
+            metadata = dict(manifest["versions"][0])
+            metadata["sha"] = "b" * 40
+            (latest / ".version.json").write_text(
+                json.dumps(metadata), encoding="utf-8"
+            )
+            args = argparse.Namespace(
+                resolved_manifest=resolved,
+                artifacts=temp / "artifacts",
+                artifact_prefix="",
+                site_origin=ORIGIN,
+                output=temp / "aggregate",
+                asf_profile=None,
+                asf_whoami=None,
+            )
+            with self.assertRaises(SystemExit):
+                versioning.aggregate(args)
+
+    def test_output_cleanup_is_limited_to_temporary_descendants(self) -> None:
+        with tempfile.TemporaryDirectory() as temp_name:
+            temp = Path(temp_name)
+            output = temp / "output"
+            output.mkdir()
+            (output / "stale").write_text("stale", encoding="utf-8")
+            self.assertEqual(
+                versioning.prepare_output_directory(output, "fixture"),
+                output.resolve(),
+            )
+            self.assertFalse(output.exists())
+            symlink = temp / "symlink"
+            symlink.symlink_to(temp, target_is_directory=True)
+            with self.assertRaises(SystemExit):
+                versioning.prepare_output_directory(symlink, "fixture")
+        with self.assertRaises(SystemExit):
+            versioning.prepare_output_directory(versioning.ROOT, "fixture")
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/scripts/versioning.py b/scripts/versioning.py
new file mode 100644
index 000000000..f82aecdcb
--- /dev/null
+++ b/scripts/versioning.py
@@ -0,0 +1,1721 @@
+#!/usr/bin/env python3
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Resolve, build, and aggregate centrally rendered HugeGraph versions."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import html
+import html.parser
+import json
+import os
+import pathlib
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+import urllib.parse
+import xml.etree.ElementTree as ET
+from typing import NoReturn
+
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+URL_CONTRACT = ROOT / "dist/url-contract.json"
+CANONICAL_ORIGIN = "https://hugegraph.apache.org/";
+SHELL_FILES = ("go.mod", "go.sum", "hugo.yaml")
+SHELL_DIRS = ("assets", "data", "i18n", "layouts")
+SHELL_CONTENT = (
+    "content/en/_index.md",
+    "content/cn/_index.md",
+    "content/en/about/_index.md",
+    "content/cn/about/_index.md",
+    "content/en/docs/SUMMARY.md",
+    "content/cn/docs/SUMMARY.md",
+)
+MENU_CONTENT = tuple(
+    f"content/{language}/{section}/_index.md"
+    for language in ("en", "cn")
+    for section in ("docs", "blog", "community")
+)
+SHA_RE = re.compile(r"^[0-9a-f]{40}$")
+REPOSITORY_URL = "https://github.com/apache/hugegraph-doc.git";
+VERSION_REFS = {
+    "latest": "master",
+    "1.7": "release-1.7.0",
+    "1.5": "release-1.5.0",
+}
+KNOWN_HISTORICAL_ROUTES = {
+    "/docs/introduction": "/docs/introduction/readme/",
+    "/cn/docs/introduction": "/cn/docs/introduction/readme/",
+    "/docs/quickstart/hugegraph-loader": 
"/docs/quickstart/toolchain/hugegraph-loader/",
+    "/cn/docs/quickstart/hugegraph-loader": 
"/cn/docs/quickstart/toolchain/hugegraph-loader/",
+}
+URL_ATTRIBUTE_RE = re.compile(
+    
r"(?P<prefix>[\s<](?:href|src|action|poster|data-td-index-src|data-td-url|data-td-image-zoom)=)"
+    r"(?P<quote>[\"']?)(?P<url>[^\s\"'<>`]+)(?P=quote)",
+    re.IGNORECASE,
+)
+ACTION_MANIFEST_RE = re.compile(
+    r"(?P<open><script\b[^>]*\bid=[\"']?td-action-manifest[\"']?[^>]*>)"
+    r"(?P<body>.*?)"
+    r"(?P<close></script>)",
+    re.IGNORECASE | re.DOTALL,
+)
+MARKDOWN_DESTINATION_RE = re.compile(
+    
r"(?P<open>\]\(\s*<?)(?P<url>(?:https?://[^\s)>]+|/[^\s)>]+))(?P<close>>?[^)]*\))"
+)
+HREFLANG_FALLBACKS = {
+    "cn/docs/changelog/hugegraph-0.12.0-release-notes/index.html": {"en-US": 
"/"},
+    "community/maturity/index.html": {"zh-CN": "/cn/"},
+}
+
+
+class DocumentParser(html.parser.HTMLParser):
+    def __init__(self) -> None:
+        super().__init__(convert_charrefs=True)
+        self.urls: list[tuple[str, str]] = []
+        self.canonical: list[str] = []
+        self.hreflang: list[tuple[str, str]] = []
+        self.meta: list[dict[str, str]] = []
+
+    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) 
-> None:
+        values = {key.lower(): value or "" for key, value in attrs}
+        for attribute in (
+            "href",
+            "src",
+            "action",
+            "poster",
+            "data-td-index-src",
+            "data-td-url",
+            "data-td-image-zoom",
+        ):
+            if values.get(attribute):
+                self.urls.append((attribute, values[attribute]))
+        if tag == "link" and values.get("rel", "").lower() == "canonical":
+            self.canonical.append(values.get("href", ""))
+        if (
+            tag == "link"
+            and values.get("rel", "").lower() == "alternate"
+            and values.get("hreflang")
+        ):
+            self.hreflang.append((values["hreflang"], values.get("href", "")))
+        if tag == "meta":
+            self.meta.append(values)
+
+
+def refresh_target(parser: DocumentParser) -> str | None:
+    refresh = [
+        item.get("content", "")
+        for item in parser.meta
+        if item.get("http-equiv", "").lower() == "refresh"
+    ]
+    if not refresh:
+        return None
+    if len(refresh) != 1:
+        fail(f"expected one refresh directive, found {len(refresh)}")
+    match = re.search(r"(?:^|;)\s*url\s*=\s*(.+)\s*$", refresh[0], 
re.IGNORECASE)
+    if not match:
+        fail(f"malformed refresh directive: {refresh[0]}")
+    return match.group(1).strip(" \"'")
+
+
+def require_safe_url_scheme(value: str, source: str) -> bool:
+    """Reject active or ambiguous schemes; return whether target validation 
applies."""
+    if value.startswith("//"):
+        fail(f"protocol-relative URL in {source}: {value}")
+    scheme = urllib.parse.urlsplit(value).scheme.lower()
+    if scheme in {"mailto", "tel"}:
+        return False
+    if scheme not in {"", "http", "https"}:
+        fail(f"forbidden URL scheme in {source}: {value}")
+    return True
+
+
+def fail(message: str) -> NoReturn:
+    raise SystemExit(message)
+
+
+def prepare_output_directory(path: pathlib.Path, label: str) -> pathlib.Path:
+    raw = path.expanduser()
+    if raw.is_symlink():
+        fail(f"{label} must not be a symbolic link: {raw}")
+    output = raw.resolve()
+    allowed_roots = {
+        pathlib.Path(tempfile.gettempdir()).resolve(),
+        pathlib.Path("/tmp").resolve(),
+    }
+    runner_temp = os.environ.get("RUNNER_TEMP")
+    if runner_temp:
+        allowed_roots.add(pathlib.Path(runner_temp).resolve())
+    if not any(root != output and root in output.parents for root in 
allowed_roots):
+        fail(f"{label} must be below a controlled temporary directory: 
{output}")
+    if output.exists():
+        if output.is_symlink() or not output.is_dir():
+            fail(f"{label} is not a removable directory: {output}")
+        shutil.rmtree(output)
+    output.parent.mkdir(parents=True, exist_ok=True)
+    return output
+
+
+def run(command: list[str], *, cwd: pathlib.Path = ROOT) -> str:
+    result = subprocess.run(
+        command,
+        cwd=cwd,
+        check=True,
+        stdout=subprocess.PIPE,
+        stderr=None,
+        text=True,
+    )
+    return result.stdout.strip()
+
+
+def load_manifest(path: pathlib.Path) -> dict:
+    data = json.loads(path.read_text(encoding="utf-8"))
+    if data.get("schemaVersion") != 1:
+        fail("versions manifest schemaVersion must be 1")
+    versions = data.get("versions")
+    if not isinstance(versions, list) or not versions:
+        fail("versions manifest must contain a non-empty versions array")
+    ids: set[str] = set()
+    paths: set[str] = set()
+    for entry in versions:
+        required = {"id", "name", "ref", "publishPath", "archived", 
"githubBranch"}
+        if not isinstance(entry, dict) or not required.issubset(entry):
+            fail(f"invalid version entry: {entry!r}")
+        version_id = entry["id"]
+        expected_ref = VERSION_REFS.get(version_id)
+        if entry["ref"] != expected_ref:
+            fail(f"unexpected source ref for {version_id}: {entry['ref']}")
+        if entry["githubBranch"] != expected_ref:
+            fail(f"unexpected GitHub branch for {version_id}: 
{entry['githubBranch']}")
+        if entry["name"] != version_id:
+            fail(f"unexpected display name for {version_id}: {entry['name']}")
+        if entry["archived"] is not (version_id != "latest"):
+            fail(f"unexpected archive state for {version_id}: 
{entry['archived']}")
+        publish_path = entry["publishPath"].strip("/")
+        if version_id in ids or publish_path in paths:
+            fail(f"duplicate version id or publish path: {version_id}")
+        if version_id == "latest" and publish_path:
+            fail("latest must publish at the site root")
+        if version_id != "latest" and not publish_path.startswith("versions/"):
+            fail(f"historical version {version_id} must publish below 
versions/")
+        if ".." in pathlib.PurePosixPath(publish_path).parts:
+            fail(f"unsafe publish path for {version_id}: {publish_path}")
+        entry["publishPath"] = publish_path
+        ids.add(version_id)
+        paths.add(publish_path)
+    if [entry["id"] for entry in versions] != ["latest", "1.7", "1.5"]:
+        fail("version order must be latest, 1.7, 1.5")
+    if data.get("repository") != REPOSITORY_URL:
+        fail(f"versions manifest repository must be {REPOSITORY_URL}")
+    return data
+
+
+def load_resolved_manifest(path: pathlib.Path) -> dict:
+    resolved = load_manifest(path)
+    expected = load_manifest(ROOT / "versions.json")
+    if resolved.get("repository") != expected.get("repository"):
+        fail("resolved manifest repository does not match versions.json")
+    fields = ("id", "name", "ref", "publishPath", "archived", "githubBranch")
+    for expected_entry, resolved_entry in zip(
+        expected["versions"], resolved["versions"]
+    ):
+        if any(
+            resolved_entry.get(field) != expected_entry.get(field) for field 
in fields
+        ):
+            fail(
+                f"resolved manifest entry drifted from versions.json: 
{resolved_entry!r}"
+            )
+        if not SHA_RE.fullmatch(resolved_entry.get("sha", "")):
+            fail(f"resolved manifest has invalid SHA: {resolved_entry!r}")
+    return resolved
+
+
+def require_metadata_matches(entry: dict, metadata: dict, source: 
pathlib.Path) -> None:
+    fields = (
+        "id",
+        "name",
+        "ref",
+        "publishPath",
+        "archived",
+        "githubBranch",
+        "sha",
+    )
+    mismatches = [field for field in fields if metadata.get(field) != 
entry.get(field)]
+    if mismatches:
+        fail(f"version metadata mismatch in {source}: {', '.join(mismatches)}")
+
+
+def resolve_remote(repository: str, ref: str) -> str:
+    output = run(["git", "ls-remote", "--heads", repository, 
f"refs/heads/{ref}"])
+    rows = [row.split() for row in output.splitlines() if row.strip()]
+    if len(rows) != 1 or len(rows[0]) != 2 or not SHA_RE.fullmatch(rows[0][0]):
+        fail(f"cannot resolve exactly one branch SHA for {ref}: {output!r}")
+    return rows[0][0]
+
+
+def prepare(args: argparse.Namespace) -> None:
+    manifest = load_manifest(args.manifest)
+    resolved = []
+    for entry in manifest["versions"]:
+        item = dict(entry)
+        if entry["id"] == "latest":
+            sha = run(["git", "rev-parse", f"{args.latest_sha}^{{commit}}"])
+        elif args.local:
+            sha = run(
+                ["git", "rev-parse", 
f"refs/remotes/origin/{entry['ref']}^{{commit}}"]
+            )
+        else:
+            sha = resolve_remote(manifest["repository"], entry["ref"])
+        if not SHA_RE.fullmatch(sha):
+            fail(f"resolved value is not a commit SHA for {entry['id']}: 
{sha}")
+        item["sha"] = sha
+        resolved.append(item)
+    result = {
+        "schemaVersion": 1,
+        "repository": manifest["repository"],
+        "versions": resolved,
+        "include": resolved,
+    }
+    rendered = json.dumps(
+        result, ensure_ascii=False, sort_keys=True, separators=(",", ":")
+    )
+    if args.output:
+        args.output.write_text(rendered + "\n", encoding="utf-8")
+    print(rendered)
+
+
+def overlay_shell(assembly: pathlib.Path, *, historical: bool, origin: str) -> 
None:
+    for obsolete in (
+        "config.toml",
+        "package.json",
+        "netlify.toml",
+        "deploy.sh",
+        ".nvmrc",
+    ):
+        path = assembly / obsolete
+        if path.exists() or path.is_symlink():
+            path.unlink()
+    for obsolete_dir in (*SHELL_DIRS, "themes", "config"):
+        path = assembly / obsolete_dir
+        if path.exists():
+            shutil.rmtree(path)
+    for name in SHELL_FILES:
+        shutil.copy2(ROOT / name, assembly / name)
+    for name in SHELL_DIRS:
+        source = ROOT / name
+        if source.exists():
+            shutil.copytree(source, assembly / name, dirs_exist_ok=True)
+    license_source = ROOT / "static/licenses"
+    if license_source.exists():
+        shutil.copytree(
+            license_source, assembly / "static/licenses", dirs_exist_ok=True
+        )
+    client_go_target = assembly / "static/client-go"
+    if historical:
+        if client_go_target.exists():
+            shutil.rmtree(client_go_target)
+    else:
+        client_go_source = ROOT / "static/client-go"
+        if client_go_source.exists():
+            shutil.copytree(client_go_source, client_go_target, 
dirs_exist_ok=True)
+    for relative in SHELL_CONTENT:
+        if historical and not relative.endswith("docs/SUMMARY.md"):
+            continue
+        source = ROOT / relative
+        target = assembly / relative
+        target.parent.mkdir(parents=True, exist_ok=True)
+        legacy_html = target.with_suffix(".html")
+        if legacy_html.exists():
+            legacy_html.unlink()
+        shutil.copy2(source, target)
+    for relative in MENU_CONTENT:
+        strip_menu_frontmatter(assembly / relative)
+    if historical:
+        prune_historical_content(assembly, origin)
+
+
+def prune_historical_content(assembly: pathlib.Path, origin: str) -> None:
+    """Keep versioned Docs only; shared site surfaces continue to latest."""
+    normalized_origin = origin.rstrip("/") + "/"
+    for language in ("en", "cn"):
+        language_root = assembly / f"content/{language}"
+        for path in sorted(language_root.iterdir()):
+            if path.name == "docs":
+                continue
+            if path.is_dir():
+                shutil.rmtree(path)
+            else:
+                path.unlink()
+
+        footer_path = assembly / f"data/footer/{language}.yaml"
+        footer = footer_path.read_text(encoding="utf-8")
+        prefix = "cn/" if language == "cn" else ""
+        replacements = {
+            f"url: /{prefix}blog/": (
+                "url: '"
+                + urllib.parse.urljoin(normalized_origin, f"{prefix}blog/")
+                + "'"
+            ),
+            f"url: /{prefix}community/": (
+                "url: '"
+                + urllib.parse.urljoin(normalized_origin, 
f"{prefix}community/")
+                + "'"
+            ),
+        }
+        for old, new in replacements.items():
+            if footer.count(old) != 1:
+                fail(f"expected one shared footer route in {footer_path}: 
{old}")
+            footer = footer.replace(old, new)
+        footer_path.write_text(footer, encoding="utf-8")
+
+        blog_ref = (
+            '{{< ref path="/blog/hugegraph/toplingdb/'
+            f'toplingdb-quick-start.md" lang="{language}">}}}}'
+        )
+        latest_blog = urllib.parse.urljoin(
+            normalized_origin,
+            f"{prefix}blog/hugegraph/toplingdb/toplingdb-quick-start/",
+        )
+        for path in sorted((language_root / "docs").rglob("*.md")):
+            text = path.read_text(encoding="utf-8")
+            if blog_ref in text:
+                path.write_text(text.replace(blog_ref, latest_blog), 
encoding="utf-8")
+
+
+def strip_menu_frontmatter(path: pathlib.Path) -> None:
+    if not path.is_file():
+        return
+    lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
+    if not lines or lines[0].strip() != "---":
+        fail(f"cannot remove menu from non-YAML front matter: {path}")
+    try:
+        closing_index = next(
+            index for index, line in enumerate(lines[1:], 1) if line.strip() 
== "---"
+        )
+    except StopIteration:
+        fail(f"unterminated YAML front matter: {path}")
+    frontmatter = lines[1:closing_index]
+    output = [lines[0]]
+    index = 0
+    while index < len(frontmatter):
+        line = frontmatter[index]
+        if re.fullmatch(r"menu:\s*", line):
+            index += 1
+            while index < len(frontmatter):
+                candidate = frontmatter[index]
+                if candidate.strip() and candidate[:1] not in {" ", "\t"}:
+                    break
+                index += 1
+            continue
+        output.append(line)
+        index += 1
+    output.append(lines[closing_index])
+    output.extend(lines[closing_index + 1 :])
+    path.write_text("".join(output), encoding="utf-8")
+
+
+def base_url(origin: str, publish_path: str) -> str:
+    normalized_origin = origin.rstrip("/") + "/"
+    if not publish_path:
+        return normalized_origin
+    return urllib.parse.urljoin(normalized_origin, publish_path.rstrip("/") + 
"/")
+
+
+def apply_known_legacy_fixes(assembly: pathlib.Path, version: str) -> int:
+    fixed = 0
+    if version in {"1.7", "1.5"}:
+        for language in ("en", "cn"):
+            language_prefix = "/cn" if language == "cn" else ""
+            summary_path = assembly / f"content/{language}/docs/SUMMARY.md"
+            summary = summary_path.read_text(encoding="utf-8")
+            summary_count = summary.count("performance/api-performance")
+            if summary_count != 3:
+                fail(
+                    f"expected 3 historical performance routes in 
{summary_path}, "
+                    f"found {summary_count}"
+                )
+            summary_path.write_text(
+                summary.replace(
+                    "performance/api-performance", 
"performance/api-preformance"
+                ),
+                encoding="utf-8",
+            )
+            fixed += summary_count
+            replacements = []
+            if language == "cn":
+                replacements.append(
+                    (
+                        "/cn/docs/quickstart/hugegraph-server",
+                        "/cn/docs/quickstart/hugegraph/hugegraph-server",
+                    )
+                )
+            replacements.extend(
+                [
+                    (
+                        "/docs/quickstart/hugegraph-server",
+                        
f"{language_prefix}/docs/quickstart/hugegraph/hugegraph-server",
+                    ),
+                    (
+                        "/clients/gremlin-console.html",
+                        f"{language_prefix}/docs/clients/gremlin-console/",
+                    ),
+                    (
+                        "./hugegraph-style.xml",
+                        "https://github.com/apache/hugegraph/blob/";
+                        f"release-{version}.0/style/checkstyle.xml",
+                    ),
+                ]
+            )
+            for path in sorted((assembly / 
f"content/{language}").rglob("*.md")):
+                text = path.read_text(encoding="utf-8")
+                rendered = text
+                for old, new in replacements:
+                    count = rendered.count(old)
+                    rendered = rendered.replace(old, new)
+                    fixed += count
+                if rendered != text:
+                    path.write_text(rendered, encoding="utf-8")
+        if fixed < 4:
+            fail(f"expected known historical route fixes for {version}, found 
{fixed}")
+    return fixed
+
+
+def version_urls(manifest: dict, origin: str) -> list[dict]:
+    urls = []
+    for entry in manifest["versions"]:
+        path = f"{entry['publishPath']}/docs/" if entry["publishPath"] else 
"docs/"
+        urls.append(
+            {
+                "version": entry["id"],
+                "name": entry["name"],
+                "url": urllib.parse.urljoin(origin.rstrip("/") + "/", path),
+                "pagelinks": False,
+            }
+        )
+    return urls
+
+
+def historical_language_menus(origin: str) -> dict:
+    normalized_origin = origin.rstrip("/") + "/"
+    result = {}
+    for language, labels in (
+        (
+            "en",
+            {
+                "docs": "Documentation",
+                "download": "Download",
+                "blog": "Blog",
+                "community": "Community",
+            },
+        ),
+        (
+            "cn",
+            {"docs": "文档", "download": "下载", "blog": "博客", "community": "社区"},
+        ),
+    ):
+        language_prefix = "cn/" if language == "cn" else ""
+        result[language] = {
+            "menus": {
+                "main": [
+                    {
+                        "identifier": "docs",
+                        "name": labels["docs"],
+                        "pageRef": "/docs",
+                        "weight": 10,
+                    },
+                    {
+                        "identifier": "download",
+                        "name": labels["download"],
+                        "pageRef": "/docs/download/download",
+                        "weight": 20,
+                    },
+                    {
+                        "identifier": "blog",
+                        "name": labels["blog"],
+                        "url": urllib.parse.urljoin(
+                            normalized_origin, f"{language_prefix}blog/"
+                        ),
+                        "weight": 30,
+                    },
+                    {
+                        "identifier": "community",
+                        "name": labels["community"],
+                        "url": urllib.parse.urljoin(
+                            normalized_origin, f"{language_prefix}community/"
+                        ),
+                        "weight": 40,
+                    },
+                    {
+                        "identifier": "github",
+                        "name": "GitHub",
+                        "url": "https://github.com/apache/hugegraph";,
+                        "weight": 50,
+                    },
+                ]
+            }
+        }
+    return result
+
+
+def allowed_version_paths(manifest: dict) -> set[str]:
+    result = {"/", "/cn", "/blog", "/cn/blog", "/community", "/cn/community"}
+    for entry in manifest["versions"]:
+        path = f"/{entry['publishPath']}/docs" if entry["publishPath"] else 
"/docs"
+        result.add(path.rstrip("/") or "/")
+    return result
+
+
+def is_latest_shared_path(path: str) -> bool:
+    normalized = path.rstrip("/") or "/"
+    return normalized in {"/", "/cn"} or any(
+        normalized == shared or normalized.startswith(shared + "/")
+        for shared in ("/blog", "/cn/blog", "/community", "/cn/community")
+    )
+
+
+def rewrite_internal_url(
+    value: str,
+    *,
+    origin: str,
+    publish_path: str,
+    allowed_paths: set[str],
+) -> str:
+    """Scope a same-site absolute/root URL to one historical artifact."""
+    if not value or value.startswith(("#", "?")):
+        return value
+    if value.startswith("//"):
+        fail(f"protocol-relative URL is not allowed in a version artifact: 
{value}")
+    parsed = urllib.parse.urlsplit(value)
+    origin_parts = urllib.parse.urlsplit(origin.rstrip("/") + "/")
+    canonical_parts = urllib.parse.urlsplit(CANONICAL_ORIGIN)
+    absolute = bool(parsed.scheme or parsed.netloc)
+    if absolute:
+        if parsed.scheme not in {"http", "https"} or parsed.netloc not in {
+            origin_parts.netloc,
+            canonical_parts.netloc,
+        }:
+            return value
+        path = parsed.path or "/"
+    elif value.startswith("/"):
+        path = parsed.path or "/"
+    else:
+        return value
+
+    if not publish_path:
+        if absolute and parsed.netloc != origin_parts.netloc:
+            return urllib.parse.urlunsplit(
+                (
+                    origin_parts.scheme,
+                    origin_parts.netloc,
+                    path,
+                    parsed.query,
+                    parsed.fragment,
+                )
+            )
+        return value
+
+    normalized = path.rstrip("/") or "/"
+    prefix = "/" + publish_path.strip("/")
+    internal_path = (
+        path[len(prefix) :]
+        if normalized == prefix or normalized.startswith(prefix + "/")
+        else path
+    )
+    normalized_internal = internal_path.rstrip("/") or "/"
+    mapped_path = KNOWN_HISTORICAL_ROUTES.get(normalized_internal)
+    if mapped_path is not None:
+        scoped_path = prefix + mapped_path
+        if absolute:
+            return urllib.parse.urlunsplit(
+                (
+                    origin_parts.scheme,
+                    origin_parts.netloc,
+                    scoped_path,
+                    parsed.query,
+                    parsed.fragment,
+                )
+            )
+        return urllib.parse.urlunsplit(
+            ("", "", scoped_path, parsed.query, parsed.fragment)
+        )
+    if normalized == prefix or normalized.startswith(prefix + "/"):
+        if absolute and parsed.scheme != origin_parts.scheme:
+            return urllib.parse.urlunsplit(
+                (
+                    origin_parts.scheme,
+                    origin_parts.netloc,
+                    path,
+                    parsed.query,
+                    parsed.fragment,
+                )
+            )
+        return value
+    if is_latest_shared_path(normalized) or (absolute and normalized in 
allowed_paths):
+        return urllib.parse.urlunsplit(
+            (
+                origin_parts.scheme,
+                origin_parts.netloc,
+                path,
+                parsed.query,
+                parsed.fragment,
+            )
+        )
+    if normalized == "/versions" or normalized.startswith("/versions/"):
+        fail(f"non-selector cross-version URL is not allowed: {value}")
+
+    scoped_path = prefix + (path if path.startswith("/") else "/" + path)
+    if absolute:
+        return urllib.parse.urlunsplit(
+            (
+                origin_parts.scheme,
+                origin_parts.netloc,
+                scoped_path,
+                parsed.query,
+                parsed.fragment,
+            )
+        )
+    return urllib.parse.urlunsplit(("", "", scoped_path, parsed.query, 
parsed.fragment))
+
+
+def rewrite_json_urls(value, rewrite):
+    if isinstance(value, dict):
+        return {key: rewrite_json_urls(item, rewrite) for key, item in 
value.items()}
+    if isinstance(value, list):
+        return [rewrite_json_urls(item, rewrite) for item in value]
+    if isinstance(value, str):
+        return rewrite(value)
+    return value
+
+
+def rewrite_text_urls(text: str, rewrite, *, markdown: bool) -> tuple[str, 
int]:
+    count = 0
+
+    def replace_attribute(match: re.Match) -> str:
+        nonlocal count
+        old = match.group("url")
+        new = rewrite(old)
+        count += old != new
+        return (
+            
f"{match.group('prefix')}{match.group('quote')}{new}{match.group('quote')}"
+        )
+
+    if markdown:
+        in_fence = False
+        lines = []
+        for line in text.splitlines(keepends=True):
+            if re.match(r"^\s*(```|~~~)", line):
+                in_fence = not in_fence
+                lines.append(line)
+                continue
+            if not in_fence:
+
+                def replace_destination(match: re.Match) -> str:
+                    nonlocal count
+                    old = match.group("url")
+                    new = rewrite(old)
+                    count += old != new
+                    return f"{match.group('open')}{new}{match.group('close')}"
+
+                line = URL_ATTRIBUTE_RE.sub(replace_attribute, line)
+                line = MARKDOWN_DESTINATION_RE.sub(replace_destination, line)
+            lines.append(line)
+        text = "".join(lines)
+    else:
+        text = URL_ATTRIBUTE_RE.sub(replace_attribute, text)
+    return text, count
+
+
+def scope_version_artifact(
+    output: pathlib.Path,
+    manifest: dict,
+    entry: dict,
+    origin: str,
+) -> dict:
+    """Repair URL fields Hugo cannot canonify, then return auditable counts."""
+    if not entry["publishPath"] and origin.rstrip("/") == 
CANONICAL_ORIGIN.rstrip("/"):
+        return {"files": 0, "urls": 0, "manifests": 0, "searchRefs": 0}
+    allowed_paths = allowed_version_paths(manifest)
+
+    def rewrite(value: str) -> str:
+        return rewrite_internal_url(
+            value,
+            origin=origin,
+            publish_path=entry["publishPath"],
+            allowed_paths=allowed_paths,
+        )
+
+    stats = {"files": 0, "urls": 0, "manifests": 0, "searchRefs": 0}
+    for path in sorted(output.rglob("*")):
+        if not path.is_file():
+            continue
+        if path.match("offline-search-index.*.json"):
+            parts = path.name.split(".")
+            digest = hashlib.md5(path.read_bytes()).hexdigest()
+            if len(parts) != 4 or parts[2] != digest:
+                fail(
+                    f"stale search index fingerprint before rewrite: 
{path.relative_to(output)}"
+                )
+            data = json.loads(path.read_text(encoding="utf-8"))
+            if not isinstance(data, list):
+                fail(f"unexpected search index shape: {path}")
+            for item in data:
+                if not isinstance(item, dict) or not 
isinstance(item.get("ref"), str):
+                    fail(f"invalid search index entry: {path}")
+                old = item["ref"]
+                item["ref"] = rewrite(old)
+                stats["searchRefs"] += old != item["ref"]
+            path.write_text(
+                json.dumps(data, ensure_ascii=False, separators=(",", ":")),
+                encoding="utf-8",
+            )
+            stats["files"] += 1
+            continue
+        if path.suffix not in {".html", ".md", ".xml"}:
+            continue
+        original = path.read_text(encoding="utf-8")
+        rendered, changed = rewrite_text_urls(
+            original,
+            rewrite,
+            markdown=path.suffix == ".md",
+        )
+
+        def replace_manifest(match: re.Match) -> str:
+            data = json.loads(match.group("body"))
+            rewritten = rewrite_json_urls(data, rewrite)
+            stats["manifests"] += 1
+            return (
+                match.group("open")
+                + json.dumps(rewritten, ensure_ascii=False, separators=(",", 
":"))
+                + match.group("close")
+            )
+
+        if path.suffix == ".html":
+            rendered = ACTION_MANIFEST_RE.sub(replace_manifest, rendered)
+        if rendered != original:
+            path.write_text(rendered, encoding="utf-8")
+            stats["files"] += 1
+            stats["urls"] += changed
+    renamed_indexes = []
+    for path in sorted(output.glob("offline-search-index.*.json")):
+        digest = hashlib.md5(path.read_bytes()).hexdigest()
+        parts = path.name.split(".")
+        if len(parts) != 4:
+            fail(f"unexpected search index filename: {path.name}")
+        new_name = f"offline-search-index.{parts[1]}.{digest}.json"
+        if path.name != new_name:
+            new_path = path.with_name(new_name)
+            path.rename(new_path)
+            renamed_indexes.append((path.name, new_name))
+    for old_name, new_name in renamed_indexes:
+        references = 0
+        for path in sorted(output.rglob("*")):
+            if not path.is_file() or path.suffix not in {
+                ".html",
+                ".md",
+                ".xml",
+                ".txt",
+                ".json",
+            }:
+                continue
+            text = path.read_text(encoding="utf-8")
+            count = text.count(old_name)
+            if count:
+                path.write_text(text.replace(old_name, new_name), 
encoding="utf-8")
+                references += count
+        if references == 0:
+            fail(f"renamed search index has no generated reference: 
{old_name}")
+    stats["searchFingerprints"] = len(renamed_indexes)
+    return stats
+
+
+def write_historical_home_redirects(output: pathlib.Path, origin: str) -> int:
+    normalized_origin = origin.rstrip("/") + "/"
+    targets = (
+        (output / "index.html", "en-US", normalized_origin, "Apache 
HugeGraph"),
+        (
+            output / "cn/index.html",
+            "zh-CN",
+            urllib.parse.urljoin(normalized_origin, "cn/"),
+            "Apache HugeGraph",
+        ),
+    )
+    for path, language, target, title in targets:
+        escaped_target = html.escape(target, quote=True)
+        path.parent.mkdir(parents=True, exist_ok=True)
+        path.write_text(
+            "<!doctype html>\n"
+            f'<html lang="{language}"><head><meta charset="utf-8">\n'
+            '<meta name="robots" content="noindex,follow">\n'
+            f'<link rel="canonical" href="{escaped_target}">\n'
+            f'<meta http-equiv="refresh" content="0; url={escaped_target}">\n'
+            f"<title>{title}</title></head><body>\n"
+            f'<p><a href="{escaped_target}">Continue to {title}</a></p>\n'
+            "</body></html>\n",
+            encoding="utf-8",
+        )
+    return len(targets)
+
+
+def public_url_for_file(
+    path: pathlib.Path,
+    root: pathlib.Path,
+    origin: str,
+    publish_path: str,
+) -> str:
+    relative = path.relative_to(root).as_posix()
+    if relative == "index.html":
+        relative = ""
+    elif relative.endswith("/index.html"):
+        relative = relative[: -len("index.html")]
+    base = base_url(origin, publish_path)
+    return urllib.parse.urljoin(base, relative)
+
+
+def target_exists(root: pathlib.Path, relative: str) -> bool:
+    relative = urllib.parse.unquote(relative).lstrip("/")
+    parts = pathlib.PurePosixPath(relative).parts
+    if ".." in parts:
+        return False
+    candidate = root.joinpath(*parts)
+    return candidate.is_file() or (candidate / "index.html").is_file()
+
+
+def iter_json_strings(value):
+    if isinstance(value, dict):
+        for item in value.values():
+            yield from iter_json_strings(item)
+    elif isinstance(value, list):
+        for item in value:
+            yield from iter_json_strings(item)
+    elif isinstance(value, str):
+        yield value
+
+
+def iter_json_url_fields(value):
+    if isinstance(value, dict):
+        for key, item in value.items():
+            if key in {"baseURL", "url", "markdown"} and isinstance(item, str):
+                yield item
+            else:
+                yield from iter_json_url_fields(item)
+    elif isinstance(value, list):
+        for item in value:
+            yield from iter_json_url_fields(item)
+
+
+def validate_artifact(args: argparse.Namespace) -> None:
+    manifest = load_manifest(args.manifest)
+    entry = next(
+        (item for item in manifest["versions"] if item["id"] == args.version), 
None
+    )
+    if entry is None:
+        fail(f"unknown version {args.version}")
+    root = args.artifact.resolve()
+    metadata_path = root / ".version.json"
+    if not metadata_path.is_file():
+        fail(f"missing version metadata: {metadata_path}")
+    metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
+    expected_base = base_url(args.site_origin, entry["publishPath"])
+    expected_entry = dict(entry)
+    expected_entry["sha"] = args.sha
+    require_metadata_matches(expected_entry, metadata, metadata_path)
+    if metadata.get("baseURL") != expected_base:
+        fail(f"version metadata does not match {entry['id']} at 
{expected_base}")
+    if entry["archived"]:
+        for shared_path in (
+            "about",
+            "blog",
+            "client-go",
+            "community",
+            "cn/about",
+            "cn/blog",
+            "cn/community",
+        ):
+            if (root / shared_path).exists():
+                fail(f"historical artifact contains latest-only output: 
{shared_path}")
+
+    origin_parts = urllib.parse.urlsplit(args.site_origin.rstrip("/") + "/")
+    canonical_parts = urllib.parse.urlsplit(CANONICAL_ORIGIN)
+    prefix = "/" + entry["publishPath"].strip("/") if entry["publishPath"] 
else ""
+    allowed_paths = allowed_version_paths(manifest)
+    current_docs = (prefix + "/docs").rstrip("/") or "/docs"
+    checked_urls = 0
+    canonical_pages = 0
+    manifest_pages = 0
+    contract_data = json.loads(URL_CONTRACT.read_text(encoding="utf-8"))
+    if contract_data.get("schemaVersion") != 1 or not isinstance(
+        contract_data.get("routes"), list
+    ):
+        fail(f"invalid URL contract: {URL_CONTRACT}")
+    contract_routes = 0
+    for route in contract_data["routes"]:
+        if entry["id"] not in route.get("versions", []):
+            continue
+        public_path = route.get("path")
+        file_path = route.get("file")
+        markers = route.get("contains")
+        if (
+            not isinstance(public_path, str)
+            or not public_path.startswith("/")
+            or not isinstance(file_path, str)
+            or ".." in pathlib.PurePosixPath(file_path).parts
+            or not isinstance(markers, list)
+            or not all(isinstance(marker, str) and marker for marker in 
markers)
+        ):
+            fail(f"invalid URL contract route: {route!r}")
+        expected_file = (
+            "index.html"
+            if public_path == "/"
+            else public_path.lstrip("/")
+            + ("index.html" if public_path.endswith("/") else "")
+        )
+        if file_path != expected_file:
+            fail(f"URL contract path/file mismatch: {public_path} -> 
{file_path}")
+        target = root / file_path
+        if not target.is_file():
+            fail(f"URL contract target missing for {entry['id']}: 
{public_path}")
+        body = target.read_text(encoding="utf-8")
+        for marker in markers:
+            if marker not in body:
+                fail(
+                    f"URL contract marker missing for {entry['id']} 
{public_path}: {marker}"
+                )
+        contract_routes += 1
+
+    def validate_url(value: str, source: pathlib.Path) -> None:
+        nonlocal checked_urls
+        if not value or value.startswith(("#", "?")):
+            return
+        source_name = source.relative_to(root).as_posix()
+        if not require_safe_url_scheme(value, source_name):
+            return
+        parsed = urllib.parse.urlsplit(value)
+        if (
+            parsed.netloc == canonical_parts.netloc
+            and parsed.netloc != origin_parts.netloc
+        ):
+            fail(f"production-origin URL leaked into staging {source_name}: 
{value}")
+        if parsed.netloc and parsed.netloc != origin_parts.netloc:
+            return
+        if not parsed.netloc and not value.startswith("/"):
+            source_relative = source.relative_to(root).as_posix()
+            if source.suffix == ".xml" or source_relative.startswith(
+                ("_print/", "cn/_print/")
+            ):
+                return
+            value = urllib.parse.urljoin(
+                public_url_for_file(
+                    source, root, args.site_origin, entry["publishPath"]
+                ),
+                value,
+            )
+            parsed = urllib.parse.urlsplit(value)
+        if parsed.netloc and parsed.netloc != origin_parts.netloc:
+            return
+        if parsed.scheme and parsed.scheme != origin_parts.scheme:
+            fail(
+                f"same-site URL uses the wrong scheme in 
{source.relative_to(root)}: {value}"
+            )
+        path = parsed.path or "/"
+        normalized = path.rstrip("/") or "/"
+        if (
+            parsed.netloc
+            and (normalized in allowed_paths or 
is_latest_shared_path(normalized))
+            and normalized != current_docs
+        ):
+            checked_urls += 1
+            return
+        if prefix and not (normalized == prefix or 
normalized.startswith(prefix + "/")):
+            fail(
+                f"URL escapes version {entry['id']} in 
{source.relative_to(root)}: {value}"
+            )
+        relative = path[len(prefix) :] if prefix else path
+        if not target_exists(root, relative):
+            fail(f"missing local target from {source.relative_to(root)}: 
{value}")
+        checked_urls += 1
+
+    for path in sorted(root.rglob("*")):
+        if not path.is_file():
+            continue
+        if path.match("offline-search-index.*.json"):
+            parts = path.name.split(".")
+            digest = hashlib.md5(path.read_bytes()).hexdigest()
+            if len(parts) != 4 or parts[2] != digest:
+                fail(f"stale search index fingerprint: 
{path.relative_to(root)}")
+            data = json.loads(path.read_text(encoding="utf-8"))
+            for item in data:
+                ref = item.get("ref") if isinstance(item, dict) else None
+                if not isinstance(ref, str):
+                    fail(f"invalid search entry in {path.relative_to(root)}")
+                validate_url(ref, path)
+            continue
+        if path.name == "navigation.json":
+            data = json.loads(path.read_text(encoding="utf-8"))
+            if data.get("schemaVersion") != 1:
+                fail(f"invalid navigation schema in {path.relative_to(root)}")
+            relative = path.relative_to(root).as_posix()
+            language = "cn" if relative.startswith("cn/") else "en"
+            expected_root_url = (
+                urllib.parse.urljoin(expected_base, "cn/")
+                if language == "cn"
+                else expected_base
+            )
+            if data.get("baseURL") != expected_base or data.get("language") != 
language:
+                fail(f"navigation baseURL/language mismatch in {relative}")
+            if data.get("root", {}).get("url") != expected_root_url:
+                fail(f"navigation root is not language-scoped in {relative}")
+            for value in iter_json_url_fields(data):
+                validate_url(value, path)
+                value_parts = urllib.parse.urlsplit(value)
+                if value == expected_base or (
+                    value_parts.netloc and value_parts.netloc != 
origin_parts.netloc
+                ):
+                    continue
+                value_path = value_parts.path
+                language_prefix = prefix + "/cn/"
+                if language == "cn" and not 
value_path.startswith(language_prefix):
+                    fail(f"navigation URL loses Chinese scope in {relative}: 
{value}")
+                if language == "en" and value_path.startswith(language_prefix):
+                    fail(
+                        f"navigation URL crosses into Chinese scope in 
{relative}: {value}"
+                    )
+            continue
+        if path.suffix not in {".html", ".md", ".xml", ".txt"}:
+            continue
+        text = path.read_text(encoding="utf-8")
+        if path.suffix not in {".md", ".txt"}:
+            for match in URL_ATTRIBUTE_RE.finditer(text):
+                validate_url(match.group("url"), path)
+        if path.suffix == ".xml":
+            try:
+                xml_root = ET.fromstring(text)
+            except ET.ParseError as error:
+                fail(f"invalid XML in {path.relative_to(root)}: {error}")
+            for node in xml_root.iter():
+                if (
+                    node.tag.split("}")[-1] in {"loc", "link", "guid"}
+                    and (node.text or "").strip()
+                ):
+                    validate_url((node.text or "").strip(), path)
+        if path.suffix in {".md", ".txt"}:
+            in_fence = False
+            for line in text.splitlines():
+                if re.match(r"^\s*(```|~~~)", line):
+                    in_fence = not in_fence
+                    continue
+                if not in_fence:
+                    for match in URL_ATTRIBUTE_RE.finditer(line):
+                        validate_url(match.group("url"), path)
+                    for match in MARKDOWN_DESTINATION_RE.finditer(line):
+                        validate_url(match.group("url"), path)
+            relative = path.relative_to(root).as_posix()
+            if path.name == "llms.txt":
+                is_chinese = relative.startswith("cn/")
+                for value in re.findall(r"https?://[^)\s]+", text):
+                    value_path = urllib.parse.urlsplit(value).path
+                    chinese_prefix = prefix + "/cn/"
+                    shared_latest = is_latest_shared_path(value_path)
+                    if is_chinese and not 
value_path.startswith(chinese_prefix):
+                        if shared_latest and value_path.startswith("/cn/"):
+                            continue
+                        if value_path != prefix + "/index.md":
+                            fail(f"Chinese llms index loses language scope: 
{value}")
+                    if not is_chinese and 
value_path.startswith(chinese_prefix):
+                        if value_path != prefix + "/cn/index.md":
+                            fail(f"English llms index crosses language scope: 
{value}")
+                    if (
+                        not is_chinese
+                        and shared_latest
+                        and value_path.startswith("/cn/")
+                    ):
+                        fail(f"English llms index crosses language scope: 
{value}")
+        if path.suffix != ".html":
+            continue
+        relative = path.relative_to(root).as_posix()
+        archive_exceptions = {"404.html", "cn/404.html", 
"client-go/index.html"}
+        canonical_exceptions = {"client-go/index.html"}
+        if relative in {"404.html", "cn/404.html"} and (
+            "td-site-header" not in text or "td-print-view" in text
+        ):
+            fail(f"404 page does not use the interactive OINK shell: 
{relative}")
+        document = DocumentParser()
+        document.feed(text)
+        alias_target = refresh_target(document)
+        if alias_target:
+            validate_url(alias_target, path)
+        manifests = list(ACTION_MANIFEST_RE.finditer(text))
+        if manifests:
+            if len(manifests) != 1:
+                fail(f"unexpected action manifest count in 
{path.relative_to(root)}")
+            action_data = json.loads(manifests[0].group("body"))
+            for value in iter_json_url_fields(action_data):
+                if value:
+                    validate_url(value, path)
+            actions = {
+                item.get("id"): item
+                for item in action_data.get("actions", [])
+                if isinstance(item, dict)
+            }
+            for action_id in ("open_chatgpt", "open_claude"):
+                action = actions.get(action_id)
+                placements = action.get("placements", {}) if action else {}
+                if (
+                    not action
+                    or action.get("available") is not False
+                    or action.get("url") != ""
+                    or placements.get("page") is not False
+                    or placements.get("palette") is not False
+                ):
+                    fail(
+                        f"assistant action is externally enabled in "
+                        f"{path.relative_to(root)}: {action_id}"
+                    )
+            switch = next(
+                (
+                    item
+                    for item in action_data.get("actions", [])
+                    if item.get("id") == "switch_version"
+                ),
+                None,
+            )
+            expected_options = version_urls(manifest, args.site_origin)
+            if switch is None or [
+                (
+                    item.get("id"),
+                    item.get("title"),
+                    str(item.get("url", "")).rstrip("/"),
+                    item.get("active"),
+                )
+                for item in switch.get("options", [])
+            ] != [
+                (
+                    item["version"],
+                    item["name"],
+                    item["url"].rstrip("/"),
+                    item["version"] == entry["id"],
+                )
+                for item in expected_options
+            ]:
+                fail(f"version switch contract mismatch in 
{path.relative_to(root)}")
+            if (
+                entry["archived"]
+                and relative not in archive_exceptions
+                and not relative.startswith(("_print/", "cn/_print/"))
+                and "td-page-notice--primary" not in text
+            ):
+                fail(f"archive notice missing in {path.relative_to(root)}")
+            manifest_pages += 1
+
+        for _attribute, value in document.urls:
+            validate_url(value, path)
+            hostname = (urllib.parse.urlsplit(value).hostname or "").lower()
+            if hostname in {"chatgpt.com", "claude.ai", "anthropic.com"}:
+                fail(f"assistant link is externally enabled in {relative}: 
{value}")
+
+        canonical_tags = [
+            tag
+            for tag in re.findall(r"<link\b[^>]*>", text, flags=re.IGNORECASE)
+            if re.search(
+                r"\brel=[\"']?canonical(?:[\"'\s>]|$)", tag, 
flags=re.IGNORECASE
+            )
+        ]
+        if relative in canonical_exceptions:
+            if len(canonical_tags) > 1:
+                fail(f"too many canonicals on special page {relative}")
+            if canonical_tags:
+                match = URL_ATTRIBUTE_RE.search(canonical_tags[0])
+                if match is None:
+                    fail(f"cannot parse canonical in {relative}")
+                validate_url(match.group("url"), path)
+                canonical_pages += 1
+        else:
+            if len(canonical_tags) != 1:
+                fail(
+                    f"expected one canonical in {relative}, found 
{len(canonical_tags)}"
+                )
+            match = URL_ATTRIBUTE_RE.search(canonical_tags[0])
+            if match is None or not 
canonical_tags[0].lower().startswith("<link"):
+                fail(f"cannot parse canonical in {relative}")
+            validate_url(match.group("url"), path)
+            actual_canonical = match.group("url")
+            if alias_target:
+                expected_canonical = urllib.parse.urljoin(expected_base, 
alias_target)
+            elif "_print/" in relative:
+                parts = list(pathlib.PurePosixPath(relative).parts)
+                parts.remove("_print")
+                regular = pathlib.PurePosixPath(*parts).as_posix()
+                if regular.endswith("index.html"):
+                    regular = regular[: -len("index.html")]
+                expected_canonical = urllib.parse.urljoin(expected_base, 
regular)
+            else:
+                expected_canonical = public_url_for_file(
+                    path, root, args.site_origin, entry["publishPath"]
+                )
+            if actual_canonical != expected_canonical:
+                fail(
+                    f"canonical mismatch in {relative}: "
+                    f"{actual_canonical} != {expected_canonical}"
+                )
+            canonical_pages += 1
+
+        is_regular_page = (
+            "_print/" not in relative
+            and relative not in archive_exceptions
+            and not alias_target
+        )
+        if is_regular_page:
+            actual_hreflang = dict(document.hreflang)
+            if len(actual_hreflang) != len(document.hreflang):
+                fail(f"duplicate hreflang entries in {relative}")
+            current_path = public_url_for_file(
+                path, root, args.site_origin, entry["publishPath"]
+            )
+            current_parts = urllib.parse.urlsplit(current_path)
+            language_base_path = prefix + "/cn/"
+            if current_parts.path.startswith(language_base_path):
+                english_path = prefix + current_parts.path[len(prefix + "/cn") 
:]
+            else:
+                english_path = current_parts.path
+            chinese_path = (
+                prefix + "/cn/"
+                if english_path.rstrip("/") == prefix
+                else prefix + "/cn" + english_path[len(prefix) :]
+            )
+            expected_hreflang = {
+                "en-US": urllib.parse.urlunsplit(
+                    (origin_parts.scheme, origin_parts.netloc, english_path, 
"", "")
+                ),
+                "zh-CN": urllib.parse.urlunsplit(
+                    (origin_parts.scheme, origin_parts.netloc, chinese_path, 
"", "")
+                ),
+            }
+            for language, fallback_path in HREFLANG_FALLBACKS.get(relative, 
{}).items():
+                expected_hreflang[language] = urllib.parse.urljoin(
+                    expected_base, fallback_path.lstrip("/")
+                )
+            if actual_hreflang != expected_hreflang:
+                fail(
+                    f"hreflang mismatch in {relative}: "
+                    f"{actual_hreflang} != {expected_hreflang}"
+                )
+
+    if not entry["archived"]:
+        client_go = root / "client-go/index.html"
+        if not client_go.is_file():
+            fail("missing client-go/index.html")
+        client_parser = DocumentParser()
+        client_parser.feed(client_go.read_text(encoding="utf-8"))
+        go_import = [
+            item.get("content", "")
+            for item in client_parser.meta
+            if item.get("name") == "go-import"
+        ]
+        if go_import != [
+            "hugegraph.apache.org/client-go git "
+            "https://github.com/apache/hugegraph-toolchain.git";
+        ]:
+            fail("client-go go-import metadata changed")
+        expected_go_source = (
+            "hugegraph.apache.org/client-go "
+            "https://github.com/apache/hugegraph-toolchain "
+            "https://github.com/apache/hugegraph-toolchain/tree/master/";
+            "hugegraph-client-go{/dir} 
https://github.com/apache/hugegraph-toolchain/";
+            "blob/master/hugegraph-client-go{/dir}/{file}#L{line}"
+        )
+        go_source = [
+            item.get("content", "")
+            for item in client_parser.meta
+            if item.get("name") == "go-source"
+        ]
+        if go_source != [expected_go_source]:
+            fail("client-go go-source metadata changed")
+        package_url = "https://pkg.go.dev/hugegraph.apache.org/client-go";
+        if refresh_target(client_parser) != package_url:
+            fail("client-go refresh target changed")
+        if [url for attribute, url in client_parser.urls if attribute == 
"href"] != [
+            package_url
+        ]:
+            fail("client-go visible redirect link changed")
+
+    license_root = root / "licenses/oink"
+    for name in ("LICENSE", "NOTICE", "VENDOR.json"):
+        if not (license_root / name).is_file():
+            fail(f"missing OINK license bundle file: {name}")
+    vendor = json.loads((license_root / 
"VENDOR.json").read_text(encoding="utf-8"))
+    for dependency in vendor.get("dependencies", []):
+        for name in dependency.get("licenseFiles", []):
+            if not (license_root / name).is_file():
+                fail(f"missing OINK dependency license: {name}")
+
+    if manifest_pages == 0 or canonical_pages == 0:
+        fail("artifact validation did not inspect rendered pages")
+    print(
+        f"validated {entry['id']}: {canonical_pages} canonical pages, "
+        f"{manifest_pages} action manifests, {checked_urls} internal URLs, "
+        f"{contract_routes} contract routes"
+    )
+
+
+def build(args: argparse.Namespace) -> None:
+    manifest = load_manifest(args.manifest)
+    entry = next(
+        (item for item in manifest["versions"] if item["id"] == args.version), 
None
+    )
+    if entry is None:
+        fail(f"unknown version {args.version}")
+    if not SHA_RE.fullmatch(args.sha):
+        fail(f"invalid source SHA: {args.sha}")
+    try:
+        run(["git", "cat-file", "-e", f"{args.sha}^{{commit}}"])
+    except subprocess.CalledProcessError:
+        run(["git", "fetch", "--no-tags", "origin", args.sha])
+
+    output = prepare_output_directory(args.output, "version output")
+
+    with tempfile.TemporaryDirectory(prefix=f"hugegraph-{args.version}-") as 
temp_name:
+        assembly = pathlib.Path(temp_name) / "site"
+        subprocess.run(
+            [
+                "git",
+                "clone",
+                "--quiet",
+                "--shared",
+                "--no-checkout",
+                str(ROOT),
+                str(assembly),
+            ],
+            cwd=ROOT,
+            check=True,
+        )
+        subprocess.run(
+            ["git", "checkout", "--quiet", "--detach", args.sha],
+            cwd=assembly,
+            check=True,
+        )
+        overlay_shell(
+            assembly,
+            historical=bool(entry["archived"]),
+            origin=args.site_origin,
+        )
+        site_base = base_url(args.site_origin, entry["publishPath"])
+        override = {
+            "baseURL": site_base,
+            "canonifyURLs": True,
+            "params": {
+                "version": entry["id"],
+                "version_menu": "Releases",
+                "version_menu_pagelinks": False,
+                "versions": version_urls(manifest, args.site_origin),
+                "archived_version": bool(entry["archived"]),
+                "url_latest_version": urllib.parse.urljoin(
+                    args.site_origin.rstrip("/") + "/", "docs/"
+                ),
+                "github_repo": "https://github.com/apache/hugegraph-doc";,
+                "github_branch": entry["githubBranch"],
+            },
+        }
+        if entry["archived"]:
+            override["languages"] = historical_language_menus(args.site_origin)
+        override_path = assembly / "version-config.json"
+        override_path.write_text(
+            json.dumps(override, ensure_ascii=False), encoding="utf-8"
+        )
+        hugo = os.environ.get("HUGO_BIN", "hugo")
+        go = os.environ.get("GO_BIN", "go")
+        go_executable = shutil.which(go)
+        if go_executable is None:
+            fail(f"Go executable is unavailable: {go}")
+        module_result = subprocess.run(
+            [
+                go_executable,
+                "mod",
+                "download",
+                "-json",
+                "github.com/pgsty/[email protected]",
+            ],
+            cwd=assembly,
+            check=True,
+            stdout=subprocess.PIPE,
+            text=True,
+        )
+        module = json.loads(module_result.stdout)
+        if (
+            module.get("Path") != "github.com/pgsty/oink"
+            or module.get("Version") != "v1.0.0"
+            or module.get("Sum") != 
"h1:E+WHFP9zSRT+5RKoIkWNp+ASRGS1BKG+rDEi9by/BjE="
+        ):
+            fail(f"unexpected OINK module metadata: {module!r}")
+        migration_script = pathlib.Path(module["Dir"]) / 
"bin/migrations/oink06.py"
+        if not migration_script.is_file():
+            fail(f"pinned OINK migration tool is absent: {migration_script}")
+        migration_report = assembly / ".oink06-migration.json"
+        migration_python = os.environ.get("OINK_PYTHON", sys.executable)
+        subprocess.run(
+            [
+                migration_python,
+                str(migration_script),
+                "migrate",
+                "--site",
+                str(assembly),
+                "--paths",
+                "content",
+                "--write",
+                "--json",
+                str(migration_report),
+                "--quiet",
+            ],
+            cwd=assembly,
+            check=True,
+        )
+        known_fixes = apply_known_legacy_fixes(assembly, entry["id"])
+        subprocess.run(
+            [
+                migration_python,
+                str(migration_script),
+                "check",
+                "--site",
+                str(assembly),
+                "--paths",
+                "content",
+            ],
+            cwd=assembly,
+            check=True,
+        )
+        command = [
+            hugo,
+            "--config",
+            "hugo.yaml,version-config.json",
+            "--destination",
+            str(output),
+            "--cleanDestinationDir",
+            "--gc",
+            "--minify",
+            "--environment",
+            "production",
+            "--printPathWarnings",
+            "--printI18nWarnings",
+            "--panicOnWarning",
+            "--logLevel",
+            "info",
+        ]
+        build_environment = os.environ.copy()
+        go_directory = str(pathlib.Path(go_executable).resolve().parent)
+        build_environment["PATH"] = (
+            go_directory + os.pathsep + build_environment.get("PATH", "")
+        )
+        subprocess.run(command, cwd=assembly, check=True, 
env=build_environment)
+        url_scoping = scope_version_artifact(
+            output,
+            manifest,
+            entry,
+            args.site_origin,
+        )
+        url_scoping["historicalHomeRedirects"] = (
+            write_historical_home_redirects(output, args.site_origin)
+            if entry["archived"]
+            else 0
+        )
+        metadata = dict(entry)
+        migration_data = 
json.loads(migration_report.read_text(encoding="utf-8"))
+        metadata.update(
+            {
+                "sha": args.sha,
+                "baseURL": site_base,
+                "migration": {
+                    "files": len(migration_data.get("files", [])),
+                    "changed": sum(
+                        1
+                        for item in migration_data.get("files", [])
+                        if item.get("changed")
+                    ),
+                    "findings": sum(
+                        len(item.get("findings", []))
+                        for item in migration_data.get("files", [])
+                    ),
+                    "knownFixes": known_fixes,
+                    "residual": 0,
+                },
+                "urlScoping": url_scoping,
+            }
+        )
+        (output / ".version.json").write_text(
+            json.dumps(metadata, ensure_ascii=False, sort_keys=True, indent=2) 
+ "\n",
+            encoding="utf-8",
+        )
+        print(f"built {entry['id']}@{args.sha} -> {output} ({site_base})")
+
+
+def sitemap_locations(path: pathlib.Path) -> list[str]:
+    return [
+        node.text or "" for node in ET.parse(path).iter() if 
node.tag.endswith("loc")
+    ]
+
+
+def write_aggregate_sitemap(output: pathlib.Path, origin: str, manifest: dict) 
-> None:
+    locations = [
+        urllib.parse.urljoin(origin.rstrip("/") + "/", "en/sitemap.xml"),
+        urllib.parse.urljoin(origin.rstrip("/") + "/", "cn/sitemap.xml"),
+    ]
+    for entry in manifest["versions"]:
+        if entry["publishPath"]:
+            locations.append(
+                urllib.parse.urljoin(
+                    origin.rstrip("/") + "/", entry["publishPath"] + 
"/sitemap.xml"
+                )
+            )
+    root = ET.Element(
+        "sitemapindex", xmlns="http://www.sitemaps.org/schemas/sitemap/0.9";
+    )
+    for location in locations:
+        item = ET.SubElement(root, "sitemap")
+        ET.SubElement(item, "loc").text = location
+    ET.indent(root)
+    ET.ElementTree(root).write(
+        output / "sitemap.xml", encoding="utf-8", xml_declaration=True
+    )
+
+
+def copy_without_collision(
+    source: pathlib.Path, destination: pathlib.Path, seen: set[str]
+) -> None:
+    for path in sorted(source.rglob("*")):
+        if path.is_dir():
+            continue
+        relative = path.relative_to(source).as_posix()
+        target_relative = (destination / relative).as_posix()
+        if target_relative in seen:
+            fail(f"aggregate path collision: {target_relative}")
+        seen.add(target_relative)
+        target = destination / relative
+        target.parent.mkdir(parents=True, exist_ok=True)
+        shutil.copy2(path, target)
+
+
+def aggregate(args: argparse.Namespace) -> None:
+    manifest = load_resolved_manifest(args.resolved_manifest)
+    output = prepare_output_directory(args.output, "aggregate output")
+    output.mkdir(parents=True)
+    seen: set[str] = set()
+    resolved = []
+    for entry in manifest["versions"]:
+        source = args.artifacts / f"{args.artifact_prefix}{entry['id']}"
+        metadata_path = source / ".version.json"
+        if not metadata_path.is_file():
+            fail(f"missing version metadata: {metadata_path}")
+        metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
+        require_metadata_matches(entry, metadata, metadata_path)
+        validate_artifact(
+            argparse.Namespace(
+                manifest=args.resolved_manifest,
+                version=entry["id"],
+                sha=entry["sha"],
+                site_origin=args.site_origin,
+                artifact=source,
+            )
+        )
+        destination = output / entry["publishPath"]
+        destination.mkdir(parents=True, exist_ok=True)
+        copy_without_collision(source, destination, seen)
+        resolved.append(metadata)
+    write_aggregate_sitemap(output, args.site_origin, manifest)
+    expected_sitemaps = sitemap_locations(output / "sitemap.xml")
+    for location in expected_sitemaps:
+        parsed = urllib.parse.urlsplit(location)
+        origin_parts = urllib.parse.urlsplit(args.site_origin.rstrip("/") + 
"/")
+        if parsed.scheme != origin_parts.scheme or parsed.netloc != 
origin_parts.netloc:
+            fail(f"aggregate sitemap escapes the configured origin: 
{location}")
+        if not target_exists(output, parsed.path):
+            fail(f"aggregate sitemap target is missing: {location}")
+    asf_text = (ROOT / ".asf.yaml").read_text(encoding="utf-8")
+    if args.asf_profile or args.asf_whoami:
+        if not args.asf_profile or not args.asf_whoami:
+            fail("ASF staging profile and whoami must be set together")
+        old_staging = "staging:\n  profile: ~\n  whoami: asf-staging"
+        new_staging = (
+            f"staging:\n  profile: {args.asf_profile}\n  whoami: 
{args.asf_whoami}"
+        )
+        if asf_text.count(old_staging) != 1:
+            fail("cannot locate the exact ASF staging block")
+        asf_text = asf_text.replace(old_staging, new_staging)
+    (output / ".asf.yaml").write_text(asf_text, encoding="utf-8")
+    metadata_dir = output / "build-metadata"
+    metadata_dir.mkdir()
+    (metadata_dir / "versions.json").write_text(
+        json.dumps(
+            {"schemaVersion": 1, "versions": resolved},
+            ensure_ascii=False,
+            sort_keys=True,
+            indent=2,
+        )
+        + "\n",
+        encoding="utf-8",
+    )
+    print(f"aggregated {len(resolved)} versions and {len(seen)} files -> 
{output}")
+
+
+def parser() -> argparse.ArgumentParser:
+    result = argparse.ArgumentParser(description=__doc__)
+    result.add_argument("--manifest", type=pathlib.Path, default=ROOT / 
"versions.json")
+    commands = result.add_subparsers(dest="command", required=True)
+
+    prepare_parser = commands.add_parser("prepare")
+    prepare_parser.add_argument("--local", action="store_true")
+    prepare_parser.add_argument("--latest-sha", default="HEAD")
+    prepare_parser.add_argument("--output", type=pathlib.Path)
+    prepare_parser.set_defaults(func=prepare)
+
+    build_parser = commands.add_parser("build")
+    build_parser.add_argument("--version", required=True)
+    build_parser.add_argument("--sha", required=True)
+    build_parser.add_argument("--site-origin", required=True)
+    build_parser.add_argument("--output", type=pathlib.Path, required=True)
+    build_parser.set_defaults(func=build)
+
+    validate_parser = commands.add_parser("validate")
+    validate_parser.add_argument("--version", required=True)
+    validate_parser.add_argument("--sha", required=True)
+    validate_parser.add_argument("--site-origin", required=True)
+    validate_parser.add_argument("--artifact", type=pathlib.Path, 
required=True)
+    validate_parser.set_defaults(func=validate_artifact)
+
+    aggregate_parser = commands.add_parser("aggregate")
+    aggregate_parser.add_argument("--artifacts", type=pathlib.Path, 
required=True)
+    aggregate_parser.add_argument("--artifact-prefix", default="")
+    aggregate_parser.add_argument(
+        "--resolved-manifest", type=pathlib.Path, required=True
+    )
+    aggregate_parser.add_argument("--site-origin", required=True)
+    aggregate_parser.add_argument("--output", type=pathlib.Path, required=True)
+    aggregate_parser.add_argument("--asf-profile")
+    aggregate_parser.add_argument("--asf-whoami")
+    aggregate_parser.set_defaults(func=aggregate)
+    return result
+
+
+def main() -> None:
+    args = parser().parse_args()
+    args.manifest = args.manifest.resolve()
+    if hasattr(args, "resolved_manifest"):
+        args.resolved_manifest = args.resolved_manifest.resolve()
+    args.func(args)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/versions.json b/versions.json
new file mode 100644
index 000000000..0642789de
--- /dev/null
+++ b/versions.json
@@ -0,0 +1,30 @@
+{
+  "schemaVersion": 1,
+  "repository": "https://github.com/apache/hugegraph-doc.git";,
+  "versions": [
+    {
+      "id": "latest",
+      "name": "latest",
+      "ref": "master",
+      "publishPath": "",
+      "archived": false,
+      "githubBranch": "master"
+    },
+    {
+      "id": "1.7",
+      "name": "1.7",
+      "ref": "release-1.7.0",
+      "publishPath": "versions/1.7",
+      "archived": true,
+      "githubBranch": "release-1.7.0"
+    },
+    {
+      "id": "1.5",
+      "name": "1.5",
+      "ref": "release-1.5.0",
+      "publishPath": "versions/1.5",
+      "archived": true,
+      "githubBranch": "release-1.5.0"
+    }
+  ]
+}

Reply via email to