diff --git a/.dockerignore b/.dockerignore index 95a8ae3..a62d2df 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,7 @@ **/node_modules/ **/dist .git +testdata npm-debug.log .coverage .coverage.* diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml new file mode 100644 index 0000000..43d6b59 --- /dev/null +++ b/.github/workflows/docker-ci.yml @@ -0,0 +1,87 @@ +name: Docker CI + +# Builds the image of a pull request for amd64 and arm64 and runs the smoke +# test of docker-publish.yml, so a broken image shows up before a release, not +# after npm holds the version. Also tests scripts/docker-publish-tags.sh +# against a local registry. A pull request that changes the files the image is +# made of starts it: the Docker files, the lockfile, the build configuration, +# and the packages. Nothing is pushed. + +on: + pull_request: + paths: + - Dockerfile + - .dockerignore + - docker/** + - package.json + - tsconfig.json + - tsconfig.base.json + - babel.config.js + - pnpm-lock.yaml + - pnpm-workspace.yaml + - packages/** + - scripts/docker-*.sh + - .github/workflows/docker-ci.yml + - .github/workflows/docker-publish.yml + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + image: + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-24.04 + # arm64 compiles canvas from source, the riskiest part of the build. + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + with: + driver-opts: image=moby/buildkit:v0.34.0@sha256:b059f8d7226d0b326bc871af489a5dddf65e36af4d20d14251b072974d9ee87c + + - name: Build the image + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + platforms: linux/${{ matrix.arch }} + load: true + tags: static-dicomweb:ci + no-cache: true + + - name: Smoke test the image + run: bash scripts/docker-smoke-test.sh static-dicomweb:ci + + publish-tags: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Check the Docker scripts + run: shellcheck scripts/docker-*.sh + + - name: Test the tag script against a local registry + run: bash scripts/docker-publish-tags.test.sh diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..d9d7821 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,260 @@ +name: Publish Docker images + +# Builds the image of one release tag for linux/amd64 and linux/arm64, and +# publishes one multi-arch tag to Docker Hub and to GHCR. A person starts it +# from the Actions tab ("Run workflow") on master; no push or merge starts it. +# +# Each architecture builds on a native runner (no QEMU), smoke tests the image, +# and pushes it untagged, by digest, to GHCR with GITHUB_TOKEN. The `publish` +# job then joins the digests into one tag in each registry. A failed or +# rejected run leaves those untagged digests in GHCR. +# +# Only the `publish` job can read the Docker Hub token. The token lives in the +# `docker-hub` environment, which requires a reviewer and allows only master. +# Environment settings: +# secret DOCKERHUB_TOKEN - a Docker Hub personal access token, "Read & +# Write". Docker Hub cannot limit such a token +# to one repository: it can write to every +# repository of the account. +# variable DOCKERHUB_USERNAME - optional, the account that owns the token +# (default braveheartsoftware) +# Without DOCKERHUB_TOKEN the run fails, unless the `ghcr_only` input is set. +# Only a job of the environment can see its secrets, so that check runs after +# the approval. +# +# A version keeps one image, and `latest` moves only forward. The `publish` +# job runs scripts/docker-publish-tags.sh from master; its header gives the +# rules, and scripts/docker-publish-tags.test.sh (in docker-ci.yml) tests them. +# +# The environment gates Docker Hub only. GHCR takes GITHUB_TOKEN, so any +# workflow of this repository with `packages: write` can write to the GHCR +# package, from any branch. +# +# Actions are pinned by commit, and no job restores a cache: a cache that +# another workflow wrote must not reach a release image (see publish.yml). +# +# A later move to Docker Hub OIDC: because the job names an environment, the +# OIDC subject is repo:RadicalImaging/Static-DICOMWeb:environment:docker-hub, +# not a ref:refs/heads/... value. The Docker Hub ruleset must match that subject. +# +# The build uses the Dockerfile of the release tag. docker-release.mjs refuses +# a tag whose Dockerfile does not install from pnpm-lock.yaml (1.7.7 and older). + +on: + workflow_dispatch: + inputs: + tag: + description: 'Release tag, for example v1.7.7. Empty means the newest release tag.' + required: false + default: '' + ghcr_only: + description: 'Publish to GHCR only, and skip Docker Hub.' + type: boolean + default: false + overwrite: + description: 'Replace the image of a version in both registries. Not with ghcr_only.' + type: boolean + default: false + +# One run at a time, including the wait for the approval. GitHub queues at most +# one more run: a newer dispatch cancels the queued run, not the running one. +concurrency: + group: publish-docker + cancel-in-progress: false + +permissions: {} + +env: + DOCKERHUB_IMAGE: braveheartsoftware/static-dicomweb + GHCR_IMAGE: ghcr.io/radicalimaging/static-dicomweb + +jobs: + resolve: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + outputs: + tag: ${{ steps.release.outputs.tag }} + sha: ${{ steps.release.outputs.sha }} + version: ${{ steps.release.outputs.version }} + steps: + # A skip would show a green run that published nothing. + - name: Check that the run is on master + if: github.ref != 'refs/heads/master' + run: | + echo "::error::Start this workflow on master, not on ${GITHUB_REF}." + exit 1 + + # docker-publish-tags.sh refuses this too, but only after the builds. + - name: Check the inputs + if: inputs.ghcr_only && inputs.overwrite + run: | + echo '::error::ghcr_only with overwrite would give GHCR and Docker Hub two images for one version.' + exit 1 + + - name: Checkout code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + # docker-release.mjs reads the tags and origin/master. + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '24.15.0' + + - name: Choose the release tag + id: release + env: + INPUT_TAG: ${{ inputs.tag }} + run: | + RELEASE="$(node ./scripts/release/docker-release.mjs "$INPUT_TAG")" + echo "$RELEASE" + echo "$RELEASE" >> "$GITHUB_OUTPUT" + + build: + needs: resolve + strategy: + fail-fast: true + matrix: + include: + - arch: amd64 + runner: ubuntu-24.04 + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + permissions: + contents: read + packages: write # push the untagged image to GHCR + steps: + # By commit, so both builds and the checks of `resolve` see the same code. + - name: Checkout the release commit + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ needs.resolve.outputs.sha }} + persist-credentials: false + + # The BuildKit daemon runs the build and holds the push credential, so it + # is pinned like the actions. + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + with: + driver-opts: image=moby/buildkit:v0.34.0@sha256:b059f8d7226d0b326bc871af489a5dddf65e36af4d20d14251b072974d9ee87c + + - name: Log in to GHCR + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + # The commit time, not the build time, so a rebuild gives the same label. + - name: Read the commit time + id: commit + run: echo "time=$(git log -1 --format=%cI)" >> "$GITHUB_OUTPUT" + + - name: Build and push by digest + id: build + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + platforms: linux/${{ matrix.arch }} + outputs: type=image,name=${{ env.GHCR_IMAGE }},push-by-digest=true,name-canonical=true,push=true + provenance: mode=max + sbom: true + no-cache: true + build-args: | + IMAGE_VERSION=${{ needs.resolve.outputs.version }} + IMAGE_REVISION=${{ needs.resolve.outputs.sha }} + IMAGE_CREATED=${{ steps.commit.outputs.time }} + + # Before any tag exists, so a broken image never gets one. + - name: Smoke test the image + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: bash scripts/docker-smoke-test.sh "${GHCR_IMAGE}@${DIGEST}" + + - name: Export the digest + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + mkdir -p "${RUNNER_TEMP}/digests" + touch "${RUNNER_TEMP}/digests/${DIGEST#sha256:}" + + # An approval can wait up to 30 days, so the digests must last as long. + # The repository setting allows 90 days. + - name: Upload the digest + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: digests-${{ matrix.arch }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 30 + overwrite: true + + publish: + needs: [resolve, build] + # Pinned like the build jobs: the docker CLI of the runner holds both tokens. + runs-on: ubuntu-24.04 + timeout-minutes: 15 + # The approval gate. The run waits here until a reviewer approves. + environment: + name: docker-hub + url: https://hub.docker.com/r/braveheartsoftware/static-dicomweb/tags + permissions: + contents: read # check out the tag script + packages: write # tag the image in GHCR + env: + # An `if:` cannot read a secret directly. + HAS_DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN != '' }} + GHCR_ONLY: ${{ inputs.ghcr_only }} + OVERWRITE: ${{ inputs.overwrite }} + steps: + - name: Choose the registries + run: | + if [ "$GHCR_ONLY" = 'true' ]; then + echo '::notice::ghcr_only is set, so this run skips Docker Hub.' + elif [ "$HAS_DOCKERHUB_TOKEN" != 'true' ]; then + echo '::error::The docker-hub environment has no DOCKERHUB_TOKEN secret. Add the secret in Settings > Environments > docker-hub, or start the run with ghcr_only.' + exit 1 + fi + + # The tag script of master, the dispatch ref, which a review approved. + - name: Checkout the tag script + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + sparse-checkout: scripts/docker-publish-tags.sh + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Download the digests + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + # No Buildx setup: `imagetools` talks to the registries only, so the + # preinstalled docker builder is enough, and no BuildKit daemon runs here. + - name: Log in to GHCR + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Log in to Docker Hub + if: inputs.ghcr_only != true + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + username: ${{ vars.DOCKERHUB_USERNAME || 'braveheartsoftware' }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Create the multi-arch tags + env: + VERSION: ${{ needs.resolve.outputs.version }} + REVISION: ${{ needs.resolve.outputs.sha }} + run: DIGEST_DIR="${RUNNER_TEMP}/digests" bash scripts/docker-publish-tags.sh diff --git a/Dockerfile b/Dockerfile index e080474..f0bcf33 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,70 +1,116 @@ -# syntax=docker/dockerfile:1.7-labs -FROM node:24 as builder -ENV PATH /app/node_modules/.bin:$PATH -# Install global tools +# syntax=docker/dockerfile:1.7-labs@sha256:b99fecfe00268a8b556fad7d9c37ee25d716ae08a5d7320e6d51c4dd83246894 +# One Dockerfile for linux/amd64 and linux/arm64: +# docker buildx build --platform linux/arm64 . +# +# The Dockerfile frontend and the base images are pinned by digest. Both base +# images are Debian trixie, so the canvas that arm64 compiles here finds the +# same libraries at run time. To update one, pick the new tag and take its +# digest from +# docker buildx imagetools inspect : +# The apt packages are not pinned: Debian replaces old versions in trixie, so +# a pinned version stops installing. They come from the trixie security updates. + +FROM node:24-trixie@sha256:1278a37eb510ec1606fba0e80f554bcc941ae0b44f35ae373c4822ae7717c64d AS builder +ARG TARGETARCH + +# canvas has no linux-arm64 prebuilt binary, so arm64 compiles it from source +# (node:24-trixie lacks only libgif-dev; the list names every need anyway). +# On amd64 canvas uses the prebuilt binary, which bundles its libraries. If +# that download fails, canvas compiles against libraries the final stage +# lacks; the canvas check in the final stage then fails the build. +RUN if [ "$TARGETARCH" = "arm64" ]; then \ + apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + python3 \ + libpixman-1-dev \ + libcairo2-dev \ + libpango1.0-dev \ + libjpeg-dev \ + libgif-dev \ + librsvg2-dev \ + ca-certificates \ + && apt-get clean && rm -rf /var/lib/apt/lists/*; \ + fi + +# Install global tools. Keep pnpm at the packageManager version of package.json +# (corepack leaves Node after 24). pnpm needs its install script: without it, +# pnpm runs its Node launcher, which gives no node-gyp to the arm64 canvas build. RUN npm install -g pnpm@12.9.1 # Setup workdir WORKDIR /app +ENV PATH=/app/node_modules/.bin:$PATH # Copy dependency files first to leverage Docker cache -COPY --parents pnpm-lock.yaml pnpm-workspace.yaml *.tgz package.json packages/*/package.json ./ +COPY --parents pnpm-lock.yaml pnpm-workspace.yaml package.json packages/*/package.json ./ # Install dependencies -ENV PATH=/app/node_modules/.bin:$PATH RUN pnpm install --frozen-lockfile + # Copy remaining source code COPY --link --exclude=node_modules --exclude=**/dist . . -# Build and pack -RUN pnpm run build && pnpm run pack:js - +# Build the server and the workspace packages that it needs, which are the +# packages that `pnpm deploy` copies. +RUN pnpm --filter '@radicalimaging/static-wado-webserver...' --workspace-concurrency=1 run build -FROM node:24 as installer - -# Install minimal global tools -RUN npm install -g bun@1.3.13 commander@10.0.1 - -# Setup workdir and PATH -WORKDIR /app -ENV PATH=/app/node_modules/.bin:$PATH - -# Copy all .tgz packages -COPY *.tgz ./ - -# Copy prebuilt tgz artifacts from builder stage -COPY *.tgz ./ -COPY --from=builder /app/packages/cs3d/*.tgz cs3d.tgz -COPY --from=builder /app/packages/static-wado-util/*.tgz static-wado-util.tgz -COPY --from=builder /app/packages/static-wado-creator/*.tgz static-wado-creator.tgz -COPY --from=builder /app/packages/create-dicomweb/*.tgz create-dicomweb.tgz -COPY --from=builder /app/packages/static-wado-webserver/*.tgz static-wado-webserver.tgz - -# Install all modules at once -RUN npm install \ - ./cs3d.tgz \ - ./static-wado-util.tgz \ - ./static-wado-creator.tgz \ - ./create-dicomweb.tgz \ - ./static-wado-webserver.tgz \ - && rm *.tgz +# The server and the production dependencies of it, from pnpm-lock.yaml, so +# the image gets the locked versions and the overrides of pnpm-workspace.yaml. +RUN pnpm --filter @radicalimaging/static-wado-webserver deploy --prod /deploy ############## Copy the installation locally for minimal size service -FROM oven/bun:1.3.13 as dicomwebserver +FROM oven/bun:1.3.13@sha256:87416c977a612a204eb54ab9f3927023c2a3c971f4f345a01da08ea6262ae30e AS dicomwebserver +ARG TARGETARCH + +# Replace the labels of oven/bun. docker-publish.yml passes the release values; +# `created` is the commit time, so after apt-get upgrade a rebuild can hold +# newer packages under the same label. +ARG IMAGE_VERSION=dev +ARG IMAGE_REVISION=unknown +ARG IMAGE_CREATED=unknown +LABEL org.opencontainers.image.title="Static DICOMweb" \ + org.opencontainers.image.description="DICOMweb server and DICOM to DICOMweb conversion tools" \ + org.opencontainers.image.url="https://github.com/RadicalImaging/Static-DICOMWeb" \ + org.opencontainers.image.source="https://github.com/RadicalImaging/Static-DICOMWeb" \ + org.opencontainers.image.documentation="https://github.com/RadicalImaging/Static-DICOMWeb#readme" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.version="${IMAGE_VERSION}" \ + org.opencontainers.image.revision="${IMAGE_REVISION}" \ + org.opencontainers.image.created="${IMAGE_CREATED}" + +# The security updates of trixie, which the pinned base image predates. Then +# curl for health checks. On arm64, also the runtime libraries of the canvas +# build; the amd64 prebuilt binary bundles them. +RUN apt-get update && \ + apt-get upgrade -y --no-install-recommends && \ + apt-get install -y --no-install-recommends curl ca-certificates && \ + if [ "$TARGETARCH" = "arm64" ]; then \ + apt-get install -y --no-install-recommends \ + libpixman-1-0 \ + libcairo2 \ + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + libjpeg62-turbo \ + libgif7 \ + librsvg2-2; \ + fi && \ + rm -rf /var/lib/apt/lists/* # Setup workdir and PATH RUN mkdir /app WORKDIR /app ENV PATH=/app/node_modules/.bin:$PATH -# Install curl for health checks -RUN apt-get update && \ - apt-get install -y curl && \ - rm -rf /var/lib/apt/lists/* +COPY --from=builder /deploy ./ + +# node_modules/.bin holds the commands of the dependencies only, so add the +# commands of the server itself. +RUN ln -s ../../bin/dicomwebserver.mjs node_modules/.bin/dicomwebserver && \ + ln -s ../../bin/monitordicomwebserver.mjs node_modules/.bin/monitordicomwebserver -# In installer stage -COPY --from=installer /app ./ +# Fail the build, also a local one, when canvas cannot load its libraries. +RUN bun -e "require('canvas').createCanvas(1, 1)" # Set up runtime directories RUN echo 'stty erase ^H' >> /etc/profile && \ diff --git a/README.md b/README.md index b219d88..d9d1c50 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,17 @@ The build runs packages one at a time (`--workspace-concurrency=1`). For a faste ## Docker Usage -There are scripts in the root package to create a new docker deployment and to run it linking ports 25080 and 25104 to the DICOMweb and SCP endpoint. To create/start this, run: +Released images are on Docker Hub as `braveheartsoftware/static-dicomweb`, with an `X.Y.Z` tag and `latest`. +From 1.7.7, the images are multi-arch (linux/amd64 and linux/arm64); earlier images are linux/amd64 only. +Releases after 1.7.7 are also on GHCR as `ghcr.io/radicalimaging/static-dicomweb`. + +An npm release does not publish an image. A maintainer publishes the image of a release tag with the `Publish Docker images` workflow (Actions > Run workflow). +A reviewer of the `docker-hub` environment must approve each push to Docker Hub; the reviewer can be the maintainer who started the run. GHCR has no such approval: any workflow of this repository with `packages: write` can write to the GHCR image. + +A local build gets the tag `static-dicomweb:dev` (`static-dicomweb:dev-arm64` for arm64), never the Docker Hub name, so that a local build cannot replace a release by accident. +`pnpm run docker:build:arm` builds a linux/arm64 image. On an amd64 host, that build needs QEMU emulation, which Docker Desktop includes, and it is slow. + +There are scripts in the root package to create a new docker deployment and to run it, with port 25080 linked to the DICOMweb endpoint. The image has no DIMSE SCP (`dicomwebscp`). To create/start this, run: ```bash pnpm run docker:build @@ -115,10 +125,10 @@ There are a number of shared directories and files used to configure various set ```bash // Deploy the default build image -docker run --mount type=bind,source=/dicomweb,target=/dicomweb -p 25080:5000 -p 25104:11112 -d braveheartsoftware/static-dicomweb:0.6 +docker run --mount type=bind,source=/dicomweb,target=/dicomweb -p 25080:5000 -d braveheartsoftware/static-dicomweb:latest ``` -That will result in an instance running on port 25080 for dicomweb, and DIMSE services on 25104. +That will result in an instance running on port 25080 for dicomweb. ## Deployment to AWS diff --git a/arm-Dockerfile b/arm-Dockerfile deleted file mode 100644 index b7cab40..0000000 --- a/arm-Dockerfile +++ /dev/null @@ -1,111 +0,0 @@ -# syntax=docker/dockerfile:1.7-labs -FROM node:24 as builder -ENV PATH /app/node_modules/.bin:$PATH - -# Install build dependencies for canvas (needs -dev packages for compilation) -RUN apt-get update && apt-get install -y \ - build-essential \ - python3 \ - libpixman-1-dev \ - libcairo2-dev \ - libpango1.0-dev \ - libjpeg-dev \ - libgif-dev \ - librsvg2-dev \ - ca-certificates \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Install global tools -RUN npm install -g pnpm@12.9.1 - -# Setup workdir -WORKDIR /app - -# Copy dependency files first to leverage Docker cache -COPY --parents pnpm-lock.yaml pnpm-workspace.yaml *.tgz package.json packages/*/package.json ./ - -# Install dependencies -ENV PATH=/app/node_modules/.bin:$PATH -RUN pnpm install --frozen-lockfile - -# Copy remaining source code -COPY --link --exclude=node_modules --exclude=**/dist . . - -# Build and pack -RUN pnpm -r --workspace-concurrency=1 --filter '!@radicalimaging/s3-deploy' run build && pnpm run pack:js - - -FROM node:24 as installer - -# Install build dependencies for canvas (needs -dev packages for compilation) -RUN apt-get update && apt-get install -y \ - build-essential \ - python3 \ - libpixman-1-dev \ - libcairo2-dev \ - libpango1.0-dev \ - libjpeg-dev \ - libgif-dev \ - librsvg2-dev \ - ca-certificates \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Install minimal global tools -RUN npm install -g bun@1.3.13 commander@10.0.1 - -# Setup workdir and PATH -WORKDIR /app -ENV PATH=/app/node_modules/.bin:$PATH - -# Copy all .tgz packages -COPY *.tgz ./ - -# Copy prebuilt tgz artifacts from builder stage -COPY --from=builder /app/packages/cs3d/*.tgz cs3d.tgz -COPY --from=builder /app/packages/static-wado-util/*.tgz static-wado-util.tgz -COPY --from=builder /app/packages/static-wado-creator/*.tgz static-wado-creator.tgz -COPY --from=builder /app/packages/create-dicomweb/*.tgz create-dicomweb.tgz -COPY --from=builder /app/packages/static-wado-webserver/*.tgz static-wado-webserver.tgz - -# Install all modules at once -RUN npm install \ - ./cs3d.tgz \ - ./static-wado-util.tgz \ - ./static-wado-creator.tgz \ - ./create-dicomweb.tgz \ - ./static-wado-webserver.tgz \ - && rm *.tgz - -############## Copy the installation locally for minimal size service - -FROM oven/bun:1.3.13 as dicomwebserver - -# Install only essential runtime system libraries (no -dev packages needed at runtime) -RUN apt-get update && apt-get install -y \ - libpixman-1-0 \ - libcairo2 \ - libpango-1.0-0 \ - libpangocairo-1.0-0 \ - libjpeg62-turbo \ - libgif7 \ - librsvg2-2 \ - ca-certificates \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Setup workdir and PATH -RUN mkdir /app -WORKDIR /app -ENV PATH=/app/node_modules/.bin:$PATH - -COPY --from=installer /app ./ - -# Set up runtime directories -RUN echo 'stty erase ^H' >> /etc/profile && \ - mkdir /dicomweb ~/.aws && \ - ln -s /dicomweb /root/dicomweb - -# Copy application config/startup scripts -COPY ./docker/* . - -EXPOSE 5000 -CMD ["dicomwebserver"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 3f978b4..391cf43 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -6,9 +6,9 @@ services: # refer to the Keycloak documentation for instruction on how to properly # secure Keycloak. static-dicomweb: - platform: linux/amd64 - image: braveheartsoftware/static-dicomweb:v1 + image: braveheartsoftware/static-dicomweb:latest container_name: static-dicomweb + # The image serves DICOMweb only. The DIMSE SCP (dicomwebscp, port 11115) + # of the old v1 image is not in the images, at least since 1.7.6. ports: - "5000:5000" - - "11115:11115" diff --git a/docker/startStaticDicomweb.sh b/docker/startStaticDicomweb.sh old mode 100644 new mode 100755 index 0ed01eb..2662963 --- a/docker/startStaticDicomweb.sh +++ b/docker/startStaticDicomweb.sh @@ -1,10 +1,7 @@ #!/bin/bash -cd /app - -# Start the first process -dicomwebserver & - -# Start the second process -dicomwebscp scp -p 11115 +cd /app || exit 1 +# The image has no DIMSE SCP (dicomwebscp), so this starts the DICOMweb server +# only, the same as the default command of the image. +exec monitordicomwebserver diff --git a/package.json b/package.json index fb2c59c..9715c70 100644 --- a/package.json +++ b/package.json @@ -43,10 +43,10 @@ }, "scripts": { "cleanTgz": "rimraf ./packages/*/*.tgz || echo No tgz", - "docker:build": "pnpm run cleanTgz && docker build . -t braveheartsoftware/static-dicomweb:latest", - "docker:build:arm": "pnpm run cleanTgz && docker build -f arm-Dockerfile -t braveheartsoftware/static-dicomweb:arm .", - "docker:run": "docker run -it --mount type=bind,source=/c/dicomweb,target=/dicomweb --mount type=bind,source=/a/dicom,target=/dicom -p 25080:5000 --rm --entrypoint /bin/bash braveheartsoftware/static-dicomweb:latest", - "docker:dicomwebserver": "docker run -it --mount type=bind,source=/c/dicomweb,target=/dicomweb -p 25080:5000 --rm braveheartsoftware/static-dicomweb:latest", + "docker:build": "docker build . -t static-dicomweb:dev", + "docker:build:arm": "docker buildx build --platform linux/arm64 --load -t static-dicomweb:dev-arm64 .", + "docker:run": "docker run -it --mount type=bind,source=/c/dicomweb,target=/dicomweb --mount type=bind,source=/a/dicom,target=/dicom -p 25080:5000 --rm --entrypoint /bin/bash static-dicomweb:dev", + "docker:dicomwebserver": "docker run -it --mount type=bind,source=/c/dicomweb,target=/dicomweb -p 25080:5000 --rm static-dicomweb:dev", "test": "pnpm run test:release && pnpm -r --parallel --reporter-hide-prefix=false run test", "test:release": "node --test \"scripts/release/*.test.mjs\"", "supply-chain:ci": "bash ./scripts/ci-supply-chain.sh", diff --git a/scripts/docker-publish-tags.sh b/scripts/docker-publish-tags.sh new file mode 100755 index 0000000..bdc432e --- /dev/null +++ b/scripts/docker-publish-tags.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# Writes the version and `latest` tags of a release image, for the `publish` +# job of docker-publish.yml. Run it after `docker login` to each registry. +# +# Environment: +# GHCR_IMAGE, DOCKERHUB_IMAGE the image names, without a tag +# VERSION the release version, for example 1.8.0 +# REVISION the commit of the release tag +# GHCR_ONLY true to skip Docker Hub +# OVERWRITE true to replace a version that holds another image +# DIGEST_DIR one empty file per built digest (hex only) +# +# A version keeps one image: +# - When GHCR holds the version (a run that stopped before Docker Hub, or a +# ghcr_only run), the script publishes that image, not the new build. That +# image must come from REVISION, because GHCR is not behind the approval. +# - A tag that holds the same manifests counts as done, so a re-run continues. +# - A tag that holds another image stops the run, unless OVERWRITE is set; +# then the new build replaces the version in each registry. +# `latest` moves only forward, from the version label of the current `latest`: +# each release moves it unless that label is a newer X.Y.Z, so an older release +# can move it forward and never back. Only "not found" counts as a free tag; any other registry +# error stops the run. A manual push between the checks and the write is not +# detected, so do not push these tags by hand. +set -euo pipefail +# Without this, a failed command inside $(...) does not stop the script. +shopt -s inherit_errexit + +: "${GHCR_IMAGE:?}" "${DOCKERHUB_IMAGE:?}" "${VERSION:?}" "${REVISION:?}" "${DIGEST_DIR:?}" +GHCR_ONLY="${GHCR_ONLY:-false}" +OVERWRITE="${OVERWRITE:-false}" + +if [ "$GHCR_ONLY" = 'true' ] && [ "$OVERWRITE" = 'true' ]; then + echo '::error::ghcr_only with overwrite would give GHCR and Docker Hub two images for one version.' + exit 1 +fi + +# The digests of the manifests in an image index, sorted. +manifests() { + docker buildx imagetools inspect --raw "$1" | jq -r '.manifests[].digest' | sort +} + +# One OCI label of each platform image, one line per platform. +labels() { + docker buildx imagetools inspect "$1" --format '{{json .Image}}' | + jq -r --arg key "$2" '(if has("config") then [.] else [.[]] end)[] | .config.Labels[$key] // ""' +} + +# True when the tag exists. Only "not found" means a free tag. +exists() { + local error + if error="$(docker buildx imagetools inspect "$1" 2>&1 >/dev/null)"; then + return 0 + fi + if printf '%s' "$error" | grep -q ': not found'; then + return 1 + fi + echo "::error::Cannot read $1: $(printf '%s' "$error" | tail -n 1)" + exit 1 +} + +IMAGES=("$GHCR_IMAGE") +if [ "$GHCR_ONLY" != 'true' ]; then + IMAGES+=("$DOCKERHUB_IMAGE") +fi + +SOURCES=() +for FILE in "$DIGEST_DIR"/*; do + DIGEST="$(basename "$FILE")" + if ! [[ "$DIGEST" =~ ^[0-9a-f]{64}$ ]]; then + echo "::error::${DIGEST} in ${DIGEST_DIR} is not a sha256 digest." + exit 1 + fi + SOURCES+=("${GHCR_IMAGE}@sha256:${DIGEST}") +done +if [ "${#SOURCES[@]}" -ne 2 ]; then + echo "::error::Expected 2 digests (amd64, arm64), found ${#SOURCES[@]}." + exit 1 +fi + +if [ "$OVERWRITE" != 'true' ] && exists "${GHCR_IMAGE}:${VERSION}"; then + # Each platform image must name REVISION; a missing label counts as another. + REVISIONS="$(labels "${GHCR_IMAGE}:${VERSION}" org.opencontainers.image.revision)" + if [ -z "$REVISIONS" ] || printf '%s\n' "$REVISIONS" | grep -qvxF "$REVISION"; then + echo "::error::${GHCR_IMAGE}:${VERSION} does not come from ${REVISION} (revisions: $(printf '%s' "$REVISIONS" | tr '\n' ' ')). Start the run with overwrite to replace it." + exit 1 + fi + echo "::notice::GHCR holds ${VERSION} from ${REVISION}, so this run publishes that image and not the new build." + SOURCES=("${GHCR_IMAGE}:${VERSION}") +fi +WANT="$(for SOURCE in "${SOURCES[@]}"; do manifests "$SOURCE"; done | sort)" + +for IMAGE in "${IMAGES[@]}"; do + if exists "${IMAGE}:${VERSION}"; then + HAVE="$(manifests "${IMAGE}:${VERSION}")" + if [ "$HAVE" = "$WANT" ]; then + echo "${IMAGE}:${VERSION} holds this image already." + elif [ "$OVERWRITE" = 'true' ]; then + echo "::warning::This run replaces ${IMAGE}:${VERSION}." + else + echo "::error::${IMAGE}:${VERSION} holds another image. Start the run with overwrite to replace it." + exit 1 + fi + fi +done + +TAGS=() +CHECK=() +for IMAGE in "${IMAGES[@]}"; do + TAGS+=(-t "${IMAGE}:${VERSION}") + CHECK+=("${IMAGE}:${VERSION}") + if exists "${IMAGE}:latest"; then + # Only an X.Y.Z label counts: sort -V puts `dev` above every number. The + # `|| true` covers grep without a match; a registry error stops before it. + VERSIONS="$(labels "${IMAGE}:latest" org.opencontainers.image.version)" + CURRENT="$(printf '%s\n' "$VERSIONS" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1 || true)" + NEWEST="$(printf '%s\n%s\n' "$CURRENT" "$VERSION" | sort -V | tail -n 1)" + if [ -n "$CURRENT" ] && [ "$CURRENT" != "$VERSION" ] && [ "$NEWEST" = "$CURRENT" ]; then + echo "::notice::${IMAGE}:latest holds ${CURRENT}, which is newer than ${VERSION}, so latest stays." + continue + fi + fi + TAGS+=(-t "${IMAGE}:latest") + CHECK+=("${IMAGE}:latest") +done + +ANNOTATIONS=( + --annotation "index:org.opencontainers.image.title=Static DICOMweb" + --annotation "index:org.opencontainers.image.description=DICOMweb server and DICOM to DICOMweb conversion tools" + --annotation "index:org.opencontainers.image.source=https://github.com/RadicalImaging/Static-DICOMWeb" + --annotation "index:org.opencontainers.image.licenses=MIT" + --annotation "index:org.opencontainers.image.version=${VERSION}" + --annotation "index:org.opencontainers.image.revision=${REVISION}" +) +docker buildx imagetools create "${ANNOTATIONS[@]}" "${TAGS[@]}" "${SOURCES[@]}" + +for REF in "${CHECK[@]}"; do + HAVE="$(manifests "$REF")" + if [ "$HAVE" != "$WANT" ]; then + echo "::error::${REF} does not hold the published manifests." + exit 1 + fi + echo "${REF} holds the published manifests." +done diff --git a/scripts/docker-publish-tags.test.sh b/scripts/docker-publish-tags.test.sh new file mode 100755 index 0000000..934e31c --- /dev/null +++ b/scripts/docker-publish-tags.test.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# Runs scripts/docker-publish-tags.sh against a local registry, in the cases +# that decide what a release publishes. Needs docker, buildx and jq. +# bash scripts/docker-publish-tags.test.sh +# shellcheck disable=SC2317,SC2329 # `check` and `trap` call the functions indirectly. +set -euo pipefail + +SCRIPT="${PUBLISH_SCRIPT:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/docker-publish-tags.sh}" +PORT="${REGISTRY_PORT:-5555}" +REG="localhost:${PORT}" +NAME="publish-tags-test-$$" +WORK="$(mktemp -d)" +FAILED=0 + +cleanup() { + docker rm -fv "$NAME" >/dev/null 2>&1 || true + docker buildx rm "$NAME" >/dev/null 2>&1 || true + rm -rf "$WORK" +} +trap cleanup EXIT + +docker run -d --name "$NAME" -p "127.0.0.1:${PORT}:5000" \ + registry:2@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373 >/dev/null +# The builder shares the host network, so it reaches the registry on localhost. +docker buildx create --name "$NAME" --driver docker-container --driver-opt network=host \ + --driver-opt image=moby/buildkit:v0.34.0@sha256:b059f8d7226d0b326bc871af489a5dddf65e36af4d20d14251b072974d9ee87c >/dev/null +printf 'FROM scratch\nCOPY mark /mark\n' > "$WORK/Dockerfile" + +# Pushes one platform image of a fake release by digest, as a build job does, +# and prints the digest without the sha256: prefix. +build() { # platform revision version mark + echo "$4" > "$WORK/mark" + docker buildx build --builder "$NAME" --platform "$1" --provenance=mode=min \ + --label "org.opencontainers.image.revision=$2" --label "org.opencontainers.image.version=$3" \ + --output "type=image,name=${REG}/ghcr,push-by-digest=true,name-canonical=true,push=true" \ + --metadata-file "$WORK/meta.json" --quiet "$WORK" >/dev/null + jq -r '."containerimage.digest"' < "$WORK/meta.json" | sed 's/^sha256://' +} + +# Runs the script for a version with two digests; prints its output, keeps its exit code. +publish() { # version revision ghcr_only overwrite digest digest + local dir="$WORK/digests-$RANDOM" + mkdir -p "$dir" + touch "$dir/$5" "$dir/$6" + GHCR_IMAGE="${REG}/ghcr" DOCKERHUB_IMAGE="${REG}/hub" VERSION="$1" REVISION="$2" \ + GHCR_ONLY="$3" OVERWRITE="$4" DIGEST_DIR="$dir" bash "$SCRIPT" 2>&1 +} + +manifests() { + docker buildx imagetools inspect --raw "$1" | jq -r '.manifests[].digest' | sort +} + +# Puts a tag on two digests, as a manual push or an earlier run would. +tag() { # ref digest digest + docker buildx imagetools create -t "$1" "${REG}/ghcr@sha256:$2" "${REG}/ghcr@sha256:$3" >/dev/null 2>&1 +} + +check() { # description command... + local what="$1" + shift + if "$@" >"$WORK/out" 2>&1; then + echo "ok $what" + else + echo "FAILED $what" + sed 's/^/ /' "$WORK/out" + FAILED=1 + fi +} + +same() { [ "$(manifests "$1")" = "$(manifests "$2")" ]; } +absent() { ! docker buildx imagetools inspect "$1" >/dev/null 2>&1; } +# Succeeds when the command fails with the expected message. +fails_with() { # pattern command... + local pattern="$1" out + shift + if out="$("$@" 2>&1)"; then echo "$out"; return 1; fi + echo "$out" + grep -q "$pattern" <<<"$out" +} + +A1=$(build linux/amd64 rev-a 1.8.0 a1); A2=$(build linux/arm64 rev-a 1.8.0 a2) +B1=$(build linux/amd64 rev-a 1.8.0 b1); B2=$(build linux/arm64 rev-a 1.8.0 b2) +S1=$(build linux/amd64 rev-x 1.8.1 s1); S2=$(build linux/arm64 rev-x 1.8.1 s2) +M1=$(build linux/amd64 rev-m 1.8.2 m1); M2=$(build linux/arm64 rev-m 1.8.2 m2) +N1=$(build linux/amd64 rev-n 1.9.0 n1); N2=$(build linux/arm64 rev-n 1.9.0 n2) +D1=$(build linux/amd64 unknown dev d1); D2=$(build linux/arm64 unknown dev d2) +P1=$(build linux/amd64 rev-p 1.9.1 p1); P2=$(build linux/arm64 rev-p 1.9.1 p2) + +check 'a ghcr_only run publishes GHCR only' publish 1.8.0 rev-a true false "$A1" "$A2" +check '... and leaves Docker Hub alone' absent "${REG}/hub:1.8.0" +check 'ghcr_only with overwrite is refused' \ + fails_with 'two images for one version' publish 1.8.0 rev-a true true "$A1" "$A2" +check 'a digest file that is not a digest is refused' \ + fails_with 'is not a sha256 digest' publish 1.8.0 rev-a false false "$A1" not-a-digest +check 'a later full run publishes the GHCR image, not its new build' \ + publish 1.8.0 rev-a false false "$B1" "$B2" +check '... so both registries hold one image' same "${REG}/ghcr:1.8.0" "${REG}/hub:1.8.0" +check '... and latest follows' same "${REG}/hub:latest" "${REG}/hub:1.8.0" +check 'a re-run of the same continues' publish 1.8.0 rev-a false false "$B1" "$B2" + +tag "${REG}/ghcr:1.8.1" "$S1" "$S2" +check 'a GHCR version from another commit is refused' \ + fails_with 'does not come from' publish 1.8.1 rev-other false false "$S1" "$S2" + +tag "${REG}/hub:1.8.2" "$S1" "$S2" +check 'Docker Hub with another image is refused' \ + fails_with 'holds another image' publish 1.8.2 rev-m false false "$M1" "$M2" +check '... and overwrite replaces it' publish 1.8.2 rev-m false true "$M1" "$M2" +check '... in both registries' same "${REG}/ghcr:1.8.2" "${REG}/hub:1.8.2" + +check 'a newer release moves latest' publish 1.9.0 rev-n false false "$N1" "$N2" +check '... to that release' same "${REG}/hub:latest" "${REG}/hub:1.9.0" +check 'a re-run of an older release leaves latest alone' \ + publish 1.8.0 rev-a false false "$B1" "$B2" +check '... so latest still holds 1.9.0' same "${REG}/hub:latest" "${REG}/hub:1.9.0" + +tag "${REG}/hub:latest" "$D1" "$D2" +check 'a local build with the label dev on latest does not hold latest' \ + publish 1.9.1 rev-p false false "$P1" "$P2" +check '... so latest moves to the release' same "${REG}/hub:latest" "${REG}/hub:1.9.1" + +exit "$FAILED" diff --git a/scripts/docker-smoke-test.sh b/scripts/docker-smoke-test.sh new file mode 100755 index 0000000..fc6fa22 --- /dev/null +++ b/scripts/docker-smoke-test.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Smoke test of a Static-DICOMWeb image, for docker-ci.yml and docker-publish.yml: +# bash scripts/docker-smoke-test.sh +# canvas must load, the commands must run, and the default command must serve +# DICOMweb. dicomwebserver --help starts the server, so only its presence counts. +set -euo pipefail + +IMAGE="$1" +NAME="smoke-$$" + +docker run --rm "$IMAGE" sh -ec ' + bun -e "const c = require(\"canvas\"); c.createCanvas(4, 4).toBuffer(\"image/png\"); console.log(\"canvas\", c.version)" + createdicomweb --help >/dev/null + mkdicomweb --help >/dev/null + for CMD in dicomwebserver monitordicomwebserver curl; do command -v "$CMD"; done +' + +# A free host port, so that the test does not collide with another server. +trap 'docker rm -f "$NAME" >/dev/null 2>&1 || true' EXIT +docker run -d --name "$NAME" -p 127.0.0.1::5000 "$IMAGE" >/dev/null +PORT="$(docker port "$NAME" 5000/tcp | head -n 1 | sed 's/.*://')" + +# A native start takes seconds. An arm64 image under QEMU takes minutes, and +# the monitor can restart it first; raise SMOKE_TIMEOUT_SECONDS for that case. +TIMEOUT="${SMOKE_TIMEOUT_SECONDS:-180}" +DEADLINE=$((SECONDS + TIMEOUT)) +while [ "$SECONDS" -lt "$DEADLINE" ]; do + if curl -fsS --max-time 5 "http://127.0.0.1:${PORT}/dicomweb/studies" >/dev/null; then + echo 'The server answers /dicomweb/studies.' + exit 0 + fi + sleep 2 +done + +docker logs "$NAME" +echo "::error::The server did not answer /dicomweb/studies within ${TIMEOUT} seconds." +exit 1 diff --git a/scripts/release/docker-release.mjs b/scripts/release/docker-release.mjs new file mode 100644 index 0000000..47d183f --- /dev/null +++ b/scripts/release/docker-release.mjs @@ -0,0 +1,111 @@ +import { pathToFileURL } from 'url'; +import { VERSION_SOURCE, runText } from './workspace-packages.mjs'; + +// Chooses and checks the release tag that the Docker image workflow builds. +// +// node docker-release.mjs [tag] +// +// Writes `tag=`, `sha=` and `version=` lines for $GITHUB_OUTPUT. +// Needs a checkout that holds the tags and origin/master. Node built-ins only. +// Only the tags that master contains count, so that only released code gets an +// image. docker-publish-tags.sh decides whether `latest` moves. + +const RELEASE_TAG = /^v(\d+)\.(\d+)\.(\d+)$/; + +function compareReleases(a, b) { + const [, ...partsA] = RELEASE_TAG.exec(a); + const [, ...partsB] = RELEASE_TAG.exec(b); + for (let i = 0; i < 3; i++) { + const difference = Number(partsA[i]) - Number(partsB[i]); + if (difference) { + return difference; + } + } + return 0; +} + +/** + * The release to build: `{ tag, version }`. + * + * Only `vX.Y.Z` tags of `tags` count. An empty `requested` picks the newest + * one. A requested tag that is not a release tag of the list throws. + */ +export function chooseRelease(tags, requested = '') { + const releases = tags.filter((tag) => RELEASE_TAG.test(tag)).sort(compareReleases); + const newest = releases.at(-1); + const tag = requested || newest; + + if (!tag) { + throw new Error('master contains no release tag of the form v1.2.3.'); + } + if (!RELEASE_TAG.test(tag)) { + // JSON quotes the input, so a newline in it cannot start a workflow command. + throw new Error(`${JSON.stringify(tag)} is not a release tag of the form v1.2.3.`); + } + if (!releases.includes(tag)) { + throw new Error(`master contains no tag ${tag}.`); + } + + return { tag, version: tag.slice(1) }; +} + +// The Dockerfile step that installs the image from pnpm-lock.yaml. The +// Dockerfile of 1.7.7 and earlier has an npm install stage instead. +const LOCKFILE_INSTALL = /^\s*RUN\b.*\bpnpm\b.*\bdeploy\b.*--prod\b/m; + +/** + * The problems of the tagged commit for an image build; an empty list accepts it. + * + * - `manifest`: VERSION_SOURCE at the tag, parsed, or undefined when it is missing. + * - `dockerfile`: the Dockerfile at the tag, or undefined when it is missing. + */ +export function findTagProblems({ tag, version, manifest, dockerfile }) { + const problems = []; + + if (!manifest) { + problems.push(`${tag} has no ${VERSION_SOURCE}.`); + } else if (manifest.version !== version) { + problems.push(`${tag} carries version ${manifest.version} in ${VERSION_SOURCE}.`); + } + if (!LOCKFILE_INSTALL.test(dockerfile ?? '')) { + problems.push( + `The Dockerfile of ${tag} does not install from pnpm-lock.yaml, so this workflow does not build it. Build a release after 1.7.7.` + ); + } + + return problems; +} + +async function readAt(sha, path) { + try { + return await runText('git', ['show', `${sha}:${path}`]); + } catch { + return undefined; + } +} + +async function run() { + const tags = (await runText('git', ['tag', '--list', '--merged', 'origin/master', 'v*'])).split('\n'); + const { tag, version } = chooseRelease(tags, process.argv[2] ?? ''); + const sha = await runText('git', ['rev-parse', `refs/tags/${tag}^{commit}`]); + + const manifestText = await readAt(sha, VERSION_SOURCE); + const problems = findTagProblems({ + tag, + version, + manifest: manifestText === undefined ? undefined : JSON.parse(manifestText), + dockerfile: await readAt(sha, 'Dockerfile'), + }); + if (problems.length) { + throw new Error(problems.join(' ')); + } + + console.log(`tag=${tag}\nsha=${sha}\nversion=${version}`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + run().catch((err) => { + console.error(`::error::${err.message}`); + process.exit(1); + }); +} diff --git a/scripts/release/release.test.mjs b/scripts/release/release.test.mjs index b3296e6..b187bb5 100644 --- a/scripts/release/release.test.mjs +++ b/scripts/release/release.test.mjs @@ -4,6 +4,7 @@ import { bumpVersion, releaseTypeOf, releaseTypeOfAll } from './release-type.mjs import { sortByDependencies } from './workspace-packages.mjs'; import { findProblems } from './verify-version-commit.mjs'; import { findEntryProblems } from './publish-package.mjs'; +import { chooseRelease, findTagProblems } from './docker-release.mjs'; test('the bump follows the conventional commit rules', () => { assert.equal(bumpVersion('1.7.6', 'patch'), '1.7.7'); @@ -87,3 +88,24 @@ test('a tarball holds each path once, as a file or a directory under package/', assert.notDeepEqual(findEntryProblems(entries), [], entries.at(-1).name); } }); + +test('the Docker image builds a release tag, the newest one by default', () => { + const tags = ['v1.5.0', 'v1.10.0', 'v1.9.2', 'v2.0.0-beta.1', 'healthlake-v1.0.0']; + + assert.deepEqual(chooseRelease(tags), { tag: 'v1.10.0', version: '1.10.0' }); + assert.deepEqual(chooseRelease(tags, 'v1.9.2'), { tag: 'v1.9.2', version: '1.9.2' }); + for (const requested of ['v2.0.0-beta.1', 'v1.9.3', '1.9.2']) { + assert.throws(() => chooseRelease(tags, requested), undefined, requested); + } +}); + +test('the Docker image builds only a tag that carries its version and the lockfile install', () => { + const tag = { tag: 'v1.8.0', version: '1.8.0', manifest: { version: '1.8.0' } }; + const dockerfile = 'RUN pnpm --filter @x/server deploy --prod /deploy'; + + assert.deepEqual(findTagProblems({ ...tag, dockerfile }), []); + assert.equal(findTagProblems({ ...tag, manifest: { version: '1.7.9' }, dockerfile }).length, 1); + assert.equal(findTagProblems({ ...tag, manifest: undefined, dockerfile }).length, 1); + assert.equal(findTagProblems({ ...tag, dockerfile: 'RUN npm install ./x.tgz' }).length, 1); + assert.equal(findTagProblems({ ...tag, dockerfile: '# RUN pnpm deploy --prod /deploy' }).length, 1); +});