Build #3046
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Build | |
| # Only build the latest commit per ref: a newer run cancels a superseded | |
| # in-progress one. Safe for master/scheduled publishes too, because every run | |
| # rebuilds and republishes all tags, so the superseding run converges the | |
| # registry to the latest commit. | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| # prepare -> build (one job per arch on a native runner: test + push by digest) | |
| # -> merge (assemble the multi-arch tag from the per-arch digests) | |
| # | |
| # All images are described as data in .github/images.yml. Each architecture is | |
| # built exactly once on its own native runner (no QEMU), tested there, and only | |
| # then assembled into the published multi-arch tag. A failing arch fails in | |
| # isolation (fail-fast: false): every tag whose architectures all built is still | |
| # pushed. An incomplete tag fails its merge job (red) instead of publishing | |
| # half-built, so a transient failure can be recovered with "Re-run failed jobs". | |
| on: | |
| push: | |
| branches: | |
| - master | |
| pull_request: | |
| branches: | |
| - master | |
| schedule: | |
| # Off-peak slot: midnight UTC is the most congested moment on GitHub | |
| # Actions and Docker Hub (everyone's default cron), which worsens the | |
| # Hub connection timeouts this workflow is prone to. | |
| - cron: '47 3 * * *' | |
| env: | |
| IMAGE: sbtscala/scala-sbt | |
| jobs: | |
| prepare: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| build: ${{ steps.gen.outputs.build }} | |
| merge: ${{ steps.gen.outputs.merge }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| - name: Generate build and merge matrices | |
| id: gen | |
| run: | | |
| data=$(yq -o=json '.' .github/images.yml) | |
| # jq helpers (duplicated in both programs; jq has no shared library here): | |
| # imageLabel - public "<java>" tag label, full JDK version (e.g. | |
| # eclipse-temurin-25.0.1_8, eclipse-temurin-alpine-25.0.1_8) | |
| # majorLabel - same but only the JDK major (e.g. eclipse-temurin-25) | |
| # scalasFor - Scala versions to build for an image (global list minus | |
| # the image's dropScala "major.minor" prefixes) | |
| # lightSbtsFor - lightSbt versions to build for an image (global list | |
| # minus the image's dropSbt prefixes) | |
| # sbtLine - rolling line for an sbt version (1.12.11 -> 1.x) | |
| defs=' | |
| def imageLabel($img): | |
| $img.dockerContext as $c | $img.baseImageTag as $t | |
| | if $c == "eclipse-temurin" then | |
| (if ($t | endswith("-jdk-alpine")) then "eclipse-temurin-alpine-" + ($t | sub("-jdk-alpine$";"")) | |
| else "eclipse-temurin-" + ($t | sub("-jdk$";"")) end) | |
| elif $c == "graalvm-community" then "graalvm-community-" + ($t | sub("-ol[0-9]+$";"")) | |
| elif $c == "graalvm-jdk-community" then "graalvm-jdk-community-" + ($t | sub("-ol[0-9]+$";"")) | |
| elif $c == "amazoncorretto" then "amazoncorretto-al2023-" + ($t | sub("-al2023$";"")) | |
| else error("unknown dockerContext: \($c)") end; | |
| def majorLabel($img): | |
| ($img.baseImageTag | capture("^(?<m>[0-9]+)").m) as $maj | |
| | (imageLabel($img) | sub("[0-9].*$"; $maj)); | |
| def scalasFor($root; $img): | |
| [ $root.scalaVersion[] | . as $s | |
| | select([ ($img.dropScala // [])[] as $p | $s | startswith($p) ] | any | not) ]; | |
| def lightSbtsFor($root; $img): | |
| [ $root.lightSbt[] | . as $s | |
| | select([ ($img.dropSbt // [])[] as $p | $s | startswith($p) ] | any | not) ]; | |
| def sbtLine($sbt): ($sbt | capture("^(?<m>[0-9]+)").m) + ".x"; | |
| ' | |
| # build matrix: fat (javaImage x scala) + light (light:true x lightSbt), | |
| # one job per (image, sbt[, scala], platform). `key` is the build identity | |
| # used for the cache scope and digest artifact name. For fat it is also the | |
| # published tag (<java>_<sbt>_<scala>); for light it is <java-full>_<sbt> | |
| # (exact sbt, unique per build) while the published tags are rolling lines | |
| # assembled in the merge job. Empty scala -> no Scala warm step (Dockerfile). | |
| build=$(echo "$data" | jq -c "$defs"' | |
| def entry($img; $scala; $sbt; $key; $platform): | |
| { scala: $scala, | |
| context: $img.dockerContext, | |
| dockerfile: ($img.dockerfile // "Dockerfile"), | |
| baseImageTag: $img.baseImageTag, | |
| key: $key, | |
| platform: $platform, | |
| platformId: ($platform | sub("^linux/";"") | gsub("/";"-")), | |
| runner: (if ($platform | startswith("linux/arm64")) then "ubuntu-24.04-arm" else "ubuntu-24.04" end), | |
| sbt: $sbt }; | |
| . as $root | |
| | ( [ $root.javaImage[] as $img | |
| | (scalasFor($root; $img))[] as $scala | |
| | ($img.sbt // $root.SBT_VERSION) as $sbt | |
| | ($img.platforms | split(",")[]) as $platform | |
| | entry($img; $scala; $sbt; (imageLabel($img) + "_" + $sbt + "_" + $scala); $platform) ] | |
| + [ $root.javaImage[] as $img | select($img.light == true) | |
| | (lightSbtsFor($root; $img))[] as $sbt | |
| | ($img.platforms | split(",")[]) as $platform | |
| | entry($img; ""; $sbt; (imageLabel($img) + "_" + $sbt); $platform) ] )') | |
| # merge matrix: one job per build identity (`key`, matches the digest | |
| # artifacts) listing the published tags (`tags`, space-separated). Fat tags | |
| # publish a single <java>_<sbt>_<scala>; light publishes two rolling tags | |
| # per line: <java-major>_<line> and <java-full>_<line> on the same digests. | |
| merge=$(echo "$data" | jq -c "$defs"' | |
| . as $root | |
| | ( [ $root.javaImage[] as $img | |
| | (scalasFor($root; $img))[] as $scala | |
| | (imageLabel($img) + "_" + ($img.sbt // $root.SBT_VERSION) + "_" + $scala) as $t | |
| | { key: $t, tags: $t, platformCount: ($img.platforms | split(",") | length) } ] | |
| + [ $root.javaImage[] as $img | select($img.light == true) | |
| | (lightSbtsFor($root; $img))[] as $sbt | |
| | sbtLine($sbt) as $line | |
| | { key: (imageLabel($img) + "_" + $sbt), | |
| tags: (majorLabel($img) + "_" + $line + " " + imageLabel($img) + "_" + $line), | |
| platformCount: ($img.platforms | split(",") | length) } ] )') | |
| echo "build={\"include\":$build}" >> "$GITHUB_OUTPUT" | |
| echo "merge={\"include\":$merge}" >> "$GITHUB_OUTPUT" | |
| - name: Show matrices | |
| run: | | |
| echo '${{ steps.gen.outputs.build }}' | jq '.include | length as $n | "build jobs: \($n)"' | |
| echo '${{ steps.gen.outputs.merge }}' | jq -r '.include[] | " " + .tags' | |
| build: | |
| needs: prepare | |
| runs-on: ${{ matrix.runner }} | |
| strategy: | |
| fail-fast: false | |
| matrix: ${{ fromJSON(needs.prepare.outputs.build) }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| # Log in before anything pulls from Docker Hub (the buildkit image in the | |
| # Buildx setup, the base images in the build): anonymous pulls from the | |
| # shared runner IP pool get throttled and regularly fail the nightly run. | |
| # Same-repo PRs get secrets and authenticate too; fork PRs have no secrets | |
| # and stay anonymous. Pushing remains excluded for all PRs below. | |
| # Uses a public-repo read-only token so no push-capable credential sits on | |
| # the runner during build and test; the read-write login happens right | |
| # before the push step. The throttling hits the login endpoint itself too | |
| # (docker/login-action has no retry), so retry here, and degrade to an | |
| # anonymous build instead of failing the job if Hub stays unreachable. | |
| - name: Log in to DockerHub (read-only) | |
| if: ${{ github.actor != 'dependabot[bot]' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} | |
| continue-on-error: true | |
| env: | |
| DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} | |
| DOCKERHUB_READ_TOKEN: ${{ secrets.DOCKERHUB_READ_TOKEN }} | |
| run: | | |
| for i in 1 2 3 4 5; do | |
| echo "$DOCKERHUB_READ_TOKEN" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin && exit 0 | |
| echo "docker login failed (attempt $i); retrying in $((i*10))s" | |
| sleep $((i*10)) | |
| done | |
| echo "::warning::DockerHub login failed after 5 attempts; building anonymously" | |
| exit 1 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v4.1.0 | |
| - name: Build and load for testing (${{ matrix.platform }}) | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: ${{ matrix.context }} | |
| file: ${{ matrix.context }}/${{ matrix.dockerfile }} | |
| platforms: ${{ matrix.platform }} | |
| no-cache: true | |
| load: true | |
| tags: scala-sbt:test | |
| build-args: | | |
| BASE_IMAGE_TAG=${{ matrix.baseImageTag }} | |
| SBT_VERSION=${{ matrix.sbt }} | |
| SCALA_VERSION=${{ matrix.scala }} | |
| cache-to: type=gha,mode=max,scope=${{ matrix.key }}-${{ matrix.platformId }} | |
| - name: Test image as root (default) | |
| # fully boot sbt (not just --script-version): this is the default run path | |
| # and the only one that follows the /root/.sbt and /root/.cache symlinks, | |
| # which were left dangling on light images (see #401). touch build.sbt so | |
| # sbt does not refuse an empty directory. | |
| run: docker run --rm scala-sbt:test /bin/bash -c "touch build.sbt && sbt exit" | |
| - name: Test image as sbtuser | |
| run: docker run --rm -u sbtuser -w /home/sbtuser scala-sbt:test /bin/bash -c "touch build.sbt && sbt exit" | |
| - name: Test image launches as an arbitrary uid (issue #258) | |
| # write a build file (also checks the home dir is writable) then fully | |
| # launch sbt, which exercises the temp/HOME writes that previously failed | |
| run: docker run --rm -u 1234 -w /home/sbtuser scala-sbt:test /bin/bash -c "touch build.sbt && sbt exit" | |
| - name: Log in to DockerHub (read-write, for push) | |
| if: ${{ github.event_name != 'pull_request' && github.actor != 'dependabot[bot]' }} | |
| uses: docker/login-action@v4 | |
| with: | |
| username: ${{ secrets.DOCKERHUB_USERNAME }} | |
| password: ${{ secrets.DOCKERHUB_TOKEN }} | |
| - name: Build and push by digest (${{ matrix.platform }}) | |
| id: push | |
| if: ${{ github.event_name != 'pull_request' && github.actor != 'dependabot[bot]' }} | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: ${{ matrix.context }} | |
| file: ${{ matrix.context }}/${{ matrix.dockerfile }} | |
| platforms: ${{ matrix.platform }} | |
| # Reuses the layers just built for the test above (same run, same scope), | |
| # so this does not rebuild from scratch. | |
| cache-from: type=gha,scope=${{ matrix.key }}-${{ matrix.platformId }} | |
| build-args: | | |
| BASE_IMAGE_TAG=${{ matrix.baseImageTag }} | |
| SBT_VERSION=${{ matrix.sbt }} | |
| SCALA_VERSION=${{ matrix.scala }} | |
| outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true | |
| - name: Export digest | |
| if: ${{ github.event_name != 'pull_request' && github.actor != 'dependabot[bot]' }} | |
| run: | | |
| mkdir -p /tmp/digests | |
| digest="${{ steps.push.outputs.digest }}" | |
| touch "/tmp/digests/${digest#sha256:}" | |
| - name: Upload digest | |
| if: ${{ github.event_name != 'pull_request' && github.actor != 'dependabot[bot]' }} | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: digest-${{ matrix.key }}-${{ matrix.platformId }} | |
| path: /tmp/digests/* | |
| if-no-files-found: error | |
| retention-days: 1 | |
| merge: | |
| needs: [prepare, build] | |
| # Run even when some build jobs failed, so every tag whose architectures all | |
| # built still gets pushed. Only require prepare (for the matrix) to succeed. | |
| if: ${{ !cancelled() && needs.prepare.result == 'success' && github.event_name != 'pull_request' && github.actor != 'dependabot[bot]' }} | |
| runs-on: ubuntu-latest | |
| strategy: | |
| fail-fast: false | |
| matrix: ${{ fromJSON(needs.prepare.outputs.merge) }} | |
| steps: | |
| - name: Download digests for ${{ matrix.key }} | |
| uses: actions/download-artifact@v8 | |
| with: | |
| path: /tmp/digests | |
| pattern: digest-${{ matrix.key }}-* | |
| merge-multiple: true | |
| - name: Log in to DockerHub | |
| uses: docker/login-action@v4 | |
| with: | |
| username: ${{ secrets.DOCKERHUB_USERNAME }} | |
| password: ${{ secrets.DOCKERHUB_TOKEN }} | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v4.1.0 | |
| - name: Create and push multi-arch manifest | |
| working-directory: /tmp/digests | |
| run: | | |
| shopt -s nullglob | |
| digests=( * ) | |
| expected=${{ matrix.platformCount }} | |
| # Fail (rather than silently skip) when not all architectures are present, | |
| # so a transient build failure can be recovered with "Re-run failed jobs": | |
| # the failed build re-uploads its digest and this job re-runs to push it. | |
| # fail-fast: false keeps this from affecting the other tags. | |
| if [ "${#digests[@]}" -ne "$expected" ]; then | |
| echo "::error title=Incomplete::${#digests[@]}/${expected} architectures available for '${{ matrix.key }}'; not publishing. Re-run the failed build job(s) then this job to recover." | |
| exit 1 | |
| fi | |
| # One build can publish several tags (light images get rolling | |
| # <java-major>_<line> and <java-full>_<line> on the same digests). | |
| read -ra names <<< "${{ matrix.tags }}" | |
| targs=() | |
| for n in "${names[@]}"; do targs+=( -t "${IMAGE}:${n}" ); done | |
| docker buildx imagetools create "${targs[@]}" "${digests[@]/#/${IMAGE}@sha256:}" | |
| for n in "${names[@]}"; do docker buildx imagetools inspect "${IMAGE}:${n}"; done |