From 6350ee0a300dff83bc0d030bd4dd104547899518 Mon Sep 17 00:00:00 2001 From: Matthias Linhuber Date: Tue, 25 Aug 2026 15:52:27 +0200 Subject: [PATCH 1/5] feat: add shared dual-arch Docker build workflow Adds the org-wide reusable workflow that every EduIDE image will build through, replacing the current dependency on ls1intum/.github@feature/split-build-workflow-modes - an unmerged PR branch in another organisation that all six of our build call sites currently point at. Modelled on ls1intum/.github@main digest-merge pattern, with three deliberate changes: - Both architectures are always built, including on pull requests. Today build-arm64 is disabled for PRs, so a PR image cannot be scheduled onto an arm64 node - which our test clusters have. - GitHub-hosted runners only. No execution-mode input, no ARC path, no network: host, so what CI proves is what ships. - The published manifest is verified, not assumed. A silently single-architecture image is the failure this workflow exists to prevent. Fixes a tag-slugging bug inherited from the upstream idiom: echo "$TAG" | tr -c "a-z0-9_.-" "-" echo appends a newline and tr maps it to a hyphen, so every tag derived this way gains a trailing "-". This is not theoretical - GHCR currently holds both 1.1.0 and 1.1.0- / 1.1.0--375ef32 for eduide-cloud/service and eduidec-landing-page. Uses printf instead. Also validates the derived tag against Docker tag grammar before building. scorpio carries real tags v.1.1.1 / v.1.0.11 / v.1.0.12 which strip to ".1.1.1"; a tag may not start with a period, so today that fails at push time after a full multi-arch build. Now it fails in seconds with the reason. tests/test-derive-tags.sh extracts the derivation shell straight out of the workflow YAML and executes it, so the tests cannot drift from what runs in CI. 17 cases covering tag derivation, platform selection and guards. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019qeiQRFu8xAMRYWPdZewjG --- .../workflows/build-and-push-docker-image.yml | 386 ++++++++++++++++++ .github/workflows/ci.yml | 34 ++ README.md | 85 +++- tests/test-derive-tags.sh | 98 +++++ 4 files changed, 602 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/build-and-push-docker-image.yml create mode 100644 .github/workflows/ci.yml create mode 100755 tests/test-derive-tags.sh diff --git a/.github/workflows/build-and-push-docker-image.yml b/.github/workflows/build-and-push-docker-image.yml new file mode 100644 index 0000000..e4ba07e --- /dev/null +++ b/.github/workflows/build-and-push-docker-image.yml @@ -0,0 +1,386 @@ +# ============================================================================== +# Reusable Workflow: Build and Push a multi-arch Docker image +# ============================================================================== +# Builds linux/amd64 and linux/arm64 on GitHub-hosted runners and publishes a +# single multi-arch manifest. Every EduIDE container image goes through here. +# +# Design notes: +# - Both architectures are ALWAYS built, including on pull requests. A PR image +# that exists only for amd64 cannot be deployed to a mixed-architecture +# cluster, which is exactly the case for our test environments. +# - Runners are GitHub-hosted only. There is deliberately no execution-mode +# input and no self-hosted/ARC path: one way to build, so what CI proves is +# what ships. +# - Each platform builds independently and pushes by digest; the merge job +# assembles the manifest list. This is the standard buildx pattern and means +# a partial failure never publishes a half-built tag. +# +# Tags published (both point at the same manifest): +# latest | pr- | | | +# - immutable, always safe to pin +# +# Usage: +# jobs: +# build: +# uses: EduIDE/.github/.github/workflows/build-and-push-docker-image.yml@v1 +# with: +# image-name: eduide/eduide-cloud/service +# docker-file: dockerfiles/service/Dockerfile +# secrets: inherit +# ============================================================================== + +name: Build and Push Docker Image + +on: + workflow_call: + inputs: + image-name: + type: string + required: true + description: "Image path WITHOUT registry, e.g. eduide/eduide-cloud/service" + docker-file: + type: string + default: Dockerfile + description: "Path to the Dockerfile" + docker-context: + type: string + default: . + description: "Docker build context" + ref: + type: string + default: '' + description: "Branch, tag or SHA to build (default: the triggering ref)" + build-args: + type: string + required: false + description: "Newline-separated build args, e.g. BASE_IDE_TAG=latest-abc1234" + labels: + type: string + required: false + description: "Additional image labels" + image-tag: + type: string + default: '' + description: "Override the derived base tag (used by the release train)" + registry: + type: string + default: ghcr.io + cache-image: + type: string + default: '' + description: "Image path to store layer cache in (default: same as image-name)" + no-cache: + type: boolean + default: false + description: "Bypass the layer cache (nightly builds use this)" + free-disk-space: + type: boolean + default: true + description: "Reclaim ~30GB of preinstalled toolchains before building. Needed for the large IDE images; harmless otherwise." + build-amd64: + type: boolean + default: true + build-arm64: + type: boolean + default: true + outputs: + image_tag: + description: "The primary tag that was published" + value: ${{ jobs.setup.outputs.base_tag }} + base_tag: + description: "Same as image_tag" + value: ${{ jobs.setup.outputs.base_tag }} + sha_tag: + description: "Immutable - tag" + value: ${{ jobs.setup.outputs.sha_tag }} + cache_tag: + description: "Layer cache tag namespace" + value: ${{ jobs.setup.outputs.cache_tag }} + image_repo: + description: "Lower-cased owner/repository of the calling repo" + value: ${{ jobs.setup.outputs.image_repo }} + secrets: + registry-user: + required: false + registry-password: + required: false + docker-secrets: + required: false + description: "Build secrets, e.g. SENTRY_AUTH_TOKEN=xxx" + +jobs: + # --------------------------------------------------------------------------- + # Derive tags once, so the build and merge jobs cannot disagree about them. + # --------------------------------------------------------------------------- + setup: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.derive.outputs.matrix }} + base_tag: ${{ steps.derive.outputs.base_tag }} + sha_tag: ${{ steps.derive.outputs.sha_tag }} + cache_tag: ${{ steps.derive.outputs.cache_tag }} + cache_image: ${{ steps.derive.outputs.cache_image }} + image_repo: ${{ steps.derive.outputs.image_repo }} + steps: + - id: derive + shell: bash + env: + OVERRIDE: ${{ inputs.image-tag }} + PR_NUMBER: ${{ github.event.pull_request.number || '' }} + RELEASE_TAG: ${{ github.event.release.tag_name || '' }} + BUILD_AMD64: ${{ inputs.build-amd64 }} + BUILD_ARM64: ${{ inputs.build-arm64 }} + CACHE_IMAGE_IN: ${{ inputs.cache-image }} + IMAGE_NAME: ${{ inputs.image-name }} + run: | + set -euo pipefail + + SHORT_SHA="${GITHUB_SHA::7}" + + # NOTE: printf, not echo. `echo | tr -c 'a-z0-9_.-' '-'` converts the + # trailing newline into a hyphen, which is how tags like "1.1.0-" and + # "1.1.0--375ef32" ended up published in GHCR. Do not reintroduce echo here. + slug() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9_.-' '-'; } + + SAFE_REF=$(slug "${GITHUB_REF_NAME}") + + # Release tags are normalised to X.Y.Z so that a chart's appVersion and + # the image tag it points at are literally the same string. + if [[ -n "${OVERRIDE}" ]]; then + BASE_TAG="${OVERRIDE}" + elif [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then + if [[ -z "${PR_NUMBER}" ]]; then + echo "::error::pull_request event but no PR number available" + exit 1 + fi + BASE_TAG="pr-${PR_NUMBER}" + elif [[ "${GITHUB_EVENT_NAME}" == "release" ]]; then + if [[ -z "${RELEASE_TAG}" ]]; then + echo "::error::release event but release.tag_name is empty" + exit 1 + fi + BASE_TAG=$(slug "${RELEASE_TAG#v}") + elif [[ "${GITHUB_REF}" == "refs/heads/main" || "${GITHUB_REF}" == "refs/heads/master" ]]; then + BASE_TAG="latest" + else + BASE_TAG="${SAFE_REF}" + fi + + # A Docker tag must start with an alphanumeric or underscore and be + # <=128 chars. Malformed git tags do exist in this org (v.1.1.1, + # v.1.0.11 on scorpio) and would derive to ".1.1.1", which the registry + # rejects only at push time, after a full multi-arch build. Fail now. + if ! [[ "${BASE_TAG}" =~ ^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$ ]]; then + echo "::error::derived tag '${BASE_TAG}' is not a valid Docker tag." + echo "::error::Tags must match ^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$ - check the git tag name (expected vX.Y.Z)." + exit 1 + fi + + if [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then + CACHE_TAG="build-cache-pr-${PR_NUMBER}" + elif [[ "${BASE_TAG}" == "latest" ]]; then + CACHE_TAG="build-cache" + else + CACHE_TAG="build-cache-${BASE_TAG}" + fi + + # Build the platform matrix. Both are on by default; the inputs exist + # only so a caller can debug a single architecture. + PLATFORMS=() + [[ "${BUILD_AMD64}" == "true" ]] && PLATFORMS+=('{"platform":"linux/amd64","arch":"amd64","runner":"ubuntu-24.04"}') + [[ "${BUILD_ARM64}" == "true" ]] && PLATFORMS+=('{"platform":"linux/arm64","arch":"arm64","runner":"ubuntu-24.04-arm"}') + if [[ ${#PLATFORMS[@]} -eq 0 ]]; then + echo "::error::build-amd64 and build-arm64 are both false; nothing to build" + exit 1 + fi + JOINED=$(IFS=,; echo "${PLATFORMS[*]}") + + { + echo "matrix={\"include\":[${JOINED}]}" + echo "base_tag=${BASE_TAG}" + echo "sha_tag=${BASE_TAG}-${SHORT_SHA}" + echo "cache_tag=${CACHE_TAG}" + echo "cache_image=${CACHE_IMAGE_IN:-$IMAGE_NAME}" + echo "image_repo=$(echo "${GITHUB_REPOSITORY}" | tr '[:upper:]' '[:lower:]')" + } >> "$GITHUB_OUTPUT" + + { + echo "### ${IMAGE_NAME}" + echo "" + echo "| | |" + echo "|---|---|" + echo "| Tags | \`${BASE_TAG}\`, \`${BASE_TAG}-${SHORT_SHA}\` |" + echo "| Platforms | $(echo "${JOINED}" | grep -o 'linux/[a-z0-9]*' | paste -sd', ' -) |" + } >> "$GITHUB_STEP_SUMMARY" + + # --------------------------------------------------------------------------- + # One job per architecture. Pushes by digest only; publishes no tag. + # --------------------------------------------------------------------------- + build: + needs: setup + name: ${{ matrix.arch }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.setup.outputs.matrix) }} + permissions: + contents: read + packages: write + steps: + - name: Free disk space + if: ${{ inputs.free-disk-space }} + shell: bash + run: | + # The GitHub-hosted image ships ~30GB of toolchains we never use, and + # the Theia IDE images are large enough that this is the difference + # between a green build and ENOSPC. Best-effort: paths differ per arch. + echo "Before: $(df -h / | awk 'NR==2{print $4}') available" + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \ + /opt/hostedtoolcache/CodeQL /usr/local/share/boost \ + /usr/share/swift 2>/dev/null || true + sudo docker image prune --all --force >/dev/null 2>&1 || true + echo "After: $(df -h / | awk 'NR==2{print $4}') available" + + - name: Checkout (explicit ref) + if: ${{ inputs.ref != '' }} + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + + - name: Checkout + if: ${{ inputs.ref == '' }} + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to ${{ inputs.registry }} + uses: docker/login-action@v3 + with: + registry: ${{ inputs.registry }} + username: ${{ secrets.registry-user || github.actor }} + password: ${{ secrets.registry-password || secrets.GITHUB_TOKEN }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: ${{ inputs.docker-context }} + file: ${{ inputs.docker-file }} + platforms: ${{ matrix.platform }} + build-args: ${{ inputs.build-args }} + labels: ${{ inputs.labels }} + secrets: ${{ secrets.docker-secrets }} + no-cache: ${{ inputs.no-cache }} + cache-from: ${{ inputs.no-cache && '' || format('type=registry,ref={0}/{1}:{2}-{3}', inputs.registry, needs.setup.outputs.cache_image, needs.setup.outputs.cache_tag, matrix.arch) }} + cache-to: ${{ inputs.no-cache && '' || format('type=registry,ref={0}/{1}:{2}-{3},mode=max,image-manifest=true,oci-mediatypes=true', inputs.registry, needs.setup.outputs.cache_image, needs.setup.outputs.cache_tag, matrix.arch) }} + outputs: type=image,name=${{ inputs.registry }}/${{ inputs.image-name }},push-by-digest=true,name-canonical=true,push=true + + - name: Export digest + shell: bash + run: | + set -euo pipefail + mkdir -p "${{ runner.temp }}/digests" + digest="${{ steps.build.outputs.digest }}" + if [[ -z "${digest}" ]]; then + echo "::error::build produced no digest" + exit 1 + fi + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ github.run_id }}-${{ github.run_attempt }}-${{ hashFiles(inputs.docker-file) }}-${{ matrix.arch }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + # --------------------------------------------------------------------------- + # Assemble the per-architecture digests into one multi-arch manifest. + # --------------------------------------------------------------------------- + merge: + needs: [setup, build] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-${{ github.run_id }}-${{ github.run_attempt }}-${{ hashFiles(inputs.docker-file) }}-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to ${{ inputs.registry }} + uses: docker/login-action@v3 + with: + registry: ${{ inputs.registry }} + username: ${{ secrets.registry-user || github.actor }} + password: ${{ secrets.registry-password || secrets.GITHUB_TOKEN }} + + - name: Create and push manifest list + working-directory: ${{ runner.temp }}/digests + shell: bash + env: + IMAGE: ${{ inputs.registry }}/${{ inputs.image-name }} + BASE_TAG: ${{ needs.setup.outputs.base_tag }} + SHA_TAG: ${{ needs.setup.outputs.sha_tag }} + run: | + set -euo pipefail + shopt -s nullglob + digests=(*) + if [[ ${#digests[@]} -eq 0 ]]; then + echo "::error::no digests were produced; refusing to publish an empty manifest" + exit 1 + fi + echo "Assembling ${#digests[@]} digest(s) into ${IMAGE}:${BASE_TAG}" + refs=() + for d in "${digests[@]}"; do + refs+=("${IMAGE}@sha256:${d}") + done + docker buildx imagetools create \ + -t "${IMAGE}:${BASE_TAG}" \ + -t "${IMAGE}:${SHA_TAG}" \ + "${refs[@]}" + + - name: Verify published manifest + shell: bash + env: + IMAGE: ${{ inputs.registry }}/${{ inputs.image-name }} + BASE_TAG: ${{ needs.setup.outputs.base_tag }} + EXPECT_AMD64: ${{ inputs.build-amd64 }} + EXPECT_ARM64: ${{ inputs.build-arm64 }} + run: | + set -euo pipefail + out=$(docker buildx imagetools inspect "${IMAGE}:${BASE_TAG}") + echo "$out" + + # A silently single-arch image is the failure mode this workflow exists + # to prevent, so assert the manifest rather than trusting the build. + fail=0 + if [[ "${EXPECT_AMD64}" == "true" ]] && ! grep -q "linux/amd64" <<< "$out"; then + echo "::error::${IMAGE}:${BASE_TAG} is missing linux/amd64"; fail=1 + fi + if [[ "${EXPECT_ARM64}" == "true" ]] && ! grep -q "linux/arm64" <<< "$out"; then + echo "::error::${IMAGE}:${BASE_TAG} is missing linux/arm64"; fail=1 + fi + exit $fail + + - name: Summary + shell: bash + env: + IMAGE: ${{ inputs.registry }}/${{ inputs.image-name }} + BASE_TAG: ${{ needs.setup.outputs.base_tag }} + SHA_TAG: ${{ needs.setup.outputs.sha_tag }} + run: | + { + echo "" + echo "Published \`${IMAGE}:${BASE_TAG}\` and \`${IMAGE}:${SHA_TAG}\` (amd64 + arm64)." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..39ce63b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + lint: + name: actionlint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run actionlint + run: | + set -euo pipefail + bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.7/scripts/download-actionlint.bash) + ./actionlint -color + + test: + name: Tag derivation tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install yq + run: | + sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/v4.44.3/yq_linux_amd64 + sudo chmod +x /usr/local/bin/yq + - name: Run tests + run: ./tests/test-derive-tags.sh diff --git a/README.md b/README.md index a46ae92..e181b25 100644 --- a/README.md +++ b/README.md @@ -1 +1,84 @@ -# .github \ No newline at end of file +# EduIDE `.github` + +Org-level defaults and shared reusable workflows for the [EduIDE](https://github.com/EduIDE) organisation. + +## Reusable workflows + +### `build-and-push-docker-image.yml` + +Builds a container image for **linux/amd64 and linux/arm64** on GitHub-hosted +runners and publishes one multi-arch manifest. Every EduIDE image goes through +this workflow. + +```yaml +jobs: + build: + uses: EduIDE/.github/.github/workflows/build-and-push-docker-image.yml@v1 + with: + image-name: eduide/eduide-cloud/service + docker-file: dockerfiles/service/Dockerfile + secrets: inherit +``` + +**Tags published** (both point at the same manifest): + +| Trigger | `base_tag` | also published | +|---|---|---| +| push to `main` | `latest` | `latest-` | +| pull request | `pr-` | `pr--` | +| release `v2.3.0` | `2.3.0` | `2.3.0-` | +| other branch | `` | `-` | +| `image-tag` input | that value | `-` | + +The leading `v` is stripped from release tags so that a Helm chart's +`appVersion` and the image tag it refers to are the same string. + +**Inputs** (all optional except `image-name`): `image-name`, `docker-file`, +`docker-context`, `ref`, `build-args`, `labels`, `image-tag`, `registry`, +`cache-image`, `no-cache`, `free-disk-space`, `build-amd64`, `build-arm64`. + +**Outputs:** `image_tag`, `base_tag`, `sha_tag`, `cache_tag`, `image_repo`. +Use `sha_tag` when one image must build on top of another: + +```yaml +build-args: | + BASE_IDE_TAG=${{ needs.build-base.outputs.sha_tag }} +``` + +**Secrets:** `registry-user`, `registry-password` (both default to the GitHub +token), and `docker-secrets` for build-time secrets such as `SENTRY_AUTH_TOKEN`. + +#### Design decisions worth knowing + +- **Both architectures are always built, including on pull requests.** A PR image + that exists only for amd64 cannot be scheduled onto an arm64 node, which our + test clusters have. Building one architecture is possible via `build-amd64` / + `build-arm64` but is intended for debugging only. +- **GitHub-hosted runners only.** There is deliberately no `execution-mode` input + and no self-hosted or ARC path, so what CI proves is what ships. +- **`free-disk-space` defaults to `true`.** The runner image ships ~30GB of + toolchains we never use, and the Theia IDE images are large enough that + reclaiming it is the difference between a green build and `ENOSPC`. +- **Push-by-digest, then merge.** Each architecture pushes an untagged digest; + a final job assembles the manifest list. A partial failure therefore never + publishes a half-built tag. +- **The manifest is verified after publishing.** A silently single-architecture + image is the failure this workflow exists to prevent, so the merge job asserts + the platforms are actually present rather than trusting the build. +- **`printf`, not `echo`, when slugging tags.** `echo | tr -c` turns the trailing + newline into a hyphen. That bug published `1.1.0-` and `1.1.0--375ef32` to + GHCR before this workflow existed; `tests/test-derive-tags.sh` guards it. + +## Tests + +```bash +./tests/test-derive-tags.sh # requires yq +``` + +The test extracts the tag-derivation shell directly out of the workflow YAML and +executes it, so it cannot drift from what runs in CI. + +## Versioning + +Consumers should pin the `v1` tag. Breaking changes to inputs or outputs get a +new major tag. diff --git a/tests/test-derive-tags.sh b/tests/test-derive-tags.sh new file mode 100755 index 0000000..901975f --- /dev/null +++ b/tests/test-derive-tags.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Regression tests for tag derivation in build-and-push-docker-image.yml. +# +# This does NOT reimplement the logic. It extracts the `derive` step's shell +# body straight out of the workflow YAML and executes it, so the test can never +# drift from what actually runs in CI. +# +# Requires: yq, bash 4+ +# +# Run: ./tests/test-derive-tags.sh + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORKFLOW="${ROOT}/.github/workflows/build-and-push-docker-image.yml" + +command -v yq >/dev/null || { echo "yq is required"; exit 2; } +[[ -f "$WORKFLOW" ]] || { echo "not found: $WORKFLOW"; exit 2; } + +SCRIPT="$(mktemp)" +yq -r '.jobs.setup.steps[] | select(.id == "derive") | .run' "$WORKFLOW" > "$SCRIPT" +[[ -s "$SCRIPT" ]] || { echo "could not extract the derive step from the workflow"; exit 2; } + +if grep -q '\${{' "$SCRIPT"; then + echo "FAIL: the derive step contains \${{ }} expressions, so it cannot be tested in isolation." + echo " Move them into the step's env: block." + exit 1 +fi + +FAILED=0 + +# Runs the real extracted step and prints "base|sha|cache|image" from the +# GITHUB_OUTPUT it produces, or "ERROR " if it exits non-zero. +derive() { + local out; out="$(mktemp)" + local summary; summary="$(mktemp)" + local stderr; stderr="$(mktemp)" + if ( export GITHUB_OUTPUT="$out" GITHUB_STEP_SUMMARY="$summary"; bash "$SCRIPT" ) >"$stderr" 2>&1; then + printf '%s|%s|%s|%s' \ + "$(grep '^base_tag=' "$out" | cut -d= -f2-)" \ + "$(grep '^sha_tag=' "$out" | cut -d= -f2-)" \ + "$(grep '^cache_tag=' "$out" | cut -d= -f2-)" \ + "$(grep '^cache_image=' "$out" | cut -d= -f2-)" + else + printf 'ERROR %s' "$(grep -o '::error::.*' "$stderr" | head -1 | sed 's/::error:://')" + fi + rm -f "$out" "$summary" "$stderr" +} + +expect() { # [ENV=val ...] + local name="$1" want="$2"; shift 2 + local got + got="$( export GITHUB_SHA=abc1234567890 GITHUB_REF_NAME=main GITHUB_REF=refs/heads/main \ + GITHUB_EVENT_NAME=push GITHUB_REPOSITORY=EduIDE/Example \ + OVERRIDE="" PR_NUMBER="" RELEASE_TAG="" \ + BUILD_AMD64=true BUILD_ARM64=true \ + CACHE_IMAGE_IN="" IMAGE_NAME="eduide/example" + for kv in "$@"; do export "$kv"; done + derive )" + if [[ "$got" == "$want" ]]; then + printf ' PASS %-32s %s\n' "$name" "$got" + else + printf ' FAIL %-32s\n got: %s\n want: %s\n' "$name" "$got" "$want" + FAILED=1 + fi +} + +IMG="eduide/example" + +echo "=== tag derivation (base|sha|cache|cache_image) ===" +expect "push to main" "latest|latest-abc1234|build-cache|$IMG" +expect "push to master" "latest|latest-abc1234|build-cache|$IMG" GITHUB_REF=refs/heads/master GITHUB_REF_NAME=master +expect "pull request" "pr-451|pr-451-abc1234|build-cache-pr-451|$IMG" GITHUB_EVENT_NAME=pull_request PR_NUMBER=451 +expect "release v2.3.0" "2.3.0|2.3.0-abc1234|build-cache-2.3.0|$IMG" GITHUB_EVENT_NAME=release RELEASE_TAG=v2.3.0 +expect "release without v" "1.1.0|1.1.0-abc1234|build-cache-1.1.0|$IMG" GITHUB_EVENT_NAME=release RELEASE_TAG=1.1.0 +expect "release candidate" "2.3.0-rc.1|2.3.0-rc.1-abc1234|build-cache-2.3.0-rc.1|$IMG" GITHUB_EVENT_NAME=release RELEASE_TAG=v2.3.0-rc.1 +expect "release-train override" "2.3.0|2.3.0-abc1234|build-cache-2.3.0|$IMG" OVERRIDE=2.3.0 +expect "feature branch" "feat-foo_bar|feat-foo_bar-abc1234|build-cache-feat-foo_bar|$IMG" GITHUB_REF=refs/heads/feat/foo_bar GITHUB_REF_NAME=feat/foo_bar +expect "uppercase branch" "feat-abc|feat-abc-abc1234|build-cache-feat-abc|$IMG" GITHUB_REF=refs/heads/Feat/ABC GITHUB_REF_NAME=Feat/ABC +expect "custom cache image" "latest|latest-abc1234|build-cache|eduide/shared-cache" CACHE_IMAGE_IN=eduide/shared-cache + +echo +echo "=== no trailing hyphen (regression: 1.1.0- and 1.1.0--375ef32 reached GHCR) ===" +expect "release tag is exact" "2.3.0|2.3.0-abc1234|build-cache-2.3.0|$IMG" GITHUB_EVENT_NAME=release RELEASE_TAG=v2.3.0 +expect "branch tag is exact" "my-branch|my-branch-abc1234|build-cache-my-branch|$IMG" GITHUB_REF=refs/heads/my-branch GITHUB_REF_NAME=my-branch + +echo +echo "=== guards ===" +expect "PR without number" "ERROR pull_request event but no PR number available" GITHUB_EVENT_NAME=pull_request PR_NUMBER= +expect "release without tag" "ERROR release event but release.tag_name is empty" GITHUB_EVENT_NAME=release RELEASE_TAG= +expect "no platform selected" "ERROR build-amd64 and build-arm64 are both false; nothing to build" BUILD_AMD64=false BUILD_ARM64=false +expect "malformed tag v.1.1.1" "ERROR derived tag '.1.1.1' is not a valid Docker tag." GITHUB_EVENT_NAME=release RELEASE_TAG=v.1.1.1 +expect "tag starting with -" "ERROR derived tag '-nope' is not a valid Docker tag." OVERRIDE=-nope + +rm -f "$SCRIPT" +echo +if [[ $FAILED -eq 0 ]]; then echo "ALL PASS"; else echo "SOME FAILED"; fi +exit $FAILED From 6388608e2028378853e189e61e7a6be7978a9bc4 Mon Sep 17 00:00:00 2001 From: Matthias Linhuber Date: Tue, 25 Aug 2026 15:55:59 +0200 Subject: [PATCH 2/5] fix: key digest artifacts by image instead of hashFiles The merge job has no checkout, so hashFiles(inputs.docker-file) returned an empty string there while the build job returned a real hash. The download pattern would never have matched the uploaded artifact, so every build would have failed at manifest assembly. Derive an artifact_key in the setup job instead, which both jobs can read. Also correctly namespaces digests when one caller run builds several images, which is the normal case here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019qeiQRFu8xAMRYWPdZewjG --- .github/workflows/build-and-push-docker-image.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-push-docker-image.yml b/.github/workflows/build-and-push-docker-image.yml index e4ba07e..c15dad5 100644 --- a/.github/workflows/build-and-push-docker-image.yml +++ b/.github/workflows/build-and-push-docker-image.yml @@ -116,6 +116,7 @@ jobs: runs-on: ubuntu-latest outputs: matrix: ${{ steps.derive.outputs.matrix }} + artifact_key: ${{ steps.derive.outputs.artifact_key }} base_tag: ${{ steps.derive.outputs.base_tag }} sha_tag: ${{ steps.derive.outputs.sha_tag }} cache_tag: ${{ steps.derive.outputs.cache_tag }} @@ -195,8 +196,15 @@ jobs: fi JOINED=$(IFS=,; echo "${PLATFORMS[*]}") + # Several images are usually built from one caller run, and artifact + # names share a namespace across the whole run. Key them by image so + # the merge job cannot pick up another image's digests. Derived here + # rather than with hashFiles() because the merge job has no checkout. + ARTIFACT_KEY=$(printf '%s' "${IMAGE_NAME}" | tr -c 'a-zA-Z0-9_.-' '-') + { echo "matrix={\"include\":[${JOINED}]}" + echo "artifact_key=${ARTIFACT_KEY}" echo "base_tag=${BASE_TAG}" echo "sha_tag=${BASE_TAG}-${SHORT_SHA}" echo "cache_tag=${CACHE_TAG}" @@ -293,7 +301,7 @@ jobs: - name: Upload digest uses: actions/upload-artifact@v4 with: - name: digests-${{ github.run_id }}-${{ github.run_attempt }}-${{ hashFiles(inputs.docker-file) }}-${{ matrix.arch }} + name: digests-${{ github.run_id }}-${{ github.run_attempt }}-${{ needs.setup.outputs.artifact_key }}-${{ matrix.arch }} path: ${{ runner.temp }}/digests/* if-no-files-found: error retention-days: 1 @@ -312,7 +320,7 @@ jobs: uses: actions/download-artifact@v4 with: path: ${{ runner.temp }}/digests - pattern: digests-${{ github.run_id }}-${{ github.run_attempt }}-${{ hashFiles(inputs.docker-file) }}-* + pattern: digests-${{ github.run_id }}-${{ github.run_attempt }}-${{ needs.setup.outputs.artifact_key }}-* merge-multiple: true - name: Set up Docker Buildx From 938bef5b0c44e38f2266faae68b3830322c56a6d Mon Sep 17 00:00:00 2001 From: Matthias Linhuber Date: Tue, 25 Aug 2026 16:08:49 +0200 Subject: [PATCH 3/5] feat: add org-wide governance defaults Community health files here are inherited by every repository in the organisation that does not define its own. - profile/README.md: the organisation landing page, which is currently blank. Explains what EduIDE is and what each repository does. - SECURITY.md: private vulnerability reporting via GitHub advisories, with a scope section covering the risks specific to running untrusted student code in shared per-session containers. - ISSUE_TEMPLATE: seeded from EduIDE-Cloud, with the upstream Theia Cloud references removed. config.yml previously pointed at eclipse-theia/theia-cloud discussions and EclipseSource commercial support; bug_report.yml told reporters to mail security@theia-cloud.io. - PULL_REQUEST_TEMPLATE.md: asks what was actually verified, what the deployment impact is, and how to roll back. Also adds auto-assign as a reusable workflow. Note that GitHub does not inherit workflows from the org .github repository, only community health files, so the five duplicated copies cannot simply be deleted. Each repository keeps a short caller instead, which puts the logic in one place while respecting that constraint. The shared version additionally skips bot-authored pull requests and does not overwrite existing assignees. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019qeiQRFu8xAMRYWPdZewjG --- .github/ISSUE_TEMPLATE/bug_report.yml | 33 +++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 11 +++++ .github/ISSUE_TEMPLATE/feature_request.yml | 30 ++++++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 36 ++++++++++++++++ .github/workflows/auto-assign.yml | 48 ++++++++++++++++++++++ SECURITY.md | 40 ++++++++++++++++++ profile/README.md | 45 ++++++++++++++++++++ 7 files changed, 243 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/auto-assign.yml create mode 100644 SECURITY.md create mode 100644 profile/README.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..efd8e4e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,33 @@ +name: Bug Report +description: Create a bug report to help us improve +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Do not report security vulnerabilities here. Any such issue will be deleted on sight. + Report them privately via [GitHub security advisories](https://github.com/EduIDE/.github/security/advisories/new) instead - see SECURITY.md. + - type: textarea + validations: + required: true + attributes: + label: Describe the bug + description: A clear and concise description of what the bug is. + - type: textarea + validations: + required: true + attributes: + label: Expected behavior + description: A clear and concise description of what you expected to happen. + - type: input + attributes: + label: Cluster provider + description: The cluster provider (e.g. Minikube, AWS, GKE, Azure, Docker Desktop). Please include your OS when using a local cluster. If you are using Terraform, please include the configuration files if possible. + - type: input + attributes: + label: Version + description: The used version. Such as the helm chart version or the commit id the bug was discovered on + - type: textarea + attributes: + label: Additional information + description: Additional information to analyze the bug such as logs, screenshots, screencasts. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..b413532 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: true +contact_links: + - name: Question or discussion + url: https://github.com/orgs/EduIDE/discussions + about: Ask questions about using or running EduIDE here. + - name: Documentation + url: https://eduide.github.io/Docs/ + about: Guides for students, instructors, administrators and developers. + - name: Report a security vulnerability + url: https://github.com/EduIDE/.github/security/advisories/new + about: Report privately. Do not open a public issue for security problems. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..aac2a31 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,30 @@ +name: Feature request +description: Suggest an idea for this project +labels: enhancement +body: +- type: textarea + validations: + required: true + attributes: + label: Is your feature request related to a problem? Please describe. + description: A clear and concise description of what the problem is. E.g. I'm always frustrated when [...] +- type: textarea + validations: + required: true + attributes: + label: Describe the solution you'd like + description: A clear and concise description of what you want to happen. +- type: textarea + validations: + required: true + attributes: + label: Describe alternatives you've considered + description: A clear and concise description of any alternative solutions or features you've considered. +- type: input + attributes: + label: Cluster provider + description: The cluster provider (e.g. Minikube, AWS, GKE, Azure, Docker Desktop) the feature is for. Leave out if the feature is not related to (a) specific provider(s). +- type: textarea + attributes: + label: Additional information + description: Any additional information to describe the feature such as screenshots, diagrams, etc. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..2840fbb --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,36 @@ + + +## What and why + + + +## How it was verified + + + +## Deployment impact + + + +- [ ] Changes a Helm chart (chart `version` bumped) +- [ ] Changes a published image +- [ ] Requires a config change in EduIDE-deployment +- [ ] Requires a cluster-level change (CRDs, Gateway, ClusterRoles) +- [ ] None of the above + +## Risk and rollback + + diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml new file mode 100644 index 0000000..55f5a92 --- /dev/null +++ b/.github/workflows/auto-assign.yml @@ -0,0 +1,48 @@ +# Reusable: assign a pull request to its author. +# +# GitHub does NOT inherit workflows from the org .github repository - only +# community health files (issue templates, PR template, SECURITY.md, and so on) +# are inherited. So this cannot simply replace the copies in each repository. +# Instead each repository keeps a short caller: +# +# name: Auto Assign PR Author +# on: +# pull_request: +# types: [opened, reopened, ready_for_review] +# jobs: +# auto-assign: +# uses: EduIDE/.github/.github/workflows/auto-assign.yml@v1 +# +# That keeps the logic in one place while respecting the platform constraint. + +name: Auto Assign PR Author + +on: + workflow_call: + +jobs: + auto-assign: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Auto assign PR author + uses: actions/github-script@v7 + with: + script: | + // Dependabot and other bots do not benefit from being assigned. + const login = context.payload.pull_request.user.login; + if (context.payload.pull_request.user.type === 'Bot') { + core.info(`Skipping bot author ${login}`); + return; + } + if (context.payload.pull_request.assignees.length > 0) { + core.info('PR already has assignees; leaving them alone.'); + return; + } + await github.rest.issues.addAssignees({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + assignees: [login], + }); diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..fb6d7fa --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,40 @@ +# Security Policy + +Org-wide default for all repositories under [EduIDE](https://github.com/EduIDE). + +## Reporting a vulnerability + +**Do not open a public issue.** Report privately through GitHub security +advisories: + + + +If the vulnerability is specific to one repository, use that repository's +"Report a vulnerability" button instead so the advisory lands with the code. + +Please include what you can: + +- which component and version (image tag or chart version) +- what an attacker can do with it +- how to reproduce +- whether it is already public anywhere + +## Scope + +EduIDE runs untrusted student code in per-session containers on a shared +Kubernetes cluster. Findings that are especially relevant: + +- escaping a session container, or reaching another user's session or workspace +- reading another user's workspace storage +- bypassing Keycloak authentication or the oauth2-proxy layer in front of a session +- reaching the operator, the REST service, or their service accounts from inside a session +- privilege escalation via the admin API token + +Denial of service caused by a session consuming its own quota is expected +behaviour, not a vulnerability. Resource limits are enforced per AppDefinition. + +## What to expect + +We will acknowledge the report, tell you whether we consider it in scope, and +keep you informed while we work on a fix. We will credit you in the advisory +unless you would rather stay anonymous. diff --git a/profile/README.md b/profile/README.md new file mode 100644 index 0000000..438dc8f --- /dev/null +++ b/profile/README.md @@ -0,0 +1,45 @@ +# EduIDE + +Browser-based programming environments for teaching, running on Kubernetes and +integrated with [Artemis](https://github.com/ls1intum/Artemis). + +Students open an exercise and get a full IDE in the browser - no local setup, +no toolchain installation, the same environment for everyone. + +📖 **[Documentation](https://eduide.github.io/Docs/)** - for students, +instructors, administrators and developers. + +## The main repositories + +| Repository | What it is | +|---|---| +| [EduIDE](https://github.com/EduIDE/EduIDE) | The IDE itself. Builds the per-language Theia images students work in. | +| [EduIDE-Cloud](https://github.com/EduIDE/EduIDE-Cloud) | The control plane. Kubernetes operator and REST service that create and manage sessions. | +| [EduIDE-Landing-Page](https://github.com/EduIDE/EduIDE-Landing-Page) | Where users pick an environment and launch a session. | +| [EduIDE-Helm](https://github.com/EduIDE/EduIDE-Helm) | The Helm charts. How you install EduIDE on a cluster. | +| [EduIDE-deployment](https://github.com/EduIDE/EduIDE-deployment) | The TUM installations: environment configuration and deployment workflows. | +| [Docs](https://github.com/EduIDE/Docs) | The documentation site. | +| [theia-scale-tests](https://github.com/EduIDE/theia-scale-tests) | End-to-end and scalability tests. | + +Supporting components: [scorpio](https://github.com/EduIDE/scorpio) (Artemis +integration extension), [EduIDE-data-bridge](https://github.com/EduIDE/EduIDE-data-bridge) +(runtime data injection), [EduIDE-shared-cache](https://github.com/EduIDE/EduIDE-shared-cache) +(shared Gradle and Bazel build cache), +[workspace-garbage-collector](https://github.com/EduIDE/workspace-garbage-collector) +(reclaims abandoned workspace storage). + +## Running it yourself + +```bash +helm install eduide oci://ghcr.io/eduide/charts/eduide --version +``` + +See the [administrator documentation](https://eduide.github.io/Docs/admins/) +for cluster prerequisites and configuration. + +--- + +EduIDE is developed at the +[Applied Education Technologies](https://ase.cit.tum.de/) group, TUM. +It began as a fork of [Eclipse Theia Cloud](https://github.com/eclipse-theia/theia-cloud) +and [Theia IDE](https://github.com/eclipse-theia/theia-ide). From a3d385db2de54c894f1db468c8bbbeb194a60ebf Mon Sep 17 00:00:00 2001 From: Matthias Linhuber Date: Tue, 25 Aug 2026 16:27:33 +0200 Subject: [PATCH 4/5] fix: prevent one image swallowing another image digests at merge The merge job downloads digests by glob. With a bare slug as the artifact key, an image whose name is a prefix of another image name also matches that other image artifacts: pattern digests--1-eduide-eduide-c-* matched digests--1-eduide-eduide-c-amd64 (correct) digests--1-eduide-eduide-c-arm64 (correct) digests--1-eduide-eduide-c-templates-amd64 (wrong image) digests--1-eduide-eduide-c-templates-arm64 (wrong image) imagetools create then failed because those digests do not exist in the c repository. Caught by the first full EduIDE run: 12 of 15 images published fine and exactly the three prefix-colliding names failed - c, java-17 and rust. Every artifact key now ends in an 8 character hash of the full image name, so no key can prefix-match another. Adds a regression test over all five colliding pairs in the real image set; it fails against the previous implementation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019qeiQRFu8xAMRYWPdZewjG --- .../workflows/build-and-push-docker-image.yml | 11 +++++- tests/test-derive-tags.sh | 36 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-and-push-docker-image.yml b/.github/workflows/build-and-push-docker-image.yml index c15dad5..0631a32 100644 --- a/.github/workflows/build-and-push-docker-image.yml +++ b/.github/workflows/build-and-push-docker-image.yml @@ -200,7 +200,16 @@ jobs: # names share a namespace across the whole run. Key them by image so # the merge job cannot pick up another image's digests. Derived here # rather than with hashFiles() because the merge job has no checkout. - ARTIFACT_KEY=$(printf '%s' "${IMAGE_NAME}" | tr -c 'a-zA-Z0-9_.-' '-') + # + # The trailing hash is load-bearing, not decoration. The merge job + # downloads by glob, so a bare slug lets one image swallow another's + # digests whenever one name is a prefix of another: pattern + # "...-eduide-eduide-c-*" also matches "...-eduide-eduide-c-templates-amd64". + # That is real here - c/c-templates, java-17/java-17-templates, + # rust/rust-no-ls - and produced a manifest containing the wrong + # image's digests. Every key now ends in "-<8 hex>", which no other + # key can prefix-match. + ARTIFACT_KEY="$(printf '%s' "${IMAGE_NAME}" | tr -c 'a-zA-Z0-9_.-' '-')-$(printf '%s' "${IMAGE_NAME}" | shasum -a 256 | cut -c1-8)" { echo "matrix={\"include\":[${JOINED}]}" diff --git a/tests/test-derive-tags.sh b/tests/test-derive-tags.sh index 901975f..9e24215 100755 --- a/tests/test-derive-tags.sh +++ b/tests/test-derive-tags.sh @@ -92,6 +92,42 @@ expect "no platform selected" "ERROR build-amd64 and build-arm64 are both false expect "malformed tag v.1.1.1" "ERROR derived tag '.1.1.1' is not a valid Docker tag." GITHUB_EVENT_NAME=release RELEASE_TAG=v.1.1.1 expect "tag starting with -" "ERROR derived tag '-nope' is not a valid Docker tag." OVERRIDE=-nope +echo +echo "=== artifact key: no image name may prefix-match another ===" +# The merge job downloads digests by glob. A bare slug lets one image swallow +# another's digests when one name is a prefix of another - "...-c-*" also +# matches "...-c-templates-amd64". That shipped once and produced manifests +# containing the wrong image's digests for c, java-17 and rust. +key_for() { + local out; out="$(mktemp)" + ( export GITHUB_SHA=abc1234567890 GITHUB_REF_NAME=main GITHUB_REF=refs/heads/main \ + GITHUB_EVENT_NAME=push GITHUB_REPOSITORY=EduIDE/Example \ + OVERRIDE="" PR_NUMBER="" RELEASE_TAG="" BUILD_AMD64=true BUILD_ARM64=true \ + CACHE_IMAGE_IN="" IMAGE_NAME="$1" GITHUB_OUTPUT="$out" \ + GITHUB_STEP_SUMMARY=/dev/null + bash "$SCRIPT" >/dev/null 2>&1 ) + grep '^artifact_key=' "$out" | cut -d= -f2- + rm -f "$out" +} + +# Every colliding pair that actually exists in the EduIDE image set. +for pair in \ + "eduide/eduide/c:eduide/eduide/c-templates" \ + "eduide/eduide/java-17:eduide/eduide/java-17-templates" \ + "eduide/eduide/java-17:eduide/eduide/java-17-no-ls" \ + "eduide/eduide/rust:eduide/eduide/rust-no-ls" \ + "eduide/eduide/base:eduide/eduide/base-extra" +do + short="${pair%%:*}"; long="${pair##*:}" + ks="$(key_for "$short")"; kl="$(key_for "$long")" + if [[ "$kl" == "$ks"* ]]; then + printf ' FAIL %-42s glob "%s-*" would also match "%s"\n' "$short vs $long" "$ks" "$kl" + FAILED=1 + else + printf ' PASS %-42s %s vs %s\n' "$short vs $long" "$ks" "$kl" + fi +done + rm -f "$SCRIPT" echo if [[ $FAILED -eq 0 ]]; then echo "ALL PASS"; else echo "SOME FAILED"; fi From fb026025bbeef58bfaa0d3f84fdff4cf28e5da93 Mon Sep 17 00:00:00 2001 From: Matthias Linhuber Date: Tue, 25 Aug 2026 16:29:55 +0200 Subject: [PATCH 5/5] fix: explain the empty-digests failure instead of dying on a missing dir When download-artifact matches nothing it never creates the digests directory, so the step failed on working-directory with An error occurred trying to start process /usr/bin/bash with working directory /home/runner/work/_temp/digests. No such file or directory which says nothing about the actual problem. Create the directory first and let the existing guard report it. The guard now also names the real cause. Artifact names include github.run_attempt, so re-running only the merge job looks for attempt-2 artifacts while the build jobs uploaded attempt-1 ones and nothing matches. Re-run all jobs instead. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019qeiQRFu8xAMRYWPdZewjG --- .github/workflows/build-and-push-docker-image.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-push-docker-image.yml b/.github/workflows/build-and-push-docker-image.yml index 0631a32..b1c6137 100644 --- a/.github/workflows/build-and-push-docker-image.yml +++ b/.github/workflows/build-and-push-docker-image.yml @@ -343,8 +343,8 @@ jobs: password: ${{ secrets.registry-password || secrets.GITHUB_TOKEN }} - name: Create and push manifest list - working-directory: ${{ runner.temp }}/digests shell: bash + working-directory: ${{ runner.temp }} env: IMAGE: ${{ inputs.registry }}/${{ inputs.image-name }} BASE_TAG: ${{ needs.setup.outputs.base_tag }} @@ -352,9 +352,18 @@ jobs: run: | set -euo pipefail shopt -s nullglob + # mkdir, not cd into a directory that download-artifact may never have + # created: without this the step dies on a missing working directory + # before the check below can explain what actually went wrong. + mkdir -p digests + cd digests digests=(*) if [[ ${#digests[@]} -eq 0 ]]; then - echo "::error::no digests were produced; refusing to publish an empty manifest" + echo "::error::No digests found for ${IMAGE}. Expected artifacts named" + echo "::error:: digests-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}--" + echo "::error::Artifact names include the run attempt, so re-running ONLY the" + echo "::error::merge job cannot see artifacts uploaded by a previous attempt." + echo "::error::Re-run all jobs instead." exit 1 fi echo "Assembling ${#digests[@]} digest(s) into ${IMAGE}:${BASE_TAG}"