From 1b2a72ebc8984c49206eb8ce52c628bce674ad32 Mon Sep 17 00:00:00 2001 From: Aman <1402357+Zartharas@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:51:50 -0600 Subject: [PATCH] feat(docker): publish next from active release branches (#9181) Validated in local merge-train T4 (HouMinXi+Zartharas+Andrian+artickc) --- .github/workflows/docker-publish.yml | 44 ++---- README.md | 6 + docs/guides/DOCKER_RELEASE_CHANNELS.md | 52 +++++++ scripts/ci/resolve-docker-publish-version.sh | 60 ++++++++ scripts/ci/should-promote-latest.sh | 12 +- .../build/docker-next-channel-8576.test.ts | 128 ++++++++++++++++++ 6 files changed, 266 insertions(+), 36 deletions(-) create mode 100644 docs/guides/DOCKER_RELEASE_CHANNELS.md create mode 100644 scripts/ci/resolve-docker-publish-version.sh create mode 100644 tests/unit/build/docker-next-channel-8576.test.ts diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 4ec8ae2dab..ccfd170c9b 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - "release/v*" tags: - "v*" paths-ignore: @@ -57,39 +58,20 @@ jobs: REF_TYPE: ${{ github.ref_type }} INPUT_VERSION: ${{ inputs.version }} PROMOTE_INPUT: ${{ inputs.promote_latest }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | set -euo pipefail - # 1) Resolve version string from the trigger (all inputs come via env). - case "$EVENT_NAME" in - workflow_dispatch) - VERSION="${INPUT_VERSION#v}" - ;; - push) - if [ "$REF_TYPE" = "tag" ]; then - VERSION="${REF_NAME#v}" - else - # Push to main → build & tag as `main` only. Never touch :latest. - VERSION="main" - fi - ;; - release) - VERSION="${REF_NAME#v}" - ;; - *) - VERSION="${REF_NAME#v}" - ;; - esac - # Sanity-check: only allow [A-Za-z0-9._-] in VERSION (defense in depth). - if ! printf '%s' "$VERSION" | grep -qE '^[A-Za-z0-9._-]+$'; then - echo "Refusing to use unsafe VERSION value: $VERSION" >&2 - exit 1 - fi + # 1) Resolve version/channel from the trigger. Only the current default + # release branch publishes the mutable `next` channel; main keeps `main`. + VERSION=$(bash scripts/ci/resolve-docker-publish-version.sh \ + "$EVENT_NAME" "$REF_TYPE" "$REF_NAME" "$INPUT_VERSION" "$DEFAULT_BRANCH") echo "version=$VERSION" >> "$GITHUB_OUTPUT" - # 2) Decide whether to promote :latest. + # 2) Decide whether to promote :latest. Floating channels are never + # eligible, and the helper independently fails closed for non-semver. PROMOTE="false" - if [ "$VERSION" = "main" ]; then + if [ "$VERSION" = "main" ] || [ "$VERSION" = "next" ]; then PROMOTE="false" elif printf '%s' "$VERSION" | grep -qE -- '-(rc|alpha|beta|pre|next)'; then echo "Pre-release identifier detected — skipping :latest." @@ -109,10 +91,10 @@ jobs: fi echo "promote_latest=$PROMOTE" >> "$GITHUB_OUTPUT" - # 3) Skip if this exact version is already published in Docker Hub. - # `main` is always rebuilt (mutable floating tag). + # 3) Skip immutable version tags that already exist. Floating `main` + # and `next` channels are intentionally rebuilt on every matching push. SKIP="false" - if [ "$VERSION" != "main" ]; then + if [ "$VERSION" != "main" ] && [ "$VERSION" != "next" ]; then if docker manifest inspect "diegosouzapw/omniroute:${VERSION}" >/dev/null 2>&1; then echo "Image diegosouzapw/omniroute:${VERSION} already exists on Docker Hub — skipping rebuild." SKIP="true" @@ -397,7 +379,7 @@ jobs: - name: Update Docker Hub description # Only refresh README/description when we actually promote :latest - # (avoids overwriting from main pushes or back-fill builds). + # (avoids overwriting from main, next, or back-fill builds). if: needs.prepare.outputs.promote_latest == 'true' uses: peter-evans/dockerhub-description@v5 with: diff --git a/README.md b/README.md index e0fba74af8..96d39c9a84 100644 --- a/README.md +++ b/README.md @@ -890,6 +890,12 @@ docker run -d --name omniroute --restart unless-stopped --stop-timeout 40 \ -p 127.0.0.1:20128:20128 -v omniroute-data:/app/data diegosouzapw/omniroute:latest ``` +> **Pre-release Docker channel:** `diegosouzapw/omniroute:next` and +> `diegosouzapw/omniroute:next-web` follow the current default `release/v*` +> branch. These mutable tags are intended only for testing unreleased fixes and +> are **not supported for production**. See +> [Docker Release Channels](docs/guides/DOCKER_RELEASE_CHANNELS.md). + **🛠️ From source** ```bash diff --git a/docs/guides/DOCKER_RELEASE_CHANNELS.md b/docs/guides/DOCKER_RELEASE_CHANNELS.md new file mode 100644 index 0000000000..46b4e44406 --- /dev/null +++ b/docs/guides/DOCKER_RELEASE_CHANNELS.md @@ -0,0 +1,52 @@ +# Docker Release Channels + +OmniRoute publishes separate Docker channels for stable releases, active release-branch testing, and development builds. + +## Channel summary + +| Channel | Source | Mutability | Recommended use | +| --- | --- | --- | --- | +| `:` / `:-web` | Signed/versioned release | Immutable | Production deployments that pin an exact release | +| `:latest` / `:latest-web` | Highest stable release | Mutable stable pointer | Production deployments that intentionally follow stable releases | +| `:next` / `:next-web` | Current default `release/v*` branch | Mutable pre-release pointer | Testing fixes that have landed on the active release branch but are not yet in a stable release | +| `:main` / `:main-web` | `main` branch | Mutable development pointer | Development and integration testing only | + +## Using the pre-release channel + +The `next` channel is rebuilt on every push to the current default `release/v*` branch and is published for both AMD64 and ARM64. Older maintenance branches cannot overwrite it. The channel provides a pullable image for fixes that have merged into the active release branch before the next stable tag is cut. + +```bash +docker pull diegosouzapw/omniroute:next +docker pull diegosouzapw/omniroute:next-web +``` + +For Docker Compose, override the image tag used by the selected profile, then pull and recreate the service: + +```yaml +services: + omniroute: + image: diegosouzapw/omniroute:next +``` + +```bash +docker compose pull +docker compose up -d +``` + +## Safety and rollback + +`next` is a floating pre-release channel. It may change on any push to the active release branch and is **not supported for production use**. Pin the image digest while evaluating a specific build: + +```bash +docker pull diegosouzapw/omniroute:next +docker image inspect diegosouzapw/omniroute:next --format '{{index .RepoDigests 0}}' +``` + +Before testing, back up the OmniRoute data volume or bind-mounted data directory. To roll back, restore the previously used stable version or digest and recreate the container: + +```bash +docker pull diegosouzapw/omniroute: +docker compose up -d +``` + +A release-branch build can never move `latest`; only an eligible stable semantic version may promote the stable pointer. The `next` images retain the release image inspection and blocking CRITICAL-vulnerability gate. diff --git a/scripts/ci/resolve-docker-publish-version.sh b/scripts/ci/resolve-docker-publish-version.sh new file mode 100644 index 0000000000..a9844e76a7 --- /dev/null +++ b/scripts/ci/resolve-docker-publish-version.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Resolve the Docker tag/channel for a docker-publish workflow event. +# +# Usage: +# resolve-docker-publish-version.sh EVENT_NAME REF_TYPE REF_NAME [INPUT_VERSION] [DEFAULT_BRANCH] +# +# Outputs exactly one safe tag string: +# - workflow_dispatch: requested version without a leading v +# - push tag: tag without a leading v +# - push main: main +# - push to the current default release/v* branch: next +# - release: release tag without a leading v +set -euo pipefail + +EVENT_NAME="${1:?event name required}" +REF_TYPE="${2:-}" +REF_NAME="${3:-}" +INPUT_VERSION="${4:-}" +DEFAULT_BRANCH="${5:-}" + +case "$EVENT_NAME" in + workflow_dispatch) + VERSION="${INPUT_VERSION#v}" + ;; + push) + if [ "$REF_TYPE" = "tag" ]; then + VERSION="${REF_NAME#v}" + else + case "$REF_NAME" in + main) + VERSION="main" + ;; + release/v*) + if [ -z "$DEFAULT_BRANCH" ] || [ "$REF_NAME" != "$DEFAULT_BRANCH" ]; then + echo "Refusing to publish next from non-default release branch: $REF_NAME" >&2 + exit 1 + fi + VERSION="next" + ;; + *) + echo "Unsupported Docker publish branch: $REF_NAME" >&2 + exit 1 + ;; + esac + fi + ;; + release) + VERSION="${REF_NAME#v}" + ;; + *) + VERSION="${REF_NAME#v}" + ;; +esac + +if ! printf '%s' "$VERSION" | grep -qE '^[A-Za-z0-9._-]+$'; then + echo "Refusing to use unsafe VERSION value: $VERSION" >&2 + exit 1 +fi + +printf '%s\n' "$VERSION" diff --git a/scripts/ci/should-promote-latest.sh b/scripts/ci/should-promote-latest.sh index 12704b7962..e118086c88 100755 --- a/scripts/ci/should-promote-latest.sh +++ b/scripts/ci/should-promote-latest.sh @@ -22,11 +22,13 @@ set -euo pipefail VERSION="${1:?version required}" -# A pre-release VERSION must never grab :latest (callers already short-circuit -# this, but stay safe as a standalone unit). -case "$VERSION" in - *-*) echo "false"; exit 0 ;; -esac +# Only a stable x.y.z release may ever grab :latest. Floating channels such as +# `main` and `next`, plus every pre-release identifier, fail closed here even if +# a caller forgets to short-circuit them first. +if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "false" + exit 0 +fi # Build the stable candidate set: incoming tags (v-stripped, pre-releases # dropped) plus VERSION itself, then pick the numerically highest. diff --git a/tests/unit/build/docker-next-channel-8576.test.ts b/tests/unit/build/docker-next-channel-8576.test.ts new file mode 100644 index 0000000000..4d7a6d6b9b --- /dev/null +++ b/tests/unit/build/docker-next-channel-8576.test.ts @@ -0,0 +1,128 @@ +// tests/unit/build/docker-next-channel-8576.test.ts +// Regression coverage for #8576 — publish a floating :next Docker channel from +// the active release branch without ever moving :latest. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(here, "../../.."); +const RESOLVE_VERSION = path.join( + ROOT, + "scripts/ci/resolve-docker-publish-version.sh", +); +const SHOULD_PROMOTE = path.join(ROOT, "scripts/ci/should-promote-latest.sh"); +const WORKFLOW = readFileSync( + path.join(ROOT, ".github/workflows/docker-publish.yml"), + "utf8", +); + +function resolveVersion( + eventName: string, + refType: string, + refName: string, + inputVersion = "", + defaultBranch = "release/v3.8.50", +): string { + return execFileSync( + "bash", + [ + RESOLVE_VERSION, + eventName, + refType, + refName, + inputVersion, + defaultBranch, + ], + { encoding: "utf8" }, + ).trim(); +} + +function shouldPromote(version: string, tags: string[] = []): string { + return execFileSync("bash", [SHOULD_PROMOTE, version], { + input: tags.join("\n") + (tags.length ? "\n" : ""), + encoding: "utf8", + }).trim(); +} + +test("the current default release branch resolves to next", () => { + assert.equal( + resolveVersion( + "push", + "branch", + "release/v3.8.50", + "", + "release/v3.8.50", + ), + "next", + ); + assert.equal( + resolveVersion( + "push", + "branch", + "release/v4.0.0", + "", + "release/v4.0.0", + ), + "next", + ); +}); + +test("a stale release branch cannot overwrite next", () => { + assert.throws( + () => + resolveVersion( + "push", + "branch", + "release/v3.8.49", + "", + "release/v3.8.50", + ), + /Refusing to publish next from non-default release branch/, + ); +}); + +test("existing main, tag, dispatch, and release behavior is preserved", () => { + assert.equal(resolveVersion("push", "branch", "main"), "main"); + assert.equal(resolveVersion("push", "tag", "v3.8.50"), "3.8.50"); + assert.equal( + resolveVersion("workflow_dispatch", "branch", "main", "v3.8.50"), + "3.8.50", + ); + assert.equal(resolveVersion("release", "tag", "v3.8.50"), "3.8.50"); +}); + +test("unsupported push branches fail closed", () => { + assert.throws( + () => resolveVersion("push", "branch", "feature/not-a-publish-source"), + /Unsupported Docker publish branch/, + ); +}); + +test("next and other non-semver channels can never promote latest", () => { + assert.equal(shouldPromote("next", ["v99.0.0"]), "false"); + assert.equal(shouldPromote("main", []), "false"); + assert.equal(shouldPromote("3.8.51-rc.1", ["v3.8.50"]), "false"); +}); + +test("workflow triggers release branches and keeps next mutable", () => { + assert.match(WORKFLOW, /- ["']?release\/v\*["']?/); + assert.match(WORKFLOW, /DEFAULT_BRANCH:.*repository\.default_branch/); + assert.match( + WORKFLOW, + /\[ "\$VERSION" != "main" \] && \[ "\$VERSION" != "next" \]/, + ); +}); + +test("next images retain the blocking vulnerability gate", () => { + const gate = WORKFLOW.match( + /- name: Trivy CRITICAL gate \(blocking\)[\s\S]*?exit-code: "1"/, + ); + assert.ok(gate, "blocking Trivy gate must remain present"); + assert.match(gate[0], /version != 'main'/); + assert.doesNotMatch(gate[0], /version != 'next'/); +});