From 28da3ace48ba34d6f67b50d8a515db64eac0e16b Mon Sep 17 00:00:00 2001 From: Connor McEntee Date: Wed, 1 Jul 2026 14:21:35 -0600 Subject: [PATCH] feat: reproducible org.opencontainers.image.created for helm_package Derive the chart's `created` timestamp from its own `org.opencontainers.image.created` annotation in Chart.yaml. The resolved value drives the packaged .tgz mtimes, the metadata.json sidecar, and (via oci_digest) the pushed OCI manifest. - absent, or an unresolved `{KEY}` stamp token (`--nostamp`): epoch-0 - a `{KEY}` stamp token resolved at package time: the stamped value - a literal RFC3339 timestamp or literal epoch-seconds string: that time The embedded annotation value is canonicalized to RFC3339 via a yaml.Node round-trip, which keeps Chart.yaml fields the HelmChart struct does not model, so the chart, metadata, and manifest agree and no raw token or epoch integer ships. Behavior change: stamped builds no longer embed wall-clock time.Now() into `created`, which made stamped charts non-reproducible. A stamped build without the annotation now defaults to the reproducible epoch-0; opt into a real date via the annotation, using `{KEY}` stamping for build-time values. --- helm/private/packager/packager.go | 92 +++++++++++- tests/created/.gitignore | 1 + tests/created/BUILD.bazel | 5 + .../created/created.digest.oci_digest.golden | 1 + tests/created/created_test.bzl | 142 ++++++++++++++++++ tests/created/templates/configmap.yaml | 6 + tests/created/values.yaml | 2 + .../version_stamp/version_stamp_unit_test.bzl | 20 +-- tools/workspace_status.bat | 4 + tools/workspace_status.sh | 4 + 10 files changed, 253 insertions(+), 24 deletions(-) create mode 100644 tests/created/.gitignore create mode 100644 tests/created/BUILD.bazel create mode 100644 tests/created/created.digest.oci_digest.golden create mode 100644 tests/created/created_test.bzl create mode 100644 tests/created/templates/configmap.yaml create mode 100644 tests/created/values.yaml diff --git a/helm/private/packager/packager.go b/helm/private/packager/packager.go index 2908b075..aea0bf97 100644 --- a/helm/private/packager/packager.go +++ b/helm/private/packager/packager.go @@ -14,6 +14,7 @@ import ( "os" "path/filepath" "regexp" + "strconv" "strings" "time" @@ -461,6 +462,71 @@ func sanitizeChartContent(content string) (string, error) { return content, nil } +// OCI image-spec pre-defined annotation key. +const ociCreatedAnnotation = "org.opencontainers.image.created" + +// parseCreated accepts either epoch seconds (all digits) or RFC3339. +func parseCreated(s string) (time.Time, error) { + if n, err := strconv.ParseInt(s, 10, 64); err == nil { + return time.Unix(n, 0).UTC(), nil + } + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return time.Time{}, err + } + return t.UTC(), nil +} + +// A brace survives only when a {KEY} stamp token went unresolved; no valid +// created value (RFC3339 or epoch seconds) contains one. +func hasUnresolvedToken(s string) bool { return strings.Contains(s, "{") } + +// A yaml.Node round-trip preserves Chart.yaml fields the HelmChart struct does +// not model; re-marshalling through HelmChart would drop them. +func normalizeCreatedAnnotation(content, canonical string) (string, error) { + var doc yaml.Node + if err := yaml.Unmarshal([]byte(content), &doc); err != nil { + return "", fmt.Errorf("unmarshal chart: %w", err) + } + if len(doc.Content) == 0 { + return content, nil + } + + // A document node wraps the top mapping in Content[0]. + top := doc.Content[0] + if top.Kind != yaml.MappingNode { + return content, nil + } + + // Mapping node `.Content` is alternating key,value pairs. + var annotations *yaml.Node + for i := 0; i+1 < len(top.Content); i += 2 { + if top.Content[i].Value == "annotations" { + annotations = top.Content[i+1] + break + } + } + if annotations == nil || annotations.Kind != yaml.MappingNode { + return content, nil + } + + for i := 0; i+1 < len(annotations.Content); i += 2 { + if annotations.Content[i].Value == ociCreatedAnnotation { + value := annotations.Content[i+1] + value.Value = canonical + value.Tag = "!!str" + value.Style = yaml.DoubleQuotedStyle + break + } + } + + out, err := yaml.Marshal(&doc) + if err != nil { + return "", fmt.Errorf("marshal chart: %w", err) + } + return string(out), nil +} + func copyFile(source string, dest string) error { srcFile, err := os.Open(source) if err != nil { @@ -958,12 +1024,6 @@ func main() { log.SetFlags(log.LstdFlags | log.Lshortfile) - // Stamped builds embed wall-clock time into the OCI `created` annotation - // so the published manifest reflects build time. - if args.VolatileStatusFile != "" { - chartTime = time.Now().UTC() - } - cwd, err := os.Getwd() if err != nil { log.Fatal(err) @@ -1047,6 +1107,26 @@ func main() { log.Fatal(err) } + // oci_digest merges this annotation over its mtime-derived value into the OCI + // manifest, so the resolved value also fixes the published digest. Canonicalize + // it to RFC3339 to keep the tgz mtimes, metadata.json, and manifest in + // agreement. chartTime otherwise stays at the reproducible epoch-0 default. + stampedChart, err := loadChart(stampedChartContent) + if err != nil { + log.Fatal(err) + } + if raw := stampedChart.Annotations[ociCreatedAnnotation]; raw != "" { + if !hasUnresolvedToken(raw) { + if chartTime, err = parseCreated(raw); err != nil { + log.Fatalf("%s=%q is not epoch seconds or RFC3339: %v", ociCreatedAnnotation, raw, err) + } + } + stampedChartContent, err = normalizeCreatedAnnotation(stampedChartContent, chartTime.Format(time.RFC3339)) + if err != nil { + log.Fatalf("normalize %s: %v", ociCreatedAnnotation, err) + } + } + // Create a directory in which to run helm package helmDir, err := installHelmContent(dir, args.Package, stampedChartContent, stampedValuesContent, stampedSchemaContent, args.TemplatesManifest, args.FilesManifest, args.CrdsManifest, args.DepsManifest) if err != nil { diff --git a/tests/created/.gitignore b/tests/created/.gitignore new file mode 100644 index 00000000..ebf1d3dc --- /dev/null +++ b/tests/created/.gitignore @@ -0,0 +1 @@ +charts diff --git a/tests/created/BUILD.bazel b/tests/created/BUILD.bazel new file mode 100644 index 00000000..6629323b --- /dev/null +++ b/tests/created/BUILD.bazel @@ -0,0 +1,5 @@ +load(":created_test.bzl", "created_test_suite") + +created_test_suite( + name = "created_test_suite", +) diff --git a/tests/created/created.digest.oci_digest.golden b/tests/created/created.digest.oci_digest.golden new file mode 100644 index 00000000..f6cd1559 --- /dev/null +++ b/tests/created/created.digest.oci_digest.golden @@ -0,0 +1 @@ +sha256:abef8a69dbd58aad23804d65a6a9726601ee208b55b82c329e861309c04d3f06 \ No newline at end of file diff --git a/tests/created/created_test.bzl b/tests/created/created_test.bzl new file mode 100644 index 00000000..3fb0a021 --- /dev/null +++ b/tests/created/created_test.bzl @@ -0,0 +1,142 @@ +"""helm_package derives the OCI `org.opencontainers.image.created` annotation +from the chart's own annotation, canonicalized to RFC3339, defaulting to +reproducible epoch-0 when it is absent or a `{KEY}` stamp token is unresolved. +""" + +load("@bazel_skylib//rules:diff_test.bzl", "diff_test") +load("@bazel_skylib//rules:write_file.bzl", "write_file") +load("//helm:defs.bzl", "helm_chart", "helm_package") +load("//helm:helm_package_info.bzl", "HelmPackageInfo") + +def _helm_pkg_metadata_impl(ctx): + return DefaultInfo(files = depset([ctx.attr.chart[HelmPackageInfo].metadata])) + +_helm_pkg_metadata = rule( + implementation = _helm_pkg_metadata_impl, + doc = "Extracts the `metadata.json` sidecar from a `helm_package` target.", + attrs = { + "chart": attr.label( + doc = "The `helm_package` target to parse metadata from.", + providers = [HelmPackageInfo], + mandatory = True, + ), + }, +) + +def _chart_json(created = None): + content = { + "apiVersion": "v2", + "appVersion": "1.16.0", + "description": "A Helm chart for testing the created annotation.", + "icon": "https://helm.sh/img/helm.svg", + "name": "created", + "type": "application", + "version": "0.1.0", + } + if created != None: + content["annotations"] = {"org.opencontainers.image.created": created} + return json.encode(content) + +def _metadata_golden(name, created): + write_file( + name = "{}.expected_metadata".format(name), + out = "{}.expected_metadata.json".format(name), + content = """\ +{{ + "created": "{}", + "name": "created", + "version": "0.1.0" +}} +""".format(created).splitlines(), + newline = "unix", + ) + +def created_test_suite(name): + """Declares the created-annotation test targets. + + Args: + name: Name for the wrapping test_suite target. + """ + + tests = [] + + # variant -> (annotation value, stamp, expected canonical `created`) + variants = { + "default.no_stamp": (None, 0, "1970-01-01T00:00:00Z"), + "default.stamp": (None, 1, "1970-01-01T00:00:00Z"), + "literal_epoch": ("1751328000", 0, "2025-07-01T00:00:00Z"), + "literal_rfc3339": ("2026-07-01T00:00:00Z", 0, "2026-07-01T00:00:00Z"), + "token.no_stamp": ("{STABLE_SOURCE_DATE_EPOCH}", 0, "1970-01-01T00:00:00Z"), + "token.stamp": ("{STABLE_SOURCE_DATE_EPOCH}", 1, "2009-02-13T23:31:30Z"), + } + + for variant, (annotation, stamp, expected) in variants.items(): + helm_package( + name = "created.{}".format(variant), + chart_json = _chart_json(annotation), + templates = native.glob(["templates/**"]), + values = "values.yaml", + stamp = stamp, + ) + + _helm_pkg_metadata( + name = "created.{}.metadata".format(variant), + chart = ":created.{}".format(variant), + ) + + _metadata_golden("created.{}".format(variant), expected) + + diff_test( + name = "created.{}.metadata_test".format(variant), + file1 = "created.{}.expected_metadata".format(variant), + file2 = "created.{}.metadata".format(variant), + ) + tests.append("created.{}.metadata_test".format(variant)) + + # Assert the packaged Chart.yaml carries canonical RFC3339, not the raw epoch + # `1234567890`. The tar path uses the chart `name:` (`created`). + native.genrule( + name = "created.token.stamp.chart_created_line", + srcs = [":created.token.stamp"], + outs = ["created.token.stamp.chart_created_line.txt"], + cmd = "tar -xzOf $(location :created.token.stamp) created/Chart.yaml | grep org.opencontainers.image.created > $@", + ) + + write_file( + name = "created.token.stamp.expected_chart_created_line", + out = "created.token.stamp.expected_chart_created_line.txt", + content = [ + ' org.opencontainers.image.created: "2009-02-13T23:31:30Z"', + "", + ], + newline = "unix", + ) + + diff_test( + name = "created.token.stamp.normalization_test", + file1 = "created.token.stamp.expected_chart_created_line", + file2 = "created.token.stamp.chart_created_line", + ) + tests.append("created.token.stamp.normalization_test") + + # A stamped chart yields a byte-identical OCI digest across builds: the + # immutable-re-push guarantee. + helm_chart( + name = "created.digest", + chart_json = _chart_json("{STABLE_SOURCE_DATE_EPOCH}"), + templates = native.glob(["templates/**"]), + values = "values.yaml", + stamp = 1, + ) + + diff_test( + name = "created.digest_test", + file1 = "created.digest.oci_digest.golden", + file2 = ":created.digest.oci_digest", + ) + tests.append("created.digest_test") + + native.test_suite( + name = name, + tests = tests, + ) diff --git a/tests/created/templates/configmap.yaml b/tests/created/templates/configmap.yaml new file mode 100644 index 00000000..247e0871 --- /dev/null +++ b/tests/created/templates/configmap.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Release.Name }}-created +data: + data: {{ .Values.data }} diff --git a/tests/created/values.yaml b/tests/created/values.yaml new file mode 100644 index 00000000..c835fa6d --- /dev/null +++ b/tests/created/values.yaml @@ -0,0 +1,2 @@ +# Values for the `created` annotation test chart. +data: hello diff --git a/tests/version_stamp/version_stamp_unit_test.bzl b/tests/version_stamp/version_stamp_unit_test.bzl index ca6db596..0b782627 100644 --- a/tests/version_stamp/version_stamp_unit_test.bzl +++ b/tests/version_stamp/version_stamp_unit_test.bzl @@ -6,19 +6,7 @@ load("//helm:defs.bzl", "helm_lint_test", "helm_package", "helm_template_test") load("//helm:helm_package_info.bzl", "HelmPackageInfo") def _helm_pkg_metadata_impl(ctx): - src = ctx.attr.chart[HelmPackageInfo].metadata - if not ctx.attr.stamp: - return DefaultInfo(files = depset([src])) - - # Stamped builds embed time.Now() into `created`, so strip it before - # diffing — the rest of the metadata is deterministic and worth pinning. - out = ctx.actions.declare_file(ctx.label.name + ".filtered.json") - ctx.actions.run_shell( - inputs = [src], - outputs = [out], - command = "printf '%s' \"$(grep -v created {})\" > {}".format(src.path, out.path), - ) - return DefaultInfo(files = depset([out])) + return DefaultInfo(files = depset([ctx.attr.chart[HelmPackageInfo].metadata])) _helm_pkg_metadata = rule( implementation = _helm_pkg_metadata_impl, @@ -29,10 +17,6 @@ _helm_pkg_metadata = rule( providers = [HelmPackageInfo], mandatory = True, ), - "stamp": attr.bool( - doc = "Whether to stamp the metadata with build time.", - default = False, - ), }, ) @@ -60,7 +44,6 @@ def version_stamp_test_suite(name): _helm_pkg_metadata( name = "version_stamp.{}.metadata".format(name), chart = ":version_stamp.{}".format(name), - stamp = stamp_value, ) helm_lint_test( @@ -97,6 +80,7 @@ def version_stamp_test_suite(name): out = "version_stamp.stamp.expected_metadata.json", content = """\ { + "created": "1970-01-01T00:00:00Z", "name": "version-stamp", "version": "0.1.0+stable-volatile" } diff --git a/tools/workspace_status.bat b/tools/workspace_status.bat index c3ce5483..4db021db 100755 --- a/tools/workspace_status.bat +++ b/tools/workspace_status.bat @@ -2,3 +2,7 @@ ECHO OFF echo STABLE_STAMP_VALUE stable echo VOLATILE_STAMP_VALUE volatile +REM Fixed stand-in for a commit-derived source-date epoch (real builds: +REM `git log -1 --format=%%ct`). STABLE_ puts it in the stable status file, +REM part of the action cache key, so a given commit stays reproducible. +echo STABLE_SOURCE_DATE_EPOCH 1234567890 diff --git a/tools/workspace_status.sh b/tools/workspace_status.sh index 41b8f06f..3925734e 100755 --- a/tools/workspace_status.sh +++ b/tools/workspace_status.sh @@ -4,3 +4,7 @@ set -euo pipefail echo STABLE_STAMP_VALUE "stable" echo VOLATILE_STAMP_VALUE "volatile" +# Fixed stand-in for a commit-derived source-date epoch (real builds: `git log +# -1 --format=%ct`). The STABLE_ prefix puts it in the stable status file, part +# of the action cache key, so a given commit stays reproducible. +echo STABLE_SOURCE_DATE_EPOCH "1234567890"