diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index e8ef957e..64be9c1c 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -5,22 +5,164 @@ on: tags: - "v*" workflow_dispatch: + inputs: + release_tag: + description: "Existing vX.Y.Z tag to publish" + required: true + type: string + promote_latest: + description: "Promote this republish to latest" + required: false + default: false + type: boolean + +# Keep every release in one FIFO queue. A per-tag group would still allow an +# older release to finish after a newer release and move latest backwards. +concurrency: + group: docker-publish-${{ github.repository }} + cancel-in-progress: false + queue: max env: - GHCR_IMAGE: ghcr.io/${{ github.repository }} DOCKERHUB_IMAGE: decolua/9router jobs: - build-and-push: + prepare: + name: Validate release runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + + outputs: + tag: ${{ steps.release.outputs.tag }} + version: ${{ steps.release.outputs.version }} + commit: ${{ steps.release.outputs.commit }} + publish_dockerhub: ${{ steps.release.outputs.publish_dockerhub }} + promote_latest: ${{ steps.release.outputs.promote_latest }} + ghcr_image: ${{ steps.release.outputs.ghcr_image }} + + steps: + - name: Check out release tag + uses: actions/checkout@v4 + with: + ref: ${{ inputs.release_tag || github.ref_name }} + fetch-depth: 1 + + - name: Validate tag and package versions + id: release + env: + RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }} + REPOSITORY: ${{ github.repository }} + EVENT_NAME: ${{ github.event_name }} + PROMOTE_LATEST_INPUT: ${{ inputs.promote_latest && 'true' || 'false' }} + run: | + node <<'NODE' + const fs = require("fs"); + const { execFileSync } = require("child_process"); + + const tag = process.env.RELEASE_TAG || ""; + const match = /^v((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)$/.exec(tag); + + if (tag.includes("+")) { + console.error(`Build metadata is not supported in Docker release tags: ${tag}`); + process.exit(1); + } + + if (!match) { + console.error(`Expected a Docker-safe semver tag like v0.5.81 or v0.5.81-rc.1, received: ${tag || ""}`); + process.exit(1); + } + + const version = match[1]; + if (version.length > 128 || !/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/.test(version)) { + console.error(`Version is not a valid Docker tag: ${version}`); + process.exit(1); + } + + const prerelease = version.includes("-") + ? version.slice(version.indexOf("-") + 1).split(".") + : []; + for (const identifier of prerelease) { + if (/^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith("0")) { + console.error(`Numeric prerelease identifiers cannot contain leading zeroes: ${identifier}`); + process.exit(1); + } + } + + const rootVersion = require("./package.json").version; + const cliVersion = require("./cli/package.json").version; + + if (rootVersion !== version) { + console.error(`package.json version ${rootVersion} does not match tag ${tag}`); + process.exit(1); + } + + if (cliVersion !== version) { + console.error(`cli/package.json version ${cliVersion} does not match tag ${tag}`); + process.exit(1); + } + + const commit = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + const publishDockerHub = process.env.REPOSITORY === "decolua/9router"; + const ghcrImage = `ghcr.io/${process.env.REPOSITORY.toLowerCase()}`; + const isPrerelease = version.includes("-"); + const promoteLatest = (process.env.EVENT_NAME === "push" && !isPrerelease) + || process.env.PROMOTE_LATEST_INPUT === "true"; + const output = process.env.GITHUB_OUTPUT; + + fs.appendFileSync(output, `tag=${tag}\n`); + fs.appendFileSync(output, `version=${version}\n`); + fs.appendFileSync(output, `commit=${commit}\n`); + fs.appendFileSync(output, `publish_dockerhub=${publishDockerHub}\n`); + fs.appendFileSync(output, `promote_latest=${promoteLatest}\n`); + fs.appendFileSync(output, `ghcr_image=${ghcrImage}\n`); + + console.log(`Validated ${tag} at ${commit}`); + console.log(`latest promotion: ${promoteLatest ? "enabled" : "disabled"}`); + NODE + + build: + name: Build ${{ matrix.platform }} + needs: prepare + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + env: + GHCR_IMAGE: ${{ needs.prepare.outputs.ghcr_image }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + suffix: amd64 + runner: ubuntu-24.04 + - platform: linux/arm64 + suffix: arm64 + runner: ubuntu-24.04-arm + permissions: contents: read packages: write steps: - - uses: actions/checkout@v4 + - name: Check out release source at validated commit + uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.commit }} + path: source + fetch-depth: 1 - - uses: docker/setup-buildx-action@v3 + - name: Check out publishing Dockerfile + uses: actions/checkout@v4 + with: + ref: ${{ github.workflow_sha }} + path: workflow + sparse-checkout: | + Dockerfile + fetch-depth: 1 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - name: Log in to GHCR uses: docker/login-action@v3 @@ -29,32 +171,267 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push platform image by digest + id: build + uses: docker/build-push-action@v6 + with: + context: source + file: workflow/Dockerfile + platforms: ${{ matrix.platform }} + outputs: type=image,name=${{ env.GHCR_IMAGE }},push-by-digest=true,name-canonical=true,push=true + build-args: | + APP_VERSION=${{ needs.prepare.outputs.version }} + ALPINE_MIRROR=${{ vars.ALPINE_MIRROR || 'dl-cdn.alpinelinux.org' }} + NPM_REGISTRY=${{ vars.NPM_REGISTRY || 'https://registry.npmjs.org/' }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ needs.prepare.outputs.commit }} + org.opencontainers.image.version=${{ needs.prepare.outputs.version }} + cache-from: type=gha,scope=9router-${{ matrix.suffix }} + cache-to: type=gha,mode=max,scope=9router-${{ matrix.suffix }} + provenance: false + sbom: false + + - name: Smoke-test platform image before publishing digest artifact + env: + GHCR_IMAGE: ${{ env.GHCR_IMAGE }} + IMAGE_DIGEST: ${{ steps.build.outputs.digest }} + PLATFORM: ${{ matrix.platform }} + run: | + set -Eeuo pipefail + [[ "$IMAGE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] + + container="9router-platform-smoke-${GITHUB_RUN_ID}-${{ matrix.suffix }}" + trap 'docker rm -f "$container" >/dev/null 2>&1 || true' EXIT + + docker run --detach \ + --name "$container" \ + --platform "$PLATFORM" \ + --publish 20128:20128 \ + "${GHCR_IMAGE}@${IMAGE_DIGEST}" + + for attempt in {1..45}; do + if curl --fail --silent --show-error http://127.0.0.1:20128/api/health; then + echo "${PLATFORM} health check passed" + exit 0 + fi + if (( attempt % 5 == 0 )); then + echo "Waiting for ${PLATFORM} health check (${attempt}/45)" >&2 + fi + sleep 2 + done + + echo "${PLATFORM} health check failed; container logs follow:" >&2 + docker logs "$container" || true + exit 1 + + - name: Save image digest + env: + IMAGE_DIGEST: ${{ steps.build.outputs.digest }} + run: | + set -euo pipefail + test -n "$IMAGE_DIGEST" + mkdir -p "$RUNNER_TEMP/digests" + printf '%s\n' "$IMAGE_DIGEST" > "$RUNNER_TEMP/digests/${{ matrix.suffix }}.txt" + + - name: Upload image digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ matrix.suffix }} + path: ${{ runner.temp }}/digests/${{ matrix.suffix }}.txt + if-no-files-found: error + + publish: + name: Publish and verify manifest + needs: + - prepare + - build + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + GHCR_IMAGE: ${{ needs.prepare.outputs.ghcr_image }} + permissions: + contents: read + packages: write + + steps: + - name: Download platform digests + uses: actions/download-artifact@v4 + with: + pattern: digests-* + path: ${{ runner.temp }}/digests + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create and verify version manifest + env: + GHCR_IMAGE: ${{ env.GHCR_IMAGE }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + set -euo pipefail + shopt -s nullglob + digest_files=("$RUNNER_TEMP"/digests/*.txt) + + if [[ "${#digest_files[@]}" -ne 2 ]]; then + echo "Expected two platform digests, found ${#digest_files[@]}" >&2 + exit 1 + fi + + sources=() + for digest_file in "${digest_files[@]}"; do + digest="$(tr -d '\n' < "$digest_file")" + if [[ ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "Invalid image digest in $digest_file: $digest" >&2 + exit 1 + fi + sources+=("${GHCR_IMAGE}@${digest}") + done + + docker buildx imagetools create \ + --tag "${GHCR_IMAGE}:${VERSION}" \ + "${sources[@]}" + + docker buildx imagetools inspect "${GHCR_IMAGE}:${VERSION}" | tee "$RUNNER_TEMP/version-manifest.txt" + docker buildx imagetools inspect --raw "${GHCR_IMAGE}:${VERSION}" > "$RUNNER_TEMP/version-manifest.json" + + expected=$'linux/amd64\nlinux/arm64' + actual="$(jq -r '[.manifests[] | select(.platform != null and .platform.os != null and .platform.architecture != null) | "\(.platform.os)/\(.platform.architecture)"] | sort | .[]' "$RUNNER_TEMP/version-manifest.json")" + if [[ "$actual" != "$expected" ]]; then + echo "Version manifest platforms do not match exactly:" >&2 + printf '%s\n' "$actual" >&2 + exit 1 + fi + + - name: Smoke-test resolved version manifest + env: + GHCR_IMAGE: ${{ env.GHCR_IMAGE }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + set -Eeuo pipefail + container="9router-manifest-smoke-${GITHUB_RUN_ID}" + trap 'docker rm -f "$container" >/dev/null 2>&1 || true' EXIT + + docker run --detach \ + --name "$container" \ + --platform linux/amd64 \ + --publish 20128:20128 \ + "${GHCR_IMAGE}:${VERSION}" + + for attempt in {1..30}; do + if curl --fail --silent --show-error http://127.0.0.1:20128/api/health; then + echo "Resolved version manifest health check passed" + exit 0 + fi + if (( attempt % 5 == 0 )); then + echo "Waiting for resolved manifest health check (${attempt}/30)" >&2 + fi + sleep 2 + done + + echo "Resolved version manifest health check failed; container logs follow:" >&2 + docker logs "$container" || true + exit 1 + - name: Log in to Docker Hub + if: needs.prepare.outputs.publish_dockerhub == 'true' uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: | - ${{ env.GHCR_IMAGE }} - ${{ env.DOCKERHUB_IMAGE }} - tags: | - type=semver,pattern={{version}} - type=raw,value=latest,enable={{is_default_branch}} + - name: Publish version image to Docker Hub + if: needs.prepare.outputs.publish_dockerhub == 'true' + env: + DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_IMAGE }} + GHCR_IMAGE: ${{ env.GHCR_IMAGE }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + set -euo pipefail + docker buildx imagetools create \ + --tag "${DOCKERHUB_IMAGE}:${VERSION}" \ + "${GHCR_IMAGE}:${VERSION}" - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=registry,ref=${{ env.GHCR_IMAGE }}:buildcache - cache-to: type=registry,ref=${{ env.GHCR_IMAGE }}:buildcache,mode=max - platforms: linux/amd64,linux/arm64 - provenance: false - sbom: false + docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:${VERSION}" | tee "$RUNNER_TEMP/dockerhub-version-manifest.txt" + docker buildx imagetools inspect --raw "${DOCKERHUB_IMAGE}:${VERSION}" > "$RUNNER_TEMP/dockerhub-version-manifest.json" + + expected=$'linux/amd64\nlinux/arm64' + actual="$(jq -r '[.manifests[] | select(.platform != null and .platform.os != null and .platform.architecture != null) | "\(.platform.os)/\(.platform.architecture)"] | sort | .[]' "$RUNNER_TEMP/dockerhub-version-manifest.json")" + if [[ "$actual" != "$expected" ]]; then + echo "Docker Hub version manifest platforms do not match exactly:" >&2 + printf '%s\n' "$actual" >&2 + exit 1 + fi + + - name: Record latest promotion policy + env: + PROMOTE_LATEST: ${{ needs.prepare.outputs.promote_latest }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + if [[ "$PROMOTE_LATEST" == "true" ]]; then + echo "### Latest promotion" >> "$GITHUB_STEP_SUMMARY" + echo "- Policy: promote \`latest\` after the verified ${VERSION} manifest." >> "$GITHUB_STEP_SUMMARY" + else + echo "### Latest promotion" >> "$GITHUB_STEP_SUMMARY" + echo "- Policy: leave \`latest\` unchanged; this is a numbered-tag-only manual republish." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Promote verified version to latest + if: needs.prepare.outputs.promote_latest == 'true' + env: + DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_IMAGE }} + GHCR_IMAGE: ${{ env.GHCR_IMAGE }} + PUBLISH_DOCKERHUB: ${{ needs.prepare.outputs.publish_dockerhub }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + set -euo pipefail + + docker buildx imagetools create \ + --tag "${GHCR_IMAGE}:latest" \ + "${GHCR_IMAGE}:${VERSION}" + + if [[ "$PUBLISH_DOCKERHUB" == "true" ]]; then + docker buildx imagetools create \ + --tag "${DOCKERHUB_IMAGE}:latest" \ + "${GHCR_IMAGE}:${VERSION}" + fi + + docker buildx imagetools inspect "${GHCR_IMAGE}:latest" | tee "$RUNNER_TEMP/ghcr-latest-manifest.txt" + docker buildx imagetools inspect --raw "${GHCR_IMAGE}:latest" > "$RUNNER_TEMP/ghcr-latest-manifest.json" + + expected=$'linux/amd64\nlinux/arm64' + actual="$(jq -r '[.manifests[] | select(.platform != null and .platform.os != null and .platform.architecture != null) | "\(.platform.os)/\(.platform.architecture)"] | sort | .[]' "$RUNNER_TEMP/ghcr-latest-manifest.json")" + if [[ "$actual" != "$expected" ]]; then + echo "GHCR latest manifest platforms do not match exactly:" >&2 + printf '%s\n' "$actual" >&2 + exit 1 + fi + + if [[ "$PUBLISH_DOCKERHUB" == "true" ]]; then + docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:latest" | tee "$RUNNER_TEMP/dockerhub-latest-manifest.txt" + docker buildx imagetools inspect --raw "${DOCKERHUB_IMAGE}:latest" > "$RUNNER_TEMP/dockerhub-latest-manifest.json" + actual="$(jq -r '[.manifests[] | select(.platform != null and .platform.os != null and .platform.architecture != null) | "\(.platform.os)/\(.platform.architecture)"] | sort | .[]' "$RUNNER_TEMP/dockerhub-latest-manifest.json")" + if [[ "$actual" != "$expected" ]]; then + echo "Docker Hub latest manifest platforms do not match exactly:" >&2 + printf '%s\n' "$actual" >&2 + exit 1 + fi + fi + + { + echo "### Published Docker images" + echo "- GHCR: \`${GHCR_IMAGE}:${VERSION}\`" + echo "- GHCR latest: \`${GHCR_IMAGE}:latest\`" + if [[ "$PUBLISH_DOCKERHUB" == "true" ]]; then + echo "- Docker Hub: \`${DOCKERHUB_IMAGE}:${VERSION}\`" + echo "- Docker Hub latest: \`${DOCKERHUB_IMAGE}:latest\`" + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/DOCKER.md b/DOCKER.md index 1f280d97..727d7bfb 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -100,6 +100,12 @@ docker rm -f 9router # re-run the quick start command ``` +To pin a specific version instead of following `latest`, use a numbered image tag: + +```bash +docker pull decolua/9router:0.5.81 +``` + --- # 🛠 For Developers @@ -107,7 +113,7 @@ docker rm -f 9router ## Build image locally (test) ```bash -cd app && docker build -t 9router . +docker build -t 9router . docker run --rm -p 20128:20128 \ -v "$HOME/.9router:/app/data" \ @@ -115,18 +121,67 @@ docker run --rm -p 20128:20128 \ 9router ``` +The Dockerfile uses the official Alpine and npm registries by default. Regional mirrors can be supplied when needed: + +```bash +docker build \ + --build-arg ALPINE_MIRROR=mirrors.aliyun.com \ + --build-arg NPM_REGISTRY=https://registry.npmmirror.com/ \ + -t 9router . +``` + ## Publish (automatic via CI) -Push a git tag `v*` → GitHub Actions builds multi-platform (amd64+arm64) and pushes to: -- `ghcr.io/decolua/9router:v{version}` + `:latest` -- `decolua/9router:v{version}` + `:latest` +Push a Docker-safe semver git tag `vX.Y.Z` (or a prerelease such as `vX.Y.Z-rc.1`) → GitHub Actions builds `linux/amd64` and `linux/arm64` on native runners, health-checks each platform image, verifies the resulting manifest and `/api/health`, then publishes: + +- `ghcr.io/decolua/9router:X.Y.Z` + `:latest` +- `decolua/9router:X.Y.Z` + `:latest` + +The `v` prefix is used only for the git tag; image tags omit it. A stable tag push promotes `latest`, but a prerelease tag such as `vX.Y.Z-rc.1` publishes only its numbered image by default. Prereleases require an explicit manual `promote_latest` opt-in. Promotion happens only after both native platform builds, both platform health checks, manifest inspection, and the resolved-manifest smoke test succeed. A failed or timed-out platform build therefore cannot move `latest`. + +The workflow rejects SemVer build metadata such as `v1.2.3+build.7` because the `+` form is not a valid Docker image tag. The git tag and both `package.json` versions must match exactly. ```bash # Use scripts/release.js (recommended) node scripts/release.js "Release title" "Notes" # Or manually -git tag v0.4.x && git push origin v0.4.x +git tag v0.5.81 && git push origin v0.5.81 ``` -Workflow: `app/.github/workflows/docker-publish.yml` +To republish an existing tag, run the `Build and Push Docker Image` workflow manually and provide the exact tag, for example `v0.5.81`, in the `release_tag` input. Manual runs publish the numbered tag but leave `latest` unchanged by default: + +```text +release_tag: v0.5.81 +promote_latest: false +``` + +The `promote_latest` checkbox is an explicit opt-in for changing `latest`. Use it when a deliberate rollback or recovery should make that version the current default: + +```text +release_tag: v0.5.75 +promote_latest: true +``` + +Numbered image tags are mutable because a republish can replace their manifest. For a deployment that must be immutable, pin the image digest instead: + +```bash +docker pull decolua/9router@sha256: +``` + +The release workflow runs `/api/health` on each native `amd64` and `arm64` platform image before it uploads the digest artifact or assembles the multi-platform manifest. It then runs a second health check against the resolved version manifest before any requested `latest` promotion. + +During recovery, the selected tag remains the application source while the Dockerfile from the workflow revision is used, so an older tag can be rebuilt with the current publishing fixes. + +The workflow is tag-driven. Creating a git tag does not automatically create a GitHub Release, so the Releases page and the published package/image tags can be at different versions unless a maintainer creates a release separately. + +The upstream repository needs these repository secrets for Docker Hub publishing: + +- `DOCKERHUB_USERNAME` +- `DOCKERHUB_TOKEN` + +GHCR publishing uses the workflow's `GITHUB_TOKEN` with package write permission. Forks can publish to their own GHCR namespace, but Docker Hub publication is restricted to the upstream `decolua/9router` repository. + +The optional repository variables `ALPINE_MIRROR` and `NPM_REGISTRY` can override the default package mirrors used by the CI Docker build. + +Workflow: `.github/workflows/docker-publish.yml` diff --git a/Dockerfile b/Dockerfile index 547be9dd..9309de7e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,29 +1,49 @@ # syntax=docker/dockerfile:1.7 ARG NODE_IMAGE=node:22-alpine +ARG ALPINE_MIRROR=dl-cdn.alpinelinux.org +ARG NPM_REGISTRY=https://registry.npmjs.org/ +ARG APP_VERSION=unknown + FROM ${NODE_IMAGE} AS base +ARG ALPINE_MIRROR WORKDIR /app -# CN mirror for apk (used by builder and runner stages) -RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories + +# Use the official Alpine mirror by default. A repository variable/build arg can +# override it for environments that require a regional mirror. +RUN if [ "$ALPINE_MIRROR" != "dl-cdn.alpinelinux.org" ]; then \ + sed -i "s|dl-cdn.alpinelinux.org|${ALPINE_MIRROR}|g" /etc/apk/repositories; \ + fi FROM base AS builder +ARG NPM_REGISTRY -RUN apk --no-cache upgrade && apk --no-cache add python3 make g++ linux-headers +RUN apk add --no-cache python3 make g++ linux-headers COPY package.json ./ -RUN npm install --registry=https://registry.npmmirror.com +RUN --mount=type=cache,target=/root/.npm \ + npm install \ + --registry="${NPM_REGISTRY}" \ + --fetch-retries=5 \ + --fetch-retry-factor=2 \ + --fetch-retry-mintimeout=10000 \ + --fetch-retry-maxtimeout=120000 \ + --fetch-timeout=300000 COPY . ./ ENV NEXT_TELEMETRY_DISABLED=1 RUN npm run build FROM ${NODE_IMAGE} AS runner +ARG ALPINE_MIRROR +ARG APP_VERSION WORKDIR /app -# The base stage's mirror swap does not reach here: runner starts from -# ${NODE_IMAGE} directly, so the apk upgrade below would go to -# dl-cdn.alpinelinux.org and hang forever on networks that cannot reach it. -RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories -LABEL org.opencontainers.image.title="9router" +RUN if [ "$ALPINE_MIRROR" != "dl-cdn.alpinelinux.org" ]; then \ + sed -i "s|dl-cdn.alpinelinux.org|${ALPINE_MIRROR}|g" /etc/apk/repositories; \ + fi + +LABEL org.opencontainers.image.title="9router" \ + org.opencontainers.image.version="${APP_VERSION}" ENV NODE_ENV=production ENV PORT=20128 @@ -52,8 +72,9 @@ RUN mkdir -p /app/data && chown -R node:node /app && \ mkdir -p /app/data-home && chown node:node /app/data-home && \ ln -sf /app/data-home /root/.9router 2>/dev/null || true -# Fix permissions at runtime (handles mounted volumes) -RUN apk --no-cache upgrade && apk --no-cache add su-exec && \ +# Avoid a full distribution upgrade in the runtime image. It makes builds less +# reproducible and is unrelated to installing the runtime entrypoint helper. +RUN apk add --no-cache su-exec && \ printf '#!/bin/sh\nchown -R node:node /app/data /app/data-home 2>/dev/null\nexec su-exec node "$@"\n' > /entrypoint.sh && \ chmod +x /entrypoint.sh