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 <` 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 @@ -339,15 +341,17 @@ A release is not finished when the image is pushed. The compose files and the sn If the base image or the installed packages changed since the last release, run `./build-image.sh` locally first and commit the regenerated `pinned-packages.txt` in the same batch. The build refuses to publish an image whose packages that file does not record, so a stale one fails the release after a full CI build. 2. Tag that commit `dstack-ingress-v` 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/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}" 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/^/ /'