feat(release): 统一 2.x 镜像标签通道 (#512)
This commit is contained in:
1 parent
d6a66e9877
commit
3d14c18dcf
10 files changed
+832
-181
No files matched your search
Executable
+85
@@ -0,0 +1,85 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
VERSION_PATTERN = re.compile(
|
||||||
|
r"^v?(0|[1-9][0-9]*)\."
|
||||||
|
r"(0|[1-9][0-9]*)\."
|
||||||
|
r"(0|[1-9][0-9]*)"
|
||||||
|
r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_version(raw: str) -> tuple[int, int, int, list[tuple[bool, int | str]]]:
|
||||||
|
match = VERSION_PATTERN.fullmatch(raw)
|
||||||
|
if match is None:
|
||||||
|
raise ValueError(f"invalid semantic version: {raw!r}")
|
||||||
|
|
||||||
|
prerelease: list[tuple[bool, int | str]] = []
|
||||||
|
raw_prerelease = match.group(4)
|
||||||
|
if raw_prerelease is not None:
|
||||||
|
for identifier in raw_prerelease.split("."):
|
||||||
|
if identifier.isascii() and identifier.isdigit():
|
||||||
|
if len(identifier) > 1 and identifier.startswith("0"):
|
||||||
|
raise ValueError(
|
||||||
|
f"numeric prerelease identifier has a leading zero: {raw!r}"
|
||||||
|
)
|
||||||
|
prerelease.append((True, int(identifier)))
|
||||||
|
else:
|
||||||
|
prerelease.append((False, identifier))
|
||||||
|
|
||||||
|
return int(match.group(1)), int(match.group(2)), int(match.group(3)), prerelease
|
||||||
|
|
||||||
|
|
||||||
|
def compare_identifiers(
|
||||||
|
left: tuple[bool, int | str], right: tuple[bool, int | str]
|
||||||
|
) -> int:
|
||||||
|
left_numeric, left_value = left
|
||||||
|
right_numeric, right_value = right
|
||||||
|
if left_numeric != right_numeric:
|
||||||
|
return -1 if left_numeric else 1
|
||||||
|
return (left_value > right_value) - (left_value < right_value)
|
||||||
|
|
||||||
|
|
||||||
|
def compare_versions(left_raw: str, right_raw: str) -> int:
|
||||||
|
left_major, left_minor, left_patch, left_prerelease = parse_version(left_raw)
|
||||||
|
right_major, right_minor, right_patch, right_prerelease = parse_version(right_raw)
|
||||||
|
|
||||||
|
left_core = (left_major, left_minor, left_patch)
|
||||||
|
right_core = (right_major, right_minor, right_patch)
|
||||||
|
if left_core != right_core:
|
||||||
|
return (left_core > right_core) - (left_core < right_core)
|
||||||
|
|
||||||
|
if not left_prerelease and not right_prerelease:
|
||||||
|
return 0
|
||||||
|
if not left_prerelease:
|
||||||
|
return 1
|
||||||
|
if not right_prerelease:
|
||||||
|
return -1
|
||||||
|
|
||||||
|
for left_identifier, right_identifier in zip(left_prerelease, right_prerelease):
|
||||||
|
comparison = compare_identifiers(left_identifier, right_identifier)
|
||||||
|
if comparison != 0:
|
||||||
|
return comparison
|
||||||
|
return (len(left_prerelease) > len(right_prerelease)) - (
|
||||||
|
len(left_prerelease) < len(right_prerelease)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
print("usage: release-compare-semver.py LEFT RIGHT", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
comparison = compare_versions(sys.argv[1], sys.argv[2])
|
||||||
|
except ValueError as error:
|
||||||
|
print(error, file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
print(comparison)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+43
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 读取多架构镜像内的软件版本标签。registry 返回值属于不可信输入,只有两个
|
||||||
|
# 目标架构完全一致且满足严格 SemVer(允许 Git 的 v 前缀)时才输出。
|
||||||
|
|
||||||
|
image="${1:?image is required}"
|
||||||
|
|
||||||
|
if ! inspection="$(
|
||||||
|
docker buildx imagetools inspect "${image}" --format '{{json .}}' 2>&1
|
||||||
|
)"; then
|
||||||
|
printf '%s\n' "${inspection}" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
semver_regex='^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'
|
||||||
|
if ! version="$(
|
||||||
|
jq -er \
|
||||||
|
--arg semver_regex "${semver_regex}" \
|
||||||
|
'[
|
||||||
|
.image["linux/amd64"].config.Labels[
|
||||||
|
"org.opencontainers.image.version"
|
||||||
|
],
|
||||||
|
.image["linux/arm64"].config.Labels[
|
||||||
|
"org.opencontainers.image.version"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
| if
|
||||||
|
length == 2 and
|
||||||
|
all(type == "string") and
|
||||||
|
.[0] == .[1] and
|
||||||
|
(.[0] | test($semver_regex))
|
||||||
|
then .[0]
|
||||||
|
else error("image version labels are missing, invalid, or inconsistent")
|
||||||
|
end' \
|
||||||
|
<<<"${inspection}" 2>/dev/null
|
||||||
|
)"; then
|
||||||
|
printf 'image version labels are missing, invalid, or inconsistent for %s\n' \
|
||||||
|
"${image}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s' "${version}"
|
||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
release_version="${RELEASE_VERSION:?RELEASE_VERSION is required}"
|
||||||
|
image_exact="${IMAGE_EXACT:?IMAGE_EXACT is required}"
|
||||||
|
image_beta="${IMAGE_BETA:-}"
|
||||||
|
image_major="${IMAGE_MAJOR:?IMAGE_MAJOR is required}"
|
||||||
|
promote_beta="${PROMOTE_BETA:?PROMOTE_BETA is required}"
|
||||||
|
promote_major="${PROMOTE_MAJOR:?PROMOTE_MAJOR is required}"
|
||||||
|
expected_revision="${EXPECTED_REVISION:?EXPECTED_REVISION is required}"
|
||||||
|
expected_ghcr_latest="${EXPECTED_GHCR_LATEST:?EXPECTED_GHCR_LATEST is required}"
|
||||||
|
expected_dockerhub_latest="${EXPECTED_DOCKERHUB_LATEST:?EXPECTED_DOCKERHUB_LATEST is required}"
|
||||||
|
output_file="${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}"
|
||||||
|
|
||||||
|
semver_regex='^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'
|
||||||
|
digest_regex='^sha256:[0-9a-f]{64}$'
|
||||||
|
alias_regex='^[0-9]+([.][0-9]+)?(-beta)?$'
|
||||||
|
|
||||||
|
[[ "${release_version}" =~ ${semver_regex} ]]
|
||||||
|
[[ "${image_exact}" == "${release_version#v}" ]]
|
||||||
|
[[ "${image_major}" =~ ^[0-9]+$ ]]
|
||||||
|
[[ -z "${image_beta}" || "${image_beta}" =~ ${alias_regex} ]]
|
||||||
|
[[ "${promote_beta}" == "true" || "${promote_beta}" == "false" ]]
|
||||||
|
[[ "${promote_major}" == "true" || "${promote_major}" == "false" ]]
|
||||||
|
[[ "${expected_revision}" =~ ^[0-9a-f]{40}$ ]]
|
||||||
|
[[ "${expected_ghcr_latest}" == "absent" || "${expected_ghcr_latest}" =~ ${digest_regex} ]]
|
||||||
|
[[ "${expected_dockerhub_latest}" == "absent" || "${expected_dockerhub_latest}" =~ ${digest_regex} ]]
|
||||||
|
if [[ "${promote_beta}" == "true" ]]; then
|
||||||
|
test -n "${image_beta}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
repositories=(ghcr.io/tbphp/gpt-load tbphp/gpt-load)
|
||||||
|
exact_digest=
|
||||||
|
for repository in "${repositories[@]}"; do
|
||||||
|
exact="${repository}:${image_exact}"
|
||||||
|
.github/scripts/release-verify-image-revision.sh \
|
||||||
|
"${exact}" "${expected_revision}" >/dev/null
|
||||||
|
digest="$(.github/scripts/release-image-digest.sh "${exact}")"
|
||||||
|
[[ "${digest}" =~ ${digest_regex} ]]
|
||||||
|
if [[ -z "${exact_digest}" ]]; then
|
||||||
|
exact_digest="${digest}"
|
||||||
|
else
|
||||||
|
test "${digest}" = "${exact_digest}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
channel_is_current=false
|
||||||
|
promote_channel() {
|
||||||
|
local alias="$1"
|
||||||
|
local enabled="$2"
|
||||||
|
local repository=
|
||||||
|
local channel=
|
||||||
|
local current_digest=
|
||||||
|
local current_version=
|
||||||
|
local comparison=
|
||||||
|
local newer_exists=false
|
||||||
|
local expected_digest=
|
||||||
|
local source=
|
||||||
|
local promoted_digest=
|
||||||
|
local promoted_version=
|
||||||
|
|
||||||
|
channel_is_current=false
|
||||||
|
if [[ "${enabled}" != "true" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
for repository in "${repositories[@]}"; do
|
||||||
|
channel="${repository}:${alias}"
|
||||||
|
current_digest="$(.github/scripts/release-image-digest.sh "${channel}")"
|
||||||
|
if [[ "${current_digest}" == "absent" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
[[ "${current_digest}" =~ ${digest_regex} ]]
|
||||||
|
current_version="$(.github/scripts/release-image-version.sh "${channel}")"
|
||||||
|
comparison="$(
|
||||||
|
python3 .github/scripts/release-compare-semver.py \
|
||||||
|
"${current_version}" "${release_version}"
|
||||||
|
)"
|
||||||
|
[[ "${comparison}" == "-1" || "${comparison}" == "0" || "${comparison}" == "1" ]]
|
||||||
|
if [[ "${comparison}" == "1" ]]; then
|
||||||
|
newer_exists=true
|
||||||
|
elif [[ "${comparison}" == "0" ]]; then
|
||||||
|
expected_digest="${exact_digest}"
|
||||||
|
if [[ "${current_digest}" != "${expected_digest}" ]]; then
|
||||||
|
printf 'channel %s already has version %s with unexpected digest\n' \
|
||||||
|
"${channel}" "${release_version}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "${newer_exists}" == "true" ]]; then
|
||||||
|
printf 'skip %s: a registry already points to a newer version than %s\n' \
|
||||||
|
"${alias}" "${release_version}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
for repository in "${repositories[@]}"; do
|
||||||
|
channel="${repository}:${alias}"
|
||||||
|
expected_digest="${exact_digest}"
|
||||||
|
current_digest="$(.github/scripts/release-image-digest.sh "${channel}")"
|
||||||
|
if [[ "${current_digest}" != "${expected_digest}" ]]; then
|
||||||
|
source="${repository}@${expected_digest}"
|
||||||
|
docker buildx imagetools create \
|
||||||
|
--tag "${channel}" \
|
||||||
|
"${source}"
|
||||||
|
fi
|
||||||
|
promoted_digest="$(.github/scripts/release-image-digest.sh "${channel}")"
|
||||||
|
test "${promoted_digest}" = "${expected_digest}"
|
||||||
|
promoted_version="$(.github/scripts/release-image-version.sh "${channel}")"
|
||||||
|
test "${promoted_version}" = "${release_version}"
|
||||||
|
done
|
||||||
|
channel_is_current=true
|
||||||
|
}
|
||||||
|
|
||||||
|
promote_channel "${image_beta}" "${promote_beta}"
|
||||||
|
beta_current="${channel_is_current}"
|
||||||
|
promote_channel "${image_major}" "${promote_major}"
|
||||||
|
major_current="${channel_is_current}"
|
||||||
|
|
||||||
|
test "$(.github/scripts/release-image-digest.sh ghcr.io/tbphp/gpt-load:latest)" = \
|
||||||
|
"${expected_ghcr_latest}"
|
||||||
|
test "$(.github/scripts/release-image-digest.sh tbphp/gpt-load:latest)" = \
|
||||||
|
"${expected_dockerhub_latest}"
|
||||||
|
|
||||||
|
{
|
||||||
|
printf 'beta_current=%s\n' "${beta_current}"
|
||||||
|
printf 'major_current=%s\n' "${major_current}"
|
||||||
|
} >>"${output_file}"
|
||||||
+119
-72
@@ -29,10 +29,11 @@ jobs:
|
|||||||
version: ${{ steps.tag.outputs.version }}
|
version: ${{ steps.tag.outputs.version }}
|
||||||
prerelease: ${{ steps.tag.outputs.prerelease }}
|
prerelease: ${{ steps.tag.outputs.prerelease }}
|
||||||
image_exact: ${{ steps.tag.outputs.image_exact }}
|
image_exact: ${{ steps.tag.outputs.image_exact }}
|
||||||
image_minor: ${{ steps.tag.outputs.image_minor }}
|
image_beta: ${{ steps.tag.outputs.image_beta }}
|
||||||
image_major: ${{ steps.tag.outputs.image_major }}
|
image_major: ${{ steps.tag.outputs.image_major }}
|
||||||
beta: ${{ steps.tag.outputs.beta }}
|
release_kind: ${{ steps.tag.outputs.release_kind }}
|
||||||
channel_beta: ${{ steps.tag.outputs.channel_beta }}
|
channel_beta: ${{ steps.tag.outputs.channel_beta }}
|
||||||
|
promote_major: ${{ steps.tag.outputs.promote_major }}
|
||||||
ci_verified: ${{ steps.ci.outputs.ci_verified }}
|
ci_verified: ${{ steps.ci.outputs.ci_verified }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout tag commit
|
- name: Checkout tag commit
|
||||||
@@ -56,15 +57,14 @@ jobs:
|
|||||||
version="${tag#v}"
|
version="${tag#v}"
|
||||||
core="${version%%-*}"
|
core="${version%%-*}"
|
||||||
prerelease=false
|
prerelease=false
|
||||||
beta=false
|
release_kind=stable
|
||||||
channel_beta=false
|
channel_beta=false
|
||||||
|
promote_major=true
|
||||||
if [[ "${version}" == *-* ]]; then
|
if [[ "${version}" == *-* ]]; then
|
||||||
prerelease=true
|
prerelease=true
|
||||||
prerelease_value="${version#*-}"
|
prerelease_value="${version#*-}"
|
||||||
if [[ "${prerelease_value}" == "beta" ||
|
release_kind=other
|
||||||
"${prerelease_value}" == beta.* ]]; then
|
promote_major=false
|
||||||
beta=true
|
|
||||||
fi
|
|
||||||
IFS='.' read -r -a identifiers <<<"${prerelease_value}"
|
IFS='.' read -r -a identifiers <<<"${prerelease_value}"
|
||||||
for identifier in "${identifiers[@]}"; do
|
for identifier in "${identifiers[@]}"; do
|
||||||
if [[ "${identifier}" =~ ^[0-9]+$ ]] &&
|
if [[ "${identifier}" =~ ^[0-9]+$ ]] &&
|
||||||
@@ -79,17 +79,29 @@ jobs:
|
|||||||
channel_beta_regex='^v2\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-beta\.(0|[1-9][0-9]*)$'
|
channel_beta_regex='^v2\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-beta\.(0|[1-9][0-9]*)$'
|
||||||
if [[ "${tag}" =~ ${channel_beta_regex} ]]; then
|
if [[ "${tag}" =~ ${channel_beta_regex} ]]; then
|
||||||
channel_beta=true
|
channel_beta=true
|
||||||
|
release_kind=beta
|
||||||
|
elif [[ "${tag}" =~ ^v2\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-rc\.(0|[1-9][0-9]*)$ ]]; then
|
||||||
|
release_kind=rc
|
||||||
fi
|
fi
|
||||||
|
|
||||||
IFS='.' read -r major minor _ <<<"${core}"
|
IFS='.' read -r major minor _ <<<"${core}"
|
||||||
|
image_beta=
|
||||||
|
if [[ "${channel_beta}" == "true" ]]; then
|
||||||
|
image_beta="${major}.${minor}-beta"
|
||||||
|
fi
|
||||||
|
if [[ "${core}" == "2.0.0" ]] &&
|
||||||
|
[[ "${release_kind}" == "beta" || "${release_kind}" == "rc" ]]; then
|
||||||
|
promote_major=true
|
||||||
|
fi
|
||||||
{
|
{
|
||||||
printf 'version=%s\n' "${tag}"
|
printf 'version=%s\n' "${tag}"
|
||||||
printf 'prerelease=%s\n' "${prerelease}"
|
printf 'prerelease=%s\n' "${prerelease}"
|
||||||
printf 'image_exact=%s\n' "${tag}"
|
printf 'image_exact=%s\n' "${version}"
|
||||||
printf 'image_minor=%s.%s\n' "${major}" "${minor}"
|
printf 'image_beta=%s\n' "${image_beta}"
|
||||||
printf 'image_major=%s\n' "${major}"
|
printf 'image_major=%s\n' "${major}"
|
||||||
printf 'beta=%s\n' "${beta}"
|
printf 'release_kind=%s\n' "${release_kind}"
|
||||||
printf 'channel_beta=%s\n' "${channel_beta}"
|
printf 'channel_beta=%s\n' "${channel_beta}"
|
||||||
|
printf 'promote_major=%s\n' "${promote_major}"
|
||||||
} >>"${GITHUB_OUTPUT}"
|
} >>"${GITHUB_OUTPUT}"
|
||||||
# release-tag-validation:end
|
# release-tag-validation:end
|
||||||
|
|
||||||
@@ -1038,33 +1050,6 @@ jobs:
|
|||||||
test -n "${ghcr_exact_digest}"
|
test -n "${ghcr_exact_digest}"
|
||||||
test "${ghcr_exact_digest}" = "${dockerhub_exact_digest}"
|
test "${ghcr_exact_digest}" = "${dockerhub_exact_digest}"
|
||||||
|
|
||||||
- name: Update beta channel alias
|
|
||||||
if: ${{ needs.publication-preflight.outputs.write_mode == 'publish' && needs.validate-tag.outputs.channel_beta == 'true' }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
for repository in ghcr.io/tbphp/gpt-load tbphp/gpt-load; do
|
|
||||||
exact="${repository}:${{ needs.validate-tag.outputs.image_exact }}"
|
|
||||||
docker buildx imagetools create \
|
|
||||||
--tag "${repository}:v2beta" \
|
|
||||||
"${exact}"
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Update stable major and minor aliases
|
|
||||||
if: ${{ needs.publication-preflight.outputs.write_mode == 'publish' && needs.validate-tag.outputs.prerelease == 'false' }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
for repository in ghcr.io/tbphp/gpt-load tbphp/gpt-load; do
|
|
||||||
exact="${repository}:${{ needs.validate-tag.outputs.image_exact }}"
|
|
||||||
docker buildx imagetools create \
|
|
||||||
--tag "${repository}:${{ needs.validate-tag.outputs.image_minor }}" \
|
|
||||||
"${exact}"
|
|
||||||
docker buildx imagetools create \
|
|
||||||
--tag "${repository}:${{ needs.validate-tag.outputs.image_major }}" \
|
|
||||||
"${exact}"
|
|
||||||
done
|
|
||||||
|
|
||||||
publish-github:
|
publish-github:
|
||||||
needs:
|
needs:
|
||||||
- validate-tag
|
- validate-tag
|
||||||
@@ -1297,26 +1282,6 @@ jobs:
|
|||||||
test "${latest_digest}" = "${expected_latest_digest}"
|
test "${latest_digest}" = "${expected_latest_digest}"
|
||||||
test "${latest_digest}" != "${exact_digest}"
|
test "${latest_digest}" != "${exact_digest}"
|
||||||
|
|
||||||
if [[ "${{ needs.validate-tag.outputs.channel_beta }}" == "true" ]]; then
|
|
||||||
beta_digest="$(
|
|
||||||
.github/scripts/release-image-digest.sh \
|
|
||||||
"${repository}:v2beta"
|
|
||||||
)"
|
|
||||||
test "${beta_digest}" = "${exact_digest}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${{ needs.validate-tag.outputs.prerelease }}" == "false" ]]; then
|
|
||||||
for alias in \
|
|
||||||
"${{ needs.validate-tag.outputs.image_minor }}" \
|
|
||||||
"${{ needs.validate-tag.outputs.image_major }}"; do
|
|
||||||
alias_digest="$(
|
|
||||||
docker buildx imagetools inspect \
|
|
||||||
"${repository}:${alias}" \
|
|
||||||
--format '{{.Manifest.Digest}}'
|
|
||||||
)"
|
|
||||||
test "${alias_digest}" = "${exact_digest}"
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
done
|
done
|
||||||
test "${ghcr_exact_digest}" = "${dockerhub_exact_digest}"
|
test "${ghcr_exact_digest}" = "${dockerhub_exact_digest}"
|
||||||
|
|
||||||
@@ -1333,19 +1298,80 @@ jobs:
|
|||||||
docker image inspect -f '{{.Config.User}}' "${DOCKERHUB_EXACT_IMAGE}"
|
docker image inspect -f '{{.Config.User}}' "${DOCKERHUB_EXACT_IMAGE}"
|
||||||
)" = "10001:10001"
|
)" = "10001:10001"
|
||||||
|
|
||||||
deploy-render:
|
promote-image-channels:
|
||||||
name: Deploy Render
|
|
||||||
needs:
|
needs:
|
||||||
- validate-tag
|
- validate-tag
|
||||||
|
- publication-preflight
|
||||||
- post-publish-image-smoke
|
- post-publish-image-smoke
|
||||||
- post-publish-verify
|
- post-publish-verify
|
||||||
if: >-
|
if: >-
|
||||||
${{
|
${{
|
||||||
!cancelled() &&
|
!cancelled() &&
|
||||||
needs.validate-tag.result == 'success' &&
|
needs.validate-tag.result == 'success' &&
|
||||||
|
needs.publication-preflight.result == 'success' &&
|
||||||
needs.post-publish-image-smoke.result == 'success' &&
|
needs.post-publish-image-smoke.result == 'success' &&
|
||||||
needs.post-publish-verify.result == 'success' &&
|
needs.post-publish-verify.result == 'success'
|
||||||
needs.validate-tag.outputs.channel_beta == 'true'
|
}}
|
||||||
|
concurrency:
|
||||||
|
group: gpt-load-v2-image-channels
|
||||||
|
cancel-in-progress: false
|
||||||
|
queue: max
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
timeout-minutes: 20
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
outputs:
|
||||||
|
beta_current: ${{ steps.promote.outputs.beta_current }}
|
||||||
|
major_current: ${{ steps.promote.outputs.major_current }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout tag commit
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||||
|
|
||||||
|
- name: Log in to GHCR for channel promotion
|
||||||
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub for channel promotion
|
||||||
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Promote verified image channels
|
||||||
|
id: promote
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
RELEASE_VERSION: ${{ needs.validate-tag.outputs.version }}
|
||||||
|
IMAGE_EXACT: ${{ needs.validate-tag.outputs.image_exact }}
|
||||||
|
IMAGE_BETA: ${{ needs.validate-tag.outputs.image_beta }}
|
||||||
|
IMAGE_MAJOR: ${{ needs.validate-tag.outputs.image_major }}
|
||||||
|
PROMOTE_BETA: ${{ needs.validate-tag.outputs.channel_beta }}
|
||||||
|
PROMOTE_MAJOR: ${{ needs.validate-tag.outputs.promote_major }}
|
||||||
|
EXPECTED_REVISION: ${{ github.sha }}
|
||||||
|
EXPECTED_GHCR_LATEST: ${{ needs.publication-preflight.outputs.ghcr_latest_digest }}
|
||||||
|
EXPECTED_DOCKERHUB_LATEST: ${{ needs.publication-preflight.outputs.dockerhub_latest_digest }}
|
||||||
|
run: .github/scripts/release-promote-image-channels.sh
|
||||||
|
|
||||||
|
deploy-render:
|
||||||
|
name: Deploy Render
|
||||||
|
needs:
|
||||||
|
- validate-tag
|
||||||
|
- promote-image-channels
|
||||||
|
if: >-
|
||||||
|
${{
|
||||||
|
!cancelled() &&
|
||||||
|
needs.validate-tag.result == 'success' &&
|
||||||
|
needs.promote-image-channels.result == 'success' &&
|
||||||
|
needs.promote-image-channels.outputs.major_current == 'true'
|
||||||
}}
|
}}
|
||||||
concurrency:
|
concurrency:
|
||||||
group: gpt-load-render
|
group: gpt-load-render
|
||||||
@@ -1360,7 +1386,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
RENDER_SERVICE_ID: ${{ vars.RENDER_SERVICE_ID }}
|
RENDER_SERVICE_ID: ${{ vars.RENDER_SERVICE_ID }}
|
||||||
RENDER_SERVICE_URL: ${{ vars.RENDER_SERVICE_URL }}
|
RENDER_SERVICE_URL: ${{ vars.RENDER_SERVICE_URL }}
|
||||||
RELEASE_VERSION: ${{ needs.validate-tag.outputs.image_exact }}
|
RELEASE_VERSION: ${{ needs.validate-tag.outputs.version }}
|
||||||
IMAGE: ghcr.io/tbphp/gpt-load:${{ needs.validate-tag.outputs.image_exact }}
|
IMAGE: ghcr.io/tbphp/gpt-load:${{ needs.validate-tag.outputs.image_exact }}
|
||||||
RENDER_CLI_VERSION: "2.25.0"
|
RENDER_CLI_VERSION: "2.25.0"
|
||||||
RENDER_CLI_SHA256: "3b3f1f839ef36b81f12d84ac7288f1c96f9f7519b39c53fe6f866612f704e7cd"
|
RENDER_CLI_SHA256: "3b3f1f839ef36b81f12d84ac7288f1c96f9f7519b39c53fe6f866612f704e7cd"
|
||||||
@@ -1371,8 +1397,8 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
[[ "${RENDER_SERVICE_ID}" =~ ^srv-[a-z0-9]+$ ]]
|
[[ "${RENDER_SERVICE_ID}" =~ ^srv-[a-z0-9]+$ ]]
|
||||||
[[ "${RENDER_SERVICE_URL}" =~ ^https://[^/?#]+/?$ ]]
|
[[ "${RENDER_SERVICE_URL}" =~ ^https://[^/?#]+/?$ ]]
|
||||||
[[ "${RELEASE_VERSION}" =~ ^v2\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-beta\.(0|[1-9][0-9]*)$ ]]
|
[[ "${RELEASE_VERSION}" =~ ^v2\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]
|
||||||
[[ "${IMAGE}" == "ghcr.io/tbphp/gpt-load:${RELEASE_VERSION}" ]]
|
[[ "${IMAGE}" == "ghcr.io/tbphp/gpt-load:${RELEASE_VERSION#v}" ]]
|
||||||
|
|
||||||
- name: Install pinned Render CLI
|
- name: Install pinned Render CLI
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -1466,11 +1492,13 @@ jobs:
|
|||||||
reconcile-publication:
|
reconcile-publication:
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
needs:
|
needs:
|
||||||
|
- validate-tag
|
||||||
- publication-preflight
|
- publication-preflight
|
||||||
- publish-images
|
- publish-images
|
||||||
- publish-github
|
- publish-github
|
||||||
- post-publish-image-smoke
|
- post-publish-image-smoke
|
||||||
- post-publish-verify
|
- post-publish-verify
|
||||||
|
- promote-image-channels
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
permissions:
|
permissions:
|
||||||
@@ -1512,6 +1540,10 @@ jobs:
|
|||||||
GITHUB_WRITER_RESULT: ${{ needs.publish-github.result }}
|
GITHUB_WRITER_RESULT: ${{ needs.publish-github.result }}
|
||||||
IMAGE_SMOKE_RESULT: ${{ needs.post-publish-image-smoke.result }}
|
IMAGE_SMOKE_RESULT: ${{ needs.post-publish-image-smoke.result }}
|
||||||
VERIFY_POST_RESULT: ${{ needs.post-publish-verify.result }}
|
VERIFY_POST_RESULT: ${{ needs.post-publish-verify.result }}
|
||||||
|
PROMOTION_RESULT: ${{ needs.promote-image-channels.result }}
|
||||||
|
IMAGE_EXACT: ${{ needs.validate-tag.outputs.image_exact }}
|
||||||
|
IMAGE_BETA: ${{ needs.validate-tag.outputs.image_beta }}
|
||||||
|
IMAGE_MAJOR: ${{ needs.validate-tag.outputs.image_major }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set +e
|
set +e
|
||||||
@@ -1558,17 +1590,27 @@ jobs:
|
|||||||
|
|
||||||
ghcr_inventory="$(
|
ghcr_inventory="$(
|
||||||
read_image_inventory \
|
read_image_inventory \
|
||||||
"ghcr.io/tbphp/gpt-load:${GITHUB_REF_NAME}"
|
"ghcr.io/tbphp/gpt-load:${IMAGE_EXACT}"
|
||||||
)"
|
)"
|
||||||
dockerhub_inventory="$(
|
dockerhub_inventory="$(
|
||||||
read_image_inventory \
|
read_image_inventory \
|
||||||
"tbphp/gpt-load:${GITHUB_REF_NAME}"
|
"tbphp/gpt-load:${IMAGE_EXACT}"
|
||||||
)"
|
)"
|
||||||
ghcr_beta_inventory="$(
|
ghcr_beta_inventory=not-applicable
|
||||||
read_image_inventory ghcr.io/tbphp/gpt-load:v2beta
|
dockerhub_beta_inventory=not-applicable
|
||||||
|
if [[ -n "${IMAGE_BETA}" ]]; then
|
||||||
|
ghcr_beta_inventory="$(
|
||||||
|
read_image_inventory "ghcr.io/tbphp/gpt-load:${IMAGE_BETA}"
|
||||||
|
)"
|
||||||
|
dockerhub_beta_inventory="$(
|
||||||
|
read_image_inventory "tbphp/gpt-load:${IMAGE_BETA}"
|
||||||
|
)"
|
||||||
|
fi
|
||||||
|
ghcr_major_inventory="$(
|
||||||
|
read_image_inventory "ghcr.io/tbphp/gpt-load:${IMAGE_MAJOR}"
|
||||||
)"
|
)"
|
||||||
dockerhub_beta_inventory="$(
|
dockerhub_major_inventory="$(
|
||||||
read_image_inventory tbphp/gpt-load:v2beta
|
read_image_inventory "tbphp/gpt-load:${IMAGE_MAJOR}"
|
||||||
)"
|
)"
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -1578,14 +1620,19 @@ jobs:
|
|||||||
printf -- '- GitHub writer: `%s`\n' "${GITHUB_WRITER_RESULT}"
|
printf -- '- GitHub writer: `%s`\n' "${GITHUB_WRITER_RESULT}"
|
||||||
printf -- '- published image smoke: `%s`\n' "${IMAGE_SMOKE_RESULT}"
|
printf -- '- published image smoke: `%s`\n' "${IMAGE_SMOKE_RESULT}"
|
||||||
printf -- '- draft and image verification: `%s`\n' "${VERIFY_POST_RESULT}"
|
printf -- '- draft and image verification: `%s`\n' "${VERIFY_POST_RESULT}"
|
||||||
|
printf -- '- image channel promotion: `%s`\n' "${PROMOTION_RESULT}"
|
||||||
printf -- '- GitHub exact inventory: `%s`\n' "${github_inventory}"
|
printf -- '- GitHub exact inventory: `%s`\n' "${github_inventory}"
|
||||||
printf -- '- GHCR exact inventory: `%s`\n' "${ghcr_inventory}"
|
printf -- '- GHCR exact inventory: `%s`\n' "${ghcr_inventory}"
|
||||||
printf -- '- Docker Hub exact inventory: `%s`\n' \
|
printf -- '- Docker Hub exact inventory: `%s`\n' \
|
||||||
"${dockerhub_inventory}"
|
"${dockerhub_inventory}"
|
||||||
printf -- '- GHCR v2beta inventory: `%s`\n' \
|
printf -- '- GHCR %s inventory: `%s`\n' "${IMAGE_BETA:-beta-not-applicable}" \
|
||||||
"${ghcr_beta_inventory}"
|
"${ghcr_beta_inventory}"
|
||||||
printf -- '- Docker Hub v2beta inventory: `%s`\n' \
|
printf -- '- Docker Hub %s inventory: `%s`\n' "${IMAGE_BETA:-beta-not-applicable}" \
|
||||||
"${dockerhub_beta_inventory}"
|
"${dockerhub_beta_inventory}"
|
||||||
|
printf -- '- GHCR %s inventory: `%s`\n' "${IMAGE_MAJOR}" \
|
||||||
|
"${ghcr_major_inventory}"
|
||||||
|
printf -- '- Docker Hub %s inventory: `%s`\n' "${IMAGE_MAJOR}" \
|
||||||
|
"${dockerhub_major_inventory}"
|
||||||
printf '\nmanual recovery: inspect the exact inventories and job logs; '
|
printf '\nmanual recovery: inspect the exact inventories and job logs; '
|
||||||
printf 'do not assume cross-system atomic rollback.\n'
|
printf 'do not assume cross-system atomic rollback.\n'
|
||||||
} >>"${GITHUB_STEP_SUMMARY}"
|
} >>"${GITHUB_STEP_SUMMARY}"
|
||||||
@@ -13,7 +13,7 @@ English · [中文](README_CN.md) · [日本語](README_JP.md)
|
|||||||
[Official Website](https://www.gpt-load.com)
|
[Official Website](https://www.gpt-load.com)
|
||||||
|
|
||||||
[](https://github.com/tbphp/gpt-load/releases)
|
[](https://github.com/tbphp/gpt-load/releases)
|
||||||
[](https://github.com/tbphp/gpt-load/pkgs/container/gpt-load)
|
[](https://github.com/tbphp/gpt-load/pkgs/container/gpt-load)
|
||||||
[](go.mod)
|
[](go.mod)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ docker compose pull && docker compose up -d # update to the latest 2.x image
|
|||||||
docker compose stop # stop the service
|
docker compose stop # stop the service
|
||||||
```
|
```
|
||||||
|
|
||||||
The official 2.x Compose file uses `ghcr.io/tbphp/gpt-load:v2beta` and does not rely on the `latest` tag.
|
The official Compose file uses `ghcr.io/tbphp/gpt-load:2`. Before GA, `2` tracks verified 2.0 Beta and RC releases; after GA, it tracks stable 2.x releases only. Exact image tags omit the Git tag's `v` prefix (for example, `2.0.0-beta.25`), while `2.0-beta` remains the 2.0 Beta channel. `latest` remains on 1.x.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Using a native binary</summary>
|
<summary>Using a native binary</summary>
|
||||||
|
|||||||
+2
-2
@@ -13,7 +13,7 @@
|
|||||||
[官方网站](https://www.gpt-load.com)
|
[官方网站](https://www.gpt-load.com)
|
||||||
|
|
||||||
[](https://github.com/tbphp/gpt-load/releases)
|
[](https://github.com/tbphp/gpt-load/releases)
|
||||||
[](https://github.com/tbphp/gpt-load/pkgs/container/gpt-load)
|
[](https://github.com/tbphp/gpt-load/pkgs/container/gpt-load)
|
||||||
[](go.mod)
|
[](go.mod)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ docker compose pull && docker compose up -d # 更新到最新 2.x 镜像
|
|||||||
docker compose stop # 停止服务
|
docker compose stop # 停止服务
|
||||||
```
|
```
|
||||||
|
|
||||||
官方 2.x Compose 使用 `ghcr.io/tbphp/gpt-load:v2beta`,不依赖 `latest` 标签。
|
官方 Compose 使用 `ghcr.io/tbphp/gpt-load:2`。GA 前,`2` 跟随已验证的 2.0 Beta 和 RC;GA 后只跟随稳定的 2.x。镜像精确标签会去掉 Git tag 的 `v` 前缀(例如 `2.0.0-beta.25`),`2.0-beta` 则保留为 2.0 Beta 通道;`latest` 继续留在 1.x。
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>使用原生二进制</summary>
|
<summary>使用原生二进制</summary>
|
||||||
|
|||||||
+2
-2
@@ -13,7 +13,7 @@ API キー、サブスクリプションアカウント、トラフィック制
|
|||||||
[公式サイト](https://www.gpt-load.com)
|
[公式サイト](https://www.gpt-load.com)
|
||||||
|
|
||||||
[](https://github.com/tbphp/gpt-load/releases)
|
[](https://github.com/tbphp/gpt-load/releases)
|
||||||
[](https://github.com/tbphp/gpt-load/pkgs/container/gpt-load)
|
[](https://github.com/tbphp/gpt-load/pkgs/container/gpt-load)
|
||||||
[](go.mod)
|
[](go.mod)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ docker compose pull && docker compose up -d # 最新の 2.x イメージへ更
|
|||||||
docker compose stop # サービスを停止
|
docker compose stop # サービスを停止
|
||||||
```
|
```
|
||||||
|
|
||||||
公式の 2.x Compose は `ghcr.io/tbphp/gpt-load:v2beta` を使用し、`latest` タグには依存しません。
|
公式 Compose は `ghcr.io/tbphp/gpt-load:2` を使用します。GA 前の `2` は検証済みの 2.0 Beta / RC を追跡し、GA 後は安定版 2.x のみを追跡します。イメージの完全なタグからは Git tag の `v` 接頭辞を除き(例:`2.0.0-beta.25`)、`2.0-beta` は 2.0 Beta チャネルとして残します。`latest` は引き続き 1.x を指します。
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>ネイティブバイナリを使う</summary>
|
<summary>ネイティブバイナリを使う</summary>
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
gpt-load:
|
gpt-load:
|
||||||
image: ghcr.io/tbphp/gpt-load:v2beta
|
image: ghcr.io/tbphp/gpt-load:2
|
||||||
# build:
|
# build:
|
||||||
# context: .
|
# context: .
|
||||||
# dockerfile: Dockerfile
|
# dockerfile: Dockerfile
|
||||||
|
|||||||
@@ -513,7 +513,7 @@ func TestComposeProjectsHaveIndependentNamesApplicationPortsAndVolumes(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestComposeResolvesNamedVolumeContainerPathsAndBetaChannelImage(t *testing.T) {
|
func TestComposeResolvesNamedVolumeContainerPathsAndMajorChannelImage(t *testing.T) {
|
||||||
t.Setenv("DATA_DIR", "/host/path/must-not-reach-container")
|
t.Setenv("DATA_DIR", "/host/path/must-not-reach-container")
|
||||||
t.Setenv("DATABASE_DSN", "/host/database/must-not-reach-container.db")
|
t.Setenv("DATABASE_DSN", "/host/database/must-not-reach-container.db")
|
||||||
|
|
||||||
@@ -561,8 +561,8 @@ func TestComposeResolvesNamedVolumeContainerPathsAndBetaChannelImage(t *testing.
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("resolved Compose lacks gpt-load service")
|
t.Fatal("resolved Compose lacks gpt-load service")
|
||||||
}
|
}
|
||||||
if service.Image != "ghcr.io/tbphp/gpt-load:v2beta" {
|
if service.Image != "ghcr.io/tbphp/gpt-load:2" {
|
||||||
t.Fatalf("resolved image = %q, want ghcr.io/tbphp/gpt-load:v2beta", service.Image)
|
t.Fatalf("resolved image = %q, want ghcr.io/tbphp/gpt-load:2", service.Image)
|
||||||
}
|
}
|
||||||
if service.Environment["DATA_DIR"] != "/app/data" {
|
if service.Environment["DATA_DIR"] != "/app/data" {
|
||||||
t.Fatalf("resolved DATA_DIR = %q, want /app/data", service.Environment["DATA_DIR"])
|
t.Fatalf("resolved DATA_DIR = %q, want /app/data", service.Environment["DATA_DIR"])
|
||||||
|
|||||||
+446
-99
@@ -335,38 +335,43 @@ func TestReleaseWorkflowUsesTagOnlyTriggerAndStrictSemverGuard(t *testing.T) {
|
|||||||
tag: "v2.0.0",
|
tag: "v2.0.0",
|
||||||
valid: true,
|
valid: true,
|
||||||
wantOutput: []string{
|
wantOutput: []string{
|
||||||
"version=v2.0.0", "prerelease=false", "image_exact=v2.0.0",
|
"version=v2.0.0", "prerelease=false", "image_exact=2.0.0",
|
||||||
"image_minor=2.0", "image_major=2", "beta=false", "channel_beta=false",
|
"image_beta=", "image_major=2", "release_kind=stable",
|
||||||
|
"channel_beta=false", "promote_major=true",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: "v2.0.0-rc.1",
|
tag: "v2.0.0-rc.1",
|
||||||
valid: true,
|
valid: true,
|
||||||
wantOutput: []string{
|
wantOutput: []string{
|
||||||
"version=v2.0.0-rc.1", "prerelease=true", "image_exact=v2.0.0-rc.1",
|
"version=v2.0.0-rc.1", "prerelease=true", "image_exact=2.0.0-rc.1",
|
||||||
"image_minor=2.0", "image_major=2", "beta=false", "channel_beta=false",
|
"image_beta=", "image_major=2", "release_kind=rc",
|
||||||
|
"channel_beta=false", "promote_major=true",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: "v2.0.0-beta.1",
|
tag: "v2.0.0-beta.1",
|
||||||
valid: true,
|
valid: true,
|
||||||
wantOutput: []string{
|
wantOutput: []string{
|
||||||
"version=v2.0.0-beta.1", "prerelease=true", "image_exact=v2.0.0-beta.1",
|
"version=v2.0.0-beta.1", "prerelease=true", "image_exact=2.0.0-beta.1",
|
||||||
"image_minor=2.0", "image_major=2", "beta=true", "channel_beta=true",
|
"image_beta=2.0-beta", "image_major=2", "release_kind=beta",
|
||||||
|
"channel_beta=true", "promote_major=true",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: "v2.0.0-beta.0",
|
tag: "v2.0.0-beta.0",
|
||||||
valid: true,
|
valid: true,
|
||||||
wantOutput: []string{
|
wantOutput: []string{
|
||||||
"beta=true", "channel_beta=true",
|
"image_exact=2.0.0-beta.0", "image_beta=2.0-beta",
|
||||||
|
"release_kind=beta", "channel_beta=true", "promote_major=true",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: "v2.0.0-beta",
|
tag: "v2.0.0-beta",
|
||||||
valid: true,
|
valid: true,
|
||||||
wantOutput: []string{
|
wantOutput: []string{
|
||||||
"beta=true", "channel_beta=false",
|
"image_exact=2.0.0-beta", "image_beta=", "release_kind=other",
|
||||||
|
"channel_beta=false", "promote_major=false",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -374,15 +379,43 @@ func TestReleaseWorkflowUsesTagOnlyTriggerAndStrictSemverGuard(t *testing.T) {
|
|||||||
valid: true,
|
valid: true,
|
||||||
wantOutput: []string{
|
wantOutput: []string{
|
||||||
"version=v2.0.0-beta.test-build-1", "prerelease=true",
|
"version=v2.0.0-beta.test-build-1", "prerelease=true",
|
||||||
"image_exact=v2.0.0-beta.test-build-1", "image_minor=2.0", "image_major=2",
|
"image_exact=2.0.0-beta.test-build-1", "image_beta=", "image_major=2",
|
||||||
"beta=true", "channel_beta=false",
|
"release_kind=other", "channel_beta=false", "promote_major=false",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: "v2.0.0-beta.1.extra",
|
tag: "v2.0.0-beta.1.extra",
|
||||||
valid: true,
|
valid: true,
|
||||||
wantOutput: []string{
|
wantOutput: []string{
|
||||||
"beta=true", "channel_beta=false",
|
"image_exact=2.0.0-beta.1.extra", "image_beta=", "release_kind=other",
|
||||||
|
"channel_beta=false", "promote_major=false",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "v2.1.0-beta.1",
|
||||||
|
valid: true,
|
||||||
|
wantOutput: []string{
|
||||||
|
"version=v2.1.0-beta.1", "image_exact=2.1.0-beta.1",
|
||||||
|
"image_beta=2.1-beta", "release_kind=beta",
|
||||||
|
"channel_beta=true", "promote_major=false",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "v2.1.0-rc.1",
|
||||||
|
valid: true,
|
||||||
|
wantOutput: []string{
|
||||||
|
"version=v2.1.0-rc.1", "image_exact=2.1.0-rc.1",
|
||||||
|
"image_beta=", "release_kind=rc",
|
||||||
|
"channel_beta=false", "promote_major=false",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: "v2.1.0",
|
||||||
|
valid: true,
|
||||||
|
wantOutput: []string{
|
||||||
|
"version=v2.1.0", "image_exact=2.1.0",
|
||||||
|
"image_beta=", "release_kind=stable",
|
||||||
|
"channel_beta=false", "promote_major=true",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -390,8 +423,8 @@ func TestReleaseWorkflowUsesTagOnlyTriggerAndStrictSemverGuard(t *testing.T) {
|
|||||||
valid: true,
|
valid: true,
|
||||||
wantOutput: []string{
|
wantOutput: []string{
|
||||||
"version=v2.10.3-alpha-1.0", "prerelease=true",
|
"version=v2.10.3-alpha-1.0", "prerelease=true",
|
||||||
"image_exact=v2.10.3-alpha-1.0", "image_minor=2.10", "image_major=2",
|
"image_exact=2.10.3-alpha-1.0", "image_beta=", "image_major=2",
|
||||||
"beta=false", "channel_beta=false",
|
"release_kind=other", "channel_beta=false", "promote_major=false",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{tag: "v2.0.0.1", valid: false},
|
{tag: "v2.0.0.1", valid: false},
|
||||||
@@ -1032,6 +1065,69 @@ func TestReleaseWorkflowKeepsUntrustedImageRevisionInsideJQComparison(t *testing
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReleaseImageVersionAcceptsOnlyMatchingStrictSemverLabels(t *testing.T) {
|
||||||
|
script := filepath.Join("..", "..", ".github", "scripts", "release-image-version.sh")
|
||||||
|
fakeBin := t.TempDir()
|
||||||
|
fakeDocker := filepath.Join(fakeBin, "docker")
|
||||||
|
if err := os.WriteFile(
|
||||||
|
fakeDocker,
|
||||||
|
[]byte("#!/usr/bin/env sh\nprintf '%s' \"${RELEASE_TEST_INSPECTION}\"\n"),
|
||||||
|
0o700,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("write fake docker: %v", err)
|
||||||
|
}
|
||||||
|
inspection := func(amd64, arm64 any) string {
|
||||||
|
value := map[string]any{
|
||||||
|
"image": map[string]any{
|
||||||
|
"linux/amd64": map[string]any{
|
||||||
|
"config": map[string]any{"Labels": map[string]any{
|
||||||
|
"org.opencontainers.image.version": amd64,
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
"linux/arm64": map[string]any{
|
||||||
|
"config": map[string]any{"Labels": map[string]any{
|
||||||
|
"org.opencontainers.image.version": arm64,
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal image inspection: %v", err)
|
||||||
|
}
|
||||||
|
return string(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
inspection string
|
||||||
|
want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "prefixed", inspection: inspection("v2.0.0-beta.25", "v2.0.0-beta.25"), want: "v2.0.0-beta.25"},
|
||||||
|
{name: "unprefixed", inspection: inspection("2.1.0", "2.1.0"), want: "2.1.0"},
|
||||||
|
{name: "different architectures", inspection: inspection("v2.0.0", "v2.0.1"), wantErr: true},
|
||||||
|
{name: "invalid semver", inspection: inspection("v2.01.0", "v2.01.0"), wantErr: true},
|
||||||
|
{name: "shell payload", inspection: inspection("$(touch pwned)", "$(touch pwned)"), wantErr: true},
|
||||||
|
{name: "missing", inspection: `{}`, wantErr: true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
command := exec.Command("bash", script, "example.test/gpt-load:2")
|
||||||
|
command.Env = []string{
|
||||||
|
"PATH=" + fakeBin + string(os.PathListSeparator) + os.Getenv("PATH"),
|
||||||
|
"RELEASE_TEST_INSPECTION=" + test.inspection,
|
||||||
|
}
|
||||||
|
output, err := command.CombinedOutput()
|
||||||
|
if (err != nil) != test.wantErr {
|
||||||
|
t.Fatalf("version inspection error = %v, want error %t\n%s", err, test.wantErr, output)
|
||||||
|
}
|
||||||
|
if !test.wantErr && strings.TrimSpace(string(output)) != test.want {
|
||||||
|
t.Fatalf("version = %q, want %q", output, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReleaseWorkflowPublishesImagesBeforeGitHubRelease(t *testing.T) {
|
func TestReleaseWorkflowPublishesImagesBeforeGitHubRelease(t *testing.T) {
|
||||||
content := readRepositoryFile(t, ".github/workflows/release.yml")
|
content := readRepositoryFile(t, ".github/workflows/release.yml")
|
||||||
imageJob := workflowJobBlock(t, content, "publish-images")
|
imageJob := workflowJobBlock(t, content, "publish-images")
|
||||||
@@ -1046,19 +1142,79 @@ func TestReleaseWorkflowPublishesImagesBeforeGitHubRelease(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReleaseWorkflowUpdatesAliasesOnlyAfterExactVerification(t *testing.T) {
|
func TestReleaseWorkflowPromotesVerifiedImageChannelsMonotonically(t *testing.T) {
|
||||||
content := readRepositoryFile(t, ".github/workflows/release.yml")
|
content := readRepositoryFile(t, ".github/workflows/release.yml")
|
||||||
imageJob := workflowJobBlock(t, content, "publish-images")
|
imageJob := workflowJobBlock(t, content, "publish-images")
|
||||||
exactVerification := strings.Index(imageJob, "name: Verify exact published images")
|
for _, forbidden := range []string{
|
||||||
aliasUpdate := strings.Index(imageJob, "name: Update stable major and minor aliases")
|
"Update beta channel alias",
|
||||||
if exactVerification < 0 || aliasUpdate < 0 || exactVerification >= aliasUpdate {
|
"Update stable major and minor aliases",
|
||||||
t.Fatalf(
|
"docker buildx imagetools create",
|
||||||
"alias update is not ordered after exact verification: exact=%d alias=%d\n%s",
|
} {
|
||||||
exactVerification,
|
if strings.Contains(imageJob, forbidden) {
|
||||||
aliasUpdate,
|
t.Fatalf("exact image publication still mutates shared channels via %q:\n%s", forbidden, imageJob)
|
||||||
imageJob,
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
promotion := workflowJobBlock(t, content, "promote-image-channels")
|
||||||
|
promotionScript := readRepositoryFile(
|
||||||
|
t, ".github/scripts/release-promote-image-channels.sh",
|
||||||
|
)
|
||||||
|
promotionContract := promotion + "\n" + promotionScript
|
||||||
|
for _, required := range []string{
|
||||||
|
"- validate-tag",
|
||||||
|
"- publication-preflight",
|
||||||
|
"- post-publish-image-smoke",
|
||||||
|
"- post-publish-verify",
|
||||||
|
"group: gpt-load-v2-image-channels",
|
||||||
|
"cancel-in-progress: false",
|
||||||
|
"queue: max",
|
||||||
|
"packages: write",
|
||||||
|
"major_current: ${{ steps.promote.outputs.major_current }}",
|
||||||
|
".github/scripts/release-image-version.sh",
|
||||||
|
".github/scripts/release-compare-semver.py",
|
||||||
|
"needs.validate-tag.outputs.image_beta",
|
||||||
|
"needs.validate-tag.outputs.image_major",
|
||||||
|
"needs.validate-tag.outputs.channel_beta",
|
||||||
|
"needs.validate-tag.outputs.promote_major",
|
||||||
|
".github/scripts/release-promote-image-channels.sh",
|
||||||
|
`source="${repository}@${expected_digest}"`,
|
||||||
|
"docker buildx imagetools create",
|
||||||
|
`test "${promoted_digest}" = "${expected_digest}"`,
|
||||||
|
"ghcr.io/tbphp/gpt-load:latest",
|
||||||
|
"tbphp/gpt-load:latest",
|
||||||
|
"ghcr_latest_digest",
|
||||||
|
"dockerhub_latest_digest",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(promotionContract, required) {
|
||||||
|
t.Fatalf("image channel promotion does not contain %q:\n%s", required, promotionContract)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count := strings.Count(promotion, "uses: "+dockerLoginActionRef); count != 2 {
|
||||||
|
t.Fatalf("image channel promotion uses the pinned Docker login action %d times, want 2:\n%s", count, promotion)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, forbidden := range []string{"v2beta", "image_minor", `:${GITHUB_REF_NAME}`} {
|
||||||
|
if strings.Contains(promotionContract, forbidden) {
|
||||||
|
t.Fatalf("image channel promotion contains retired contract %q:\n%s", forbidden, promotionContract)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render := workflowJobBlock(t, content, "deploy-render")
|
||||||
|
for _, required := range []string{
|
||||||
|
"- promote-image-channels",
|
||||||
|
"needs.promote-image-channels.outputs.major_current == 'true'",
|
||||||
|
"RELEASE_VERSION: ${{ needs.validate-tag.outputs.version }}",
|
||||||
|
"IMAGE: ghcr.io/tbphp/gpt-load:${{ needs.validate-tag.outputs.image_exact }}",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(render, required) {
|
||||||
|
t.Fatalf("Render deployment does not follow the verified major channel via %q:\n%s", required, render)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseWorkflowKeepsExactImagePublicationImmutable(t *testing.T) {
|
||||||
|
content := readRepositoryFile(t, ".github/workflows/release.yml")
|
||||||
|
imageJob := workflowJobBlock(t, content, "publish-images")
|
||||||
exactStep := workflowStepBlock(t, imageJob, "Verify exact published images")
|
exactStep := workflowStepBlock(t, imageJob, "Verify exact published images")
|
||||||
for _, required := range []string{
|
for _, required := range []string{
|
||||||
".github/scripts/release-verify-image-revision.sh",
|
".github/scripts/release-verify-image-revision.sh",
|
||||||
@@ -1076,83 +1232,41 @@ func TestReleaseWorkflowUpdatesAliasesOnlyAfterExactVerification(t *testing.T) {
|
|||||||
t.Fatalf("exact image metadata does not contain the exact tag:\n%s", metadataStep)
|
t.Fatalf("exact image metadata does not contain the exact tag:\n%s", metadataStep)
|
||||||
}
|
}
|
||||||
for _, forbidden := range []string{
|
for _, forbidden := range []string{
|
||||||
"needs.validate-tag.outputs.image_minor",
|
"needs.validate-tag.outputs.image_beta",
|
||||||
"needs.validate-tag.outputs.image_major",
|
"needs.validate-tag.outputs.image_major",
|
||||||
|
"imagetools create",
|
||||||
"latest",
|
"latest",
|
||||||
} {
|
} {
|
||||||
if strings.Contains(metadataStep, forbidden) {
|
if strings.Contains(metadataStep, forbidden) {
|
||||||
t.Fatalf("exact image metadata contains non-exact tag %q:\n%s", forbidden, metadataStep)
|
t.Fatalf("exact image metadata contains non-exact tag %q:\n%s", forbidden, metadataStep)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
aliasStep := workflowStepBlock(t, imageJob, "Update stable major and minor aliases")
|
|
||||||
for _, required := range []string{
|
|
||||||
"needs.publication-preflight.outputs.write_mode == 'publish'",
|
|
||||||
"needs.validate-tag.outputs.prerelease == 'false'",
|
|
||||||
"docker buildx imagetools create",
|
|
||||||
"image_minor",
|
|
||||||
"image_major",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(aliasStep, required) {
|
|
||||||
t.Fatalf("alias update does not contain %q:\n%s", required, aliasStep)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(strings.ToLower(aliasStep), "latest") {
|
|
||||||
t.Fatalf("alias update writes latest:\n%s", aliasStep)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReleaseWorkflowMaintainsBetaChannelAlias(t *testing.T) {
|
func TestReleaseWorkflowMaintainsVersionedBetaChannelAlias(t *testing.T) {
|
||||||
content := readRepositoryFile(t, ".github/workflows/release.yml")
|
content := readRepositoryFile(t, ".github/workflows/release.yml")
|
||||||
validateJob := workflowJobBlock(t, content, "validate-tag")
|
validateJob := workflowJobBlock(t, content, "validate-tag")
|
||||||
if !strings.Contains(validateJob, "channel_beta: ${{ steps.tag.outputs.channel_beta }}") {
|
|
||||||
t.Fatalf("tag validation does not expose the beta channel decision:\n%s", validateJob)
|
|
||||||
}
|
|
||||||
|
|
||||||
imageJob := workflowJobBlock(t, content, "publish-images")
|
|
||||||
exactVerification := strings.Index(imageJob, "name: Verify exact published images")
|
|
||||||
betaUpdate := strings.Index(imageJob, "name: Update beta channel alias")
|
|
||||||
if exactVerification < 0 || betaUpdate < 0 || exactVerification >= betaUpdate {
|
|
||||||
t.Fatalf(
|
|
||||||
"beta alias update is not ordered after exact verification: exact=%d beta=%d\n%s",
|
|
||||||
exactVerification,
|
|
||||||
betaUpdate,
|
|
||||||
imageJob,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
betaStep := workflowStepBlock(t, imageJob, "Update beta channel alias")
|
|
||||||
for _, required := range []string{
|
for _, required := range []string{
|
||||||
"needs.publication-preflight.outputs.write_mode == 'publish'",
|
"image_beta: ${{ steps.tag.outputs.image_beta }}",
|
||||||
"needs.validate-tag.outputs.channel_beta == 'true'",
|
"channel_beta: ${{ steps.tag.outputs.channel_beta }}",
|
||||||
"docker buildx imagetools create",
|
"promote_major: ${{ steps.tag.outputs.promote_major }}",
|
||||||
`exact="${repository}:${{ needs.validate-tag.outputs.image_exact }}"`,
|
|
||||||
`--tag "${repository}:v2beta"`,
|
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(betaStep, required) {
|
if !strings.Contains(validateJob, required) {
|
||||||
t.Fatalf("beta alias update does not contain %q:\n%s", required, betaStep)
|
t.Fatalf("tag validation does not expose %q:\n%s", required, validateJob)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.Contains(betaStep, "needs.validate-tag.outputs.beta") {
|
promotion := readRepositoryFile(
|
||||||
t.Fatalf("beta alias update still uses the broad beta classification:\n%s", betaStep)
|
t, ".github/scripts/release-promote-image-channels.sh",
|
||||||
}
|
|
||||||
|
|
||||||
verification := workflowStepBlock(
|
|
||||||
t,
|
|
||||||
workflowJobBlock(t, content, "post-publish-verify"),
|
|
||||||
"Verify published image manifests",
|
|
||||||
)
|
)
|
||||||
for _, required := range []string{
|
for _, required := range []string{
|
||||||
`needs.validate-tag.outputs.channel_beta`,
|
`promote_channel "${image_beta}" "${promote_beta}"`,
|
||||||
`"${repository}:v2beta"`,
|
`promote_channel "${image_major}" "${promote_major}"`,
|
||||||
"beta_digest",
|
"release-compare-semver.py",
|
||||||
`test "${beta_digest}" = "${exact_digest}"`,
|
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(verification, required) {
|
if !strings.Contains(promotion, required) {
|
||||||
t.Fatalf("beta alias verification does not contain %q:\n%s", required, verification)
|
t.Fatalf("channel promotion does not contain %q:\n%s", required, promotion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.Contains(verification, "needs.validate-tag.outputs.beta") {
|
|
||||||
t.Fatalf("beta alias verification still uses the broad beta classification:\n%s", verification)
|
|
||||||
}
|
|
||||||
|
|
||||||
reconciliation := workflowStepBlock(
|
reconciliation := workflowStepBlock(
|
||||||
t,
|
t,
|
||||||
@@ -1160,10 +1274,10 @@ func TestReleaseWorkflowMaintainsBetaChannelAlias(t *testing.T) {
|
|||||||
"Summarize exact publication inventory and job results",
|
"Summarize exact publication inventory and job results",
|
||||||
)
|
)
|
||||||
for _, required := range []string{
|
for _, required := range []string{
|
||||||
"ghcr.io/tbphp/gpt-load:v2beta",
|
"IMAGE_BETA: ${{ needs.validate-tag.outputs.image_beta }}",
|
||||||
"tbphp/gpt-load:v2beta",
|
"IMAGE_MAJOR: ${{ needs.validate-tag.outputs.image_major }}",
|
||||||
"GHCR v2beta inventory",
|
`"ghcr.io/tbphp/gpt-load:${IMAGE_BETA}"`,
|
||||||
"Docker Hub v2beta inventory",
|
`"tbphp/gpt-load:${IMAGE_MAJOR}"`,
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(reconciliation, required) {
|
if !strings.Contains(reconciliation, required) {
|
||||||
t.Fatalf("publication reconciliation does not contain %q:\n%s", required, reconciliation)
|
t.Fatalf("publication reconciliation does not contain %q:\n%s", required, reconciliation)
|
||||||
@@ -1171,24 +1285,21 @@ func TestReleaseWorkflowMaintainsBetaChannelAlias(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReleaseWorkflowDeploysFormalBetaToRenderAfterPublishedImageGates(t *testing.T) {
|
func TestReleaseWorkflowDeploysCurrentMajorChannelToRender(t *testing.T) {
|
||||||
content := readRepositoryFile(t, ".github/workflows/release.yml")
|
content := readRepositoryFile(t, ".github/workflows/release.yml")
|
||||||
job := workflowJobBlock(t, content, "deploy-render")
|
job := workflowJobBlock(t, content, "deploy-render")
|
||||||
|
|
||||||
for _, required := range []string{
|
for _, required := range []string{
|
||||||
"- validate-tag",
|
"- validate-tag",
|
||||||
"- post-publish-image-smoke",
|
"- promote-image-channels",
|
||||||
"- post-publish-verify",
|
"needs.promote-image-channels.outputs.major_current == 'true'",
|
||||||
"needs.validate-tag.outputs.channel_beta == 'true'",
|
|
||||||
"needs.post-publish-image-smoke.result == 'success'",
|
|
||||||
"needs.post-publish-verify.result == 'success'",
|
|
||||||
"group: gpt-load-render",
|
"group: gpt-load-render",
|
||||||
"cancel-in-progress: false",
|
"cancel-in-progress: false",
|
||||||
"name: render",
|
"name: render",
|
||||||
"url: ${{ vars.RENDER_SERVICE_URL }}",
|
"url: ${{ vars.RENDER_SERVICE_URL }}",
|
||||||
"RENDER_SERVICE_ID: ${{ vars.RENDER_SERVICE_ID }}",
|
"RENDER_SERVICE_ID: ${{ vars.RENDER_SERVICE_ID }}",
|
||||||
"RENDER_SERVICE_URL: ${{ vars.RENDER_SERVICE_URL }}",
|
"RENDER_SERVICE_URL: ${{ vars.RENDER_SERVICE_URL }}",
|
||||||
"RELEASE_VERSION: ${{ needs.validate-tag.outputs.image_exact }}",
|
"RELEASE_VERSION: ${{ needs.validate-tag.outputs.version }}",
|
||||||
"IMAGE: ghcr.io/tbphp/gpt-load:${{ needs.validate-tag.outputs.image_exact }}",
|
"IMAGE: ghcr.io/tbphp/gpt-load:${{ needs.validate-tag.outputs.image_exact }}",
|
||||||
`RENDER_CLI_VERSION: "2.25.0"`,
|
`RENDER_CLI_VERSION: "2.25.0"`,
|
||||||
`RENDER_CLI_SHA256: "3b3f1f839ef36b81f12d84ac7288f1c96f9f7519b39c53fe6f866612f704e7cd"`,
|
`RENDER_CLI_SHA256: "3b3f1f839ef36b81f12d84ac7288f1c96f9f7519b39c53fe6f866612f704e7cd"`,
|
||||||
@@ -1197,7 +1308,7 @@ func TestReleaseWorkflowDeploysFormalBetaToRenderAfterPublishedImageGates(t *tes
|
|||||||
t.Fatalf("Render deployment job does not contain %q:\n%s", required, job)
|
t.Fatalf("Render deployment job does not contain %q:\n%s", required, job)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, forbidden := range []string{"v2beta", "RENDER_DEPLOY_HOOK_URL"} {
|
for _, forbidden := range []string{"v2beta", "image_minor", "RENDER_DEPLOY_HOOK_URL"} {
|
||||||
if strings.Contains(job, forbidden) {
|
if strings.Contains(job, forbidden) {
|
||||||
t.Fatalf("Render deployment job contains forbidden %q:\n%s", forbidden, job)
|
t.Fatalf("Render deployment job contains forbidden %q:\n%s", forbidden, job)
|
||||||
}
|
}
|
||||||
@@ -1451,9 +1562,8 @@ func TestReleaseWorkflowPreparesDraftsWithoutLatest(t *testing.T) {
|
|||||||
dockerBuildActionRef,
|
dockerBuildActionRef,
|
||||||
"linux/amd64,linux/arm64",
|
"linux/amd64,linux/arm64",
|
||||||
`value=${{ needs.validate-tag.outputs.image_exact }}`,
|
`value=${{ needs.validate-tag.outputs.image_exact }}`,
|
||||||
"docker buildx imagetools create",
|
`org.opencontainers.image.version=${{ github.ref_name }}`,
|
||||||
`needs.validate-tag.outputs.image_minor`,
|
`VERSION=${{ github.ref_name }}`,
|
||||||
`needs.validate-tag.outputs.image_major`,
|
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(imageJob, required) {
|
if !strings.Contains(imageJob, required) {
|
||||||
t.Fatalf("image publication job does not contain %q:\n%s", required, imageJob)
|
t.Fatalf("image publication job does not contain %q:\n%s", required, imageJob)
|
||||||
@@ -1462,6 +1572,11 @@ func TestReleaseWorkflowPreparesDraftsWithoutLatest(t *testing.T) {
|
|||||||
if strings.Contains(strings.ToLower(imageJob), "latest") {
|
if strings.Contains(strings.ToLower(imageJob), "latest") {
|
||||||
t.Fatalf("image publication job contains latest:\n%s", imageJob)
|
t.Fatalf("image publication job contains latest:\n%s", imageJob)
|
||||||
}
|
}
|
||||||
|
for _, forbidden := range []string{"imagetools create", "image_minor", "image_major"} {
|
||||||
|
if strings.Contains(imageJob, forbidden) {
|
||||||
|
t.Fatalf("exact image publication contains shared channel %q:\n%s", forbidden, imageJob)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
githubJob := workflowJobBlock(t, content, "publish-github")
|
githubJob := workflowJobBlock(t, content, "publish-github")
|
||||||
draftStep := workflowStepBlock(t, githubJob, "Create or update GitHub Release draft")
|
draftStep := workflowStepBlock(t, githubJob, "Create or update GitHub Release draft")
|
||||||
@@ -1890,17 +2005,14 @@ func TestReleaseDockerSmokeDefersOwnedResourceCleanupUntilAfterConflictChecks(t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReleaseWorkflowPostPublishVerifiesAliasesAndExactDigests(t *testing.T) {
|
func TestReleaseWorkflowPostPublishVerifiesExactDigestsBeforePromotion(t *testing.T) {
|
||||||
content := readRepositoryFile(t, ".github/workflows/release.yml")
|
content := readRepositoryFile(t, ".github/workflows/release.yml")
|
||||||
job := workflowJobBlock(t, content, "post-publish-verify")
|
job := workflowJobBlock(t, content, "post-publish-verify")
|
||||||
for _, required := range []string{
|
for _, required := range []string{
|
||||||
"packages: read",
|
"packages: read",
|
||||||
dockerLoginActionRef,
|
dockerLoginActionRef,
|
||||||
"registry: ghcr.io",
|
"registry: ghcr.io",
|
||||||
"image_minor",
|
|
||||||
"image_major",
|
|
||||||
"exact_digest",
|
"exact_digest",
|
||||||
"alias_digest",
|
|
||||||
"ghcr.io/tbphp/gpt-load",
|
"ghcr.io/tbphp/gpt-load",
|
||||||
"tbphp/gpt-load",
|
"tbphp/gpt-load",
|
||||||
} {
|
} {
|
||||||
@@ -1908,6 +2020,11 @@ func TestReleaseWorkflowPostPublishVerifiesAliasesAndExactDigests(t *testing.T)
|
|||||||
t.Fatalf("post-publish verification does not contain %q:\n%s", required, job)
|
t.Fatalf("post-publish verification does not contain %q:\n%s", required, job)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, forbidden := range []string{"image_minor", "image_major", "alias_digest", "v2beta"} {
|
||||||
|
if strings.Contains(job, forbidden) {
|
||||||
|
t.Fatalf("exact post-publication verification contains channel %q:\n%s", forbidden, job)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReleaseWorkflowPostPublishVerifiesDraftAssetsAgainstCurrentRun(t *testing.T) {
|
func TestReleaseWorkflowPostPublishVerifiesDraftAssetsAgainstCurrentRun(t *testing.T) {
|
||||||
@@ -2085,6 +2202,236 @@ func TestReleaseImageDigestFailsClosedOnOperationalInspectionErrors(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReleaseSemverComparatorOrdersChannelCandidates(t *testing.T) {
|
||||||
|
script := filepath.Join("..", "..", ".github", "scripts", "release-compare-semver.py")
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
left string
|
||||||
|
right string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "beta sequence", left: "v2.0.0-beta.24", right: "v2.0.0-beta.25", want: "-1"},
|
||||||
|
{name: "beta before rc", left: "v2.0.0-beta.25", right: "v2.0.0-rc.1", want: "-1"},
|
||||||
|
{name: "rc before stable", left: "v2.0.0-rc.2", right: "v2.0.0", want: "-1"},
|
||||||
|
{name: "stable after rc", left: "v2.0.0", right: "v2.0.0-rc.99", want: "1"},
|
||||||
|
{name: "next minor", left: "v2.0.9", right: "2.1.0", want: "-1"},
|
||||||
|
{name: "equal with optional prefix", left: "v2.1.0", right: "2.1.0", want: "0"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
command := exec.Command("python3", script, test.left, test.right)
|
||||||
|
output, err := command.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("compare %s and %s: %v\n%s", test.left, test.right, err, output)
|
||||||
|
}
|
||||||
|
if got := strings.TrimSpace(string(output)); got != test.want {
|
||||||
|
t.Fatalf("compare %s and %s = %q, want %q", test.left, test.right, got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, invalid := range []string{"2.01.0", "v2.0", "2.0.0-rc.01", "$(touch pwned)"} {
|
||||||
|
t.Run("invalid/"+invalid, func(t *testing.T) {
|
||||||
|
command := exec.Command("python3", script, invalid, "2.0.0")
|
||||||
|
if output, err := command.CombinedOutput(); err == nil {
|
||||||
|
t.Fatalf("invalid version %q accepted: %s", invalid, output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseImageChannelPromotionIsMonotonicAndPreservesLatest(t *testing.T) {
|
||||||
|
repositoryRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve repository root: %v", err)
|
||||||
|
}
|
||||||
|
statePath := filepath.Join(t.TempDir(), "registry.json")
|
||||||
|
fakeBin := t.TempDir()
|
||||||
|
fakeDocker := filepath.Join(fakeBin, "docker")
|
||||||
|
fakeDockerBody := `#!/usr/bin/env python3
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
state_path = os.environ["FAKE_REGISTRY_STATE"]
|
||||||
|
with open(state_path, encoding="utf-8") as source:
|
||||||
|
state = json.load(source)
|
||||||
|
|
||||||
|
args = sys.argv[1:]
|
||||||
|
if args[:3] == ["buildx", "imagetools", "inspect"]:
|
||||||
|
image = args[3]
|
||||||
|
record = state.get(image)
|
||||||
|
if record is None:
|
||||||
|
print(f"ERROR: {image}: not found", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
output_format = args[args.index("--format") + 1]
|
||||||
|
if output_format == "{{.Manifest.Digest}}":
|
||||||
|
print(record["digest"], end="")
|
||||||
|
else:
|
||||||
|
labels = {
|
||||||
|
"org.opencontainers.image.revision": record["revision"],
|
||||||
|
"org.opencontainers.image.version": record["version"],
|
||||||
|
}
|
||||||
|
print(json.dumps({"image": {
|
||||||
|
"linux/amd64": {"config": {"Labels": labels}},
|
||||||
|
"linux/arm64": {"config": {"Labels": labels}},
|
||||||
|
}}), end="")
|
||||||
|
elif args[:3] == ["buildx", "imagetools", "create"]:
|
||||||
|
target = args[args.index("--tag") + 1]
|
||||||
|
source = args[-1]
|
||||||
|
repository, digest = source.rsplit("@", 1)
|
||||||
|
matches = [
|
||||||
|
record for image, record in state.items()
|
||||||
|
if image.startswith(repository + ":") and record["digest"] == digest
|
||||||
|
]
|
||||||
|
if not matches:
|
||||||
|
print(f"source not found: {source}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
state[target] = dict(matches[0])
|
||||||
|
with open(state_path, "w", encoding="utf-8") as destination:
|
||||||
|
json.dump(state, destination, sort_keys=True)
|
||||||
|
else:
|
||||||
|
print(f"unsupported docker invocation: {args}", file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(fakeDocker, []byte(fakeDockerBody), 0o700); err != nil {
|
||||||
|
t.Fatalf("write fake docker: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type imageRecord struct {
|
||||||
|
Digest string `json:"digest"`
|
||||||
|
Revision string `json:"revision"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
writeState := func(state map[string]imageRecord) {
|
||||||
|
t.Helper()
|
||||||
|
encoded, marshalErr := json.Marshal(state)
|
||||||
|
if marshalErr != nil {
|
||||||
|
t.Fatalf("marshal registry state: %v", marshalErr)
|
||||||
|
}
|
||||||
|
if writeErr := os.WriteFile(statePath, encoded, 0o600); writeErr != nil {
|
||||||
|
t.Fatalf("write registry state: %v", writeErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
readState := func() map[string]imageRecord {
|
||||||
|
t.Helper()
|
||||||
|
encoded, readErr := os.ReadFile(statePath)
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatalf("read registry state: %v", readErr)
|
||||||
|
}
|
||||||
|
var state map[string]imageRecord
|
||||||
|
if unmarshalErr := json.Unmarshal(encoded, &state); unmarshalErr != nil {
|
||||||
|
t.Fatalf("decode registry state: %v", unmarshalErr)
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
candidateDigest := "sha256:" + strings.Repeat("a", 64)
|
||||||
|
previousDigest := "sha256:" + strings.Repeat("b", 64)
|
||||||
|
latestDigest := "sha256:" + strings.Repeat("c", 64)
|
||||||
|
candidateRevision := strings.Repeat("1", 40)
|
||||||
|
previousRevision := strings.Repeat("2", 40)
|
||||||
|
state := map[string]imageRecord{}
|
||||||
|
for _, repository := range []string{"ghcr.io/tbphp/gpt-load", "tbphp/gpt-load"} {
|
||||||
|
state[repository+":2.0.0-beta.25"] = imageRecord{
|
||||||
|
Digest: candidateDigest, Revision: candidateRevision, Version: "v2.0.0-beta.25",
|
||||||
|
}
|
||||||
|
state[repository+":2.0-beta"] = imageRecord{
|
||||||
|
Digest: previousDigest, Revision: previousRevision, Version: "v2.0.0-beta.24",
|
||||||
|
}
|
||||||
|
state[repository+":latest"] = imageRecord{
|
||||||
|
Digest: latestDigest, Revision: previousRevision, Version: "v1.4.10",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeState(state)
|
||||||
|
|
||||||
|
runPromotion := func(version, exact, revision string) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
outputPath := filepath.Join(t.TempDir(), "github-output")
|
||||||
|
command := exec.Command(
|
||||||
|
"bash", ".github/scripts/release-promote-image-channels.sh",
|
||||||
|
)
|
||||||
|
command.Dir = repositoryRoot
|
||||||
|
command.Env = append(os.Environ(),
|
||||||
|
"PATH="+fakeBin+string(os.PathListSeparator)+os.Getenv("PATH"),
|
||||||
|
"FAKE_REGISTRY_STATE="+statePath,
|
||||||
|
"RELEASE_VERSION="+version,
|
||||||
|
"IMAGE_EXACT="+exact,
|
||||||
|
"IMAGE_BETA=2.0-beta",
|
||||||
|
"IMAGE_MAJOR=2",
|
||||||
|
"PROMOTE_BETA=true",
|
||||||
|
"PROMOTE_MAJOR=true",
|
||||||
|
"EXPECTED_REVISION="+revision,
|
||||||
|
"EXPECTED_GHCR_LATEST="+latestDigest,
|
||||||
|
"EXPECTED_DOCKERHUB_LATEST="+latestDigest,
|
||||||
|
"GITHUB_OUTPUT="+outputPath,
|
||||||
|
)
|
||||||
|
if output, runErr := command.CombinedOutput(); runErr != nil {
|
||||||
|
t.Fatalf("promote %s: %v\n%s", version, runErr, output)
|
||||||
|
}
|
||||||
|
encoded, readErr := os.ReadFile(outputPath)
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatalf("read promotion outputs: %v", readErr)
|
||||||
|
}
|
||||||
|
outputs := map[string]string{}
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(string(encoded)), "\n") {
|
||||||
|
key, value, found := strings.Cut(line, "=")
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("malformed promotion output %q", line)
|
||||||
|
}
|
||||||
|
outputs[key] = value
|
||||||
|
}
|
||||||
|
return outputs
|
||||||
|
}
|
||||||
|
|
||||||
|
outputs := runPromotion("v2.0.0-beta.25", "2.0.0-beta.25", candidateRevision)
|
||||||
|
if outputs["beta_current"] != "true" || outputs["major_current"] != "true" {
|
||||||
|
t.Fatalf("promotion outputs = %#v, want both channels current", outputs)
|
||||||
|
}
|
||||||
|
state = readState()
|
||||||
|
for _, repository := range []string{"ghcr.io/tbphp/gpt-load", "tbphp/gpt-load"} {
|
||||||
|
for _, alias := range []string{"2.0-beta", "2"} {
|
||||||
|
if got := state[repository+":"+alias].Digest; got != candidateDigest {
|
||||||
|
t.Fatalf("%s:%s digest = %q, want %q", repository, alias, got, candidateDigest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := state[repository+":latest"].Digest; got != latestDigest {
|
||||||
|
t.Fatalf("%s:latest digest = %q, want preserved %q", repository, got, latestDigest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(state, "tbphp/gpt-load:2")
|
||||||
|
writeState(state)
|
||||||
|
outputs = runPromotion("v2.0.0-beta.25", "2.0.0-beta.25", candidateRevision)
|
||||||
|
if outputs["major_current"] != "true" {
|
||||||
|
t.Fatalf("repair outputs = %#v, want major channel current", outputs)
|
||||||
|
}
|
||||||
|
state = readState()
|
||||||
|
if got := state["tbphp/gpt-load:2"].Digest; got != candidateDigest {
|
||||||
|
t.Fatalf("partial Docker Hub channel repair digest = %q, want %q", got, candidateDigest)
|
||||||
|
}
|
||||||
|
|
||||||
|
olderDigest := "sha256:" + strings.Repeat("d", 64)
|
||||||
|
olderRevision := strings.Repeat("3", 40)
|
||||||
|
for _, repository := range []string{"ghcr.io/tbphp/gpt-load", "tbphp/gpt-load"} {
|
||||||
|
state[repository+":2.0.0-beta.24"] = imageRecord{
|
||||||
|
Digest: olderDigest, Revision: olderRevision, Version: "v2.0.0-beta.24",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeState(state)
|
||||||
|
outputs = runPromotion("v2.0.0-beta.24", "2.0.0-beta.24", olderRevision)
|
||||||
|
if outputs["beta_current"] != "false" || outputs["major_current"] != "false" {
|
||||||
|
t.Fatalf("older promotion outputs = %#v, want both channels skipped", outputs)
|
||||||
|
}
|
||||||
|
state = readState()
|
||||||
|
for _, repository := range []string{"ghcr.io/tbphp/gpt-load", "tbphp/gpt-load"} {
|
||||||
|
for _, alias := range []string{"2.0-beta", "2"} {
|
||||||
|
if got := state[repository+":"+alias].Digest; got != candidateDigest {
|
||||||
|
t.Fatalf("older run rolled back %s:%s to %q", repository, alias, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReleaseImageDigestReturnsAbsentOnlyForMissingManifest(t *testing.T) {
|
func TestReleaseImageDigestReturnsAbsentOnlyForMissingManifest(t *testing.T) {
|
||||||
script := filepath.Join("..", "..", ".github", "scripts", "release-image-digest.sh")
|
script := filepath.Join("..", "..", ".github", "scripts", "release-image-digest.sh")
|
||||||
fakeBin := t.TempDir()
|
fakeBin := t.TempDir()
|
||||||
|
|||||||
Reference in new issue
Block a user