Add support for stamping (#205)

Provides `$stamping.stable.key_name` and `$stamping.volatile.key_name` in the header template.
diff --git a/docs/stardoc_rule.md b/docs/stardoc_rule.md
index cf79770..580298d 100644
--- a/docs/stardoc_rule.md
+++ b/docs/stardoc_rule.md
@@ -10,7 +10,7 @@
 stardoc(<a href="#stardoc-name">name</a>, <a href="#stardoc-input">input</a>, <a href="#stardoc-out">out</a>, <a href="#stardoc-deps">deps</a>, <a href="#stardoc-format">format</a>, <a href="#stardoc-symbol_names">symbol_names</a>, <a href="#stardoc-semantic_flags">semantic_flags</a>, <a href="#stardoc-stardoc">stardoc</a>, <a href="#stardoc-renderer">renderer</a>,
         <a href="#stardoc-aspect_template">aspect_template</a>, <a href="#stardoc-func_template">func_template</a>, <a href="#stardoc-header_template">header_template</a>, <a href="#stardoc-table_of_contents_template">table_of_contents_template</a>,
         <a href="#stardoc-provider_template">provider_template</a>, <a href="#stardoc-rule_template">rule_template</a>, <a href="#stardoc-repository_rule_template">repository_rule_template</a>, <a href="#stardoc-module_extension_template">module_extension_template</a>,
-        <a href="#stardoc-use_starlark_doc_extract">use_starlark_doc_extract</a>, <a href="#stardoc-render_main_repo_name">render_main_repo_name</a>, <a href="#stardoc-kwargs">kwargs</a>)
+        <a href="#stardoc-use_starlark_doc_extract">use_starlark_doc_extract</a>, <a href="#stardoc-render_main_repo_name">render_main_repo_name</a>, <a href="#stardoc-stamp">stamp</a>, <a href="#stardoc-kwargs">kwargs</a>)
 </pre>
 
 Generates documentation for exported starlark rule definitions in a target starlark file.
@@ -39,6 +39,7 @@
 | <a id="stardoc-module_extension_template"></a>module_extension_template |  The input file template for generating documentation of module extensions. This template is used only when using the native `starlark_doc_extract` rule.   |  `Label("@io_bazel_stardoc//stardoc:templates/markdown_tables/module_extension.vm")` |
 | <a id="stardoc-use_starlark_doc_extract"></a>use_starlark_doc_extract |  Use the native `starlark_doc_extract` rule if available.   |  `True` |
 | <a id="stardoc-render_main_repo_name"></a>render_main_repo_name |  Render labels in the main repository with a repo component (either the module name or workspace name). This parameter is used only when using the native `starlark_doc_extract` rule.   |  `True` |
+| <a id="stardoc-stamp"></a>stamp |  Whether to provide stamping information to templates.   |  `False` |
 | <a id="stardoc-kwargs"></a>kwargs |  Further arguments to pass to stardoc.   |  none |
 
 
diff --git a/src/main/java/com/google/devtools/build/skydoc/renderer/RendererMain.java b/src/main/java/com/google/devtools/build/skydoc/renderer/RendererMain.java
index 9bf7cf7..20c5845 100644
--- a/src/main/java/com/google/devtools/build/skydoc/renderer/RendererMain.java
+++ b/src/main/java/com/google/devtools/build/skydoc/renderer/RendererMain.java
@@ -23,6 +23,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.devtools.build.skydoc.rendering.MarkdownRenderer;
 import com.google.devtools.build.skydoc.rendering.MarkdownRenderer.Renderer;
+import com.google.devtools.build.skydoc.rendering.Stamping;
 import com.google.devtools.build.skydoc.rendering.proto.StardocOutputProtos.AspectInfo;
 import com.google.devtools.build.skydoc.rendering.proto.StardocOutputProtos.AttributeInfo;
 import com.google.devtools.build.skydoc.rendering.proto.StardocOutputProtos.ModuleExtensionInfo;
@@ -61,6 +62,17 @@
     String inputPath = rendererOptions.inputPath;
     String outputPath = rendererOptions.outputFilePath;
 
