From 3d14c18dcf85b8a4edf187bf74d4ac33b7ea4165 Mon Sep 17 00:00:00 2001 From: tbphp Date: Mon, 31 Aug 2026 17:35:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(release):=20=E7=BB=9F=E4=B8=80=202.x=20?= =?UTF-8?q?=E9=95=9C=E5=83=8F=E6=A0=87=E7=AD=BE=E9=80=9A=E9=81=93=20(#512)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/scripts/release-compare-semver.py | 85 +++ .github/scripts/release-image-version.sh | 43 ++ .../scripts/release-promote-image-channels.sh | 129 +++++ .github/workflows/release.yml | 191 +++--- README.md | 4 +- README_CN.md | 4 +- README_JP.md | 4 +- docker-compose.yml | 2 +- internal/webui/container_contract_test.go | 6 +- internal/webui/workflow_test.go | 545 ++++++++++++++---- 10 files changed, 832 insertions(+), 181 deletions(-) create mode 100755 .github/scripts/release-compare-semver.py create mode 100755 .github/scripts/release-image-version.sh create mode 100755 .github/scripts/release-promote-image-channels.sh diff --git a/.github/scripts/release-compare-semver.py b/.github/scripts/release-compare-semver.py new file mode 100755 index 00000000..dcebb0c8 --- /dev/null +++ b/.github/scripts/release-compare-semver.py @@ -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()) diff --git a/.github/scripts/release-image-version.sh b/.github/scripts/release-image-version.sh new file mode 100755 index 00000000..669d85ad --- /dev/null +++ b/.github/scripts/release-image-version.sh @@ -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}" diff --git a/.github/scripts/release-promote-image-channels.sh b/.github/scripts/release-promote-image-channels.sh new file mode 100755 index 00000000..42269798 --- /dev/null +++ b/.github/scripts/release-promote-image-channels.sh @@ -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}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d5eee42..d0cbf805 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,10 +29,11 @@ jobs: version: ${{ steps.tag.outputs.version }} prerelease: ${{ steps.tag.outputs.prerelease }} 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 }} - beta: ${{ steps.tag.outputs.beta }} + release_kind: ${{ steps.tag.outputs.release_kind }} channel_beta: ${{ steps.tag.outputs.channel_beta }} + promote_major: ${{ steps.tag.outputs.promote_major }} ci_verified: ${{ steps.ci.outputs.ci_verified }} steps: - name: Checkout tag commit @@ -56,15 +57,14 @@ jobs: version="${tag#v}" core="${version%%-*}" prerelease=false - beta=false + release_kind=stable channel_beta=false + promote_major=true if [[ "${version}" == *-* ]]; then prerelease=true prerelease_value="${version#*-}" - if [[ "${prerelease_value}" == "beta" || - "${prerelease_value}" == beta.* ]]; then - beta=true - fi + release_kind=other + promote_major=false IFS='.' read -r -a identifiers <<<"${prerelease_value}" for identifier in "${identifiers[@]}"; do 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]*)$' if [[ "${tag}" =~ ${channel_beta_regex} ]]; then 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 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 'prerelease=%s\n' "${prerelease}" - printf 'image_exact=%s\n' "${tag}" - printf 'image_minor=%s.%s\n' "${major}" "${minor}" + printf 'image_exact=%s\n' "${version}" + printf 'image_beta=%s\n' "${image_beta}" 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 'promote_major=%s\n' "${promote_major}" } >>"${GITHUB_OUTPUT}" # release-tag-validation:end @@ -1038,33 +1050,6 @@ jobs: test -n "${ghcr_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: needs: - validate-tag @@ -1297,26 +1282,6 @@ jobs: test "${latest_digest}" = "${expected_latest_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 test "${ghcr_exact_digest}" = "${dockerhub_exact_digest}" @@ -1333,19 +1298,80 @@ jobs: docker image inspect -f '{{.Config.User}}' "${DOCKERHUB_EXACT_IMAGE}" )" = "10001:10001" - deploy-render: - name: Deploy Render + promote-image-channels: needs: - validate-tag + - publication-preflight - post-publish-image-smoke - post-publish-verify if: >- ${{ !cancelled() && needs.validate-tag.result == 'success' && + needs.publication-preflight.result == 'success' && needs.post-publish-image-smoke.result == 'success' && - needs.post-publish-verify.result == 'success' && - needs.validate-tag.outputs.channel_beta == 'true' + needs.post-publish-verify.result == 'success' + }} + 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: group: gpt-load-render @@ -1360,7 +1386,7 @@ jobs: env: RENDER_SERVICE_ID: ${{ vars.RENDER_SERVICE_ID }} 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 }} RENDER_CLI_VERSION: "2.25.0" RENDER_CLI_SHA256: "3b3f1f839ef36b81f12d84ac7288f1c96f9f7519b39c53fe6f866612f704e7cd" @@ -1371,8 +1397,8 @@ jobs: set -euo pipefail [[ "${RENDER_SERVICE_ID}" =~ ^srv-[a-z0-9]+$ ]] [[ "${RENDER_SERVICE_URL}" =~ ^https://[^/?#]+/?$ ]] - [[ "${RELEASE_VERSION}" =~ ^v2\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-beta\.(0|[1-9][0-9]*)$ ]] - [[ "${IMAGE}" == "ghcr.io/tbphp/gpt-load:${RELEASE_VERSION}" ]] + [[ "${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#v}" ]] - name: Install pinned Render CLI shell: bash @@ -1466,11 +1492,13 @@ jobs: reconcile-publication: if: ${{ always() }} needs: + - validate-tag - publication-preflight - publish-images - publish-github - post-publish-image-smoke - post-publish-verify + - promote-image-channels runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -1512,6 +1540,10 @@ jobs: GITHUB_WRITER_RESULT: ${{ needs.publish-github.result }} IMAGE_SMOKE_RESULT: ${{ needs.post-publish-image-smoke.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 run: | set +e @@ -1558,17 +1590,27 @@ jobs: ghcr_inventory="$( read_image_inventory \ - "ghcr.io/tbphp/gpt-load:${GITHUB_REF_NAME}" + "ghcr.io/tbphp/gpt-load:${IMAGE_EXACT}" )" dockerhub_inventory="$( read_image_inventory \ - "tbphp/gpt-load:${GITHUB_REF_NAME}" + "tbphp/gpt-load:${IMAGE_EXACT}" )" - ghcr_beta_inventory="$( - read_image_inventory ghcr.io/tbphp/gpt-load:v2beta + ghcr_beta_inventory=not-applicable + 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="$( - read_image_inventory tbphp/gpt-load:v2beta + dockerhub_major_inventory="$( + read_image_inventory "tbphp/gpt-load:${IMAGE_MAJOR}" )" { @@ -1578,14 +1620,19 @@ jobs: printf -- '- GitHub writer: `%s`\n' "${GITHUB_WRITER_RESULT}" printf -- '- published image smoke: `%s`\n' "${IMAGE_SMOKE_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 -- '- GHCR exact inventory: `%s`\n' "${ghcr_inventory}" printf -- '- Docker Hub exact inventory: `%s`\n' \ "${dockerhub_inventory}" - printf -- '- GHCR v2beta inventory: `%s`\n' \ + printf -- '- GHCR %s inventory: `%s`\n' "${IMAGE_BETA:-beta-not-applicable}" \ "${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}" + 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 'do not assume cross-system atomic rollback.\n' } >>"${GITHUB_STEP_SUMMARY}" diff --git a/README.md b/README.md index ba751791..bd697621 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ English · [中文](README_CN.md) · [日本語](README_JP.md) [Official Website](https://www.gpt-load.com) [![Release](https://img.shields.io/github/v/tag/tbphp/gpt-load?filter=v2.*)](https://github.com/tbphp/gpt-load/releases) -[![Docker](https://img.shields.io/badge/Docker-ghcr.io%2Ftbphp%2Fgpt--load%3Av2beta-2496ED?logo=docker&logoColor=white)](https://github.com/tbphp/gpt-load/pkgs/container/gpt-load) +[![Docker](https://img.shields.io/badge/Docker-ghcr.io%2Ftbphp%2Fgpt--load%3A2-2496ED?logo=docker&logoColor=white)](https://github.com/tbphp/gpt-load/pkgs/container/gpt-load) [![Go](https://img.shields.io/badge/Go-1.27-00ADD8?logo=go&logoColor=white)](go.mod) [![License](https://img.shields.io/badge/License-MIT-green.svg)](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 ``` -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.
Using a native binary diff --git a/README_CN.md b/README_CN.md index dbfe10f8..26d68593 100644 --- a/README_CN.md +++ b/README_CN.md @@ -13,7 +13,7 @@ [官方网站](https://www.gpt-load.com) [![Release](https://img.shields.io/github/v/tag/tbphp/gpt-load?filter=v2.*)](https://github.com/tbphp/gpt-load/releases) -[![Docker](https://img.shields.io/badge/Docker-ghcr.io%2Ftbphp%2Fgpt--load%3Av2beta-2496ED?logo=docker&logoColor=white)](https://github.com/tbphp/gpt-load/pkgs/container/gpt-load) +[![Docker](https://img.shields.io/badge/Docker-ghcr.io%2Ftbphp%2Fgpt--load%3A2-2496ED?logo=docker&logoColor=white)](https://github.com/tbphp/gpt-load/pkgs/container/gpt-load) [![Go](https://img.shields.io/badge/Go-1.27-00ADD8?logo=go&logoColor=white)](go.mod) [![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) @@ -182,7 +182,7 @@ docker compose pull && docker compose up -d # 更新到最新 2.x 镜像 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。
使用原生二进制 diff --git a/README_JP.md b/README_JP.md index b94a6f39..1167576d 100644 --- a/README_JP.md +++ b/README_JP.md @@ -13,7 +13,7 @@ API キー、サブスクリプションアカウント、トラフィック制 [公式サイト](https://www.gpt-load.com) [![Release](https://img.shields.io/github/v/tag/tbphp/gpt-load?filter=v2.*)](https://github.com/tbphp/gpt-load/releases) -[![Docker](https://img.shields.io/badge/Docker-ghcr.io%2Ftbphp%2Fgpt--load%3Av2beta-2496ED?logo=docker&logoColor=white)](https://github.com/tbphp/gpt-load/pkgs/container/gpt-load) +[![Docker](https://img.shields.io/badge/Docker-ghcr.io%2Ftbphp%2Fgpt--load%3A2-2496ED?logo=docker&logoColor=white)](https://github.com/tbphp/gpt-load/pkgs/container/gpt-load) [![Go](https://img.shields.io/badge/Go-1.27-00ADD8?logo=go&logoColor=white)](go.mod) [![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) @@ -182,7 +182,7 @@ docker compose pull && docker compose up -d # 最新の 2.x イメージへ更 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 を指します。
ネイティブバイナリを使う diff --git a/docker-compose.yml b/docker-compose.yml index ed9ef89f..eb3f6a33 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: gpt-load: - image: ghcr.io/tbphp/gpt-load:v2beta + image: ghcr.io/tbphp/gpt-load:2 # build: # context: . # dockerfile: Dockerfile diff --git a/internal/webui/container_contract_test.go b/internal/webui/container_contract_test.go index be36be66..4afdbd9a 100644 --- a/internal/webui/container_contract_test.go +++ b/internal/webui/container_contract_test.go @@ -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("DATABASE_DSN", "/host/database/must-not-reach-container.db") @@ -561,8 +561,8 @@ func TestComposeResolvesNamedVolumeContainerPathsAndBetaChannelImage(t *testing. if !ok { t.Fatal("resolved Compose lacks gpt-load service") } - if service.Image != "ghcr.io/tbphp/gpt-load:v2beta" { - t.Fatalf("resolved image = %q, want ghcr.io/tbphp/gpt-load:v2beta", service.Image) + if service.Image != "ghcr.io/tbphp/gpt-load:2" { + t.Fatalf("resolved image = %q, want ghcr.io/tbphp/gpt-load:2", service.Image) } if service.Environment["DATA_DIR"] != "/app/data" { t.Fatalf("resolved DATA_DIR = %q, want /app/data", service.Environment["DATA_DIR"]) diff --git a/internal/webui/workflow_test.go b/internal/webui/workflow_test.go index 74b1fea8..6331b703 100644 --- a/internal/webui/workflow_test.go +++ b/internal/webui/workflow_test.go @@ -335,38 +335,43 @@ func TestReleaseWorkflowUsesTagOnlyTriggerAndStrictSemverGuard(t *testing.T) { tag: "v2.0.0", valid: true, wantOutput: []string{ - "version=v2.0.0", "prerelease=false", "image_exact=v2.0.0", - "image_minor=2.0", "image_major=2", "beta=false", "channel_beta=false", + "version=v2.0.0", "prerelease=false", "image_exact=2.0.0", + "image_beta=", "image_major=2", "release_kind=stable", + "channel_beta=false", "promote_major=true", }, }, { tag: "v2.0.0-rc.1", valid: true, wantOutput: []string{ - "version=v2.0.0-rc.1", "prerelease=true", "image_exact=v2.0.0-rc.1", - "image_minor=2.0", "image_major=2", "beta=false", "channel_beta=false", + "version=v2.0.0-rc.1", "prerelease=true", "image_exact=2.0.0-rc.1", + "image_beta=", "image_major=2", "release_kind=rc", + "channel_beta=false", "promote_major=true", }, }, { tag: "v2.0.0-beta.1", valid: true, wantOutput: []string{ - "version=v2.0.0-beta.1", "prerelease=true", "image_exact=v2.0.0-beta.1", - "image_minor=2.0", "image_major=2", "beta=true", "channel_beta=true", + "version=v2.0.0-beta.1", "prerelease=true", "image_exact=2.0.0-beta.1", + "image_beta=2.0-beta", "image_major=2", "release_kind=beta", + "channel_beta=true", "promote_major=true", }, }, { tag: "v2.0.0-beta.0", valid: true, 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", valid: true, 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, wantOutput: []string{ "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", - "beta=true", "channel_beta=false", + "image_exact=2.0.0-beta.test-build-1", "image_beta=", "image_major=2", + "release_kind=other", "channel_beta=false", "promote_major=false", }, }, { tag: "v2.0.0-beta.1.extra", valid: true, 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, wantOutput: []string{ "version=v2.10.3-alpha-1.0", "prerelease=true", - "image_exact=v2.10.3-alpha-1.0", "image_minor=2.10", "image_major=2", - "beta=false", "channel_beta=false", + "image_exact=2.10.3-alpha-1.0", "image_beta=", "image_major=2", + "release_kind=other", "channel_beta=false", "promote_major=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) { content := readRepositoryFile(t, ".github/workflows/release.yml") 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") imageJob := workflowJobBlock(t, content, "publish-images") - exactVerification := strings.Index(imageJob, "name: Verify exact published images") - aliasUpdate := strings.Index(imageJob, "name: Update stable major and minor aliases") - if exactVerification < 0 || aliasUpdate < 0 || exactVerification >= aliasUpdate { - t.Fatalf( - "alias update is not ordered after exact verification: exact=%d alias=%d\n%s", - exactVerification, - aliasUpdate, - imageJob, - ) + for _, forbidden := range []string{ + "Update beta channel alias", + "Update stable major and minor aliases", + "docker buildx imagetools create", + } { + if strings.Contains(imageJob, forbidden) { + t.Fatalf("exact image publication still mutates shared channels via %q:\n%s", forbidden, 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") for _, required := range []string{ ".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) } for _, forbidden := range []string{ - "needs.validate-tag.outputs.image_minor", + "needs.validate-tag.outputs.image_beta", "needs.validate-tag.outputs.image_major", + "imagetools create", "latest", } { if strings.Contains(metadataStep, forbidden) { 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") 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{ - "needs.publication-preflight.outputs.write_mode == 'publish'", - "needs.validate-tag.outputs.channel_beta == 'true'", - "docker buildx imagetools create", - `exact="${repository}:${{ needs.validate-tag.outputs.image_exact }}"`, - `--tag "${repository}:v2beta"`, + "image_beta: ${{ steps.tag.outputs.image_beta }}", + "channel_beta: ${{ steps.tag.outputs.channel_beta }}", + "promote_major: ${{ steps.tag.outputs.promote_major }}", } { - if !strings.Contains(betaStep, required) { - t.Fatalf("beta alias update does not contain %q:\n%s", required, betaStep) + if !strings.Contains(validateJob, required) { + t.Fatalf("tag validation does not expose %q:\n%s", required, validateJob) } } - if strings.Contains(betaStep, "needs.validate-tag.outputs.beta") { - t.Fatalf("beta alias update still uses the broad beta classification:\n%s", betaStep) - } - - verification := workflowStepBlock( - t, - workflowJobBlock(t, content, "post-publish-verify"), - "Verify published image manifests", + promotion := readRepositoryFile( + t, ".github/scripts/release-promote-image-channels.sh", ) for _, required := range []string{ - `needs.validate-tag.outputs.channel_beta`, - `"${repository}:v2beta"`, - "beta_digest", - `test "${beta_digest}" = "${exact_digest}"`, + `promote_channel "${image_beta}" "${promote_beta}"`, + `promote_channel "${image_major}" "${promote_major}"`, + "release-compare-semver.py", } { - if !strings.Contains(verification, required) { - t.Fatalf("beta alias verification does not contain %q:\n%s", required, verification) + if !strings.Contains(promotion, required) { + 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( t, @@ -1160,10 +1274,10 @@ func TestReleaseWorkflowMaintainsBetaChannelAlias(t *testing.T) { "Summarize exact publication inventory and job results", ) for _, required := range []string{ - "ghcr.io/tbphp/gpt-load:v2beta", - "tbphp/gpt-load:v2beta", - "GHCR v2beta inventory", - "Docker Hub v2beta inventory", + "IMAGE_BETA: ${{ needs.validate-tag.outputs.image_beta }}", + "IMAGE_MAJOR: ${{ needs.validate-tag.outputs.image_major }}", + `"ghcr.io/tbphp/gpt-load:${IMAGE_BETA}"`, + `"tbphp/gpt-load:${IMAGE_MAJOR}"`, } { if !strings.Contains(reconciliation, required) { 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") job := workflowJobBlock(t, content, "deploy-render") for _, required := range []string{ "- validate-tag", - "- post-publish-image-smoke", - "- post-publish-verify", - "needs.validate-tag.outputs.channel_beta == 'true'", - "needs.post-publish-image-smoke.result == 'success'", - "needs.post-publish-verify.result == 'success'", + "- promote-image-channels", + "needs.promote-image-channels.outputs.major_current == 'true'", "group: gpt-load-render", "cancel-in-progress: false", "name: render", "url: ${{ vars.RENDER_SERVICE_URL }}", "RENDER_SERVICE_ID: ${{ vars.RENDER_SERVICE_ID }}", "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 }}", `RENDER_CLI_VERSION: "2.25.0"`, `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) } } - 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) { t.Fatalf("Render deployment job contains forbidden %q:\n%s", forbidden, job) } @@ -1451,9 +1562,8 @@ func TestReleaseWorkflowPreparesDraftsWithoutLatest(t *testing.T) { dockerBuildActionRef, "linux/amd64,linux/arm64", `value=${{ needs.validate-tag.outputs.image_exact }}`, - "docker buildx imagetools create", - `needs.validate-tag.outputs.image_minor`, - `needs.validate-tag.outputs.image_major`, + `org.opencontainers.image.version=${{ github.ref_name }}`, + `VERSION=${{ github.ref_name }}`, } { if !strings.Contains(imageJob, required) { 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") { 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") 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") job := workflowJobBlock(t, content, "post-publish-verify") for _, required := range []string{ "packages: read", dockerLoginActionRef, "registry: ghcr.io", - "image_minor", - "image_major", "exact_digest", - "alias_digest", "ghcr.io/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) } } + 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) { @@ -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) { script := filepath.Join("..", "..", ".github", "scripts", "release-image-digest.sh") fakeBin := t.TempDir()