feat: add stamp support for pkg_rpm Release tag (#1039)

* feat: add stamp support for pkg_rpm Release tag

Allow `{VARIABLE_NAME}` placeholders in the `release` attribute of
`pkg_rpm`. When `stamp = 1` (or `stamp = -1` with `--stamp`), workspace
status variables from volatile-status.txt are substituted at build time.

- Add `get_status_vars()` to `pkg/private/build_info.py`
- Add `--volatile_status_file` and `--stable_status_file` args to
  `make_rpm.py`; apply substitution in `RpmBuilder.__init__` via the
  new `stamp_vars` parameter
- Add `stamp` and `private_stamp_detect` attrs to the `pkg_rpm` rule;
  rename the rule to `_pkg_rpm_rule` and expose a `pkg_rpm` macro that
  injects `private_stamp_detect` via `select()`, matching the pattern
  used by `pkg_tar` and `pkg_zip`
- Avoid embedding template placeholders in the output filename when stamp
  is active

Fixes #1038

* clean up uses of str.strip() in build_info.py

We don't need to call it multiple times on the same line value, we can
just call it once and save it.

* Address PR #1039 review feedback

- Add docstring to get_status_vars() in build_info.py
- Use get_stamp_detect() from util.bzl instead of inline select()
  to match the pattern from PR #1035
- Expand release attr docstring to document stamp variable substitution
- Add stamped RPM example to examples/rpm/system_rpmbuild_pfg
diff --git a/doc_build/BUILD b/doc_build/BUILD
index 55837af..b144f5a 100644
--- a/doc_build/BUILD
+++ b/doc_build/BUILD
@@ -44,6 +44,7 @@
     ("pkg_deb_impl", "//pkg/private/deb:deb.bzl"),
     ("pkg_sub_rpm", "//pkg:rpm_pfg.bzl"),
     ("pkg_rpm", "//pkg:rpm_pfg.bzl"),
+    ("pkg_rpm_impl", "//pkg:rpm_pfg.bzl"),
     ("pkg_tar", "//pkg/private/tar:tar.bzl"),
     ("pkg_tar_impl", "//pkg/private/tar:tar.bzl"),
     ("pkg_zip", "//pkg/private/zip:zip.bzl"),
diff --git a/examples/rpm/system_rpmbuild_stamp_bzlmod/BUILD b/examples/rpm/system_rpmbuild_stamp_bzlmod/BUILD
new file mode 100644
index 0000000..5619cc8
--- /dev/null
+++ b/examples/rpm/system_rpmbuild_stamp_bzlmod/BUILD
@@ -0,0 +1,42 @@
+# Copyright 2026 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.
+
+# Example: use stamp = 1 to substitute workspace status variables in the
+# release tag.  Build with `bazel build --stamp :test-rpm-stamped` to embed
+# the build timestamp.
+
+load("@rules_pkg//pkg:mappings.bzl", "pkg_files")
+load("@rules_pkg//pkg:rpm.bzl", "pkg_rpm")
+
+pkg_files(
+    name = "rpm_content",
+    srcs = [
+        "BUILD",
+        "MODULE.bazel",
+        "README.md",
+    ],
+    prefix = "/usr/share",
+)
+
+pkg_rpm(
+    name = "test-rpm-stamped",
+    srcs = ["rpm_content"],
+    architecture = "noarch",
+    description = "placeholder 2",
+    license = "Apache License, v2.0",
+    release = "0.{BUILD_TIMESTAMP}",
+    stamp = 1,
+    summary = "rules_pkg example RPM with stamped release",
+    version = "1",
+)
diff --git a/examples/rpm/system_rpmbuild_stamp_bzlmod/MODULE.bazel b/examples/rpm/system_rpmbuild_stamp_bzlmod/MODULE.bazel
new file mode 100644
index 0000000..3b1faa3
--- /dev/null
+++ b/examples/rpm/system_rpmbuild_stamp_bzlmod/MODULE.bazel
@@ -0,0 +1,33 @@
+# Copyright 2026 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.
+
+module(name = "rules_pkg_example_rpm_system_rpmbuild_pfg")
+
+bazel_dep(name = "bazel_skylib", version = "1.8.1")
+bazel_dep(name = "rules_cc", version = "0.1.2")
+bazel_dep(name = "rules_pkg")
+local_path_override(
+    module_name = "rules_pkg",
+    path = "../../..",
+)
+
+find_rpmbuild = use_extension(
+    "@rules_pkg//toolchains/rpm:rpmbuild_configure.bzl",
+    "find_system_rpmbuild_bzlmod",
+)
+use_repo(find_rpmbuild, "rules_pkg_rpmbuild")
+
+register_toolchains(
+    "@rules_pkg_rpmbuild//:all",
+)
diff --git a/examples/rpm/system_rpmbuild_stamp_bzlmod/README.md b/examples/rpm/system_rpmbuild_stamp_bzlmod/README.md
new file mode 100644
index 0000000..e100da8
--- /dev/null
+++ b/examples/rpm/system_rpmbuild_stamp_bzlmod/README.md
@@ -0,0 +1,19 @@
+# Using system rpmbuild with bzlmod and stamp = 1
+
+## Summary
+
+Target declarations may use the `stamp` attribute to control the time
+stamping of files in an archive. The behavior follows the pattern of
+the cc_binary rule:
+
+https://docs.bazel.build/versions/main/be/c-cpp.html#cc_binary
+
+Read the BUILD file for more details.
+
+## To Demonstrate
+
+```
+bazel build test-rpm-stamped
+# look for the build timestamp in the Release field:
+rpm -qip bazel-bin/test-rpm-stamped-1.noarch.rpm
+```
diff --git a/pkg/BUILD b/pkg/BUILD
index 0de5b7b..58ac877 100644
--- a/pkg/BUILD
+++ b/pkg/BUILD
@@ -79,10 +79,7 @@
         "//conditions:default": [":not_compatible"],
     }),
     visibility = ["//visibility:public"],