+    Stamping stamping;
+    if (rendererOptions.stampingStableStatusFilePath != null
+        && rendererOptions.stampingVolatileStatusFilePath != null) {
+      stamping =
+          Stamping.read(
+              rendererOptions.stampingStableStatusFilePath,
+              rendererOptions.stampingVolatileStatusFilePath);
+    } else {
+      stamping = Stamping.empty();
+    }
+
     try (PrintWriter printWriter =
         new PrintWriter(outputPath, UTF_8) {
           // Use consistent line endings on all platforms.
@@ -83,7 +95,8 @@
               rendererOptions.aspectTemplateFilePath,
               rendererOptions.repositoryRuleTemplateFilePath,
               rendererOptions.moduleExtensionTemplateFilePath,
-              !moduleInfo.getFile().isEmpty() ? moduleInfo.getFile() : "...");
+              !moduleInfo.getFile().isEmpty() ? moduleInfo.getFile() : "...",
+              stamping);
 
       // rules are printed sorted by their qualified name, and their attributes are sorted by name,
       // with ATTRIBUTE_ORDERING specifying a fixed sort order for some standard attributes.
diff --git a/src/main/java/com/google/devtools/build/skydoc/renderer/RendererOptions.java b/src/main/java/com/google/devtools/build/skydoc/renderer/RendererOptions.java
index 004c428..e015372 100644
--- a/src/main/java/com/google/devtools/build/skydoc/renderer/RendererOptions.java
+++ b/src/main/java/com/google/devtools/build/skydoc/renderer/RendererOptions.java
@@ -81,6 +81,16 @@
   String moduleExtensionTemplateFilePath;
 
   @Parameter(
+      names = "--stamping_stable_status_file",
+      description = "The file path to the stable status file for stamping")
+  String stampingStableStatusFilePath;
+
+  @Parameter(
+      names = "--stamping_volatile_status_file",
+      description = "The file path to the volatile status file for stamping")
+  String stampingVolatileStatusFilePath;
+
+  @Parameter(
       names = {"--help", "-h"},
       description = "Print help and exit",
       help = true)
diff --git a/src/main/java/com/google/devtools/build/skydoc/rendering/MarkdownRenderer.java b/src/main/java/com/google/devtools/build/skydoc/rendering/MarkdownRenderer.java
index ca07c34..e91da0c 100644
--- a/src/main/java/com/google/devtools/build/skydoc/rendering/MarkdownRenderer.java
+++ b/src/main/java/com/google/devtools/build/skydoc/rendering/MarkdownRenderer.java
@@ -54,6 +54,7 @@
   private final String repositoryRuleTemplateFilename;
   private final String moduleExtensionTemplateFilename;
   private final String extensionBzlFile;
+  private final Stamping stamping;
 
   public MarkdownRenderer(
       String headerTemplate,
@@ -64,7 +65,8 @@
       String aspectTemplate,
       String repositoryRuleTemplate,
       String moduleExtensionTemplate,
-      String extensionBzlFile) {
+      String extensionBzlFile,
+      Stamping stamping) {
     this.headerTemplateFilename = headerTemplate;
     this.tableOfContentsTemplateFilename = tableOfContentsTemplateFilename;
     this.ruleTemplateFilename = ruleTemplate;
@@ -74,6 +76,7 @@
     this.repositoryRuleTemplateFilename = repositoryRuleTemplate;
     this.moduleExtensionTemplateFilename = moduleExtensionTemplate;
     this.extensionBzlFile = extensionBzlFile;
+    this.stamping = stamping;
   }
 
   /**
@@ -86,7 +89,9 @@
             "util",
             new MarkdownUtil(extensionBzlFile),
             "moduleDocstring",
-            moduleInfo.getModuleDocstring());
+            moduleInfo.getModuleDocstring(),
+            "stamping",
+            stamping);
     Reader reader = readerFromPath(headerTemplateFilename);
     try {
       return Template.parseFrom(reader).evaluate(vars);
diff --git a/src/main/java/com/google/devtools/build/skydoc/rendering/MarkdownUtil.java b/src/main/java/com/google/devtools/build/skydoc/rendering/MarkdownUtil.java
index afb2b5e..c486f57 100644
--- a/src/main/java/com/google/devtools/build/skydoc/rendering/MarkdownUtil.java
+++ b/src/main/java/com/google/devtools/build/skydoc/rendering/MarkdownUtil.java
@@ -31,6 +31,9 @@
 import com.google.devtools.build.skydoc.rendering.proto.StardocOutputProtos.RepositoryRuleInfo;
 import com.google.devtools.build.skydoc.rendering.proto.StardocOutputProtos.RuleInfo;
 import com.google.devtools.build.skydoc.rendering.proto.StardocOutputProtos.StarlarkFunctionInfo;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.regex.Matcher;
@@ -440,4 +443,16 @@
     }
     throw new IllegalArgumentException("Unhandled type " + attributeType);
   }
+
+  /**
+   * Formats a build timestamp from stamping with the given format. For example:
+   *
+   * <p>`$util.formatBuildTimestamp($stamping.volatile.BUILD_TIMESTAMP, "UTC", "yyyy MMM dd, HH:mm")
+   * UTC`
+   */
+  public String formatBuildTimestamp(String buildTimestampSeconds, String zoneId, String format) {
+    return Instant.ofEpochMilli(Long.parseLong(buildTimestampSeconds) * 1000)
+        .atZone(ZoneId.of(zoneId))
+        .format(DateTimeFormatter.ofPattern(format));
+  }
 }
