feat(docker): publish next from active release branches (#9181)

Validated in local merge-train T4 (HouMinXi+Zartharas+Andrian+artickc)
This commit is contained in:
Aman
2026-08-05 20:51:50 -06:00
committed by GitHub
parent 76e127beb1
commit 1b2a72ebc8
6 changed files with 266 additions and 36 deletions

View File

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

View File

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

View File

@@ -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 |
| --- | --- | --- | --- |
| `:<version>` / `:<version>-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:<stable-version>
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.

View File

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

View File

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

View File

@@ -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'/);
});