-    deps = [
-        "//pkg:make_rpm_lib",
-        "//pkg/private:archive",
-    ],
+    deps = ["//pkg:make_rpm_lib"],
 )
 
 py_library(
@@ -96,6 +93,7 @@
     ],
     deps = [
         "//pkg/private:archive",
+        "//pkg/private:build_info",
         "//pkg/private:helpers",
     ],
 )
diff --git a/pkg/make_rpm.py b/pkg/make_rpm.py
index d4a7c37..a6a84b8 100644
--- a/pkg/make_rpm.py
+++ b/pkg/make_rpm.py
@@ -29,6 +29,7 @@
 import tempfile
 from string import Template
 
+from pkg.private import build_info
 from pkg.private import helpers
 
 
@@ -188,11 +189,14 @@
   }
 
   def __init__(self, name, version, release, arch, rpmbuild_path,
-               source_date_epoch=None,
+               source_date_epoch=None, stamp_vars=None,
                debug=False):
     self.name = name
     self.version = helpers.GetFlagValue(version)
     self.release = helpers.GetFlagValue(release)
+    if stamp_vars and self.release:
+      for key, val in stamp_vars.items():
+        self.release = self.release.replace('{' + key + '}', val)
     self.arch = arch
     self.files = []
     self.rpmbuild_path = FindRpmbuild(rpmbuild_path)
@@ -560,6 +564,10 @@
                            'environment variable')
   parser.add_argument('--debug', action='store_true', default=False,
                       help='Print debug messages.')
