Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 102 additions & 1 deletion .github/workflows/dstack-ingress-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <<EOF
${TITLE}

Published by the dstack-ingress-v${VERSION} release run from
${GITHUB_SHA}. Opened automatically: a release is not finished until
the examples point at what it published.
EOF

cat > /tmp/pr-body <<EOF
Step 3 of the release, opened by the \`dstack-ingress-v${VERSION}\` release run.
Until it lands, every example in this repository still deploys the previous
release -- which is how 2.4 and 2.5 both shipped.

Every digest-pinned reference now reads:

\`\`\`text
${PINNED_REFERENCE}
\`\`\`

\`./dev.sh check-all\` passed on this tree in the release run. GitHub does not
start \`pull_request\` workflows for a pull request opened with \`GITHUB_TOKEN\`,
so the check list below will be empty; close and reopen this pull request to
run them for real.

To verify the digest independently, on a native linux/amd64 host:

\`\`\`bash
git checkout dstack-ingress-v${VERSION}
cd custom-domain/dstack-ingress
./build-image.sh
skopeo inspect oci-archive:./oci.tar | jq -r '.Digest'
\`\`\`
EOF

git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git checkout -B "${BRANCH}"
git commit -a -F /tmp/commit-message
git push --force origin "${BRANCH}"

if [ -n "$(gh pr list --head "${BRANCH}" --state open --json number --jq '.[].number')" ]; then
echo "A pull request for ${BRANCH} is already open; pushed to it."
exit 0
fi

gh pr create \
--base "${DEFAULT_BRANCH}" \
--head "${BRANCH}" \
--title "${TITLE}" \
--body-file /tmp/pr-body
16 changes: 10 additions & 6 deletions custom-domain/dstack-ingress/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<version>` 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<version>` (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:<tag> --owner Dstack-TEE`.

### Releasing
Expand All @@ -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<version>` 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 `<version>@sha256:<digest>` 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:<version>@sha256:<digest>
```

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
Expand Down
2 changes: 1 addition & 1 deletion custom-domain/dstack-ingress/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.6
2.7
18 changes: 12 additions & 6 deletions custom-domain/dstack-ingress/build-image.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}"
Expand Down
60 changes: 60 additions & 0 deletions custom-domain/dstack-ingress/pin-release.sh
Original file line number Diff line number Diff line change
@@ -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 <image-reference>@sha256:<digest>"
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/^/ /'
Loading