Add script to build release documentation

The script builds a snapshot of the documentation, based on the state in the release branch.
Next, it rewrites all links to point to the versioned documentation for that release.
Finally, it creates an archive of all documentation files.

Closes #15686.

PiperOrigin-RevId: 460684222
Change-Id: Ic466e9e72cc862079f0551703fc2a16701888a2e
diff --git a/scripts/docs/create_release_docs.py b/scripts/docs/create_release_docs.py
new file mode 100644
index 0000000..3aa1c23
--- /dev/null
+++ b/scripts/docs/create_release_docs.py
@@ -0,0 +1,193 @@
+# Lint as: python3
+# pylint: disable=g-direct-third-party-import
+# Copyright 2022 The Bazel Authors. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# 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.
+"""A tool for building the documentation for a Bazel release."""
+import os
+import shutil
+import sys
+import tarfile
+import tempfile
+import zipfile
+
+from absl import app
+from absl import flags
+
+from scripts.docs import rewriter
+
+FLAGS = flags.FLAGS
+
+flags.DEFINE_string("version", None, "Name of the Bazel release.")
+flags.DEFINE_string(
+    "toc_path",
+    None,
+    "Path to the _toc.yaml file that contains the table of contents for the versions menu.",
+)
+flags.DEFINE_string(
+    "narrative_docs_path",
+    None,
+    "Path of the archive (zip or tar) that contains the narrative documentation.",
+)
+flags.DEFINE_string(
+    "reference_docs_path",
+    None,
+    "Path of the archive (zip or tar) that contains the reference documentation.",
+)
+flags.DEFINE_string(
+    "output_path", None,
+    "Location where the zip'ed documentation should be written to.")
+
+_ARCHIVE_FUNCTIONS = {".tar": tarfile.open, ".zip": zipfile.ZipFile}
+
+
+def validate_flag(name):
+  """Ensures that a flag is set, and returns its value (if yes).
+
+  This function exits with an error if the flag was not set.
+
+  Args:
+    name: Name of the flag.
+
+  Returns:
+    The value of the flag, if set.
+  """
+  value = getattr(FLAGS, name, None)
+  if value:
+    return value
+
+  print("Missing --{} flag.".format(name), file=sys.stderr)
+  exit(1)
+
+
+def create_docs_tree(version, toc_path, narrative_docs_path,
+                     reference_docs_path):
+  """Creates a directory tree containing the docs for the Bazel version.
+
+  Args:
+    version: Version of this Bazel release.
+    toc_path: Absolute path to the _toc.yaml file that lists the most recent
+      Bazel versions.
+    narrative_docs_path: Absolute path of an archive that contains the narrative
+      documentation (can be .zip or .tar).
+    reference_docs_path: Absolute path of an archive that contains the reference
+      documentation (can be .zip or .tar).
+
+  Returns:
+    The absolute paths of the root of the directory tree and of
+      the final _toc.yaml file.
+  """
+  root_dir = tempfile.mkdtemp()
+
+  versions_dir = os.path.join(root_dir, "versions")
+  os.makedirs(versions_dir)
+
+  toc_dest_path = os.path.join(versions_dir, "_toc.yaml")
+  shutil.copyfile(toc_path, toc_dest_path)
+
+  release_dir = os.path.join(versions_dir, version)
+  os.makedirs(release_dir)
+
+  try_extract(narrative_docs_path, release_dir)
+  try_extract(reference_docs_path, release_dir)
+
+  return root_dir, toc_dest_path
+
+
+def try_extract(archive_path, output_dir):
+  """Tries to extract the given archive into the given directory.
+
+  This function will raise an error if the archive type is not supported.
+
+  Args:
+    archive_path: Absolute path of an archive that should be extracted. Can be
+      .tar or .zip.
+    output_dir: Absolute path of the directory into which the archive should be
+      extracted
+
+  Raises:
+    ValueError: If the archive has an unsupported file type.
+  """
+  _, ext = os.path.splitext(archive_path)
+  open_func = _ARCHIVE_FUNCTIONS.get(ext)
+  if not open_func:
+    raise ValueError("File {}: Invalid file extension '{}'. Allowed: {}".format(
+        archive_path, ext, _ARCHIVE_FUNCTIONS.keys.join(", ")))
+
+  with open_func(archive_path, "r") as archive:
+    archive.extractall(output_dir)
+
+
+def build_archive(version, root_dir, toc_path, output_path):
+  """Builds a documentation archive for the given Bazel release.
+
+  This function reads all documentation files from the tree rooted in root_dir,
+  fixes all links so that they point at versioned files, then builds a zip
+  archive of all files.
+
+  Args:
+    version: Version of the Bazel release whose documentation is being built.
+    root_dir: Absolute path of the directory that contains the documentation
+      tree.
+    toc_path: Absolute path of the _toc.yaml file.
+    output_path: Absolute path where the archive should be written to.
+  """
+  with zipfile.ZipFile(output_path, "w") as archive:
+    for root, _, files in os.walk(root_dir):
+      for f in files:
+        src = os.path.join(root, f)
+        dest = src[len(root_dir) + 1:]
+
+        if src != toc_path and rewriter.can_rewrite(src):
+          archive.writestr(dest, get_versioned_content(src, version))
+        else:
+          archive.write(src, dest)
+
+
+def get_versioned_content(path, version):
+  """Rewrites links in the given file to point at versioned docs.
+
+  Args:
+    path: Absolute path of the file that should be rewritten.
+    version: Version of the Bazel release whose documentation is being built.
+
+  Returns:
+    The content of the given file, with rewritten links.
+  """
+  with open(path, "rt", encoding="utf-8") as f:
+    content = f.read()
+
+  return rewriter.rewrite_links(path, content, version)
+
+
+def main(unused_argv):
+  version = validate_flag("version")
+  output_path = validate_flag("output_path")
+  root_dir, toc_path = create_docs_tree(
+      version=version,
+      toc_path=validate_flag("toc_path"),
+      narrative_docs_path=validate_flag("narrative_docs_path"),
+      reference_docs_path=validate_flag("reference_docs_path"),
+  )
+
+  build_archive(
+      version=version,
+      root_dir=root_dir,
+      toc_path=toc_path,
+      output_path=output_path,
+  )
+
+
+if __name__ == "__main__":
+  FLAGS(sys.argv)
+  app.run(main)