fix(docker): publish verified multi-platform images

- Build linux/amd64 and linux/arm64 on native GitHub runners
- Assemble version manifests from platform digests and promote latest only after verification
- Add release/tag validation, manual republishing, timeouts, and health smoke tests
- Make Docker build mirrors configurable via build args and remove unnecessary runtime apk upgrades
- Update DOCKER.md documentation
This commit is contained in:
DaDecky
2026-09-21 20:27:03 +07:00
parent c7df895bbb
commit f67d5a0c93
3 changed files with 496 additions and 43 deletions

View File

@@ -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 || "<empty>"}`);
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"

View File

@@ -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:<verified-digest>
```
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`

View File

@@ -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