diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b32418..3bf1bd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,7 +78,7 @@ jobs: sbt-disk-cache: "false" cache-mode: save purge: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'purge') }} - cache-epoch: "5a3a551ef12b52cb" + cache-epoch: "99966e7ef507c25b" - name: cache-rehydrate run: sbt 'Test/compile' test: @@ -104,7 +104,7 @@ jobs: sbt-disk-cache: "false" cache-mode: save purge: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'purge') }} - cache-epoch: "5a3a551ef12b52cb" + cache-epoch: "99966e7ef507c25b" - name: Pre-pull remote-cache IT images run: | set -euo pipefail @@ -177,7 +177,7 @@ jobs: sbt-disk-cache: "false" cache-mode: restore purge: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'purge') }} - cache-epoch: "5a3a551ef12b52cb" + cache-epoch: "99966e7ef507c25b" - name: fmt run: | if [ "$ZIPX_VERIFY_CLEAN_FULL" = "true" ]; then @@ -210,7 +210,7 @@ jobs: sbt-disk-cache: "false" cache-mode: restore purge: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'purge') }} - cache-epoch: "5a3a551ef12b52cb" + cache-epoch: "99966e7ef507c25b" - name: workflow-check run: | if [ "$ZIPX_VERIFY_CLEAN_FULL" = "true" ]; then @@ -243,7 +243,7 @@ jobs: sbt-disk-cache: "false" cache-mode: restore purge: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'purge') }} - cache-epoch: "5a3a551ef12b52cb" + cache-epoch: "99966e7ef507c25b" - name: advisories run: | if [ "$ZIPX_VERIFY_CLEAN_FULL" = "true" ]; then @@ -280,7 +280,7 @@ jobs: sbt-disk-cache: "false" cache-mode: restore purge: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'purge') }} - cache-epoch: "5a3a551ef12b52cb" + cache-epoch: "99966e7ef507c25b" - name: Fetch PR base SHA run: git fetch --no-tags origin "$ZIPX_MODVER_BASE_SHA" - name: modver-suggest @@ -318,7 +318,7 @@ jobs: sbt-disk-cache: "false" cache-mode: restore purge: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'purge') }} - cache-epoch: "5a3a551ef12b52cb" + cache-epoch: "99966e7ef507c25b" - name: Fetch PR base SHA run: git fetch --no-tags origin "$ZIPX_MODVER_BASE_SHA" - name: modver-check @@ -353,7 +353,7 @@ jobs: sbt-disk-cache: "false" cache-mode: restore purge: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'purge') }} - cache-epoch: "5a3a551ef12b52cb" + cache-epoch: "99966e7ef507c25b" - name: scripted run: | if [ "$ZIPX_VERIFY_CLEAN_FULL" = "true" ]; then @@ -405,7 +405,7 @@ jobs: sbt-disk-cache: "false" cache-mode: restore purge: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'purge') }} - cache-epoch: "5a3a551ef12b52cb" + cache-epoch: "99966e7ef507c25b" - name: snapshots run: sbt 'zipxSnapshotPublish' snapshots-pr: @@ -434,7 +434,7 @@ jobs: sbt-disk-cache: "false" cache-mode: restore purge: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'purge') }} - cache-epoch: "5a3a551ef12b52cb" + cache-epoch: "99966e7ef507c25b" - name: snapshots-pr run: sbt 'zipxSnapshotPublish pr' docs: diff --git a/.github/workflows/zipx-release.yml b/.github/workflows/zipx-release.yml index fe1c143..eb3898b 100644 --- a/.github/workflows/zipx-release.yml +++ b/.github/workflows/zipx-release.yml @@ -44,7 +44,7 @@ jobs: sbt-disk-cache: "false" cache-mode: restore purge: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'purge') }} - cache-epoch: "5a3a551ef12b52cb" + cache-epoch: "99966e7ef507c25b" - name: Tag is on the default branch if: github.event_name == 'push' run: | diff --git a/modules/core/src/main/scala/zipx/core/SbtImageLayer.scala b/modules/core/src/main/scala/zipx/core/SbtImageLayer.scala index 09f0ae4..bb675ef 100644 --- a/modules/core/src/main/scala/zipx/core/SbtImageLayer.scala +++ b/modules/core/src/main/scala/zipx/core/SbtImageLayer.scala @@ -254,13 +254,7 @@ object SbtImageLayer: else val amd = packages.map(pkg => s"${pkg.name}=${pkg.amd}").mkString(" ") val arm = packages.map(pkg => s"${pkg.name}=${pkg.arm}").mkString(" ") - List( - """case "$(uname -m)" in""", - s" x86_64) pkgs=${q(amd)} ;;", - s" aarch64) pkgs=${q(arm)} ;;", - """ *) echo "zipx: unsupported architecture $(uname -m)" >&2; exit 1 ;;""", - "esac", - ) + List(archCase("pkgs", amd, arm)) run( choose ++ List( "set -f", @@ -328,17 +322,19 @@ object SbtImageLayer: /** Lines that set `url`, and the shell word that expands to it. One word when both arches share a URL. */ private def archUrl(amd: String, arm: String): (List[String], String) = if amd == arm then (Nil, q(amd)) - else - ( - List( - """case "$(uname -m)" in""", - s" x86_64) url=${q(amd)} ;;", - s" aarch64) url=${q(arm)} ;;", - """ *) echo "zipx: unsupported architecture $(uname -m)" >&2; exit 1 ;;""", - "esac", - ), - "\"$url\"", - ) + else (List(archCase("url", amd, arm)), "\"$url\"") + + /** A `case` on `uname -m` that sets `name`. It is one statement to [[run]]: a `;` after `in` or after `;;` is a sh + * syntax error, so the arms join on a bare continuation. + */ + private def archCase(name: String, amd: String, arm: String): String = + List( + """case "$(uname -m)" in""", + s" x86_64) $name=${q(amd)} ;;", + s" aarch64) $name=${q(arm)} ;;", + """ *) echo "zipx: unsupported architecture $(uname -m)" >&2; exit 1 ;;""", + "esac", + ).mkString(" \\\n ") private def run(lines: List[String]): String = "RUN set -eux; \\\n " + lines.mkString("; \\\n ") diff --git a/modules/core/src/test/scala/zipx/core/SbtImageSpec.scala b/modules/core/src/test/scala/zipx/core/SbtImageSpec.scala index e8aa718..8aa2ca0 100644 --- a/modules/core/src/test/scala/zipx/core/SbtImageSpec.scala +++ b/modules/core/src/test/scala/zipx/core/SbtImageSpec.scala @@ -1,6 +1,7 @@ package zipx.core import zipx.workflow.* +import zio.* import zio.test.* object SbtImageSpec extends ZIOSpecDefault: @@ -61,6 +62,22 @@ object SbtImageSpec extends ZIOSpecDefault: } .withEnv("WIDGET_PATH" -> "/usr/local/bin/gadget") + /** Each `RUN` command as the shell receives it: Docker drops every backslash-newline continuation. */ + private def runBodies(dockerfile: String): List[String] = + dockerfile + .replace("\\\n", "") + .linesIterator + .collect { case line if line.startsWith("RUN ") => line.stripPrefix("RUN ") } + .toList + + /** `sh -n` parses without running. Exit status and diagnostics are data for the assertion. */ + private def shellCheck(script: String): Task[(Int, String)] = + ZIO.attemptBlocking { + val process = new ProcessBuilder("sh", "-n", "-c", script).redirectErrorStream(true).start() + val output = String(process.getInputStream.readAllBytes()) + (process.waitFor(), output) + } + private def resolve(extra: List[Pin], layers: Seq[SbtImageLayer]): Either[SbtImageError, SbtImagePlan] = SbtImage.resolve(JdkVersion("21"), imagePin(pin) :: extra, Nil, coursier = false, docker = false, layers) @@ -353,6 +370,23 @@ object SbtImageSpec extends ZIOSpecDefault: text.contains("set -f"), ) }, + test("every RUN parses as sh when layer URLs and apt versions differ by arch") { + val gizmos = SbtImageLayer("gizmos", noLookup).install { (_, arch) => + val deb = arch match + case CpuArch.Amd64 => "1.0-amd64" + case CpuArch.Arm64 => "1.0-arm64" + List(ImageStep.Apt("libgizmos", deb)) + } + val pins = List(toolPin("widgets", "1.2.3"), toolPin("gizmos", "1.0.0")) + val text = resolve(pins, List(widgets(widgetSteps), gizmos)).toOption.flatMap(_.dockerfile).getOrElse("") + val runs = runBodies(text) + ZIO + .foreach(runs)(body => shellCheck(body).map(body -> _)) + .map { checked => + val broken = checked.collect { case (body, (exit, out)) if exit != 0 => s"$out\n$body" } + assertTrue(runs.exists(_.contains("uname -m")), broken.isEmpty) ?? broken.mkString("\n\n") + } + }, test("a download URL does not have to contain the pin text") { val layer = SbtImageLayer("widgets", noLookup).install { (version, arch) => val revision = if version == "1.2.3" then "1148" else "0"