diff --git a/src/main/java/com/google/devtools/build/skydoc/rendering/Stamping.java b/src/main/java/com/google/devtools/build/skydoc/rendering/Stamping.java
new file mode 100644
index 0000000..be7f5f3
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/skydoc/rendering/Stamping.java
@@ -0,0 +1,61 @@
+// Copyright 2024 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.
+
+package com.google.devtools.build.skydoc.rendering;
+
+import com.google.common.collect.ImmutableMap;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+/** Reads and stores stamping information. */
+public final class Stamping {
+
+  public static Stamping read(String stableStatusFile, String volatileStatusFile)
+      throws IOException {
+    return new Stamping(parse(stableStatusFile), parse(volatileStatusFile));
+  }
+
+  public static Stamping empty() {
+    return new Stamping(ImmutableMap.of(), ImmutableMap.of());
+  }
+
+  private final ImmutableMap<String, String> stableInfo;
+  private final ImmutableMap<String, String> volatileInfo;
+
+  private static ImmutableMap<String, String> parse(String path) throws IOException {
+    ImmutableMap.Builder<String, String> builder = ImmutableMap.builder();
+    List<String> lines = Files.readAllLines(Path.of(path));
+    for (String line : lines) {
+      String[] kv = line.split(" ", 2); // split on first space only
+      builder.put(kv[0], kv[1]);
+    }
+    return builder.build();
+  }
+
+  private Stamping(
+      ImmutableMap<String, String> stableInfo, ImmutableMap<String, String> volatileInfo) {
+    this.stableInfo = stableInfo;
+    this.volatileInfo = volatileInfo;
+  }
+
+  public ImmutableMap<String, String> getStable() {
+    return stableInfo;
+  }
+
+  public ImmutableMap<String, String> getVolatile() {
+    return volatileInfo;
+  }
+}
diff --git a/stardoc/private/stardoc.bzl b/stardoc/private/stardoc.bzl
index 1101524..9ebe142 100644
--- a/stardoc/private/stardoc.bzl
+++ b/stardoc/private/stardoc.bzl
@@ -28,6 +28,9 @@
     renderer_args.add("--rule_template=" + str(ctx.file.rule_template.path))
     renderer_args.add("--repository_rule_template=" + str(ctx.file.repository_rule_template.path))
     renderer_args.add("--module_extension_template=" + str(ctx.file.module_extension_template.path))
+    if ctx.attr.stamp:
+        renderer_args.add("--stamping_stable_status_file=" + str(ctx.info_file.path))
+        renderer_args.add("--stamping_volatile_status_file=" + str(ctx.version_file.path))
 
     inputs = [
         proto_file,
@@ -41,6 +44,10 @@
     ]
     if ctx.attr.table_of_contents_template:
         inputs.append(ctx.file.table_of_contents_template)