+  parser.add_argument('--volatile_status_file', default='',
+                      help='Path to volatile-status.txt for stamp variable substitution.')
+  parser.add_argument('--stable_status_file', default='',
+                      help='Path to stable-status.txt for stamp variable substitution. ')
 
   # Options currently used experimental/rpm.bzl:
   parser.add_argument('--install_script',
@@ -595,10 +603,16 @@
   options = parser.parse_args(argv or ())
 
   try:
+    stamp_vars = {}
+    if options.volatile_status_file:
+      stamp_vars = build_info.get_status_vars(options.volatile_status_file)
+    if options.stable_status_file:
+      stamp_vars.update(build_info.get_status_vars(options.stable_status_file))
     builder = RpmBuilder(options.name,
                          options.version, options.release,
                          options.arch, options.rpmbuild,
                          source_date_epoch=options.source_date_epoch,
+                         stamp_vars=stamp_vars,
                          debug=options.debug)
     builder.AddFiles(options.files)
     return builder.Build(options.spec_file, options.out_file,
diff --git a/pkg/private/build_info.py b/pkg/private/build_info.py
index 8b8f3a0..22334a3 100644
--- a/pkg/private/build_info.py
+++ b/pkg/private/build_info.py
@@ -11,14 +11,41 @@
 # 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.
-"""Get BUILD_TIMESTAMP."""
+"""Workspace status file utilities."""
 
+def get_status_vars(status_file, include_empty=True):
+  """Read workspace status variables from a status file.
+
+  Reads a file of "name<space>value" pairs and returns a dict of all
+  variables found.  The file should be in the workspace status format:
+  https://docs.bazel.build/versions/master/user-manual.html#workspace_status
+
+  Args:
+    status_file: path to a workspace status file.  Typically
+      ctx.info_file.path (stable) or ctx.version_file.path (volatile).
+    include_empty: if True (default), keys with no value are included
+      in the result with an empty-string value.
+  Returns:
+    dict: mapping of variable name to value.
+  """
+  result = {}
+  with open(status_file, 'r') as f:
+    for line in f:
+      stripped = line.strip()
+      if not stripped:
+        continue
+      parts = stripped.split(' ', 1)
+      if len(parts) == 2:
+        result[parts[0]] = parts[1]
+      elif include_empty:
+        result[parts[0]] = ""
+
+  return result
 
 def get_timestamp(volatile_status_file):
-  """Get BUILD_TIMESTAMP as an integer.
+  """Get BUILD_TIMESTAMP as an integer from volatile-status.txt.
 
-  Reads a file of "name<space>value" pairs and returns the value
-  of the BUILD_TIMESTAMP. The file should be in the workspace status
+  The file should be in the workspace status
   format: https://docs.bazel.build/versions/master/user-manual.html#workspace_status
 
   Args:
@@ -28,10 +55,10 @@
   Exceptions:
     Exception: Raised if there is no BUILD_TIMESTAMP or if it is not a number.
   """
-  with open(volatile_status_file, 'r') as status_f:
-    for line in status_f:
-      parts = line.strip().split(' ')
-      if len(parts) > 1 and parts[0] == 'BUILD_TIMESTAMP':
-        return int(parts[1])
+  status_vars = get_status_vars(volatile_status_file, include_empty=False)
+  key = 'BUILD_TIMESTAMP'
+  ts = status_vars.get(key)
+  if ts is not None:
+    return int(ts)
   raise Exception(
-      "Invalid status file <%s>. Expected to find BUILD_TIMESTAMP" % volatile_status_file)
+      "Invalid status file <%s>. Expected to find %s" % (volatile_status_file, key))
diff --git a/pkg/rpm_pfg.bzl b/pkg/rpm_pfg.bzl
index 205a620..9533ad0 100644
--- a/pkg/rpm_pfg.bzl
+++ b/pkg/rpm_pfg.bzl
@@ -33,7 +33,7 @@
     "PackageSymlinkInfo",
     "PackageVariablesInfo",
 )
-load("//pkg/private:util.bzl", "setup_output_files", "substitute_package_variables")
+load("//pkg/private:util.bzl", "get_stamp_detect", "setup_output_files", "substitute_package_variables")
 load(
     "//toolchains/rpm:rpmbuild_configure.bzl",
     "DEBUGINFO_TYPE_FEDORA",
@@ -343,7 +343,7 @@
                 rpm_ctx,
             )
 
-def _process_subrpm(ctx, rpm_name, rpm_info, rpm_ctx, debuginfo_type):
+def _process_subrpm(ctx, rpm_name, rpm_info, rpm_ctx, debuginfo_type, effective_release = None):
     sub_rpm_ctx = struct(
         dest_check_map = {},
         install_script_pieces = [],
@@ -424,7 +424,7 @@
         version = rpm_info.version or ctx.attr.version,
         architecture = rpm_info.architecture or ctx.attr.architecture,
         package_name = rpm_info.package_name,
-        release = ctx.attr.release,
+        release = effective_release,
     )
 
     default_file = ctx.actions.declare_file("{}-{}.rpm".format(rpm_name, rpm_info.package_name))
@@ -479,6 +479,7 @@
     debuginfo_type = DEBUGINFO_TYPE_NONE
     name = ctx.attr.package_name if ctx.attr.package_name else ctx.label.name
     rpm_ctx.make_rpm_args.append("--name=" + name)
+    _stamp_active = ctx.attr.stamp == 1 or (ctx.attr.stamp == -1 and ctx.attr.private_stamp_detect)
 
     if ctx.attr.debug:
         rpm_ctx.make_rpm_args.append("--debug")
@@ -516,13 +517,15 @@
 
     default_file = ctx.actions.declare_file("{}.rpm".format(rpm_name))
 
+    # When stamp is active, don't embed template placeholders in the filename.
+    effective_release = ctx.attr.release if (ctx.attr.release and not _stamp_active) else None
     package_file_name = ctx.attr.package_file_name
     if not package_file_name:
         package_file_name = _make_rpm_filename(
             rpm_name,
             ctx.attr.version,
             ctx.attr.architecture,
-            release = ctx.attr.release,
+            release = effective_release,
         )
 
     #### rpm spec "preamble"
@@ -552,10 +555,21 @@
         rpm_ctx.make_rpm_args.append("--release=@" + ctx.file.release_file.path)
         files.append(ctx.file.release_file)
     elif ctx.attr.release:
-        preamble_pieces.append("Release: " + ctx.attr.release)
+        if _stamp_active:
+            # Route through make_rpm.py so stamp vars can be substituted
+            preamble_pieces.append("Release: ${{RELEASE_FROM_FILE}}")
+            rpm_ctx.make_rpm_args.append("--release=" + ctx.attr.release)
+        else:
+            preamble_pieces.append("Release: " + ctx.attr.release)
     else:
         fail("None of the release or release_file attributes were specified")
 
+    if _stamp_active:
+        rpm_ctx.make_rpm_args.append("--volatile_status_file=" + ctx.version_file.path)
+        files.append(ctx.version_file)
+        rpm_ctx.make_rpm_args.append("--stable_status_file=" + ctx.info_file.path)
+        files.append(ctx.info_file)
+
     # source_date_epoch is an integer, and Bazel (as of 4.2.2) does not allow
     # you to put "None" as the default for an "int" attribute.  See also
     # https://github.com/bazelbuild/bazel/issues/14434.
@@ -758,6 +772,7 @@
                 s[PackageSubRPMInfo],
                 rpm_ctx,
                 debuginfo_type,
+                effective_release = effective_release,
             ))
 
         subrpm_file = ctx.actions.declare_file(
@@ -779,7 +794,7 @@
             ctx.attr.version,
             ctx.attr.architecture,
             package_name = "debuginfo",
-            release = ctx.attr.release,
+            release = effective_release,
         )
 
         _, debuginfo_output_file, _ = setup_output_files(
@@ -916,7 +931,7 @@
     ]
 
 # Define the rule.
-pkg_rpm = rule(
+pkg_rpm_impl = rule(
     doc = """Creates an RPM format package via `pkg_filegroup` and friends.
 
     The uses the outputs of the rules in `mappings.bzl` to construct arbitrary
@@ -994,6 +1009,14 @@
             doc = """RPM "Release" tag
 
             Exactly one of `release` or `release_file` must be provided.
+
+            When `stamp` is enabled, workspace status variable placeholders
+            of the form `{VARIABLE_NAME}` will be substituted at build time
+            using values from the stable and volatile status files.  For
+            example, setting `release = "0.{BUILD_TIMESTAMP}"` with
+            `stamp = 1` will embed the build timestamp in the release tag.
+            See https://bazel.build/docs/user-manual#workspace-status for
+            details on workspace status variables.
             """,
         ),
         "release_file": attr.label(
@@ -1293,6 +1316,18 @@
             doc = """Extra files that are needed by rpmbuild or find-debuginfo""",
             allow_files = True,
         ),
+        "stamp": attr.int(
+            doc = """Enable stamping for volatile release values.  Possible values:
+<li>stamp = 1: Substitute workspace status variables in the release tag.
+<li>stamp = 0: No substitution; release tag used as-is.
+<li>stamp = -1: Controlled by the --[no]stamp flag.
+""",
+            values = [-1, 0, 1],
+            default = 0,
+        ),
+        # Is --stamp set on the command line?
+        # TODO(https://github.com/bazelbuild/rules_pkg/issues/340): Remove this.
+        "private_stamp_detect": attr.bool(default = False),
         # Implicit dependencies.
         "_make_rpm": attr.label(
             default = Label("//pkg:make_rpm"),
@@ -1312,6 +1347,17 @@
     toolchains = ["@rules_pkg//toolchains/rpm:rpmbuild_toolchain_type"],
 )
 
+def pkg_rpm(name, **kwargs):
+    """Creates an RPM format package via `pkg_filegroup` and friends.
+
+    @wraps(pkg_rpm_impl)
+    """
+    pkg_rpm_impl(
+        name = name,
+        private_stamp_detect = get_stamp_detect(kwargs.get("stamp", 0)),
+        **kwargs
+    )
+
 def _pkg_sub_rpm_impl(ctx):
     mapped_files_depsets = []