- 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
438 lines
16 KiB
YAML
438 lines
16 KiB
YAML
name: Build and Push Docker Image
|
|
|
|
on:
|
|
push:
|
|
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:
|
|
DOCKERHUB_IMAGE: decolua/9router
|
|
|
|
jobs:
|
|
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:
|
|
- name: Check out release source at validated commit
|
|
uses: actions/checkout@v4
|
|
with:
|
|
ref: ${{ needs.prepare.outputs.commit }}
|
|
path: source
|
|
fetch-depth: 1
|
|
|
|
- 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
|
|
with:
|
|
registry: ghcr.io
|
|
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: 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}"
|
|
|
|
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"
|