mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
feat(docker): publish next from active release branches (#9181)
Validated in local merge-train T4 (HouMinXi+Zartharas+Andrian+artickc)
This commit is contained in:
44
.github/workflows/docker-publish.yml
vendored
44
.github/workflows/docker-publish.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
52
docs/guides/DOCKER_RELEASE_CHANNELS.md
Normal file
52
docs/guides/DOCKER_RELEASE_CHANNELS.md
Normal 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.
|
||||
60
scripts/ci/resolve-docker-publish-version.sh
Normal file
60
scripts/ci/resolve-docker-publish-version.sh
Normal 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"
|
||||
@@ -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.
|
||||
|
||||
128
tests/unit/build/docker-next-channel-8576.test.ts
Normal file
128
tests/unit/build/docker-next-channel-8576.test.ts
Normal 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'/);
|
||||
});
|
||||
Reference in New Issue
Block a user