+    if ctx.attr.stamp:
+        inputs.append(ctx.info_file)
+        inputs.append(ctx.version_file)
+
     renderer = ctx.executable.renderer
     ctx.actions.run(
         arguments = [renderer_args],
@@ -155,6 +162,10 @@
         allow_single_file = [".vm"],
         mandatory = True,
     ),
+    "stamp": attr.bool(
+        doc = "Whether to provide stamping information to templates",
+        default = False,
+    ),
 }
 
 # TODO(arostovtsev): replace with ... attrs = { ... } | _common_renderer_attrs
diff --git a/stardoc/stardoc.bzl b/stardoc/stardoc.bzl
index ac312aa..dd350d8 100644
--- a/stardoc/stardoc.bzl
+++ b/stardoc/stardoc.bzl
@@ -39,6 +39,7 @@
         module_extension_template = Label("//stardoc:templates/markdown_tables/module_extension.vm"),
         use_starlark_doc_extract = True,
         render_main_repo_name = True,
+        stamp = False,
         **kwargs):
     """Generates documentation for exported starlark rule definitions in a target starlark file.
 
@@ -76,6 +77,7 @@
         the module name or workspace name). This parameter is used only when using the native
         `starlark_doc_extract` rule.
       use_starlark_doc_extract: Use the native `starlark_doc_extract` rule if available.
+      stamp: Whether to provide stamping information to templates.
       **kwargs: Further arguments to pass to stardoc.
     """
 
@@ -123,6 +125,7 @@
                 rule_template = rule_template,
                 repository_rule_template = repository_rule_template,
                 module_extension_template = module_extension_template,
+                stamp = stamp,
                 **kwargs
             )
         elif format == "proto" and not extractor_is_main_target:
diff --git a/test/BUILD b/test/BUILD
index 9f151e5..1f5c2f4 100644
--- a/test/BUILD
+++ b/test/BUILD
@@ -292,6 +292,15 @@
     deps = [":table_of_contents_test_deps"],
 )
 
+stardoc_test(
+    name = "stamping_test",
+    golden_file = "testdata/stamping_test/golden.md",
+    header_template = "testdata/stamping_test/stamping_header.vm",
+    input_file = "testdata/stamping_test/input.bzl",
+    stamp = True,
+    test_legacy_extractor = False,
+)
+
 sh_test(
     name = "local_repository_test_e2e_test",
     srcs = ["diff_test_runner.sh"],
diff --git a/test/testdata/stamping_test/golden.md b/test/testdata/stamping_test/golden.md
new file mode 100644
index 0000000..4432e95
--- /dev/null
+++ b/test/testdata/stamping_test/golden.md
@@ -0,0 +1,7 @@
+This Stardoc was built in the `AD` era.
+
+Host empty: false
+
+This key does not exist: 
+
+
diff --git a/test/testdata/stamping_test/input.bzl b/test/testdata/stamping_test/input.bzl
new file mode 100644
index 0000000..fd56550
--- /dev/null
+++ b/test/testdata/stamping_test/input.bzl
@@ -0,0 +1 @@
+# nothing needed, only uses header
diff --git a/test/testdata/stamping_test/stamping_header.vm b/test/testdata/stamping_test/stamping_header.vm
new file mode 100644
index 0000000..b58dc79
--- /dev/null
+++ b/test/testdata/stamping_test/stamping_header.vm
@@ -0,0 +1,11 @@
+## "G" in the format below is for "Era". So long as we don't go back in time ~2000 years, this should always be "AD",
+## and we don't have to fiddle with handling varying timestamps in the test.
+This Stardoc was built in the `$util.formatBuildTimestamp($stamping.volatile.BUILD_TIMESTAMP, "UTC", "G")` era.
+
+## Should test a stable value, but stable contains quasi sensitive info, so don't print that in the test output
+Host empty: $stamping.stable.BUILD_HOST.isEmpty()
+
+## Sometimes Stardoc is built without --workspace_status_command (e.g. in build tests), luckily "$!foo" will tell
+## Velocity to ignore null values:
+This key does not exist: $!stamping.stable.STABLE_GIT_COMMIT
+