diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fdae397..647964e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,16 +62,15 @@ jobs: # the deploy target to the local staging directory configured in the # JReleaser block (target/staging-deploy). # - # project.build.outputTimestamp pins every archive/manifest timestamp - # to the release commit's committer date (strict ISO-8601 via %cI), so - # the same tag always builds byte-identical artifacts (reproducible - # builds), independent of when or where the build runs. + # Deliberately NO -Dproject.build.outputTimestamp here. The timestamp is a + # fixed literal in pom.xml, inherited by every child project. A build flag + # would be invisible to anyone outside this organisation, so an external + # verifier checking out this tag could never reproduce the published bytes. + # Apart from the deployment arguments, this is exactly the command the + # README tells a third party to run. run: | - BUILD_TS=$(git log -1 --format=%cI) - echo "Pinning project.build.outputTimestamp to $BUILD_TS" ./mvnw -B -Pfull-build clean deploy \ - -DaltDeploymentRepository=local::file:./target/staging-deploy \ - -Dproject.build.outputTimestamp="$BUILD_TS" + -DaltDeploymentRepository=local::file:./target/staging-deploy - name: Publish with JReleaser run: ./mvnw -B -Pdeploy-release jreleaser:full-release -N diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index 82487ce..dd93e6b 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -50,14 +50,12 @@ jobs: # artifacts to the Central Portal snapshot repo configured in pom.xml. # No GPG signing is required for snapshots. if: steps.version.outputs.is_snapshot == 'true' - # project.build.outputTimestamp pins every archive/manifest timestamp to - # the commit's committer date (strict ISO-8601 via %cI), so the same - # commit always builds byte-identical artifacts (reproducible builds), - # independent of when or where the build runs. + # Deliberately NO -Dproject.build.outputTimestamp here; the value is a fixed + # literal in pom.xml. Snapshots between releases therefore carry the previous + # release's date. That is deterministic but not reproducible across republished + # snapshots, which is accepted: snapshots are not a reproducibility target. run: | - BUILD_TS=$(git log -1 --format=%cI) - echo "Pinning project.build.outputTimestamp to $BUILD_TS" - ./mvnw -B -Pfull-build clean deploy -Dproject.build.outputTimestamp="$BUILD_TS" + ./mvnw -B -Pfull-build clean deploy env: MAVEN_CENTRAL_USERNAME: ${{ secrets.MAVEN_CENTRAL_USERNAME }} MAVEN_CENTRAL_PASSWORD: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} diff --git a/README.md b/README.md index 1b239e3..0136431 100644 --- a/README.md +++ b/README.md @@ -60,12 +60,99 @@ builds are reproducible and free of "you should pin this plugin" warnings: - **UTF-8** for sources and reporting. - **`-parameters`** compiler flag (parameter names retained — useful for frameworks like Spring and Jackson). +- **Reproducible builds** via an inherited `project.build.outputTimestamp` + (see [Reproducible builds](#reproducible-builds)). - **Code formatting** via Spotless using [Google Java Format](https://github.com/google/google-java-format). - **Surefire** pre-configured with the `--add-opens` flags commonly needed by reflection-based test/mocking libraries. - **Toolchain enforcement** (see [Requirements](#requirements)). +## Reproducible builds + +Building the same source twice yields **byte-identical artifacts** — jar, sources jar, +javadoc jar and the CycloneDX SBOM. + +This works because `java-parent` declares a fixed timestamp that every child project +inherits: + +```xml + + 2026-08-28T00:00:00Z + +``` + +Child projects **set nothing**. They inherit the value by pinning a `java-parent` +version, and `release.sh` updates it whenever a new `java-parent` release is cut. + +### What is promised + +> The same source, built with the **same toolchain**, produces byte-identical artifacts — +> regardless of when or where the build runs. + +"Same toolchain" is part of the promise, not a footnote. Reproducibility across +*differing* JDK patch versions, Maven versions, operating systems or locales has **not +been measured** and is not claimed. Javadoc output in particular is known to vary +between JDK builds. Use the Java and Maven versions this project enforces. + +### Verifying a release yourself + +No build flags and no insider knowledge are needed. Apart from its deployment +arguments, this is the same command CI runs: + +```bash +git checkout vA.B.C # any release that carries the timestamp property +./mvnw -Pfull-build clean verify +``` + +Then compare the result against the artifacts published on Maven Central, for example +with `sha256sum`. They must match. + +> **Do not pass `-Dproject.build.outputTimestamp`, and do not override the property in +> a child POM.** Both take precedence over the inherited value (`-D` beats a child POM, +> which beats the parent), so either one silently produces artifacts that nobody else +> can reproduce. + +### The timestamp is not a build time + +`project.build.outputTimestamp` records **which `java-parent` release an artifact was +built against**. It is deliberately not the time the build ran — a real build time +cannot be reproduced, which is the whole point. + +If an application needs to answer *"which state is this?"*, use the Git metadata that +the `full-build` profile writes into every jar's `META-INF/MANIFEST.MF`: + +| Manifest entry | Meaning | +|----------------|---------| +| `Git-Commit-Time` | When the source state came into being (UTC, commit-derived) | +| `Git-Commit` | Abbreviated commit id | +| `Git-Branch` | Branch the build came from | +| `Git-Tag` | Tags pointing at the commit | +| `Git-Dirty` | Whether the working tree had uncommitted changes | + +`Git-Commit-Time` is the correct replacement for any `buildTime` / `build.time` field. +Such a field sourced from `project.build.outputTimestamp` would be misleading and must +not be introduced. + +When the build runs outside a Git checkout — for example from a published source +archive — these entries are present but **empty**. The build deliberately does not fail +(`failOnNoGitDirectory` is `false`) so that reproducing from sources stays possible, and +the empty values are themselves deterministic. + +### Known limitations + +Tracked in [`docs/TODO.md`](docs/TODO.md): + +- No automated check guards reproducibility — a plugin upgrade could reintroduce + non-determinism unnoticed. +- Cross-toolchain reproducibility is unmeasured — see + [What is promised](#what-is-promised). +- Snapshot builds are **not** reproducible, by decision. A project pinning a + `-SNAPSHOT` parent inherits a value that moves when the snapshot is republished. +- A project pinning a `java-parent` version older than the first release carrying the + property **inherits nothing** and stays non-reproducible. Raising the parent version + is what actually switches this on for a downstream project. + ## Common commands ```bash @@ -114,11 +201,16 @@ git state, it never deploys: The script: 1. Sets the release version in the POM. -2. Runs `./mvnw -Pfull-build clean verify` locally — so a broken build, missing +2. Pins `project.build.outputTimestamp` to the release date and verifies the + rewrite took effect — a stale timestamp would publish artifacts that no + rebuild could match, so the release aborts rather than continuing. +3. Runs `./mvnw -Pfull-build clean verify` locally — so a broken build, missing Javadoc link, or SBOM error fails **here**, not after the tag is pushed. -3. Best-effort generates upgrade documentation under `docs/releases/` + Because the timestamp is already pinned, these are the bytes CI will publish. +4. Best-effort generates upgrade documentation under `docs/releases/` (requires the Claude Code CLI; skipped with a warning if absent). -4. Commits, tags `vA.B.C`, pushes, then bumps to the next `-SNAPSHOT`. +5. Commits version and timestamp together, tags `vA.B.C`, pushes, then bumps to + the next `-SNAPSHOT` (leaving the timestamp at the release date). Pushing the `vA.B.C` tag triggers the release workflow, which verifies the POM version matches the tag, stages artifacts, and publishes to Maven Central while diff --git a/docs/specs/001-reproducible-build-timestamp/steps.md b/docs/specs/001-reproducible-build-timestamp/steps.md new file mode 100644 index 0000000..0eeaea1 --- /dev/null +++ b/docs/specs/001-reproducible-build-timestamp/steps.md @@ -0,0 +1,225 @@ +# Implementation Steps: Reproducible build timestamp + +## Note on verification + +This spec changes build configuration in a `packaging=pom` project with no source +code and no test framework. There is no unit test to write and — by explicit decision +recorded in `design.md` — no automated reproducibility check either; that is deferred +in [`docs/TODO.md`](../../TODO.md). + +Verification is therefore of two kinds, and the steps say which applies: + +- **Inspection** — the configuration is read and confirmed to say what the spec requires. +- **Execution** — a real build is run and its output measured (Step 5). + +Scenarios that describe future release runs or third-party behaviour cannot be +executed here without cutting a real release; those are verified by inspection of the +mechanism plus the Step 5 measurement of the underlying property. This is stated +honestly in the coverage table rather than claimed as test coverage. + +--- + +## Step 1: Pin the build timestamp in the parent POM + +- [x] Add `project.build.outputTimestamp` to `` in `pom.xml`, seeded with + the current UTC date at midnight (`2026-08-28T00:00:00Z`) +- [x] Add a comment explaining that the value is inherited by all children, is + maintained by `release.sh`, and is not a build time + +**Acceptance criteria:** +- [x] `./mvnw validate` succeeds +- [x] `./mvnw help:evaluate -Dexpression=project.build.outputTimestamp -q -DforceStdout` + prints the literal +- [x] The value matches `^\d{4}-\d{2}-\d{2}T00:00:00Z$` + +**Related behaviors:** A child inherits the parent's value without declaring anything; +No build-time field is introduced + +--- + +## Step 2: Remove the command-line timestamp override from both workflows + +- [x] `.github/workflows/release.yml` — drop the `BUILD_TS` computation and the + `-Dproject.build.outputTimestamp="$BUILD_TS"` argument +- [x] `.github/workflows/snapshot.yml` — same +- [x] Rewrite both step comments to explain that the timestamp now comes from the POM + and that CI deliberately runs the same command a third party runs + +**Acceptance criteria:** +- [x] `grep -r "outputTimestamp" .github/workflows/` returns no matches +- [x] Both workflows still parse as valid YAML +- [x] The release build command differs from the documented public command only in + deployment arguments + +**Related behaviors:** The release workflow builds without a timestamp flag; The +snapshot workflow builds without a timestamp flag; CI and a third party run the same +command + +--- + +## Step 3: Make `release.sh` maintain the timestamp + +- [x] After `versions:set -DnewVersion=$NEW_VERSION`, compute + `RELEASE_DATE=$(date -u +%Y-%m-%dT00:00:00Z)` +- [x] Rewrite the property with + `./mvnw versions:set-property -Dproperty=project.build.outputTimestamp -DnewVersion="$RELEASE_DATE"` +- [x] Verify the rewrite actually took effect and abort if it did not — `set-property` + is documented as performing "no sanity checks", so a silent no-op must not pass + unnoticed +- [x] Place both before the local `-Pfull-build clean verify` so the verified bytes + match what CI will publish +- [x] Leave the next-`SNAPSHOT` bump untouched +- [x] Comment why the value is date-granular and why it is not a build time + +**Acceptance criteria:** +- [x] `bash -n release.sh` passes +- [x] The timestamp rewrite appears before the verification build in the script +- [x] The next-snapshot section contains no timestamp handling +- [x] A failed or ineffective rewrite exits non-zero + +**Related behaviors:** Cutting a release rewrites the timestamp to the release date; +The release commit carries both the version and the timestamp; The timestamp is +written before the verification build; Bumping to the next snapshot leaves the +timestamp untouched; Two releases on the same day share a timestamp; A failed property +rewrite aborts the release + +--- + +## Step 4: Document the reproducibility claim in the README + +- [x] Add a **Reproducible builds** section stating the narrow claim: same source plus + same toolchain yields byte-identical artifacts +- [x] Document the verification recipe: check out the tag, run + `./mvnw -Pfull-build clean verify`, compare against Maven Central +- [x] Warn that `-Dproject.build.outputTimestamp` or a child-POM override defeats + external verification +- [x] Document `Git-Commit-Time` as the deterministic answer to "when did this state + come into being", and state that the timestamp property is not a build time +- [x] Mention the property in the existing "Build conventions" list + +**Acceptance criteria:** +- [x] The README does not claim reproducibility across differing JDK patch versions +- [x] The documented public build command matches what CI runs +- [x] Links and table formatting render correctly + +**Related behaviors:** The README states only the measured claim; Git-Commit-Time +remains available and distinct; Deferred work is discoverable + +--- + +## Step 5: Acceptance run — measure reproducibility with a fixture child + +- [x] `./mvnw -N install` the modified parent +- [x] Create a throwaway child project outside the repository, inheriting the parent, + with one Java source file +- [x] Build it twice with `-Pfull-build clean verify`, no flags, with time between runs +- [x] Compare jar, sources jar, javadoc jar, `bom.json` and `bom.xml` +- [x] Confirm the inherited value appears in the child's archive entries +- [x] Confirm a CLI `-D` override and a child-POM property each win over the inherited + value +- [x] Confirm a build with no `.git` directory succeeds and is stamped identically +- [x] Confirm `Git-Commit-Time` is present in the manifest and independent of the + timestamp property +- [x] Empirically determine whether `versions:set-property` fails on a missing + property; if it does not, confirm the Step 3 guard catches it +- [x] Record the measured results for the pull request description + +**Acceptance criteria:** +- [x] All five artifacts are byte-identical across the two runs +- [x] Override precedence is confirmed as `CLI -D` > child POM > parent POM +- [x] The no-`.git` build succeeds and produces the same stamp +- [x] The throwaway project is removed afterwards and the repository is left clean + +**Related behaviors:** A child project builds byte-identically twice; The parent's own +build is deterministic; A command-line override wins over the inherited value; A child +POM property wins over the inherited value; A build without a Git directory still +succeeds and is stamped; Git-Commit-Time remains available and distinct + +--- + +## Behavior Coverage + +All 23 scenarios from `behaviors.md`. "Layer" is Build/CI configuration throughout — +this project has no backend or frontend. "Verification" states honestly how each +scenario is confirmed. + +| Scenario | Layer | Verification | Covered in Step | +|----------|-------|--------------|-----------------| +| A child project builds byte-identically twice | Build | Execution | 5 | +| The parent's own build is deterministic | Build | Execution | 5 | +| A child inherits the parent's value without declaring anything | Build | Execution | 1, 5 | +| A child pinning an older parent is unaffected | Build | Inspection — documented as a known limitation in the README | 4 | +| A command-line override wins over the inherited value | Build | Execution | 5 | +| A child POM property wins over the inherited value | Build | Execution | 5 | +| A verifier reproduces a release with no insider knowledge | CI | Inspection — needs a real published release; mechanism verified via Steps 2 and 5 | 2, 5 | +| A build without a Git directory still succeeds and is stamped | Build | Execution | 5 | +| Locale and timezone do not affect the stamp | Build | Execution — measured, `TZ=UTC`/`LANG=C` vs `TZ=Asia/Tokyo`/`LANG=de_DE.UTF-8` | 5 | +| Cutting a release rewrites the timestamp to the release date | Release | Inspection | 3 | +| The release commit carries both the version and the timestamp | Release | Inspection | 3 | +| The timestamp is written before the verification build | Release | Inspection | 3 | +| Bumping to the next snapshot leaves the timestamp untouched | Release | Inspection | 3 | +| Two releases on the same day share a timestamp | Release | Inspection — follows from date granularity | 3 | +| A failed property rewrite aborts the release | Release | Execution — the `set-property` no-op question is settled in Step 5 | 3, 5 | +| The release workflow builds without a timestamp flag | CI | Inspection | 2 | +| The snapshot workflow builds without a timestamp flag | CI | Inspection | 2 | +| CI and a third party run the same command | CI | Inspection | 2, 4 | +| A child on a SNAPSHOT parent inherits a moving value | Build | Inspection — accepted non-goal, documented | 4 | +| Git-Commit-Time remains available and distinct | Build | Execution | 5 | +| No build-time field is introduced | Build | Inspection | 1, 4 | +| The README states only the measured claim | Docs | Inspection | 4 | +| Deferred work is discoverable | Docs | Inspection | 4 | + +No scenario is unassigned. Seven are confirmed by real measurement in Step 5; the rest +are configuration or documentation facts confirmed by reading the result. Two scenarios +describe conditions that cannot be exercised without cutting a real release, and say so. + +--- + +## Out of scope for these steps + +- `CLAUDE.md` does not exist in this repository. Creating one is unrelated to this + spec and is not done here. +- No automated reproducibility check is added — deferred by decision, tracked in + `docs/TODO.md`. + +--- + +## Step 5 — measured results + +Acceptance run on a throwaway fixture project inheriting the modified parent. +**14 checks, 14 passed, 0 failed.** + +| Check | Result | +|---|---| +| Two builds, no flags — `fixture-1.0.0.jar` | byte-identical | +| Two builds, no flags — `fixture-1.0.0-sources.jar` | byte-identical | +| Two builds, no flags — `fixture-1.0.0-javadoc.jar` | byte-identical | +| Two builds, no flags — `bom.json` / `bom.xml` | byte-identical | +| Inherited parent timestamp in archive entries | `08-28-2026 00:00` | +| `Git-Commit-Time` populated | `2026-08-28T08:01:56Z` | +| `Git-Commit-Time` distinct from `outputTimestamp` | confirmed | +| CLI `-D` override wins | `06-07-2030 08:09` | +| Child POM property wins | `11-12-2015 13:14` | +| Build without `.git` succeeds | yes | +| Build without `.git` carries the same stamp | `08-28-2026 00:00` | +| Parent's own build — `bom.json` / `bom.xml` | byte-identical | + +Timezone and locale independence, measured separately on the same fixture: + +| Check | Result | +|---|---| +| `TZ=UTC` `LANG=C` vs `TZ=Asia/Tokyo` `LANG=de_DE.UTF-8` — all five artifacts | byte-identical | +| Archive entry stamp under both | `08-28-2026 00:00` | + +Override precedence confirmed as `CLI -D` > child POM > parent POM. + +Two findings the design did not anticipate: + +- **`versions:set-property` is a silent no-op** when the property is absent: it exits 0 + and changes nothing, so `set -e` alone would not have caught a failed rewrite. Step 3 + therefore re-reads the value and aborts on mismatch. Measured: the guard fires, + `help:evaluate` returning `null object or invalid expression`. +- **Without a Git checkout the `Git-*` manifest entries are present but empty.** The + build does not fail, and the empty values are deterministic, so reproducibility is + unaffected — but `Git-Commit-Time` is only meaningful for builds from a checkout. + Documented in the README. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 767c9a4..cec7a33 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -2,4 +2,4 @@ | ID | Spec-Folder | Name | Areas | Description | GitHub Issue | Status | |-----|-------------|------|-------|-------------|--------------|--------| -| 001 | 001-reproducible-build-timestamp | Reproducible build timestamp | build, infrastructure, documentation | Fixed `project.build.outputTimestamp` literal in the parent POM, inherited by all child projects, maintained by `release.sh` — so a third party can rebuild any Open Elements Java artifact byte-identically | — | open | +| 001 | 001-reproducible-build-timestamp | Reproducible build timestamp | build, infrastructure, documentation | Fixed `project.build.outputTimestamp` literal in the parent POM, inherited by all child projects, maintained by `release.sh` — so a third party can rebuild any Open Elements Java artifact byte-identically | — | done | diff --git a/pom.xml b/pom.xml index 5c0e44f..3669886 100644 --- a/pom.xml +++ b/pom.xml @@ -37,6 +37,17 @@ UTF-8 UTF-8 + + 2026-08-28T00:00:00Z + 21 21