From 2c6247b4d09088164c42579d9444f0f614e7cd19 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sat, 19 Sep 2026 21:34:16 -0700 Subject: [PATCH 1/2] fix(ingress): point the doc links at the release, not the build commit url and documentation resolved to the build commit's tree and README. Neither can be right there: the examples pin the image by digest, so they are only updated one commit after the one being built, and a link into the build commit always lands on a page that tells the reader to deploy the previous release. The 2.6 image documents a README that pins 2.3. It is a fixed point that does not exist -- .BUILD_INFO carries the commit, so writing the digest into the tree changes the digest -- and the release page is the way out: it is the one document written after the digest is known, and so the only one that can describe the image it ships with. SOURCE_URL and VERSION are already build inputs, so deriving the URL from them leaves reproducibility alone. source and revision keep pointing at the exact commit, which is their job. Drop SUBDIR, which had no other use. Also set VERSION to 2.7. 2.6 is published, so leaving it there makes every local build claim to be a release it is not -- and, now, link to that release's page while not being it. --- custom-domain/dstack-ingress/README.md | 4 +++- custom-domain/dstack-ingress/VERSION | 2 +- custom-domain/dstack-ingress/build-image.sh | 18 ++++++++++++------ 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/custom-domain/dstack-ingress/README.md b/custom-domain/dstack-ingress/README.md index 2c6ce89..4940176 100644 --- a/custom-domain/dstack-ingress/README.md +++ b/custom-domain/dstack-ingress/README.md @@ -326,9 +326,11 @@ Every image records where it came from, using the standard [OCI image annotation | `org.opencontainers.image.source` | Repository URL (`SOURCE_URL` env when building from a fork) | | `org.opencontainers.image.revision` | Git commit; suffixed with `-dirty` when built from an unclean tree | | `org.opencontainers.image.version` | Contents of `VERSION`; the release tag `dstack-ingress-v` must match | -| `org.opencontainers.image.url` / `.documentation` | This directory / README at that exact commit | +| `org.opencontainers.image.url` / `.documentation` | The release page, `releases/tag/dstack-ingress-v` (see below) | | `org.opencontainers.image.base.name` / `.base.digest` | The pinned haproxy base image | +`url` and `documentation` are derived from `VERSION` rather than from the commit, and that is deliberate. The examples in this repository pin the image by digest, so they can only be updated one commit *after* the one that was built — a link to the build commit's tree or README therefore always lands on a page telling the reader to deploy the previous release. The release page is the one document written after the digest is known, so it is the only one that can describe the image it ships with. Exact source stays available through `source` + `revision`. + To reproduce a published image, check out the commit from its `revision` label and run `./build-image.sh` on a native Linux amd64 host with Docker Buildx, Skopeo, jq and Git installed; the digest printed at the end must match the registry. Releases are additionally signed with SLSA provenance, verifiable with `gh attestation verify oci://ghcr.io/dstack-tee/dstack-ingress: --owner Dstack-TEE`. ### Releasing diff --git a/custom-domain/dstack-ingress/VERSION b/custom-domain/dstack-ingress/VERSION index 5154b3f..1effb00 100644 --- a/custom-domain/dstack-ingress/VERSION +++ b/custom-domain/dstack-ingress/VERSION @@ -1 +1 @@ -2.6 +2.7 diff --git a/custom-domain/dstack-ingress/build-image.sh b/custom-domain/dstack-ingress/build-image.sh index 370851b..2cd4b6d 100755 --- a/custom-domain/dstack-ingress/build-image.sh +++ b/custom-domain/dstack-ingress/build-image.sh @@ -69,13 +69,11 @@ cd "$(dirname "$0")" # --------------------------------------------------------------------------- # Source metadata. Every value below is a function of the checked-out commit -# (plus SOURCE_URL for forks), so a rebuild of the same commit yields the same -# labels and therefore the same digest. +# and the committed VERSION file (plus SOURCE_URL for forks), so a rebuild of +# the same commit yields the same labels and therefore the same digest. # --------------------------------------------------------------------------- SOURCE_URL="${SOURCE_URL:-https://github.com/Dstack-TEE/dstack-examples}" SOURCE_URL="${SOURCE_URL%/}" -SUBDIR="$(git rev-parse --show-prefix)" -SUBDIR="${SUBDIR%/}" GIT_REV="$(git rev-parse HEAD)" VERSION="$(tr -d '[:space:]' < VERSION)" if [ -z "$VERSION" ]; then @@ -129,8 +127,16 @@ METADATA=( "org.opencontainers.image.source=${SOURCE_URL}" "org.opencontainers.image.revision=${GIT_REV}" "org.opencontainers.image.version=${VERSION}" - "org.opencontainers.image.url=${SOURCE_URL}/tree/${GIT_REV%-dirty}/${SUBDIR}" - "org.opencontainers.image.documentation=${SOURCE_URL}/blob/${GIT_REV%-dirty}/${SUBDIR}/README.md" + # Version-derived, not commit-derived, and deliberately so. The examples + # in the tree pin the image by digest, so they can only be updated after + # the digest exists -- one commit later than the one being built. A link + # to this commit's tree or README therefore always lands on a page that + # tells the reader to deploy the previous release. The release page is + # the one document written after the digest is known, so it is the only + # one that can describe this image. Both inputs (SOURCE_URL, VERSION) are + # already build inputs, so the digest stays reproducible. + "org.opencontainers.image.url=${SOURCE_URL}/releases/tag/dstack-ingress-v${VERSION}" + "org.opencontainers.image.documentation=${SOURCE_URL}/releases/tag/dstack-ingress-v${VERSION}" "org.opencontainers.image.licenses=MIT" "org.opencontainers.image.base.name=${BASE_NAME}" "org.opencontainers.image.base.digest=${BASE_DIGEST}" From bb781cc347e7e88bb65d571afcff4f0b2ea7d198 Mon Sep 17 00:00:00 2001 From: Kevin Wang Date: Sat, 19 Sep 2026 21:34:25 -0700 Subject: [PATCH 2/2] ci(ingress): open the pin pull request from the release job Pinning the published digest into the examples is the step that gets dropped. 2.4 and 2.5 were both tagged and published without it, so for two releases every compose file and README snippet in the repository told people to deploy 2.3, and nothing in the repository recorded which commit those two digests came from. Documenting the step did not make it happen; the release job has the digest in hand, so let it do the work. The rewrite itself is pin-release.sh, so that a human can run exactly what CI runs when the job fails. It matches any registry and version -- the registry already moved once, from Docker Hub to GHCR in 2.6 -- and refuses to run if it finds no digest-pinned reference at all, which would mean the examples stopped pinning and the release procedure needs rethinking rather than a silent no-op. The job checks out the default branch rather than the tag: the pull request has to target the branch, which may have moved on. It reports what it changed, runs ./dev.sh check-all on the result, and says in the pull request body that pull_request checks do not start for a pull request opened with GITHUB_TOKEN -- so an empty check list is not read as a passing one. --- .github/workflows/dstack-ingress-release.yml | 103 ++++++++++++++++++- custom-domain/dstack-ingress/README.md | 12 ++- custom-domain/dstack-ingress/pin-release.sh | 60 +++++++++++ 3 files changed, 169 insertions(+), 6 deletions(-) create mode 100755 custom-domain/dstack-ingress/pin-release.sh diff --git a/.github/workflows/dstack-ingress-release.yml b/.github/workflows/dstack-ingress-release.yml index bd298d4..87dde51 100644 --- a/.github/workflows/dstack-ingress-release.yml +++ b/.github/workflows/dstack-ingress-release.yml @@ -19,11 +19,15 @@ jobs: working-directory: custom-domain/dstack-ingress env: IMAGE_REGISTRY: ghcr.io + outputs: + version: ${{ steps.version.outputs.version }} + pinned-reference: ${{ steps.version.outputs.image-reference }}@${{ steps.capture-digest.outputs.digest }} steps: - name: Checkout repository uses: actions/checkout@v4 - name: Parse and check version + id: version run: | # The image records its version from the committed VERSION file, so # that a plain checkout reproduces the digest. The release tag only @@ -51,9 +55,12 @@ jobs: # GHCR rejects an uppercase path, and the owner is spelled # Dstack-TEE, so derive the repository rather than hardcode it. IMAGE_REPOSITORY=$(printf '%s/dstack-ingress' "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]') + IMAGE_REFERENCE="${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${VERSION}" echo "VERSION=${VERSION}" >> "$GITHUB_ENV" echo "IMAGE_REPOSITORY=${IMAGE_REPOSITORY}" >> "$GITHUB_ENV" - echo "IMAGE_REFERENCE=${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${VERSION}" >> "$GITHUB_ENV" + echo "IMAGE_REFERENCE=${IMAGE_REFERENCE}" >> "$GITHUB_ENV" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "image-reference=${IMAGE_REFERENCE}" >> "$GITHUB_OUTPUT" echo "Parsed version: ${VERSION}" - name: Install dependencies @@ -137,3 +144,97 @@ jobs: ``` Expected digest: `${{ steps.capture-digest.outputs.digest }}` + + # Pinning the published digest into the examples is the step that gets + # forgotten: 2.4 and 2.5 were both tagged and published without it, so every + # compose file and README snippet in the repository kept deploying 2.3. Open + # the pull request here, while the digest is in hand. + pin-digest: + needs: build-and-attest + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + env: + VERSION: ${{ needs.build-and-attest.outputs.version }} + PINNED_REFERENCE: ${{ needs.build-and-attest.outputs.pinned-reference }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Check out the default branch + uses: actions/checkout@v4 + with: + # Not the tag: the pull request has to target the branch, and the + # branch may already have moved past the commit that was built. + ref: ${{ github.event.repository.default_branch }} + + - name: Pin the published digest + id: pin + run: | + ./custom-domain/dstack-ingress/pin-release.sh "${PINNED_REFERENCE}" + if git diff --quiet; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Check the pinned tree + if: steps.pin.outputs.changed == 'true' + run: ./dev.sh check-all + + - name: Open the pull request + if: steps.pin.outputs.changed == 'true' + run: | + BRANCH="chore/pin-ingress-${VERSION}" + TITLE="chore(ingress): pin dstack-ingress ${VERSION} digest from the release build" + + cat > /tmp/commit-message < /tmp/pr-body <` and push the tag. CI builds with `--require-clean`, pushes the image, and reports the digest in the run summary and the release notes. -3. Pin the published `@sha256:` in one commit, everywhere the examples name the image: +3. Merge the pin pull request. The release workflow opens it against the default branch as its last step, with every digest-pinned reference — `docker-compose.yaml`, `docker-compose.multi.yaml`, three snippets in this README, and `k3s/docker-compose.yaml` — set to the digest it just published. + + Review it like any other: the digest in the diff must match the one in the release notes. Checks declared on `pull_request` do not start for a pull request opened with `GITHUB_TOKEN`, so its check list will be empty even though `./dev.sh check-all` ran on that tree in the release job; close and reopen it to run them. + + If that job failed, do the same thing by hand: ```bash - # from the repository root - grep -rn 'dstack-ingress:[0-9]' --include='*.yaml' --include='*.md' . + # from anywhere in the repository + ./custom-domain/dstack-ingress/pin-release.sh ghcr.io/dstack-tee/dstack-ingress:@sha256: ``` - Today that is `custom-domain/dstack-ingress/docker-compose.yaml`, `docker-compose.multi.yaml`, three snippets in this README, and `k3s/docker-compose.yaml`. - ## License MIT License diff --git a/custom-domain/dstack-ingress/pin-release.sh b/custom-domain/dstack-ingress/pin-release.sh new file mode 100755 index 0000000..f282c90 --- /dev/null +++ b/custom-domain/dstack-ingress/pin-release.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# +# Point every example in the repository at a published dstack-ingress image. +# +# A release is only finished once this has run: the compose files and README +# snippets are what people deploy, and they pin the image by digest, so they +# cannot be updated until the digest exists. 2.4 and 2.5 were both tagged and +# published without this step and every example kept deploying 2.3. +# +# The release workflow runs this and opens the resulting pull request. Run it +# by hand only when that failed. + +set -euo pipefail + +usage() { + echo "Usage: $0 @sha256:" + echo "" + echo " e.g. $0 ghcr.io/dstack-tee/dstack-ingress:2.7@sha256:0123...cdef" +} + +if [ $# -ne 1 ]; then + usage >&2 + exit 1 +fi + +PINNED_REF="$1" +if ! [[ "$PINNED_REF" =~ ^[A-Za-z0-9][A-Za-z0-9./_-]*/dstack-ingress:[A-Za-z0-9._-]+@sha256:[0-9a-f]{64}$ ]]; then + echo "Error: not a digest-pinned dstack-ingress reference: $PINNED_REF" >&2 + usage >&2 + exit 1 +fi + +ROOT="$(git rev-parse --show-toplevel)" +cd "$ROOT" + +# Any registry, any version -- the registry moved once already (Docker Hub to +# GHCR in 2.6) and will not be the last thing about the reference to change. +PATTERN='[A-Za-z0-9][A-Za-z0-9./_-]*/dstack-ingress:[^[:space:]@"'"'"']+@sha256:[0-9a-f]{64}' + +mapfile -t FILES < <(git grep -lE "$PATTERN" -- '*.yaml' '*.yml' '*.md') + +if [ ${#FILES[@]} -eq 0 ]; then + echo "Error: found no digest-pinned dstack-ingress reference to update." >&2 + echo "The examples are supposed to pin the image; check what changed." >&2 + exit 1 +fi + +# Compare the references themselves rather than the tree against HEAD, so the +# answer is the same whether or not something else is already uncommitted. +BEFORE="$(git grep -hoE "$PATTERN" -- '*.yaml' '*.yml' '*.md' | sort -u)" +sed -E -i "s#${PATTERN}#${PINNED_REF}#g" "${FILES[@]}" +AFTER="$(git grep -hoE "$PATTERN" -- '*.yaml' '*.yml' '*.md' | sort -u)" + +if [ "$BEFORE" = "$AFTER" ]; then + echo "The examples already pin ${PINNED_REF}; nothing to do." + exit 0 +fi + +echo "Pinned ${#FILES[@]} file(s) to ${PINNED_REF}:" +git grep -nE "$PATTERN" -- '*.yaml' '*.yml' '*.md' | sed 's/^/ /'