diff --git a/.github/workflows/.test-bake.yml b/.github/workflows/.test-bake.yml index c749b82c..5ef1077a 100644 --- a/.github/workflows/.test-bake.yml +++ b/.github/workflows/.test-bake.yml @@ -750,3 +750,33 @@ jobs: username: ${{ vars.DOCKERHUB_OIDC_USERNAME }} connection_id: ${{ vars.DOCKERHUB_OIDC_CONNECTIONID }} scope: 'dhi.io@pull' + + bake-source-date-epoch: + strategy: + matrix: + epoch: + - '0' + - '1704067200' + - 'commit' + output: + - image + - local + uses: ./.github/workflows/bake.yml + permissions: + contents: read + id-token: write + with: + output: ${{ matrix.output }} + source-date-epoch: ${{ matrix.epoch }} + set-meta-labels: true + set-meta-annotations: true + meta-images: example/source-date-epoch + meta-labels: | + org.opencontainers.image.created=2000-01-01T00:00:00Z + meta-annotations: | + org.opencontainers.image.created=2000-01-01T00:00:00Z + context: test + target: source-date-epoch + set: | + *.args.SOURCE_DATE_EPOCH=1 + *.args.EXPECTED_EPOCH=${{ matrix.epoch }} diff --git a/.github/workflows/.test-build.yml b/.github/workflows/.test-build.yml index 5bbc3867..d41bd756 100644 --- a/.github/workflows/.test-build.yml +++ b/.github/workflows/.test-build.yml @@ -759,3 +759,32 @@ jobs: username: ${{ vars.DOCKERHUB_OIDC_USERNAME }} connection_id: ${{ vars.DOCKERHUB_OIDC_CONNECTIONID }} scope: 'dhi.io@pull' + + build-source-date-epoch: + strategy: + matrix: + epoch: + - '0' + - '1704067200' + - 'commit' + output: + - image + - local + uses: ./.github/workflows/build.yml + permissions: + contents: read + id-token: write + with: + output: ${{ matrix.output }} + source-date-epoch: ${{ matrix.epoch }} + set-meta-labels: true + set-meta-annotations: true + meta-images: example/source-date-epoch + meta-labels: | + org.opencontainers.image.created=2000-01-01T00:00:00Z + meta-annotations: | + org.opencontainers.image.created=2000-01-01T00:00:00Z + file: test/source-date-epoch.Dockerfile + build-args: | + SOURCE_DATE_EPOCH=1 + EXPECTED_EPOCH=${{ matrix.epoch }} diff --git a/.github/workflows/bake.yml b/.github/workflows/bake.yml index 5387da6e..acc09c93 100644 --- a/.github/workflows/bake.yml +++ b/.github/workflows/bake.yml @@ -96,6 +96,10 @@ on: description: "Sign attestation manifest for image output or artifacts for local output, can be one of auto, true or false. The auto mode will enable signing if push is enabled for pushing the image or if artifact-upload is enabled for uploading the local build output as GitHub Artifact" required: false default: auto + source-date-epoch: + type: string + description: "Reproducible timestamp: commit or a non-negative Unix timestamp (empty disables)" + required: false target: type: string description: "Bake target to build" @@ -219,6 +223,8 @@ jobs: include: - name: ${{ inputs.job-name-prefix }} outputs: + sourceDateEpoch: ${{ steps.set.outputs.sourceDateEpoch }} + sourceDateCreated: ${{ steps.set.outputs.sourceDateCreated }} includes: ${{ steps.set.outputs.includes }} metaImages: ${{ steps.set.outputs.metaImages }} sign: ${{ steps.set.outputs.sign }} @@ -330,6 +336,7 @@ jobs: INPUT_SBOM: ${{ inputs.sbom }} INPUT_SET: ${{ inputs.set }} INPUT_SIGN: ${{ inputs.sign }} + INPUT_SOURCE-DATE-EPOCH: ${{ inputs.source-date-epoch }} INPUT_TARGET: ${{ inputs.target }} INPUT_VARS: ${{ inputs.vars }} INPUT_GITHUB-TOKEN: ${{ secrets.github-token || github.token }} @@ -341,6 +348,7 @@ jobs: const { Build } = require('@docker/github-builder-runtime/lib/buildx/build'); const { GitHub } = require('@docker/github-builder-runtime/lib/github/github'); const { MetaImages } = require('@docker/github-builder-runtime/lib/github-builder/meta-images'); + const { SourceDateEpoch } = require('@docker/github-builder-runtime/lib/github-builder/source-date-epoch'); const { RunnerMapping } = require('@docker/github-builder-runtime/lib/github-builder/runner-mapping'); const { BakeTargets } = require('@docker/github-builder-runtime/lib/github-builder/bake-targets'); const { BakeVars } = require('@docker/github-builder-runtime/lib/github-builder/bake-vars'); @@ -364,10 +372,17 @@ jobs: const inpSbom = core.getBooleanInput('sbom'); const inpSet = Util.getInputList('set', {ignoreComma: true, quote: false}); const inpSign = core.getInput('sign'); + const inpSourceDateEpoch = core.getInput('source-date-epoch'); const inpTarget = core.getInput('target'); const inpGitHubToken = core.getInput('github-token'); const inpProxyNetwork = core.getBooleanInput('buildkit-proxy-network'); + if (inpSourceDateEpoch !== '') { + const {epoch, created} = await SourceDateEpoch.resolve(inpSourceDateEpoch, {token: inpGitHubToken}); + core.setOutput('sourceDateEpoch', epoch); + core.setOutput('sourceDateCreated', created); + } + let runnerConfig; try { runnerConfig = RunnerMapping.parse(inpRunner); @@ -595,8 +610,12 @@ jobs: images: ${{ needs.prepare.outputs.metaImages }} tags: ${{ inputs.meta-tags }} flavor: ${{ inputs.meta-flavor }} - labels: ${{ inputs.meta-labels }} - annotations: ${{ inputs.meta-annotations }} + labels: | + ${{ inputs.meta-labels }} + ${{ needs.prepare.outputs.sourceDateCreated != '' && format('org.opencontainers.image.created={0}', needs.prepare.outputs.sourceDateCreated) || '' }} + annotations: | + ${{ inputs.meta-annotations }} + ${{ needs.prepare.outputs.sourceDateCreated != '' && format('org.opencontainers.image.created={0}', needs.prepare.outputs.sourceDateCreated) || '' }} bake-target: ${{ inputs.meta-bake-target }} - name: Set up QEMU @@ -822,6 +841,7 @@ jobs: id: prepare uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: + INPUT_SOURCE-DATE-EPOCH: ${{ needs.prepare.outputs.sourceDateEpoch }} INPUT_PLATFORM: ${{ matrix.platform }} INPUT_SBOM-IMAGE: ${{ env.SBOM_IMAGE }} INPUT_LOCAL-EXPORT-DIR: ${{ env.LOCAL_EXPORT_DIR }} @@ -856,6 +876,12 @@ jobs: const { GitHub } = require('@docker/github-builder-runtime/lib/github/github'); const { Util } = require('@docker/github-builder-runtime/lib/util'); + const inpSourceDateEpoch = core.getInput('source-date-epoch'); + if (inpSourceDateEpoch !== '') { + core.exportVariable('SOURCE_DATE_EPOCH', inpSourceDateEpoch); + } + const rewriteTimestamp = inpSourceDateEpoch !== '' ? ',rewrite-timestamp=true' : ''; + const inpPlatform = core.getInput('platform'); const platformPairSuffix = inpPlatform ? `-${inpPlatform.replace(/\//g, '-')}` : ''; core.setOutput('platform-pair-suffix', platformPairSuffix); @@ -954,7 +980,7 @@ jobs: core.setFailed('meta-images is required when output is image'); return; } - outputOverride = `*.output=type=image,"name=${inpMetaImages.join(',')}",oci-artifact=true,push-by-digest=true,name-canonical=true,push=${inpPush}`; + outputOverride = `*.output=type=image,"name=${inpMetaImages.join(',')}",oci-artifact=true,push-by-digest=true,name-canonical=true,push=${inpPush}${rewriteTimestamp}`; break; case 'local': outputOverride = `*.output=type=local,platform-split=true,dest=${inpLocalExportDir}`; @@ -972,6 +998,9 @@ jobs: return; } await core.group(`Set bake overrides`, async () => { + if (inpSourceDateEpoch !== '') { + bakeOverrides.push(`*.args.SOURCE_DATE_EPOCH=${inpSourceDateEpoch}`); + } bakeOverrides.push(outputOverride); bakeOverrides.push('*.tags='); if (GitHub.context.payload.repository?.private ?? false) { @@ -1254,8 +1283,12 @@ jobs: images: ${{ needs.prepare.outputs.metaImages }} tags: ${{ inputs.meta-tags }} flavor: ${{ inputs.meta-flavor }} - labels: ${{ inputs.meta-labels }} - annotations: ${{ inputs.meta-annotations }} + labels: | + ${{ inputs.meta-labels }} + ${{ needs.prepare.outputs.sourceDateCreated != '' && format('org.opencontainers.image.created={0}', needs.prepare.outputs.sourceDateCreated) || '' }} + annotations: | + ${{ inputs.meta-annotations }} + ${{ needs.prepare.outputs.sourceDateCreated != '' && format('org.opencontainers.image.created={0}', needs.prepare.outputs.sourceDateCreated) || '' }} bake-target: ${{ inputs.meta-bake-target }} - name: Configure AWS credentials diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 554458d3..cc54ad5b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -112,6 +112,10 @@ on: description: "Sign attestation manifest for image output or artifacts for local output, can be one of auto, true or false. The auto mode will enable signing if push is enabled for pushing the image or if artifact-upload is enabled for uploading the local build output as GitHub Artifact" required: false default: auto + source-date-epoch: + type: string + description: "Reproducible timestamp: commit or a non-negative Unix timestamp (empty disables)" + required: false target: type: string description: "Sets the target stage to build" @@ -230,6 +234,8 @@ jobs: include: - name: ${{ inputs.job-name-prefix }} outputs: + sourceDateEpoch: ${{ steps.set.outputs.sourceDateEpoch }} + sourceDateCreated: ${{ steps.set.outputs.sourceDateCreated }} includes: ${{ steps.set.outputs.includes }} metaImages: ${{ steps.set.outputs.metaImages }} sign: ${{ steps.set.outputs.sign }} @@ -328,11 +334,14 @@ jobs: INPUT_PUSH: ${{ inputs.push }} INPUT_REGISTRY-LOGIN: ${{ inputs.registry-login }} INPUT_SIGN: ${{ inputs.sign }} + INPUT_SOURCE-DATE-EPOCH: ${{ inputs.source-date-epoch }} + INPUT_GITHUB-TOKEN: ${{ secrets.github-token || github.token }} INPUT_BUILDKIT-PROXY-NETWORK: ${{ inputs.buildkit-proxy-network }} with: script: | const { GitHub } = require('@docker/github-builder-runtime/lib/github/github'); const { MetaImages } = require('@docker/github-builder-runtime/lib/github-builder/meta-images'); + const { SourceDateEpoch } = require('@docker/github-builder-runtime/lib/github-builder/source-date-epoch'); const { RunnerMapping } = require('@docker/github-builder-runtime/lib/github-builder/runner-mapping'); const { Util } = require('@docker/github-builder-runtime/lib/util'); @@ -349,8 +358,16 @@ jobs: const inpPush = core.getBooleanInput('push'); const inpRegistryLogin = core.getInput('registry-login'); const inpSign = core.getInput('sign'); + const inpSourceDateEpoch = core.getInput('source-date-epoch'); + const inpGitHubToken = core.getInput('github-token'); const inpProxyNetwork = core.getBooleanInput('buildkit-proxy-network'); + if (inpSourceDateEpoch !== '') { + const {epoch, created} = await SourceDateEpoch.resolve(inpSourceDateEpoch, {token: inpGitHubToken}); + core.setOutput('sourceDateEpoch', epoch); + core.setOutput('sourceDateCreated', created); + } + let runnerConfig; try { runnerConfig = RunnerMapping.parse(inpRunner); @@ -526,8 +543,12 @@ jobs: images: ${{ needs.prepare.outputs.metaImages }} tags: ${{ inputs.meta-tags }} flavor: ${{ inputs.meta-flavor }} - labels: ${{ inputs.meta-labels }} - annotations: ${{ inputs.meta-annotations }} + labels: | + ${{ inputs.meta-labels }} + ${{ needs.prepare.outputs.sourceDateCreated != '' && format('org.opencontainers.image.created={0}', needs.prepare.outputs.sourceDateCreated) || '' }} + annotations: | + ${{ inputs.meta-annotations }} + ${{ needs.prepare.outputs.sourceDateCreated != '' && format('org.opencontainers.image.created={0}', needs.prepare.outputs.sourceDateCreated) || '' }} - name: Set up QEMU uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 @@ -752,6 +773,7 @@ jobs: id: prepare uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: + INPUT_SOURCE-DATE-EPOCH: ${{ needs.prepare.outputs.sourceDateEpoch }} INPUT_PLATFORM: ${{ matrix.platform }} INPUT_SBOM-IMAGE: ${{ env.SBOM_IMAGE }} INPUT_LOCAL-EXPORT-DIR: ${{ env.LOCAL_EXPORT_DIR }} @@ -784,6 +806,12 @@ jobs: const { GitHub } = require('@docker/github-builder-runtime/lib/github/github'); const { Util } = require('@docker/github-builder-runtime/lib/util'); + const inpSourceDateEpoch = core.getInput('source-date-epoch'); + if (inpSourceDateEpoch !== '') { + core.exportVariable('SOURCE_DATE_EPOCH', inpSourceDateEpoch); + } + const rewriteTimestamp = inpSourceDateEpoch !== '' ? ',rewrite-timestamp=true' : ''; + const inpPlatform = core.getInput('platform'); const platformPairSuffix = inpPlatform ? `-${inpPlatform.replace(/\//g, '-')}` : ''; core.setOutput('platform-pair-suffix', platformPairSuffix); @@ -831,7 +859,7 @@ jobs: core.setFailed('meta-images is required when output is image'); return; } - core.setOutput('output', `type=image,"name=${inpMetaImages.join(',')}",oci-artifact=true,push-by-digest=true,name-canonical=true,push=${inpPush}`); + core.setOutput('output', `type=image,"name=${inpMetaImages.join(',')}",oci-artifact=true,push-by-digest=true,name-canonical=true,push=${inpPush}${rewriteTimestamp}`); break; case 'local': core.setOutput('output', `type=local,platform-split=true,dest=${inpLocalExportDir}`); @@ -877,6 +905,10 @@ jobs: labels.push(...inpMetaLabels); } core.setOutput('labels', labels.join('\n')); + + if (inpSourceDateEpoch !== '') { + buildArgs += `\nSOURCE_DATE_EPOCH=${inpSourceDateEpoch}`; + } core.setOutput('build-args', buildArgs); let buildSecrets; @@ -1151,8 +1183,12 @@ jobs: images: ${{ needs.prepare.outputs.metaImages }} tags: ${{ inputs.meta-tags }} flavor: ${{ inputs.meta-flavor }} - labels: ${{ inputs.meta-labels }} - annotations: ${{ inputs.meta-annotations }} + labels: | + ${{ inputs.meta-labels }} + ${{ needs.prepare.outputs.sourceDateCreated != '' && format('org.opencontainers.image.created={0}', needs.prepare.outputs.sourceDateCreated) || '' }} + annotations: | + ${{ inputs.meta-annotations }} + ${{ needs.prepare.outputs.sourceDateCreated != '' && format('org.opencontainers.image.created={0}', needs.prepare.outputs.sourceDateCreated) || '' }} - name: Configure AWS credentials if: ${{ inputs.push && inputs.output == 'image' && needs.registry-identities.outputs.aws-ecr-enabled == 'true' }} diff --git a/README.md b/README.md index 80d3e7db..c715bfbc 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ ___ * [Outputs](#outputs-1) * [Notes](#notes) * [BuildKit proxy network](#buildkit-proxy-network) + * [Reproducible timestamps](#reproducible-timestamps) * [Build secrets](#build-secrets) * [Signed GitHub Actions cache](#signed-github-actions-cache) * [Registry identities](#registry-identities) @@ -249,6 +250,7 @@ jobs: | `sbom` | Bool | `false` | Generate [SBOM](https://docs.docker.com/build/attestations/sbom/) attestation for the build | | `shm-size` | String | | Size of [`/dev/shm`](https://docs.docker.com/engine/reference/commandline/buildx_build/#shm-size) (e.g., `2g`) | | `sign` | String | `auto` | Sign attestation manifest for `image` output or artifacts for `local` output, can be one of `auto`, `true` or `false`. The `auto` mode will enable signing if `push` is enabled for pushing the `image` or if `artifact-upload` is enabled for uploading the `local` build output as GitHub Artifact | +| `source-date-epoch` | String | | Reproducible timestamp: `commit` or a non-negative Unix timestamp. See [Reproducible timestamps](#reproducible-timestamps). | | `target` | String | | Sets the target stage to build | | `ulimit` | List | | [Ulimit](https://docs.docker.com/engine/reference/commandline/buildx_build/#ulimit) options (e.g., `nofile=1024:1024`) | | `set-meta-annotations` | Bool | `false` | Append OCI Image Format Specification annotations generated by `docker/metadata-action` | @@ -364,6 +366,7 @@ jobs: | `sbom` | Bool | `false` | Generate [SBOM](https://docs.docker.com/build/attestations/sbom/) attestation for the build | | `set` | List | | List of [target values to override](https://docs.docker.com/engine/reference/commandline/buildx_bake/#set) (e.g., `targetpattern.key=value`) | | `sign` | String | `auto` | Sign attestation manifest for `image` output or artifacts for `local` output, can be one of `auto`, `true` or `false`. The `auto` mode will enable signing if `push` is enabled for pushing the `image` or if `artifact-upload` is enabled for uploading the `local` build output as GitHub Artifact | +| `source-date-epoch` | String | | Reproducible timestamp: `commit` or a non-negative Unix timestamp. See [Reproducible timestamps](#reproducible-timestamps). | | `target` | String | `default` | Bake target to build | | `vars` | List | | [Variables](https://docs.docker.com/build/bake/variables/) to set in the Bake definition as list of key-value pair | | `set-meta-annotations` | Bool | `false` | Append OCI Image Format Specification annotations generated by `docker/metadata-action` | @@ -439,6 +442,29 @@ proxy. Enabling `buildkit-proxy-network` replaces Docker's predefined proxy build arguments for affected `RUN` operations and can bypass an application-level organizational proxy. See BuildKit's [proxy network documentation](https://github.com/moby/buildkit/blob/master/docs/proxy.md). +### Reproducible timestamps + +Set `source-date-epoch` in either workflow to a non-negative Unix timestamp +(including `"0"`) or `commit` to use the event commit's committer time. For pull +requests, this is the merge commit. Leave it empty for default behavior. + +```yaml +with: + output: image + source-date-epoch: commit + set-meta-labels: true + set-meta-annotations: true + meta-images: name/app +``` + +This sets `SOURCE_DATE_EPOCH`, rewrites image layer timestamps, and aligns local +export and generated metadata timestamps. It overrides conflicting build +arguments and `meta-labels` / `meta-annotations` creation timestamps. Enable +`set-meta-labels` / `set-meta-annotations` to apply the generated metadata. + +Dependencies and build commands must also be reproducible. Provenance includes +execution-specific data, so final image index digests can still differ. + ### Build secrets Both workflows accept `build-secrets` as a YAML object mapping BuildKit secret diff --git a/test/docker-bake.hcl b/test/docker-bake.hcl index 21aca2fe..9e8a83e0 100644 --- a/test/docker-bake.hcl +++ b/test/docker-bake.hcl @@ -128,3 +128,8 @@ target "dhi" { inherits = ["docker-metadata-action"] dockerfile = "dhi.Dockerfile" } + +target "source-date-epoch" { + inherits = ["docker-metadata-action"] + dockerfile = "source-date-epoch.Dockerfile" +} diff --git a/test/source-date-epoch.Dockerfile b/test/source-date-epoch.Dockerfile new file mode 100644 index 00000000..f4b291ad --- /dev/null +++ b/test/source-date-epoch.Dockerfile @@ -0,0 +1,12 @@ +FROM busybox AS build +ARG SOURCE_DATE_EPOCH +ARG EXPECTED_EPOCH +RUN if [ "$EXPECTED_EPOCH" = commit ]; then \ + test "$SOURCE_DATE_EPOCH" -gt 1; \ + else \ + test "$SOURCE_DATE_EPOCH" = "$EXPECTED_EPOCH"; \ + fi && \ + echo "$SOURCE_DATE_EPOCH" > /epoch + +FROM scratch +COPY --from=build /epoch /epoch