From 1f429fc0314efc1ea036810ddaa429001797e171 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 11 Aug 2026 12:43:31 +0200 Subject: [PATCH 01/11] feat: publish deploy base images to Docker Hub Deployed task containers will build on triggerdotdev/node:-bookworm and triggerdotdev/bun:-bookworm (plus -build toolchain variants) instead of installing system packages during every project's build, so worker nodes cache one shared package layer for the whole fleet. Tags are mutable and rebuilt weekly for Debian security updates; the CLI pins digests. Packages install from a recorded Debian snapshot timestamp so any published digest can be rebuilt and verified, and every publish carries a GitHub build provenance attestation. --- .github/workflows/base-images.yml | 167 ++++++++++++++++++++++++++++++ base-images/Dockerfile | 55 ++++++++++ base-images/README.md | 50 +++++++++ base-images/images.json | 32 ++++++ 4 files changed, 304 insertions(+) create mode 100644 .github/workflows/base-images.yml create mode 100644 base-images/Dockerfile create mode 100644 base-images/README.md create mode 100644 base-images/images.json diff --git a/.github/workflows/base-images.yml b/.github/workflows/base-images.yml new file mode 100644 index 0000000000..ef4cf5bc5e --- /dev/null +++ b/.github/workflows/base-images.yml @@ -0,0 +1,167 @@ +name: 🐳 Deploy base images + +# Publishes the base images that deployed task containers build on +# (triggerdotdev/node:-bookworm and triggerdotdev/bun:-bookworm, +# each with a -build toolchain variant) to Docker Hub. Tags are mutable and +# rebuilt in place; the CLI pins images by digest, so consumers are unaffected +# until a digest bump ships in a release. +# +# Packages install from a Debian snapshot timestamp (recorded in the +# dev.trigger.debian-snapshot image label and the job summary), so any +# published digest can be rebuilt and verified from the recorded inputs. + +on: + workflow_dispatch: + inputs: + debian_snapshot: + description: "Debian snapshot timestamp (YYYYMMDDTHHMMSSZ). Defaults to yesterday 00:00 UTC." + required: false + type: string + schedule: + # Weekly rebuild so packages pick up Debian security updates + - cron: "0 6 * * 1" + push: + branches: [main] + paths: + - "base-images/**" + - ".github/workflows/base-images.yml" + pull_request: + paths: + - "base-images/**" + - ".github/workflows/base-images.yml" + +concurrency: + group: base-images-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + id-token: write + attestations: write + +jobs: + setup: + runs-on: ubuntu-latest + outputs: + images: ${{ steps.config.outputs.images }} + packages: ${{ steps.config.outputs.packages }} + build_packages: ${{ steps.config.outputs.build_packages }} + suite: ${{ steps.config.outputs.suite }} + snapshot: ${{ steps.config.outputs.snapshot }} + push: ${{ steps.config.outputs.push }} + steps: + - uses: actions/checkout@v4 + + - name: Read image matrix and resolve snapshot + id: config + run: | + echo "images=$(jq -c '.images' base-images/images.json)" >> "$GITHUB_OUTPUT" + echo "packages=$(jq -r '.packages' base-images/images.json)" >> "$GITHUB_OUTPUT" + echo "build_packages=$(jq -r '.buildPackages' base-images/images.json)" >> "$GITHUB_OUTPUT" + echo "suite=$(jq -r '.suite' base-images/images.json)" >> "$GITHUB_OUTPUT" + + SNAPSHOT="${{ inputs.debian_snapshot }}" + if [ -z "$SNAPSHOT" ]; then + SNAPSHOT="$(date -u -d yesterday +%Y%m%dT000000Z)" + fi + echo "snapshot=$SNAPSHOT" >> "$GITHUB_OUTPUT" + + # Only publish from main; pull requests validate the build without pushing + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "push=false" >> "$GITHUB_OUTPUT" + else + echo "push=true" >> "$GITHUB_OUTPUT" + fi + + publish: + needs: setup + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + image: ${{ fromJSON(needs.setup.outputs.images) }} + steps: + - uses: actions/checkout@v4 + + - name: 🐳 Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: 🐳 Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: 🐳 Login to Docker Hub + if: needs.setup.outputs.push == 'true' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: 🐳 Build and push runtime image + id: build_runtime + uses: docker/build-push-action@v6 + with: + context: base-images + file: base-images/Dockerfile + target: runtime + platforms: linux/amd64,linux/arm64 + push: ${{ needs.setup.outputs.push == 'true' }} + provenance: false + tags: triggerdotdev/${{ matrix.image.repo }}:${{ matrix.image.tag }} + build-args: | + BASE_IMAGE=${{ matrix.image.base }} + DEBIAN_SNAPSHOT=${{ needs.setup.outputs.snapshot }} + DEBIAN_SUITE=${{ needs.setup.outputs.suite }} + PACKAGES=${{ needs.setup.outputs.packages }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + dev.trigger.debian-snapshot=${{ needs.setup.outputs.snapshot }} + + - name: 🐳 Build and push build-variant image + id: build_toolchain + uses: docker/build-push-action@v6 + with: + context: base-images + file: base-images/Dockerfile + target: build + platforms: linux/amd64,linux/arm64 + push: ${{ needs.setup.outputs.push == 'true' }} + provenance: false + tags: triggerdotdev/${{ matrix.image.repo }}:${{ matrix.image.tag }}-build + build-args: | + BASE_IMAGE=${{ matrix.image.base }} + DEBIAN_SNAPSHOT=${{ needs.setup.outputs.snapshot }} + DEBIAN_SUITE=${{ needs.setup.outputs.suite }} + PACKAGES=${{ needs.setup.outputs.packages }} + BUILD_PACKAGES=${{ needs.setup.outputs.build_packages }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + dev.trigger.debian-snapshot=${{ needs.setup.outputs.snapshot }} + + - name: 🔏 Attest runtime image provenance + if: needs.setup.outputs.push == 'true' + uses: actions/attest-build-provenance@v2 + with: + subject-name: index.docker.io/triggerdotdev/${{ matrix.image.repo }} + subject-digest: ${{ steps.build_runtime.outputs.digest }} + push-to-registry: false + + - name: 🔏 Attest build-variant image provenance + if: needs.setup.outputs.push == 'true' + uses: actions/attest-build-provenance@v2 + with: + subject-name: index.docker.io/triggerdotdev/${{ matrix.image.repo }} + subject-digest: ${{ steps.build_toolchain.outputs.digest }} + push-to-registry: false + + - name: 📋 Record digests + run: | + { + echo "### triggerdotdev/${{ matrix.image.repo }}:${{ matrix.image.tag }}" + echo '```' + echo "runtime: ${{ steps.build_runtime.outputs.digest }}" + echo "build: ${{ steps.build_toolchain.outputs.digest }}" + echo "debian snapshot: ${{ needs.setup.outputs.snapshot }}" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/base-images/Dockerfile b/base-images/Dockerfile new file mode 100644 index 0000000000..ab6c79a46d --- /dev/null +++ b/base-images/Dockerfile @@ -0,0 +1,55 @@ +# syntax=docker/dockerfile:1 +# check=skip=InvalidDefaultArgInFrom + +# Base images for deployed task containers. Packages install from a pinned +# Debian snapshot so any published digest can be rebuilt and verified from +# (upstream base digest, snapshot timestamp, package list); apt is restored to +# the upstream live-archive configuration afterwards so derived images behave +# like their upstream bases. + +ARG BASE_IMAGE + +FROM ${BASE_IMAGE} AS runtime + +ARG DEBIAN_SNAPSHOT +ARG DEBIAN_SUITE=bookworm +ARG PACKAGES="busybox ca-certificates dumb-init git openssl" + +ENV DEBIAN_FRONTEND=noninteractive + +# http, not https: the slim bases have no ca-certificates yet, and apt +# integrity comes from GPG-signed Release files rather than TLS. +# check-valid-until=no: pinned Release files outlive their Valid-Until window. +RUN . /etc/os-release && [ "$VERSION_CODENAME" = "${DEBIAN_SUITE}" ] || { echo "Base image is Debian $VERSION_CODENAME but this build pins ${DEBIAN_SUITE} apt sources"; exit 1; } && \ + [ -n "${DEBIAN_SNAPSHOT}" ] || { echo "DEBIAN_SNAPSHOT build arg is required"; exit 1; } && \ + mv /etc/apt/sources.list.d/debian.sources /tmp/debian.sources && \ + printf '%s\n' \ + "deb [check-valid-until=no signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT} ${DEBIAN_SUITE} main" \ + "deb [check-valid-until=no signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT} ${DEBIAN_SUITE}-security main" \ + "deb [check-valid-until=no signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT} ${DEBIAN_SUITE}-updates main" \ + > /etc/apt/sources.list && \ + apt-get update && \ + apt-get install -y --no-install-recommends ${PACKAGES} && \ + apt-get clean && \ + rm /etc/apt/sources.list && \ + mv /tmp/debian.sources /etc/apt/sources.list.d/debian.sources && \ + rm -rf /var/lib/apt/lists/* /var/log/dpkg.log /var/log/apt /var/log/alternatives.log /var/cache/ldconfig/aux-cache /var/cache/debconf/*-old + +FROM runtime AS build + +ARG DEBIAN_SNAPSHOT +ARG DEBIAN_SUITE=bookworm +ARG BUILD_PACKAGES="python3 make g++" + +RUN mv /etc/apt/sources.list.d/debian.sources /tmp/debian.sources && \ + printf '%s\n' \ + "deb [check-valid-until=no signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT} ${DEBIAN_SUITE} main" \ + "deb [check-valid-until=no signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT} ${DEBIAN_SUITE}-security main" \ + "deb [check-valid-until=no signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT} ${DEBIAN_SUITE}-updates main" \ + > /etc/apt/sources.list && \ + apt-get update && \ + apt-get install -y --no-install-recommends ${BUILD_PACKAGES} && \ + apt-get clean && \ + rm /etc/apt/sources.list && \ + mv /tmp/debian.sources /etc/apt/sources.list.d/debian.sources && \ + rm -rf /var/lib/apt/lists/* /var/log/dpkg.log /var/log/apt /var/log/alternatives.log /var/cache/ldconfig/aux-cache /var/cache/debconf/*-old diff --git a/base-images/README.md b/base-images/README.md new file mode 100644 index 0000000000..2cb530afb0 --- /dev/null +++ b/base-images/README.md @@ -0,0 +1,50 @@ +# Deploy base images + +Base images for deployed task containers, published to Docker Hub as +`triggerdotdev/node:-bookworm` and `triggerdotdev/bun:-bookworm`, +each with a `-build` variant that adds the native-module toolchain +(python3, make, g++). + +Each image is its upstream slim base (pinned by digest in `images.json`) plus +the system packages deployed tasks rely on: busybox, ca-certificates, +dumb-init, git, openssl. Nothing else changes; apt stays configured for the +live Debian archive, so images derived from these behave exactly like their +upstream bases. + +## Tags and pinning + +Tags are mutable and rebuilt in place (weekly, and on demand) so packages pick +up Debian security updates. Consumers pin digests: the CLI's generated +Containerfile references these images as `triggerdotdev/node:22-bookworm@sha256:...`, +and digests only move when a CLI release updates its pins. + +## Reproducibility and provenance + +Packages install from a [Debian snapshot archive](https://snapshot.debian.org) +timestamp recorded in the `dev.trigger.debian-snapshot` image label, so a +published image is a pure function of (upstream base digest, snapshot +timestamp, package list) and can be rebuilt and verified: + +```bash +SNAPSHOT=$(docker inspect triggerdotdev/node:22-bookworm --format '{{index .Config.Labels "dev.trigger.debian-snapshot"}}') +docker buildx build base-images --target runtime \ + --build-arg BASE_IMAGE= \ + --build-arg DEBIAN_SNAPSHOT=$SNAPSHOT \ + --platform linux/amd64,linux/arm64 \ + --provenance false \ + --output type=oci,dest=rebuilt.tar,rewrite-timestamp=true \ + --build-arg SOURCE_DATE_EPOCH=0 +``` + +Every published digest also carries a GitHub build provenance attestation: + +```bash +gh attestation verify oci://index.docker.io/triggerdotdev/node:22-bookworm --owner triggerdotdev +``` + +## Publishing + +`.github/workflows/base-images.yml` publishes on a weekly schedule, on manual +dispatch, and on changes to this directory. Pull requests build the images +without pushing. After a publish, the digests in the job summary are used to +update the `BASE_IMAGE` pins in `packages/cli-v3/src/deploy/buildImage.ts`. diff --git a/base-images/images.json b/base-images/images.json new file mode 100644 index 0000000000..38e3837204 --- /dev/null +++ b/base-images/images.json @@ -0,0 +1,32 @@ +{ + "packages": "busybox ca-certificates dumb-init git openssl", + "buildPackages": "python3 make g++", + "suite": "bookworm", + "images": [ + { + "repo": "node", + "tag": "21-bookworm", + "base": "node:21.7.3-bookworm-slim@sha256:dfc05dee209a1d7adf2ef189bd97396daad4e97c6eaa85778d6f75205ba1b0fb" + }, + { + "repo": "node", + "tag": "22-bookworm", + "base": "node:22.16.0-bookworm-slim@sha256:048ed02c5fd52e86fda6fbd2f6a76cf0d4492fd6c6fee9e2c463ed5108da0e34" + }, + { + "repo": "node", + "tag": "24-bookworm", + "base": "node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d" + }, + { + "repo": "node", + "tag": "26-bookworm", + "base": "node:26.4.0-bookworm-slim@sha256:ec82d089a8ae2cf02628da7b34ea57dc357b24db724d557fe2d240e6beb659c1" + }, + { + "repo": "bun", + "tag": "1.3-bookworm", + "base": "imbios/bun-node:1.3.3-20-slim@sha256:59d84856a7e31eec83afedadb542f7306f672343b8b265c70d733404a6e8834b" + } + ] +} From 5e0e87167a245299f1715004a106e83adfd27463 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 11 Aug 2026 13:12:00 +0200 Subject: [PATCH 02/11] fix: harden the base image publish workflow SHA-pin every action and the QEMU binfmt image, scope permissions to the publish job, validate the snapshot dispatch input and images.json values before they reach shell or build args, and stop persisting git credentials in checkouts. Publish with SOURCE_DATE_EPOCH and rewrite-timestamp so published layers are reproducible from the recorded inputs, as the README claims; make the package list build args required so images.json stays the single source of truth. Add GHA layer caching, job timeouts, and skip the digest summary on pull request runs where no digest exists. --- .github/workflows/base-images.yml | 103 +++++++++++++++++++++--------- base-images/Dockerfile | 9 +-- base-images/README.md | 21 ++++-- 3 files changed, 92 insertions(+), 41 deletions(-) diff --git a/.github/workflows/base-images.yml b/.github/workflows/base-images.yml index ef4cf5bc5e..f4e6832af2 100644 --- a/.github/workflows/base-images.yml +++ b/.github/workflows/base-images.yml @@ -7,8 +7,9 @@ name: 🐳 Deploy base images # until a digest bump ships in a release. # # Packages install from a Debian snapshot timestamp (recorded in the -# dev.trigger.debian-snapshot image label and the job summary), so any -# published digest can be rebuilt and verified from the recorded inputs. +# dev.trigger.debian-snapshot image label and the job summary), and layers are +# exported with normalized timestamps, so any published image can be rebuilt +# from the recorded inputs and verified layer for layer. on: workflow_dispatch: @@ -34,14 +35,14 @@ concurrency: group: base-images-${{ github.ref }} cancel-in-progress: false -permissions: - contents: read - id-token: write - attestations: write +permissions: {} jobs: setup: runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read outputs: images: ${{ steps.config.outputs.images }} packages: ${{ steps.config.outputs.packages }} @@ -50,68 +51,100 @@ jobs: snapshot: ${{ steps.config.outputs.snapshot }} push: ${{ steps.config.outputs.push }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Read image matrix and resolve snapshot id: config + env: + SNAPSHOT_INPUT: ${{ inputs.debian_snapshot }} + EVENT_NAME: ${{ github.event_name }} run: | - echo "images=$(jq -c '.images' base-images/images.json)" >> "$GITHUB_OUTPUT" - echo "packages=$(jq -r '.packages' base-images/images.json)" >> "$GITHUB_OUTPUT" - echo "build_packages=$(jq -r '.buildPackages' base-images/images.json)" >> "$GITHUB_OUTPUT" - echo "suite=$(jq -r '.suite' base-images/images.json)" >> "$GITHUB_OUTPUT" + PACKAGES="$(jq -r '.packages' base-images/images.json)" + BUILD_PACKAGES="$(jq -r '.buildPackages' base-images/images.json)" + SUITE="$(jq -r '.suite' base-images/images.json)" + + # Values land in build args and shell lines; keep them boring + echo "$PACKAGES" | grep -qE '^[a-z0-9 .+-]+$' || { echo "invalid packages value"; exit 1; } + echo "$BUILD_PACKAGES" | grep -qE '^[a-z0-9 .+-]+$' || { echo "invalid buildPackages value"; exit 1; } + echo "$SUITE" | grep -qE '^[a-z]+$' || { echo "invalid suite value"; exit 1; } - SNAPSHOT="${{ inputs.debian_snapshot }}" + SNAPSHOT="$SNAPSHOT_INPUT" if [ -z "$SNAPSHOT" ]; then SNAPSHOT="$(date -u -d yesterday +%Y%m%dT000000Z)" fi - echo "snapshot=$SNAPSHOT" >> "$GITHUB_OUTPUT" + echo "$SNAPSHOT" | grep -qE '^[0-9]{8}T[0-9]{6}Z$' || { echo "invalid debian_snapshot: $SNAPSHOT"; exit 1; } # Only publish from main; pull requests validate the build without pushing - if [ "${{ github.event_name }}" = "pull_request" ]; then - echo "push=false" >> "$GITHUB_OUTPUT" + if [ "$EVENT_NAME" = "pull_request" ]; then + PUSH=false else - echo "push=true" >> "$GITHUB_OUTPUT" + PUSH=true fi + { + echo "images=$(jq -c '.images' base-images/images.json)" + echo "packages=$PACKAGES" + echo "build_packages=$BUILD_PACKAGES" + echo "suite=$SUITE" + echo "snapshot=$SNAPSHOT" + echo "push=$PUSH" + } >> "$GITHUB_OUTPUT" + publish: needs: setup runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + id-token: write + attestations: write strategy: fail-fast: false matrix: image: ${{ fromJSON(needs.setup.outputs.images) }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: 🐳 Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 + with: + image: docker.io/tonistiigi/binfmt:latest@sha256:400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0 - name: 🐳 Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: 🐳 Login to Docker Hub if: needs.setup.outputs.push == 'true' - uses: docker/login-action@v3 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: 🐳 Build and push runtime image id: build_runtime - uses: docker/build-push-action@v6 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: base-images file: base-images/Dockerfile target: runtime platforms: linux/amd64,linux/arm64 - push: ${{ needs.setup.outputs.push == 'true' }} provenance: false + # rewrite-timestamp + SOURCE_DATE_EPOCH make layer blobs a pure + # function of the build inputs, so published layers are verifiable + outputs: type=image,push=${{ needs.setup.outputs.push }},rewrite-timestamp=true tags: triggerdotdev/${{ matrix.image.repo }}:${{ matrix.image.tag }} + cache-from: type=gha,scope=base-${{ matrix.image.repo }}-${{ matrix.image.tag }} + cache-to: type=gha,scope=base-${{ matrix.image.repo }}-${{ matrix.image.tag }},mode=max build-args: | BASE_IMAGE=${{ matrix.image.base }} DEBIAN_SNAPSHOT=${{ needs.setup.outputs.snapshot }} DEBIAN_SUITE=${{ needs.setup.outputs.suite }} PACKAGES=${{ needs.setup.outputs.packages }} + SOURCE_DATE_EPOCH=0 labels: | org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.revision=${{ github.sha }} @@ -119,21 +152,24 @@ jobs: - name: 🐳 Build and push build-variant image id: build_toolchain - uses: docker/build-push-action@v6 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: base-images file: base-images/Dockerfile target: build platforms: linux/amd64,linux/arm64 - push: ${{ needs.setup.outputs.push == 'true' }} provenance: false + outputs: type=image,push=${{ needs.setup.outputs.push }},rewrite-timestamp=true tags: triggerdotdev/${{ matrix.image.repo }}:${{ matrix.image.tag }}-build + cache-from: type=gha,scope=base-${{ matrix.image.repo }}-${{ matrix.image.tag }} + cache-to: type=gha,scope=base-${{ matrix.image.repo }}-${{ matrix.image.tag }},mode=max build-args: | BASE_IMAGE=${{ matrix.image.base }} DEBIAN_SNAPSHOT=${{ needs.setup.outputs.snapshot }} DEBIAN_SUITE=${{ needs.setup.outputs.suite }} PACKAGES=${{ needs.setup.outputs.packages }} BUILD_PACKAGES=${{ needs.setup.outputs.build_packages }} + SOURCE_DATE_EPOCH=0 labels: | org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.revision=${{ github.sha }} @@ -141,7 +177,7 @@ jobs: - name: 🔏 Attest runtime image provenance if: needs.setup.outputs.push == 'true' - uses: actions/attest-build-provenance@v2 + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-name: index.docker.io/triggerdotdev/${{ matrix.image.repo }} subject-digest: ${{ steps.build_runtime.outputs.digest }} @@ -149,19 +185,26 @@ jobs: - name: 🔏 Attest build-variant image provenance if: needs.setup.outputs.push == 'true' - uses: actions/attest-build-provenance@v2 + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-name: index.docker.io/triggerdotdev/${{ matrix.image.repo }} subject-digest: ${{ steps.build_toolchain.outputs.digest }} push-to-registry: false - name: 📋 Record digests + if: needs.setup.outputs.push == 'true' + env: + IMAGE_REPO: ${{ matrix.image.repo }} + IMAGE_TAG: ${{ matrix.image.tag }} + RUNTIME_DIGEST: ${{ steps.build_runtime.outputs.digest }} + BUILD_DIGEST: ${{ steps.build_toolchain.outputs.digest }} + SNAPSHOT: ${{ needs.setup.outputs.snapshot }} run: | { - echo "### triggerdotdev/${{ matrix.image.repo }}:${{ matrix.image.tag }}" + echo "### triggerdotdev/$IMAGE_REPO:$IMAGE_TAG" echo '```' - echo "runtime: ${{ steps.build_runtime.outputs.digest }}" - echo "build: ${{ steps.build_toolchain.outputs.digest }}" - echo "debian snapshot: ${{ needs.setup.outputs.snapshot }}" + echo "runtime: $RUNTIME_DIGEST" + echo "build: $BUILD_DIGEST" + echo "debian snapshot: $SNAPSHOT" echo '```' } >> "$GITHUB_STEP_SUMMARY" diff --git a/base-images/Dockerfile b/base-images/Dockerfile index ab6c79a46d..e5e750a58c 100644 --- a/base-images/Dockerfile +++ b/base-images/Dockerfile @@ -13,7 +13,7 @@ FROM ${BASE_IMAGE} AS runtime ARG DEBIAN_SNAPSHOT ARG DEBIAN_SUITE=bookworm -ARG PACKAGES="busybox ca-certificates dumb-init git openssl" +ARG PACKAGES ENV DEBIAN_FRONTEND=noninteractive @@ -21,7 +21,7 @@ ENV DEBIAN_FRONTEND=noninteractive # integrity comes from GPG-signed Release files rather than TLS. # check-valid-until=no: pinned Release files outlive their Valid-Until window. RUN . /etc/os-release && [ "$VERSION_CODENAME" = "${DEBIAN_SUITE}" ] || { echo "Base image is Debian $VERSION_CODENAME but this build pins ${DEBIAN_SUITE} apt sources"; exit 1; } && \ - [ -n "${DEBIAN_SNAPSHOT}" ] || { echo "DEBIAN_SNAPSHOT build arg is required"; exit 1; } && \ + [ -n "${DEBIAN_SNAPSHOT}" ] && [ -n "${PACKAGES}" ] || { echo "DEBIAN_SNAPSHOT and PACKAGES build args are required (see images.json)"; exit 1; } && \ mv /etc/apt/sources.list.d/debian.sources /tmp/debian.sources && \ printf '%s\n' \ "deb [check-valid-until=no signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT} ${DEBIAN_SUITE} main" \ @@ -39,9 +39,10 @@ FROM runtime AS build ARG DEBIAN_SNAPSHOT ARG DEBIAN_SUITE=bookworm -ARG BUILD_PACKAGES="python3 make g++" +ARG BUILD_PACKAGES -RUN mv /etc/apt/sources.list.d/debian.sources /tmp/debian.sources && \ +RUN [ -n "${DEBIAN_SNAPSHOT}" ] && [ -n "${BUILD_PACKAGES}" ] || { echo "DEBIAN_SNAPSHOT and BUILD_PACKAGES build args are required (see images.json)"; exit 1; } && \ + mv /etc/apt/sources.list.d/debian.sources /tmp/debian.sources && \ printf '%s\n' \ "deb [check-valid-until=no signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT} ${DEBIAN_SUITE} main" \ "deb [check-valid-until=no signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT} ${DEBIAN_SUITE}-security main" \ diff --git a/base-images/README.md b/base-images/README.md index 2cb530afb0..62dcbdc69e 100644 --- a/base-images/README.md +++ b/base-images/README.md @@ -21,19 +21,26 @@ and digests only move when a CLI release updates its pins. ## Reproducibility and provenance Packages install from a [Debian snapshot archive](https://snapshot.debian.org) -timestamp recorded in the `dev.trigger.debian-snapshot` image label, so a -published image is a pure function of (upstream base digest, snapshot -timestamp, package list) and can be rebuilt and verified: +timestamp recorded in the `dev.trigger.debian-snapshot` image label, and the +workflow exports layers with normalized timestamps (`SOURCE_DATE_EPOCH=0` plus +`rewrite-timestamp`), so a published image's layers are a pure function of +(upstream base digest, snapshot timestamp, package list). To verify, rebuild +with the recorded inputs and compare layer digests (the manifest and config +digests differ because they carry build metadata labels like the source +revision): ```bash SNAPSHOT=$(docker inspect triggerdotdev/node:22-bookworm --format '{{index .Config.Labels "dev.trigger.debian-snapshot"}}') docker buildx build base-images --target runtime \ - --build-arg BASE_IMAGE= \ - --build-arg DEBIAN_SNAPSHOT=$SNAPSHOT \ + --build-arg BASE_IMAGE="" \ + --build-arg PACKAGES="" \ + --build-arg DEBIAN_SNAPSHOT="$SNAPSHOT" \ + --build-arg SOURCE_DATE_EPOCH=0 \ --platform linux/amd64,linux/arm64 \ --provenance false \ - --output type=oci,dest=rebuilt.tar,rewrite-timestamp=true \ - --build-arg SOURCE_DATE_EPOCH=0 + --output type=oci,dest=rebuilt.tar,rewrite-timestamp=true +# then compare .layers[].digest of the rebuilt manifests against +# `docker buildx imagetools inspect triggerdotdev/node:22-bookworm --raw` ``` Every published digest also carries a GitHub build provenance attestation: From d21530711cf6aedd3dc51d7203420f96d482c0f8 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Tue, 11 Aug 2026 13:21:30 +0200 Subject: [PATCH 03/11] fix(base-images): full snapshot upgrades, snapshot-dated builds, accurate verification docs Upgrade all preinstalled packages to the pinned snapshot state so weekly rebuilds refresh every Debian package rather than only the added ones, and so the runtime and -build variants carry the same library versions. Derive SOURCE_DATE_EPOCH from the snapshot so registries show a meaningful created date while builds stay reproducible. Make DEBIAN_FRONTEND a build arg so it stops leaking into task containers, retry apt fetches, name the bun image after its bundled node major, fail the workflow when any matrix leg fails, write the layer cache once, and fix the README verify recipe to use the published epoch and label inspection that works without pulling. --- .github/workflows/base-images.yml | 24 +++++++++++++++++++--- base-images/Dockerfile | 11 ++++++++--- base-images/README.md | 33 +++++++++++++++++++------------ base-images/images.json | 2 +- 4 files changed, 50 insertions(+), 20 deletions(-) diff --git a/.github/workflows/base-images.yml b/.github/workflows/base-images.yml index f4e6832af2..e6e99c6b9b 100644 --- a/.github/workflows/base-images.yml +++ b/.github/workflows/base-images.yml @@ -49,6 +49,7 @@ jobs: build_packages: ${{ steps.config.outputs.build_packages }} suite: ${{ steps.config.outputs.suite }} snapshot: ${{ steps.config.outputs.snapshot }} + source_date_epoch: ${{ steps.config.outputs.source_date_epoch }} push: ${{ steps.config.outputs.push }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -76,6 +77,10 @@ jobs: fi echo "$SNAPSHOT" | grep -qE '^[0-9]{8}T[0-9]{6}Z$' || { echo "invalid debian_snapshot: $SNAPSHOT"; exit 1; } + # Layer and config timestamps come from the snapshot, so images stay + # reproducible while the registry shows a meaningful created date + EPOCH="$(date -u -d "${SNAPSHOT:0:4}-${SNAPSHOT:4:2}-${SNAPSHOT:6:2} ${SNAPSHOT:9:2}:${SNAPSHOT:11:2}:${SNAPSHOT:13:2}Z" +%s)" + # Only publish from main; pull requests validate the build without pushing if [ "$EVENT_NAME" = "pull_request" ]; then PUSH=false @@ -89,6 +94,7 @@ jobs: echo "build_packages=$BUILD_PACKAGES" echo "suite=$SUITE" echo "snapshot=$SNAPSHOT" + echo "source_date_epoch=$EPOCH" echo "push=$PUSH" } >> "$GITHUB_OUTPUT" @@ -138,13 +144,12 @@ jobs: outputs: type=image,push=${{ needs.setup.outputs.push }},rewrite-timestamp=true tags: triggerdotdev/${{ matrix.image.repo }}:${{ matrix.image.tag }} cache-from: type=gha,scope=base-${{ matrix.image.repo }}-${{ matrix.image.tag }} - cache-to: type=gha,scope=base-${{ matrix.image.repo }}-${{ matrix.image.tag }},mode=max build-args: | BASE_IMAGE=${{ matrix.image.base }} DEBIAN_SNAPSHOT=${{ needs.setup.outputs.snapshot }} DEBIAN_SUITE=${{ needs.setup.outputs.suite }} PACKAGES=${{ needs.setup.outputs.packages }} - SOURCE_DATE_EPOCH=0 + SOURCE_DATE_EPOCH=${{ needs.setup.outputs.source_date_epoch }} labels: | org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.revision=${{ github.sha }} @@ -169,7 +174,7 @@ jobs: DEBIAN_SUITE=${{ needs.setup.outputs.suite }} PACKAGES=${{ needs.setup.outputs.packages }} BUILD_PACKAGES=${{ needs.setup.outputs.build_packages }} - SOURCE_DATE_EPOCH=0 + SOURCE_DATE_EPOCH=${{ needs.setup.outputs.source_date_epoch }} labels: | org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.revision=${{ github.sha }} @@ -208,3 +213,16 @@ jobs: echo "debian snapshot: $SNAPSHOT" echo '```' } >> "$GITHUB_STEP_SUMMARY" + + results: + needs: [publish] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Fail if any image build failed + env: + RESULT: ${{ needs.publish.result }} + run: | + [ "$RESULT" = "success" ] || { echo "one or more image builds failed: $RESULT"; exit 1; } diff --git a/base-images/Dockerfile b/base-images/Dockerfile index e5e750a58c..909689e3d2 100644 --- a/base-images/Dockerfile +++ b/base-images/Dockerfile @@ -15,7 +15,8 @@ ARG DEBIAN_SNAPSHOT ARG DEBIAN_SUITE=bookworm ARG PACKAGES -ENV DEBIAN_FRONTEND=noninteractive +# ARG, not ENV: needed during build only, must not leak into task containers +ARG DEBIAN_FRONTEND=noninteractive # http, not https: the slim bases have no ca-certificates yet, and apt # integrity comes from GPG-signed Release files rather than TLS. @@ -28,10 +29,12 @@ RUN . /etc/os-release && [ "$VERSION_CODENAME" = "${DEBIAN_SUITE}" ] || { echo " "deb [check-valid-until=no signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT} ${DEBIAN_SUITE}-security main" \ "deb [check-valid-until=no signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT} ${DEBIAN_SUITE}-updates main" \ > /etc/apt/sources.list && \ + printf 'Acquire::Retries "3";\n' > /etc/apt/apt.conf.d/99-snapshot-retries && \ apt-get update && \ + apt-get upgrade -y && \ apt-get install -y --no-install-recommends ${PACKAGES} && \ apt-get clean && \ - rm /etc/apt/sources.list && \ + rm /etc/apt/sources.list /etc/apt/apt.conf.d/99-snapshot-retries && \ mv /tmp/debian.sources /etc/apt/sources.list.d/debian.sources && \ rm -rf /var/lib/apt/lists/* /var/log/dpkg.log /var/log/apt /var/log/alternatives.log /var/cache/ldconfig/aux-cache /var/cache/debconf/*-old @@ -40,6 +43,7 @@ FROM runtime AS build ARG DEBIAN_SNAPSHOT ARG DEBIAN_SUITE=bookworm ARG BUILD_PACKAGES +ARG DEBIAN_FRONTEND=noninteractive RUN [ -n "${DEBIAN_SNAPSHOT}" ] && [ -n "${BUILD_PACKAGES}" ] || { echo "DEBIAN_SNAPSHOT and BUILD_PACKAGES build args are required (see images.json)"; exit 1; } && \ mv /etc/apt/sources.list.d/debian.sources /tmp/debian.sources && \ @@ -48,9 +52,10 @@ RUN [ -n "${DEBIAN_SNAPSHOT}" ] && [ -n "${BUILD_PACKAGES}" ] || { echo "DEBIAN_ "deb [check-valid-until=no signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT} ${DEBIAN_SUITE}-security main" \ "deb [check-valid-until=no signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT} ${DEBIAN_SUITE}-updates main" \ > /etc/apt/sources.list && \ + printf 'Acquire::Retries "3";\n' > /etc/apt/apt.conf.d/99-snapshot-retries && \ apt-get update && \ apt-get install -y --no-install-recommends ${BUILD_PACKAGES} && \ apt-get clean && \ - rm /etc/apt/sources.list && \ + rm /etc/apt/sources.list /etc/apt/apt.conf.d/99-snapshot-retries && \ mv /tmp/debian.sources /etc/apt/sources.list.d/debian.sources && \ rm -rf /var/lib/apt/lists/* /var/log/dpkg.log /var/log/apt /var/log/alternatives.log /var/cache/ldconfig/aux-cache /var/cache/debconf/*-old diff --git a/base-images/README.md b/base-images/README.md index 62dcbdc69e..c21397b452 100644 --- a/base-images/README.md +++ b/base-images/README.md @@ -1,20 +1,22 @@ # Deploy base images Base images for deployed task containers, published to Docker Hub as -`triggerdotdev/node:-bookworm` and `triggerdotdev/bun:-bookworm`, +`triggerdotdev/node:-bookworm` and `triggerdotdev/bun:-node-bookworm`, each with a `-build` variant that adds the native-module toolchain (python3, make, g++). -Each image is its upstream slim base (pinned by digest in `images.json`) plus +Each image is its upstream slim base (pinned by digest in `images.json`) with +all preinstalled Debian packages upgraded to the pinned snapshot state, plus the system packages deployed tasks rely on: busybox, ca-certificates, -dumb-init, git, openssl. Nothing else changes; apt stays configured for the -live Debian archive, so images derived from these behave exactly like their -upstream bases. +dumb-init, git, openssl. apt stays configured for the live Debian archive, so +images derived from these behave like their upstream bases. ## Tags and pinning -Tags are mutable and rebuilt in place (weekly, and on demand) so packages pick -up Debian security updates. Consumers pin digests: the CLI's generated +Tags are mutable and rebuilt in place (weekly, and on demand) so all Debian +packages pick up security updates published up to the new snapshot date. The +runtime itself (the node or bun binaries from the upstream base) only moves +when the base digests in `images.json` are bumped. Consumers pin digests: the CLI's generated Containerfile references these images as `triggerdotdev/node:22-bookworm@sha256:...`, and digests only move when a CLI release updates its pins. @@ -22,25 +24,30 @@ and digests only move when a CLI release updates its pins. Packages install from a [Debian snapshot archive](https://snapshot.debian.org) timestamp recorded in the `dev.trigger.debian-snapshot` image label, and the -workflow exports layers with normalized timestamps (`SOURCE_DATE_EPOCH=0` plus -`rewrite-timestamp`), so a published image's layers are a pure function of +workflow exports layers with timestamps normalized to the snapshot date +(`SOURCE_DATE_EPOCH` plus `rewrite-timestamp`), so a published image's layers +are a pure function of (upstream base digest, snapshot timestamp, package list). To verify, rebuild with the recorded inputs and compare layer digests (the manifest and config digests differ because they carry build metadata labels like the source revision): ```bash -SNAPSHOT=$(docker inspect triggerdotdev/node:22-bookworm --format '{{index .Config.Labels "dev.trigger.debian-snapshot"}}') +SNAPSHOT=$(docker buildx imagetools inspect triggerdotdev/node:22-bookworm \ + --format '{{index .Image.config.Labels "dev.trigger.debian-snapshot"}}') +# GNU date; the epoch must match the one the workflow derived from the snapshot +EPOCH=$(date -u -d "${SNAPSHOT:0:4}-${SNAPSHOT:4:2}-${SNAPSHOT:6:2} ${SNAPSHOT:9:2}:${SNAPSHOT:11:2}:${SNAPSHOT:13:2}Z" +%s) docker buildx build base-images --target runtime \ --build-arg BASE_IMAGE="" \ --build-arg PACKAGES="" \ --build-arg DEBIAN_SNAPSHOT="$SNAPSHOT" \ - --build-arg SOURCE_DATE_EPOCH=0 \ + --build-arg SOURCE_DATE_EPOCH="$EPOCH" \ --platform linux/amd64,linux/arm64 \ --provenance false \ --output type=oci,dest=rebuilt.tar,rewrite-timestamp=true -# then compare .layers[].digest of the rebuilt manifests against -# `docker buildx imagetools inspect triggerdotdev/node:22-bookworm --raw` +# then compare .layers[].digest of the rebuilt per-platform manifests against +# the published ones (imagetools inspect --raw returns the index; fetch each +# platform manifest it references to see its layers) ``` Every published digest also carries a GitHub build provenance attestation: diff --git a/base-images/images.json b/base-images/images.json index 38e3837204..36615c0e01 100644 --- a/base-images/images.json +++ b/base-images/images.json @@ -25,7 +25,7 @@ }, { "repo": "bun", - "tag": "1.3-bookworm", + "tag": "1.3-node20-bookworm", "base": "imbios/bun-node:1.3.3-20-slim@sha256:59d84856a7e31eec83afedadb542f7306f672343b8b265c70d733404a6e8834b" } ] From 2da3a4c9b60a8b1dc748d3a51c52bce1e9ae395c Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Wed, 12 Aug 2026 10:47:21 +0200 Subject: [PATCH 04/11] chore(base-images): rebuild on dispatch only, no schedule for now --- .github/workflows/base-images.yml | 3 --- base-images/README.md | 8 ++++---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/workflows/base-images.yml b/.github/workflows/base-images.yml index e6e99c6b9b..009e2a76b8 100644 --- a/.github/workflows/base-images.yml +++ b/.github/workflows/base-images.yml @@ -18,9 +18,6 @@ on: description: "Debian snapshot timestamp (YYYYMMDDTHHMMSSZ). Defaults to yesterday 00:00 UTC." required: false type: string - schedule: - # Weekly rebuild so packages pick up Debian security updates - - cron: "0 6 * * 1" push: branches: [main] paths: diff --git a/base-images/README.md b/base-images/README.md index c21397b452..02074d8bff 100644 --- a/base-images/README.md +++ b/base-images/README.md @@ -13,8 +13,8 @@ images derived from these behave like their upstream bases. ## Tags and pinning -Tags are mutable and rebuilt in place (weekly, and on demand) so all Debian -packages pick up security updates published up to the new snapshot date. The +Tags are mutable and rebuilt in place on demand; each rebuild picks up Debian +security updates published up to its snapshot date. The runtime itself (the node or bun binaries from the upstream base) only moves when the base digests in `images.json` are bumped. Consumers pin digests: the CLI's generated Containerfile references these images as `triggerdotdev/node:22-bookworm@sha256:...`, @@ -58,7 +58,7 @@ gh attestation verify oci://index.docker.io/triggerdotdev/node:22-bookworm --own ## Publishing -`.github/workflows/base-images.yml` publishes on a weekly schedule, on manual -dispatch, and on changes to this directory. Pull requests build the images +`.github/workflows/base-images.yml` publishes on manual dispatch and on +changes to this directory. Pull requests build the images without pushing. After a publish, the digests in the job summary are used to update the `BASE_IMAGE` pins in `packages/cli-v3/src/deploy/buildImage.ts`. From afe8d4a73a9aed78ffd088f7bfe739b24329352e Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Wed, 12 Aug 2026 10:57:58 +0200 Subject: [PATCH 05/11] fix(base-images): publish gates, atomic tag pairs, and a pullability check Authenticate to Docker Hub before any image pull (and on PR runs) so pulls stop counting against anonymous rate limits, build both targets before pushing either so a mid-run failure can't leave the runtime and -build tags on different snapshots, and verify pushed digests resolve anonymously so an accidentally-private repo can't publish green. Only publish from main (branch dispatches build without pushing), reject future snapshot timestamps that would silently disable timestamp normalization, upgrade with --with-new-pkgs so updates that add dependencies aren't held back, validate images.json entries, drop the per-run GHA cache which could never hit across runs, don't fail a publish on attestation hiccups, and fix the README verify recipe's label inspection to a form that works against multi-platform indexes. --- .github/workflows/base-images.yml | 95 ++++++++++++++++++++++++------- base-images/Dockerfile | 6 +- base-images/README.md | 11 +++- 3 files changed, 84 insertions(+), 28 deletions(-) diff --git a/.github/workflows/base-images.yml b/.github/workflows/base-images.yml index 009e2a76b8..abd056e897 100644 --- a/.github/workflows/base-images.yml +++ b/.github/workflows/base-images.yml @@ -1,7 +1,7 @@ name: 🐳 Deploy base images # Publishes the base images that deployed task containers build on -# (triggerdotdev/node:-bookworm and triggerdotdev/bun:-bookworm, +# (triggerdotdev/node:-bookworm and triggerdotdev/bun:-node-bookworm, # each with a -build toolchain variant) to Docker Hub. Tags are mutable and # rebuilt in place; the CLI pins images by digest, so consumers are unaffected # until a digest bump ships in a release. @@ -58,15 +58,18 @@ jobs: env: SNAPSHOT_INPUT: ${{ inputs.debian_snapshot }} EVENT_NAME: ${{ github.event_name }} + REF: ${{ github.ref }} run: | - PACKAGES="$(jq -r '.packages' base-images/images.json)" - BUILD_PACKAGES="$(jq -r '.buildPackages' base-images/images.json)" - SUITE="$(jq -r '.suite' base-images/images.json)" + PACKAGES="$(jq -er '.packages' base-images/images.json)" + BUILD_PACKAGES="$(jq -er '.buildPackages' base-images/images.json)" + SUITE="$(jq -er '.suite' base-images/images.json)" # Values land in build args and shell lines; keep them boring - echo "$PACKAGES" | grep -qE '^[a-z0-9 .+-]+$' || { echo "invalid packages value"; exit 1; } - echo "$BUILD_PACKAGES" | grep -qE '^[a-z0-9 .+-]+$' || { echo "invalid buildPackages value"; exit 1; } + echo "$PACKAGES" | grep -qE '^[a-z0-9][a-z0-9 .+:=~-]*$' || { echo "invalid packages value"; exit 1; } + echo "$BUILD_PACKAGES" | grep -qE '^[a-z0-9][a-z0-9 .+:=~-]*$' || { echo "invalid buildPackages value"; exit 1; } echo "$SUITE" | grep -qE '^[a-z]+$' || { echo "invalid suite value"; exit 1; } + jq -e '.images | length > 0 and all((.repo | test("^[a-z0-9-]+$")) and (.tag | test("^[a-z0-9.-]+$")) and (.base | test("^[a-zA-Z0-9./:@-]+$")))' base-images/images.json > /dev/null \ + || { echo "invalid images entries"; exit 1; } SNAPSHOT="$SNAPSHOT_INPUT" if [ -z "$SNAPSHOT" ]; then @@ -77,9 +80,13 @@ jobs: # Layer and config timestamps come from the snapshot, so images stay # reproducible while the registry shows a meaningful created date EPOCH="$(date -u -d "${SNAPSHOT:0:4}-${SNAPSHOT:4:2}-${SNAPSHOT:6:2} ${SNAPSHOT:9:2}:${SNAPSHOT:11:2}:${SNAPSHOT:13:2}Z" +%s)" + # A future snapshot resolves to "latest" server-side and a future + # epoch stops rewrite-timestamp normalizing mtimes; reject both + [ "$EPOCH" -le "$(date -u +%s)" ] || { echo "debian_snapshot is in the future: $SNAPSHOT"; exit 1; } - # Only publish from main; pull requests validate the build without pushing - if [ "$EVENT_NAME" = "pull_request" ]; then + # Only publish from main; pull requests and branch dispatches + # validate the build without pushing + if [ "$EVENT_NAME" = "pull_request" ] || [ "$REF" != "refs/heads/main" ]; then PUSH=false else PUSH=true @@ -107,11 +114,22 @@ jobs: fail-fast: false matrix: image: ${{ fromJSON(needs.setup.outputs.images) }} + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false + # Before any image pull so tooling and base pulls count against the + # authenticated rate limit; skipped on fork PRs, which have no secrets + - name: 🐳 Login to Docker Hub + if: env.DOCKERHUB_USERNAME != '' + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: 🐳 Set up QEMU uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 with: @@ -120,15 +138,35 @@ jobs: - name: 🐳 Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - name: 🐳 Login to Docker Hub - if: needs.setup.outputs.push == 'true' - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + # Build everything before pushing anything so a build failure can't + # leave the runtime and -build tags pointing at different snapshots + - name: 🐳 Build both targets (no push) + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + context: base-images + file: base-images/Dockerfile + target: build + platforms: linux/amd64,linux/arm64 + provenance: false + outputs: type=image,push=false,rewrite-timestamp=true + tags: triggerdotdev/${{ matrix.image.repo }}:${{ matrix.image.tag }}-build + build-args: | + BASE_IMAGE=${{ matrix.image.base }} + DEBIAN_SNAPSHOT=${{ needs.setup.outputs.snapshot }} + DEBIAN_SUITE=${{ needs.setup.outputs.suite }} + PACKAGES=${{ needs.setup.outputs.packages }} + BUILD_PACKAGES=${{ needs.setup.outputs.build_packages }} + SOURCE_DATE_EPOCH=${{ needs.setup.outputs.source_date_epoch }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + dev.trigger.debian-snapshot=${{ needs.setup.outputs.snapshot }} - - name: 🐳 Build and push runtime image + # rewrite-timestamp + SOURCE_DATE_EPOCH make layer blobs a pure + # function of the build inputs, so published layers are verifiable + - name: 🐳 Push runtime image id: build_runtime + if: needs.setup.outputs.push == 'true' uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: base-images @@ -136,11 +174,8 @@ jobs: target: runtime platforms: linux/amd64,linux/arm64 provenance: false - # rewrite-timestamp + SOURCE_DATE_EPOCH make layer blobs a pure - # function of the build inputs, so published layers are verifiable - outputs: type=image,push=${{ needs.setup.outputs.push }},rewrite-timestamp=true + outputs: type=image,push=true,rewrite-timestamp=true tags: triggerdotdev/${{ matrix.image.repo }}:${{ matrix.image.tag }} - cache-from: type=gha,scope=base-${{ matrix.image.repo }}-${{ matrix.image.tag }} build-args: | BASE_IMAGE=${{ matrix.image.base }} DEBIAN_SNAPSHOT=${{ needs.setup.outputs.snapshot }} @@ -152,8 +187,9 @@ jobs: org.opencontainers.image.revision=${{ github.sha }} dev.trigger.debian-snapshot=${{ needs.setup.outputs.snapshot }} - - name: 🐳 Build and push build-variant image + - name: 🐳 Push build-variant image id: build_toolchain + if: needs.setup.outputs.push == 'true' uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: base-images @@ -161,10 +197,8 @@ jobs: target: build platforms: linux/amd64,linux/arm64 provenance: false - outputs: type=image,push=${{ needs.setup.outputs.push }},rewrite-timestamp=true + outputs: type=image,push=true,rewrite-timestamp=true tags: triggerdotdev/${{ matrix.image.repo }}:${{ matrix.image.tag }}-build - cache-from: type=gha,scope=base-${{ matrix.image.repo }}-${{ matrix.image.tag }} - cache-to: type=gha,scope=base-${{ matrix.image.repo }}-${{ matrix.image.tag }},mode=max build-args: | BASE_IMAGE=${{ matrix.image.base }} DEBIAN_SNAPSHOT=${{ needs.setup.outputs.snapshot }} @@ -177,8 +211,24 @@ jobs: org.opencontainers.image.revision=${{ github.sha }} dev.trigger.debian-snapshot=${{ needs.setup.outputs.snapshot }} + # A repo auto-created private would let the publish go green while every + # customer pull fails; require anonymous pullability before declaring success + - name: 🔎 Verify anonymous pullability + if: needs.setup.outputs.push == 'true' + env: + IMAGE_REPO: ${{ matrix.image.repo }} + RUNTIME_DIGEST: ${{ steps.build_runtime.outputs.digest }} + BUILD_DIGEST: ${{ steps.build_toolchain.outputs.digest }} + run: | + for digest in "$RUNTIME_DIGEST" "$BUILD_DIGEST"; do + TOKEN="$(curl -fsS "https://auth.docker.io/token?service=registry.docker.io&scope=repository:triggerdotdev/$IMAGE_REPO:pull" | jq -r .token)" + curl -fsS -o /dev/null -H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.oci.image.index.v1+json" "https://registry-1.docker.io/v2/triggerdotdev/$IMAGE_REPO/manifests/$digest" || { echo "triggerdotdev/$IMAGE_REPO@$digest is not anonymously pullable; is the repo private?"; exit 1; } + done + + # Image is already pushed by this point; don't fail publishes on a Sigstore hiccup - name: 🔏 Attest runtime image provenance if: needs.setup.outputs.push == 'true' + continue-on-error: true uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-name: index.docker.io/triggerdotdev/${{ matrix.image.repo }} @@ -187,6 +237,7 @@ jobs: - name: 🔏 Attest build-variant image provenance if: needs.setup.outputs.push == 'true' + continue-on-error: true uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-name: index.docker.io/triggerdotdev/${{ matrix.image.repo }} diff --git a/base-images/Dockerfile b/base-images/Dockerfile index 909689e3d2..4fb00912b3 100644 --- a/base-images/Dockerfile +++ b/base-images/Dockerfile @@ -29,9 +29,9 @@ RUN . /etc/os-release && [ "$VERSION_CODENAME" = "${DEBIAN_SUITE}" ] || { echo " "deb [check-valid-until=no signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT} ${DEBIAN_SUITE}-security main" \ "deb [check-valid-until=no signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT} ${DEBIAN_SUITE}-updates main" \ > /etc/apt/sources.list && \ - printf 'Acquire::Retries "3";\n' > /etc/apt/apt.conf.d/99-snapshot-retries && \ + printf 'Acquire::Retries "5";\nAcquire::http::Timeout "30";\n' > /etc/apt/apt.conf.d/99-snapshot-retries && \ apt-get update && \ - apt-get upgrade -y && \ + apt-get upgrade -y --with-new-pkgs && \ apt-get install -y --no-install-recommends ${PACKAGES} && \ apt-get clean && \ rm /etc/apt/sources.list /etc/apt/apt.conf.d/99-snapshot-retries && \ @@ -52,7 +52,7 @@ RUN [ -n "${DEBIAN_SNAPSHOT}" ] && [ -n "${BUILD_PACKAGES}" ] || { echo "DEBIAN_ "deb [check-valid-until=no signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT} ${DEBIAN_SUITE}-security main" \ "deb [check-valid-until=no signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT} ${DEBIAN_SUITE}-updates main" \ > /etc/apt/sources.list && \ - printf 'Acquire::Retries "3";\n' > /etc/apt/apt.conf.d/99-snapshot-retries && \ + printf 'Acquire::Retries "5";\nAcquire::http::Timeout "30";\n' > /etc/apt/apt.conf.d/99-snapshot-retries && \ apt-get update && \ apt-get install -y --no-install-recommends ${BUILD_PACKAGES} && \ apt-get clean && \ diff --git a/base-images/README.md b/base-images/README.md index 02074d8bff..228d8a8879 100644 --- a/base-images/README.md +++ b/base-images/README.md @@ -16,7 +16,9 @@ images derived from these behave like their upstream bases. Tags are mutable and rebuilt in place on demand; each rebuild picks up Debian security updates published up to its snapshot date. The runtime itself (the node or bun binaries from the upstream base) only moves -when the base digests in `images.json` are bumped. Consumers pin digests: the CLI's generated +when the base digests in `images.json` are bumped. When bumping a base +digest, keep the snapshot at least as new as the upstream image's own archive +state, or the upgrade step silently becomes a no-op. Consumers pin digests: the CLI's generated Containerfile references these images as `triggerdotdev/node:22-bookworm@sha256:...`, and digests only move when a CLI release updates its pins. @@ -33,8 +35,9 @@ digests differ because they carry build metadata labels like the source revision): ```bash +# needs a docker-container builder (docker buildx create --use) SNAPSHOT=$(docker buildx imagetools inspect triggerdotdev/node:22-bookworm \ - --format '{{index .Image.config.Labels "dev.trigger.debian-snapshot"}}') + --format '{{index (index .Image "linux/amd64").Config.Labels "dev.trigger.debian-snapshot"}}') # GNU date; the epoch must match the one the workflow derived from the snapshot EPOCH=$(date -u -d "${SNAPSHOT:0:4}-${SNAPSHOT:4:2}-${SNAPSHOT:6:2} ${SNAPSHOT:9:2}:${SNAPSHOT:11:2}:${SNAPSHOT:13:2}Z" +%s) docker buildx build base-images --target runtime \ @@ -47,7 +50,9 @@ docker buildx build base-images --target runtime \ --output type=oci,dest=rebuilt.tar,rewrite-timestamp=true # then compare .layers[].digest of the rebuilt per-platform manifests against # the published ones (imagetools inspect --raw returns the index; fetch each -# platform manifest it references to see its layers) +# platform manifest it references to see its layers). For the -build variant, +# use --target build and additionally pass --build-arg BUILD_PACKAGES. Layer +# digests are stable for a given BuildKit version and compression settings. ``` Every published digest also carries a GitHub build provenance attestation: From 95d6f9abcaea8fa8f207a25a7a77aae53b721cf2 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Wed, 12 Aug 2026 11:18:16 +0200 Subject: [PATCH 06/11] chore(base-images): trim comments to the non-obvious constraints --- .github/workflows/base-images.yml | 33 +++++++++---------------------- base-images/Dockerfile | 7 ++----- 2 files changed, 11 insertions(+), 29 deletions(-) diff --git a/.github/workflows/base-images.yml b/.github/workflows/base-images.yml index abd056e897..1401284459 100644 --- a/.github/workflows/base-images.yml +++ b/.github/workflows/base-images.yml @@ -1,15 +1,8 @@ name: 🐳 Deploy base images -# Publishes the base images that deployed task containers build on -# (triggerdotdev/node:-bookworm and triggerdotdev/bun:-node-bookworm, -# each with a -build toolchain variant) to Docker Hub. Tags are mutable and -# rebuilt in place; the CLI pins images by digest, so consumers are unaffected -# until a digest bump ships in a release. -# -# Packages install from a Debian snapshot timestamp (recorded in the -# dev.trigger.debian-snapshot image label and the job summary), and layers are -# exported with normalized timestamps, so any published image can be rebuilt -# from the recorded inputs and verified layer for layer. +# Publishes the deploy base images (see base-images/README.md) to Docker Hub. +# Tags are mutable and rebuilt in place; the CLI pins digests, so consumers +# only move when a release bumps its pins. on: workflow_dispatch: @@ -77,15 +70,12 @@ jobs: fi echo "$SNAPSHOT" | grep -qE '^[0-9]{8}T[0-9]{6}Z$' || { echo "invalid debian_snapshot: $SNAPSHOT"; exit 1; } - # Layer and config timestamps come from the snapshot, so images stay - # reproducible while the registry shows a meaningful created date + # Snapshot-derived timestamps: reproducible, with a real created date EPOCH="$(date -u -d "${SNAPSHOT:0:4}-${SNAPSHOT:4:2}-${SNAPSHOT:6:2} ${SNAPSHOT:9:2}:${SNAPSHOT:11:2}:${SNAPSHOT:13:2}Z" +%s)" - # A future snapshot resolves to "latest" server-side and a future - # epoch stops rewrite-timestamp normalizing mtimes; reject both + # Future snapshots resolve to "latest" and break mtime normalization [ "$EPOCH" -le "$(date -u +%s)" ] || { echo "debian_snapshot is in the future: $SNAPSHOT"; exit 1; } - # Only publish from main; pull requests and branch dispatches - # validate the build without pushing + # Pull requests and branch dispatches build without pushing if [ "$EVENT_NAME" = "pull_request" ] || [ "$REF" != "refs/heads/main" ]; then PUSH=false else @@ -121,8 +111,7 @@ jobs: with: persist-credentials: false - # Before any image pull so tooling and base pulls count against the - # authenticated rate limit; skipped on fork PRs, which have no secrets + # Before any pull so rate limits are authenticated; fork PRs skip (no secrets) - name: 🐳 Login to Docker Hub if: env.DOCKERHUB_USERNAME != '' uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 @@ -138,8 +127,7 @@ jobs: - name: 🐳 Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - # Build everything before pushing anything so a build failure can't - # leave the runtime and -build tags pointing at different snapshots + # Build both targets before pushing either so the tag pair can't skew - name: 🐳 Build both targets (no push) uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: @@ -162,8 +150,6 @@ jobs: org.opencontainers.image.revision=${{ github.sha }} dev.trigger.debian-snapshot=${{ needs.setup.outputs.snapshot }} - # rewrite-timestamp + SOURCE_DATE_EPOCH make layer blobs a pure - # function of the build inputs, so published layers are verifiable - name: 🐳 Push runtime image id: build_runtime if: needs.setup.outputs.push == 'true' @@ -211,8 +197,7 @@ jobs: org.opencontainers.image.revision=${{ github.sha }} dev.trigger.debian-snapshot=${{ needs.setup.outputs.snapshot }} - # A repo auto-created private would let the publish go green while every - # customer pull fails; require anonymous pullability before declaring success + # An auto-created private repo would publish green while customer pulls fail - name: 🔎 Verify anonymous pullability if: needs.setup.outputs.push == 'true' env: diff --git a/base-images/Dockerfile b/base-images/Dockerfile index 4fb00912b3..173cb194eb 100644 --- a/base-images/Dockerfile +++ b/base-images/Dockerfile @@ -1,11 +1,8 @@ # syntax=docker/dockerfile:1 # check=skip=InvalidDefaultArgInFrom -# Base images for deployed task containers. Packages install from a pinned -# Debian snapshot so any published digest can be rebuilt and verified from -# (upstream base digest, snapshot timestamp, package list); apt is restored to -# the upstream live-archive configuration afterwards so derived images behave -# like their upstream bases. +# Base images for deployed task containers; see README.md. Packages install +# from a pinned Debian snapshot, then apt is restored to the live archive. ARG BASE_IMAGE From 26afdf5012a5aa593c2e126d5de52a7ae021873b Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Wed, 12 Aug 2026 17:06:30 +0200 Subject: [PATCH 07/11] chore(base-images): disable docker build summaries and record uploads --- .github/workflows/base-images.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/base-images.yml b/.github/workflows/base-images.yml index 1401284459..a356d7a5aa 100644 --- a/.github/workflows/base-images.yml +++ b/.github/workflows/base-images.yml @@ -106,6 +106,8 @@ jobs: image: ${{ fromJSON(needs.setup.outputs.images) }} env: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKER_BUILD_SUMMARY: "false" + DOCKER_BUILD_RECORD_UPLOAD: "false" steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: From 7507fd70e394c867fa455c4f7c0abd9ba702c454 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Wed, 12 Aug 2026 17:56:57 +0200 Subject: [PATCH 08/11] fix(base-images): whole-record input validation, scoped attestation verify Anchored per-line grep let a multi-line value pass validation on the strength of one valid line; validate NUL-delimited whole records instead. Scope the README's attestation verify to this repository and workflow, and note that provenance is best effort so a Sigstore outage doesn't block publishes. --- .github/workflows/base-images.yml | 11 ++++++----- base-images/README.md | 7 +++++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/base-images.yml b/.github/workflows/base-images.yml index a356d7a5aa..05d1ce6aeb 100644 --- a/.github/workflows/base-images.yml +++ b/.github/workflows/base-images.yml @@ -57,10 +57,11 @@ jobs: BUILD_PACKAGES="$(jq -er '.buildPackages' base-images/images.json)" SUITE="$(jq -er '.suite' base-images/images.json)" - # Values land in build args and shell lines; keep them boring - echo "$PACKAGES" | grep -qE '^[a-z0-9][a-z0-9 .+:=~-]*$' || { echo "invalid packages value"; exit 1; } - echo "$BUILD_PACKAGES" | grep -qE '^[a-z0-9][a-z0-9 .+:=~-]*$' || { echo "invalid buildPackages value"; exit 1; } - echo "$SUITE" | grep -qE '^[a-z]+$' || { echo "invalid suite value"; exit 1; } + # Values land in build args and shell lines; keep them boring. + # NUL-delimited whole-record match so multi-line values can't sneak through + printf '%s\0' "$PACKAGES" | grep -zqxE '[a-z0-9][a-z0-9 .+:=~-]*' || { echo "invalid packages value"; exit 1; } + printf '%s\0' "$BUILD_PACKAGES" | grep -zqxE '[a-z0-9][a-z0-9 .+:=~-]*' || { echo "invalid buildPackages value"; exit 1; } + printf '%s\0' "$SUITE" | grep -zqxE '[a-z]+' || { echo "invalid suite value"; exit 1; } jq -e '.images | length > 0 and all((.repo | test("^[a-z0-9-]+$")) and (.tag | test("^[a-z0-9.-]+$")) and (.base | test("^[a-zA-Z0-9./:@-]+$")))' base-images/images.json > /dev/null \ || { echo "invalid images entries"; exit 1; } @@ -68,7 +69,7 @@ jobs: if [ -z "$SNAPSHOT" ]; then SNAPSHOT="$(date -u -d yesterday +%Y%m%dT000000Z)" fi - echo "$SNAPSHOT" | grep -qE '^[0-9]{8}T[0-9]{6}Z$' || { echo "invalid debian_snapshot: $SNAPSHOT"; exit 1; } + printf '%s\0' "$SNAPSHOT" | grep -zqxE '[0-9]{8}T[0-9]{6}Z' || { echo "invalid debian_snapshot: $SNAPSHOT"; exit 1; } # Snapshot-derived timestamps: reproducible, with a real created date EPOCH="$(date -u -d "${SNAPSHOT:0:4}-${SNAPSHOT:4:2}-${SNAPSHOT:6:2} ${SNAPSHOT:9:2}:${SNAPSHOT:11:2}:${SNAPSHOT:13:2}Z" +%s)" diff --git a/base-images/README.md b/base-images/README.md index 228d8a8879..46e05f3d6d 100644 --- a/base-images/README.md +++ b/base-images/README.md @@ -55,10 +55,13 @@ docker buildx build base-images --target runtime \ # digests are stable for a given BuildKit version and compression settings. ``` -Every published digest also carries a GitHub build provenance attestation: +Publishes also attest build provenance (best effort: a Sigstore outage does +not block a publish, so a digest can occasionally lack its attestation): ```bash -gh attestation verify oci://index.docker.io/triggerdotdev/node:22-bookworm --owner triggerdotdev +gh attestation verify oci://index.docker.io/triggerdotdev/node:22-bookworm \ + --repo triggerdotdev/trigger.dev \ + --signer-workflow triggerdotdev/trigger.dev/.github/workflows/base-images.yml ``` ## Publishing From 206d57a62a29fd1019cc2ee3f0861a354f0b0e97 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Wed, 12 Aug 2026 18:06:15 +0200 Subject: [PATCH 09/11] fix(base-images): never touch upstream apt files, accept all manifest types Write the pinned snapshot sources to a dedicated sources.list.d file and move any upstream sources.list aside during the install alongside debian.sources, restoring both, so a base that ships extra apt entries keeps them byte for byte. Broaden the pullability check's Accept header to Docker and OCI manifest types so it doesn't rely on digest fetches ignoring content negotiation. --- .github/workflows/base-images.yml | 2 +- base-images/Dockerfile | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/base-images.yml b/.github/workflows/base-images.yml index 05d1ce6aeb..bd33101a85 100644 --- a/.github/workflows/base-images.yml +++ b/.github/workflows/base-images.yml @@ -210,7 +210,7 @@ jobs: run: | for digest in "$RUNTIME_DIGEST" "$BUILD_DIGEST"; do TOKEN="$(curl -fsS "https://auth.docker.io/token?service=registry.docker.io&scope=repository:triggerdotdev/$IMAGE_REPO:pull" | jq -r .token)" - curl -fsS -o /dev/null -H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.oci.image.index.v1+json" "https://registry-1.docker.io/v2/triggerdotdev/$IMAGE_REPO/manifests/$digest" || { echo "triggerdotdev/$IMAGE_REPO@$digest is not anonymously pullable; is the repo private?"; exit 1; } + curl -fsS -o /dev/null -H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json" "https://registry-1.docker.io/v2/triggerdotdev/$IMAGE_REPO/manifests/$digest" || { echo "triggerdotdev/$IMAGE_REPO@$digest is not anonymously pullable; is the repo private?"; exit 1; } done # Image is already pushed by this point; don't fail publishes on a Sigstore hiccup diff --git a/base-images/Dockerfile b/base-images/Dockerfile index 173cb194eb..e674482761 100644 --- a/base-images/Dockerfile +++ b/base-images/Dockerfile @@ -21,18 +21,20 @@ ARG DEBIAN_FRONTEND=noninteractive RUN . /etc/os-release && [ "$VERSION_CODENAME" = "${DEBIAN_SUITE}" ] || { echo "Base image is Debian $VERSION_CODENAME but this build pins ${DEBIAN_SUITE} apt sources"; exit 1; } && \ [ -n "${DEBIAN_SNAPSHOT}" ] && [ -n "${PACKAGES}" ] || { echo "DEBIAN_SNAPSHOT and PACKAGES build args are required (see images.json)"; exit 1; } && \ mv /etc/apt/sources.list.d/debian.sources /tmp/debian.sources && \ + { [ ! -f /etc/apt/sources.list ] || mv /etc/apt/sources.list /tmp/upstream-sources.list; } && \ printf '%s\n' \ "deb [check-valid-until=no signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT} ${DEBIAN_SUITE} main" \ "deb [check-valid-until=no signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT} ${DEBIAN_SUITE}-security main" \ "deb [check-valid-until=no signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT} ${DEBIAN_SUITE}-updates main" \ - > /etc/apt/sources.list && \ + > /etc/apt/sources.list.d/snapshot.list && \ printf 'Acquire::Retries "5";\nAcquire::http::Timeout "30";\n' > /etc/apt/apt.conf.d/99-snapshot-retries && \ apt-get update && \ apt-get upgrade -y --with-new-pkgs && \ apt-get install -y --no-install-recommends ${PACKAGES} && \ apt-get clean && \ - rm /etc/apt/sources.list /etc/apt/apt.conf.d/99-snapshot-retries && \ + rm /etc/apt/sources.list.d/snapshot.list /etc/apt/apt.conf.d/99-snapshot-retries && \ mv /tmp/debian.sources /etc/apt/sources.list.d/debian.sources && \ + { [ ! -f /tmp/upstream-sources.list ] || mv /tmp/upstream-sources.list /etc/apt/sources.list; } && \ rm -rf /var/lib/apt/lists/* /var/log/dpkg.log /var/log/apt /var/log/alternatives.log /var/cache/ldconfig/aux-cache /var/cache/debconf/*-old FROM runtime AS build @@ -44,15 +46,17 @@ ARG DEBIAN_FRONTEND=noninteractive RUN [ -n "${DEBIAN_SNAPSHOT}" ] && [ -n "${BUILD_PACKAGES}" ] || { echo "DEBIAN_SNAPSHOT and BUILD_PACKAGES build args are required (see images.json)"; exit 1; } && \ mv /etc/apt/sources.list.d/debian.sources /tmp/debian.sources && \ + { [ ! -f /etc/apt/sources.list ] || mv /etc/apt/sources.list /tmp/upstream-sources.list; } && \ printf '%s\n' \ "deb [check-valid-until=no signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT} ${DEBIAN_SUITE} main" \ "deb [check-valid-until=no signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT} ${DEBIAN_SUITE}-security main" \ "deb [check-valid-until=no signed-by=/usr/share/keyrings/debian-archive-keyring.gpg] http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT} ${DEBIAN_SUITE}-updates main" \ - > /etc/apt/sources.list && \ + > /etc/apt/sources.list.d/snapshot.list && \ printf 'Acquire::Retries "5";\nAcquire::http::Timeout "30";\n' > /etc/apt/apt.conf.d/99-snapshot-retries && \ apt-get update && \ apt-get install -y --no-install-recommends ${BUILD_PACKAGES} && \ apt-get clean && \ - rm /etc/apt/sources.list /etc/apt/apt.conf.d/99-snapshot-retries && \ + rm /etc/apt/sources.list.d/snapshot.list /etc/apt/apt.conf.d/99-snapshot-retries && \ mv /tmp/debian.sources /etc/apt/sources.list.d/debian.sources && \ + { [ ! -f /tmp/upstream-sources.list ] || mv /tmp/upstream-sources.list /etc/apt/sources.list; } && \ rm -rf /var/lib/apt/lists/* /var/log/dpkg.log /var/log/apt /var/log/alternatives.log /var/cache/ldconfig/aux-cache /var/cache/debconf/*-old From a506d0599e2333c6114d94d80ddc2e52b59ba4a2 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Wed, 12 Aug 2026 18:13:14 +0200 Subject: [PATCH 10/11] fix(base-images): bound the pullability check's request timeouts --- .github/workflows/base-images.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/base-images.yml b/.github/workflows/base-images.yml index bd33101a85..bcf5445a6b 100644 --- a/.github/workflows/base-images.yml +++ b/.github/workflows/base-images.yml @@ -209,8 +209,8 @@ jobs: BUILD_DIGEST: ${{ steps.build_toolchain.outputs.digest }} run: | for digest in "$RUNTIME_DIGEST" "$BUILD_DIGEST"; do - TOKEN="$(curl -fsS "https://auth.docker.io/token?service=registry.docker.io&scope=repository:triggerdotdev/$IMAGE_REPO:pull" | jq -r .token)" - curl -fsS -o /dev/null -H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json" "https://registry-1.docker.io/v2/triggerdotdev/$IMAGE_REPO/manifests/$digest" || { echo "triggerdotdev/$IMAGE_REPO@$digest is not anonymously pullable; is the repo private?"; exit 1; } + TOKEN="$(curl -fsS --connect-timeout 10 --max-time 60 "https://auth.docker.io/token?service=registry.docker.io&scope=repository:triggerdotdev/$IMAGE_REPO:pull" | jq -r .token)" + curl -fsS --connect-timeout 10 --max-time 60 -o /dev/null -H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json" "https://registry-1.docker.io/v2/triggerdotdev/$IMAGE_REPO/manifests/$digest" || { echo "triggerdotdev/$IMAGE_REPO@$digest is not anonymously pullable; is the repo private?"; exit 1; } done # Image is already pushed by this point; don't fail publishes on a Sigstore hiccup From 44a5bf821643fb20669821f75fbb7ddd1394ab46 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 13 Aug 2026 11:00:06 +0200 Subject: [PATCH 11/11] fix(base-images): fail the publish when provenance attestation fails An unattested published digest would otherwise go unnoticed forever; since builds are reproducible, re-running a red publish re-pushes identical digests and re-attests them, so failing loudly is cheap. --- .github/workflows/base-images.yml | 5 ++--- base-images/README.md | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/base-images.yml b/.github/workflows/base-images.yml index bcf5445a6b..13abecdad7 100644 --- a/.github/workflows/base-images.yml +++ b/.github/workflows/base-images.yml @@ -213,10 +213,10 @@ jobs: curl -fsS --connect-timeout 10 --max-time 60 -o /dev/null -H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json" "https://registry-1.docker.io/v2/triggerdotdev/$IMAGE_REPO/manifests/$digest" || { echo "triggerdotdev/$IMAGE_REPO@$digest is not anonymously pullable; is the repo private?"; exit 1; } done - # Image is already pushed by this point; don't fail publishes on a Sigstore hiccup + # Builds are reproducible, so re-running a red publish re-pushes the + # same digests and re-attests them - name: 🔏 Attest runtime image provenance if: needs.setup.outputs.push == 'true' - continue-on-error: true uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-name: index.docker.io/triggerdotdev/${{ matrix.image.repo }} @@ -225,7 +225,6 @@ jobs: - name: 🔏 Attest build-variant image provenance if: needs.setup.outputs.push == 'true' - continue-on-error: true uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-name: index.docker.io/triggerdotdev/${{ matrix.image.repo }} diff --git a/base-images/README.md b/base-images/README.md index 46e05f3d6d..78f63a552a 100644 --- a/base-images/README.md +++ b/base-images/README.md @@ -55,8 +55,8 @@ docker buildx build base-images --target runtime \ # digests are stable for a given BuildKit version and compression settings. ``` -Publishes also attest build provenance (best effort: a Sigstore outage does -not block a publish, so a digest can occasionally lack its attestation): +Every published digest carries a GitHub build provenance attestation (a +publish whose attestation fails goes red and is re-run): ```bash gh attestation verify oci://index.docker.io/triggerdotdev/node:22-bookworm \