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:
429
.github/workflows/docker-publish.yml
vendored
429
.github/workflows/docker-publish.yml
vendored
@@ -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"
|
||||
|
||||
67
DOCKER.md
67
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:<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`
|
||||
|
||||
43
Dockerfile
43
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user