chore(release): 规范 V2 预发布与协作流程

This commit is contained in:
tbphp committed 2026-08-19 12:52:18 +08:00
1 parent 10a7fe1b0b
commit 411a234338
8 files changed
+482 -59

No files matched your search

+27 -3
View File
@@ -9,16 +9,37 @@ assignees: ""
**例行检查 / Checklist**
<!-- 方框内填 'x' / Put an 'x' in all the boxes that apply -->
- [ ] 我已确认目前没有类似 issue (I have checked for similar issues)
- [ ] 我已确认我已升级到最新版本 (I have updated to the latest version)
- [ ] 我已完整查看过项目 README,尤其是常见问题部分 (I have read the README, especially the FAQ section)
- [ ] 我已升级到当前主版本线的最新补丁版本 (I am using the latest patch release in my current major line)
- [ ] 我已查看对应版本线的 README 和发布说明 (I have read the README and release notes for my release line)
- [ ] 我已移除 `AUTH_KEY`、`ENCRYPTION_KEY`、AccessKey、上游密钥和令牌等敏感信息 (I have removed all credentials, keys, and tokens)
- [ ] 我理解并愿意跟进此 issue,协助测试和提供反馈 (I am willing to follow up on this issue, assist with testing, and provide feedback)
- [ ] 我理解并认可上述内容,并理解项目维护者精力有限,**不遵循规则的 issue 可能会被无视或直接关闭** (I understand and agree to the above, and I understand that the maintainers have limited time, so **issues that do not follow the rules may be ignored or closed directly**)
---
**问题描述 / Bug Description**
<!-- 请清晰、简洁地描述问题。 / A clear and concise description of what the bug is. -->
**GPT-Load 版本 / Version**
<!-- 请填写精确版本或 commit,不要只写 latest。 / Provide the exact version or commit, not only "latest". -->
**版本线 / Release Line**
<!-- 请填写 1.x 或 2.x;二者不兼容且数据不互通。 / Specify 1.x or 2.x; they are incompatible and do not share data. -->
**部署环境 / Environment**
<!--
- 部署方式(Docker、原生二进制或源码)/ Deployment method
- 操作系统与架构 / Operating system and architecture
- 数据库及版本(SQLite、MySQL 或 PostgreSQL)/ Database and version
- 浏览器或客户端 SDK 及版本 / Browser or client SDK and version
-->
**渠道与客户端协议 / Channel and Client Protocol**
<!-- 例如 OpenAI 官方渠道 + openai-responses;不适用时填写 N/A。请勿粘贴凭据。 -->
<!-- Example: OpenAI official + openai-responses. Use N/A when not applicable. Never paste credentials. -->
**最小脱敏配置 / Minimal Sanitized Configuration**
<!-- 仅提供复现所需配置;不得包含密钥、令牌、Cookie 或完整请求头。 -->
<!-- Include only what is needed to reproduce; never include secrets, tokens, cookies, or complete authorization headers. -->
**复现步骤 / Steps to Reproduce**
<!-- 请提供复现问题的具体步骤。 / Steps to reproduce the behavior. -->
@@ -26,6 +47,9 @@ assignees: ""
2.
3.
**实际结果及脱敏日志 / Actual Result and Sanitized Logs**
<!-- 提供完整错误信息或最小日志片段,并确认已经脱敏。 / Include the exact error or minimal logs after removing sensitive data. -->
**预期结果 / Expected Behavior**
<!-- 请描述你预期的行为。 / A clear and concise description of what you expected to happen. -->
+20 -3
View File
@@ -9,16 +9,33 @@ assignees: ""
**例行检查 / Checklist**
<!-- 方框内填 'x' / Put an 'x' in all the boxes that apply -->
- [ ] 我已确认目前没有类似 issue (I have checked for similar issues)
- [ ] 我已确认我已升级到最新版本 (I have updated to the latest version)
- [ ] 我已完整查看过项目 README,已确定现有版本无法满足需求 (I have read the README and confirmed the current version does not meet my needs)
- [ ] 我已确认当前主版本线的最新补丁版本仍无法满足需求 (I have confirmed the latest patch release in my current major line does not meet this need)
- [ ] 我已查看对应版本线的 README 和发布说明 (I have read the README and release notes for my release line)
- [ ] 我不会在 issue 中提交任何凭据或令牌 (I will not include credentials or tokens in this issue)
- [ ] 我理解并愿意跟进此 issue,协助测试和提供反馈 (I am willing to follow up on this issue, assist with testing, and provide feedback)
- [ ] 我理解并认可上述内容,并理解项目维护者精力有限,**不遵循规则的 issue 可能会被无视或直接关闭** (I understand and agree to the above, and I understand that the maintainers have limited time, so **issues that do not follow the rules may be ignored or closed directly**)
<!-- 不要提交任何凭据或令牌。 / Never submit credentials or tokens. -->
---
**目标版本线 / Target Release Line**
<!-- 请填写 1.x 维护线或 2.x;二者不兼容且数据不互通。 / Specify the 1.x maintenance line or 2.x; they are incompatible and do not share data. -->
**功能描述 / Feature Description**
<!-- 请清晰、简洁地描述你希望实现的功能。 / A clear and concise description of what you want to happen. -->
**应用场景 / Use Case**
<!-- 请描述该功能可以解决什么问题或在什么场景下使用。 / Please describe what problem this feature would solve or in what scenarios it would be used. -->
**期望方案 / Desired Behavior**
<!-- 请描述用户可观察到的结果,而不是只描述实现方式。 / Describe the user-visible outcome, not only an implementation idea. -->
**兼容性、数据与安全影响 / Compatibility, Data, and Security Impact**
<!-- 是否影响 API、配置、数据库、密钥或现有部署?不适用时填写 N/A。 -->
<!-- Would this affect APIs, configuration, databases, credentials, or existing deployments? Use N/A when not applicable. -->
**备选方案 / Alternatives Considered**
<!-- 请说明当前使用的替代方法(如有)。 / Describe any current workaround or alternative. -->
+5 -2
View File
@@ -12,5 +12,8 @@ Closes #
### 自查清单 / Checklist
- [ ] 我已在本地测试过我的变更。 / I have tested my changes locally.
- [ ] 我已更新了必要的文档。 / I have updated the necessary documentation.
- [ ] 我已运行 `make check`,或在说明中写明无法运行的原因和未验证范围。 / I ran `make check`, or documented why it could not run and what remains unverified.
- [ ] 本 PR 范围聚焦,未包含无关改动。 / This PR is focused and contains no unrelated changes.
- [ ] 我已更新必要的公开文档或发布说明。 / I updated any required public documentation or release notes.
- [ ] 我已确认提交、日志和测试数据不包含敏感信息。 / I confirmed that commits, logs, and fixtures contain no sensitive data.
- [ ] 如适用,我已说明兼容性或数据迁移影响。 / Where applicable, I documented compatibility or data-migration impact.
+12
View File
@@ -11,6 +11,10 @@ on:
permissions:
contents: read
concurrency:
group: v2-ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-24.04
@@ -18,6 +22,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Validate and build web UI
uses: ./.github/actions/web-ci
@@ -62,6 +68,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
@@ -116,6 +124,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
@@ -135,6 +145,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
+165 -38
View File
@@ -22,10 +22,12 @@ jobs:
image_exact: ${{ steps.tag.outputs.image_exact }}
image_minor: ${{ steps.tag.outputs.image_minor }}
image_major: ${{ steps.tag.outputs.image_major }}
beta: ${{ steps.tag.outputs.beta }}
steps:
- name: Checkout tag commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
- name: Validate strict 2.x SemVer tag
@@ -43,9 +45,14 @@ jobs:
version="${tag#v}"
core="${version%%-*}"
prerelease=false
beta=false
if [[ "${version}" == *-* ]]; then
prerelease=true
prerelease_value="${version#*-}"
if [[ "${prerelease_value}" == "beta" ||
"${prerelease_value}" == beta.* ]]; then
beta=true
fi
IFS='.' read -r -a identifiers <<<"${prerelease_value}"
for identifier in "${identifiers[@]}"; do
if [[ "${identifier}" =~ ^[0-9]+$ ]] &&
@@ -64,6 +71,7 @@ jobs:
printf 'image_exact=%s\n' "${tag}"
printf 'image_minor=%s.%s\n' "${major}" "${minor}"
printf 'image_major=%s\n' "${major}"
printf 'beta=%s\n' "${beta}"
} >>"${GITHUB_OUTPUT}"
# release-tag-validation:end
@@ -79,6 +87,8 @@ jobs:
steps:
- name: Checkout tag commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Validate and build web UI
uses: ./.github/actions/web-ci
@@ -132,6 +142,8 @@ jobs:
steps:
- name: Checkout tag commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
@@ -187,6 +199,8 @@ jobs:
steps:
- name: Checkout tag commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
@@ -226,6 +240,8 @@ jobs:
steps:
- name: Checkout tag commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download verified web dist
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -265,6 +281,8 @@ jobs:
steps:
- name: Checkout tag commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
@@ -333,6 +351,8 @@ jobs:
steps:
- name: Checkout tag commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download complete release assets
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -369,6 +389,8 @@ jobs:
steps:
- name: Checkout tag commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Build and smoke local image
shell: bash
@@ -411,6 +433,7 @@ jobs:
- name: Checkout tag commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
- name: Download complete release assets
@@ -495,15 +518,32 @@ jobs:
set -euo pipefail
github_state=absent
github_release_id=
github_target_sha=
github_assets=absent
if gh api \
"repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" \
github_draft=
github_prerelease=
if gh release view "${GITHUB_REF_NAME}" \
--repo "${GITHUB_REPOSITORY}" \
--json databaseId,targetCommitish,isDraft,isPrerelease,assets \
>/tmp/existing-release.json 2>/tmp/existing-release.error; then
github_state=present
github_target_sha="$(
jq -r '.target_commitish // empty' /tmp/existing-release.json
github_release_id="$(
jq -r '.databaseId // empty' /tmp/existing-release.json
)"
github_target_sha="$(
jq -r '.targetCommitish // empty' /tmp/existing-release.json
)"
github_draft="$(
jq -r '.isDraft' /tmp/existing-release.json
)"
github_prerelease="$(
jq -r '.isPrerelease' /tmp/existing-release.json
)"
[[ "${github_release_id}" =~ ^[1-9][0-9]*$ ]]
test "${github_draft}" = "true"
test "${github_prerelease}" = \
"${{ needs.validate-tag.outputs.prerelease }}"
jq -r '.assets[].name' /tmp/existing-release.json |
sort >/tmp/existing-release-assets
if cmp \
@@ -605,7 +645,7 @@ jobs:
"${dockerhub_latest_digest}"
} >>"${GITHUB_OUTPUT}"
printf '%s\n' \
"github=${github_state}/${github_target_sha}/${github_assets}" \
"github=${github_state}/${github_target_sha}/${github_assets}/${github_draft}/${github_prerelease}" \
"ghcr=${ghcr_state}/${ghcr_digest}/${ghcr_revision}" \
"dockerhub=${dockerhub_state}/${dockerhub_digest}/${dockerhub_revision}" \
"ghcr_latest=${ghcr_latest_digest}" \
@@ -662,6 +702,8 @@ jobs:
steps:
- name: Checkout tag commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up QEMU
if: ${{ needs.publication-preflight.outputs.write_mode == 'publish' }}
@@ -758,6 +800,18 @@ 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.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
@@ -800,13 +854,29 @@ jobs:
shell: bash
run: |
set -euo pipefail
target="$(
metadata="$(
gh release view "${GITHUB_REF_NAME}" \
--repo "${GITHUB_REPOSITORY}" \
--json targetCommitish \
--jq '.targetCommitish'
--json targetCommitish,isDraft,isPrerelease
)"
test "${target}" = "${GITHUB_SHA}"
test "$(jq -r '.targetCommitish' <<<"${metadata}")" = "${GITHUB_SHA}"
test "$(jq -r '.isDraft' <<<"${metadata}")" = "true"
test "$(jq -r '.isPrerelease' <<<"${metadata}")" = \
"${{ needs.validate-tag.outputs.prerelease }}"
if latest_tag="$(
gh api \
"repos/${GITHUB_REPOSITORY}/releases/latest" \
--jq '.tag_name' 2>/tmp/latest-release.error
)"; then
test "${latest_tag}" != "${GITHUB_REF_NAME}"
elif ! grep -Eqi \
'HTTP 404|Not Found' \
/tmp/latest-release.error; then
cat /tmp/latest-release.error >&2
exit 1
fi
rm -rf existing-release
mkdir existing-release
gh release download "${GITHUB_REF_NAME}" \
@@ -818,13 +888,16 @@ jobs:
sha256sum --check ../release/SHA256SUMS
)
- name: Publish one GitHub Release
- name: Create or update GitHub Release draft
if: ${{ needs.publication-preflight.outputs.write_mode == 'publish' }}
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
name: GPT-Load ${{ github.ref_name }}
target_commitish: ${{ github.sha }}
draft: true
prerelease: ${{ needs.validate-tag.outputs.prerelease }}
make_latest: false
generate_release_notes: true
fail_on_unmatched_files: true
files: |
release/gpt-load-linux-amd64
@@ -840,8 +913,9 @@ jobs:
body: |
GPT-Load 2.x is a greenfield rewrite and is not data-compatible with 1.x.
Do not open a 1.x database with this release; rebuild configuration and cut over manually.
Earlier pre-release 2.x databases are not supported. Start this release with an empty
dedicated database and a matching new encryption key.
New 2.x deployments must start with an empty dedicated database and a matching new
encryption key. Existing supported 2.x installations must preserve their matching key
files and follow the version-specific release notes before upgrading.
This release provides OpenAI, Anthropic, and Gemini client protocol gateways backed
by channel presets for official, compatible, Azure OpenAI, AWS Bedrock, and Google
@@ -855,9 +929,11 @@ jobs:
provider SDK executes one selected logical attempt and performs supported protocol
conversion; its name is not part of persisted domain identities.
Docker runs as a fixed non-root user with a named volume. The 2.x image channels
do not move `latest`. Stop cleanly, then back up the selected database using its
engine-native procedure and keep auth/encryption key files with the recovery set.
Docker runs as a fixed non-root user with a named volume. Verified Beta images move
the shared `v2beta` channel while retaining the exact version tag; stable releases
move only their major/minor aliases. No 2.x release moves `latest`. Stop cleanly,
then back up the selected database using its engine-native procedure and keep
auth/encryption key files with the recovery set.
Managed SQLite also requires its `-wal`/`-shm` sidecars. Usage and cost are best-effort
estimates, not a billing ledger or provider invoice.
@@ -870,18 +946,16 @@ jobs:
Usage quality can be `missing`, `partial`, or `unpriced`; a compatible upstream may
never provide authoritative terminal usage. Cost remains an estimate, not a provider
bill. GPT-Load 2.0.0 does not support encryption key rotation.
bill. The current 2.x line does not support encryption key rotation.
Models.dev is the sole automatic price source. Prices are resolved by exact
`channel_id + model_id`, may be overridden explicitly, and remain estimates.
The automatic model catalog combines an embedded official catalog with Models.dev;
embedded official records take precedence while other Models.dev records remain
available. Prices resolve by exact `channel_id + model_id`; explicit user overrides
remain available, and all costs remain estimates.
GPT-Load keeps a unified data/control dual-plane architecture: Groups select a
channel and credentials, AccessKeys authorize data-plane access, and model discovery
imports available models from the selected channel. Built-in prices were reviewed on
2026-07-27 against the official
[OpenAI](https://developers.openai.com/api/docs/pricing),
[Anthropic](https://platform.claude.com/docs/en/about-claude/pricing), and
[Gemini](https://ai.google.dev/gemini-api/docs/pricing) sources.
imports available models from the selected channel.
post-publish-native-smoke:
needs:
@@ -908,34 +982,36 @@ jobs:
steps:
- name: Checkout tag commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download published GitHub Release assets
- name: Download draft GitHub Release assets
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
mkdir -p public-release
mkdir -p draft-release
gh release download "${GITHUB_REF_NAME}" \
--repo "${GITHUB_REPOSITORY}" \
--dir public-release
--dir draft-release
- name: Smoke published POSIX native artifact
- name: Smoke drafted POSIX native artifact
if: runner.os != 'Windows'
shell: bash
env:
RELEASE_VERSION: ${{ github.ref_name }}
RELEASE_SMOKE_BINARY: ${{ github.workspace }}/public-release/${{ matrix.filename }}
RELEASE_SMOKE_CHECKSUM_FILE: ${{ github.workspace }}/public-release/SHA256SUMS
RELEASE_SMOKE_BINARY: ${{ github.workspace }}/draft-release/${{ matrix.filename }}
RELEASE_SMOKE_CHECKSUM_FILE: ${{ github.workspace }}/draft-release/SHA256SUMS
run: .github/scripts/release-native-smoke.sh
- name: Smoke published Windows native artifact
- name: Smoke drafted Windows native artifact
if: runner.os == 'Windows'
shell: pwsh
env:
RELEASE_VERSION: ${{ github.ref_name }}
RELEASE_SMOKE_BINARY: ${{ github.workspace }}/public-release/${{ matrix.filename }}
RELEASE_SMOKE_CHECKSUM_FILE: ${{ github.workspace }}/public-release/SHA256SUMS
RELEASE_SMOKE_BINARY: ${{ github.workspace }}/draft-release/${{ matrix.filename }}
RELEASE_SMOKE_CHECKSUM_FILE: ${{ github.workspace }}/draft-release/SHA256SUMS
run: .github/scripts/release-native-smoke.ps1
post-publish-verify:
@@ -953,6 +1029,8 @@ jobs:
steps:
- name: Checkout tag commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download and verify exact GitHub Release asset inventory
env:
@@ -960,10 +1038,10 @@ jobs:
shell: bash
run: |
set -euo pipefail
mkdir -p public-release
mkdir -p draft-release
gh release download "${GITHUB_REF_NAME}" \
--repo "${GITHUB_REPOSITORY}" \
--dir public-release
--dir draft-release
cat >/tmp/expected-release-assets <<'ASSETS'
Apache-2.0.txt
@@ -977,13 +1055,13 @@ jobs:
gpt-load-macos-arm64
gpt-load-windows-amd64.exe
ASSETS
find public-release -mindepth 1 -maxdepth 1 -type f -exec basename {} \; |
find draft-release -mindepth 1 -maxdepth 1 -type f -exec basename {} \; |
sort >/tmp/actual-release-assets
asset_count="$(wc -l </tmp/actual-release-assets | tr -d ' ')"
test "${asset_count}" = "10"
cmp /tmp/expected-release-assets /tmp/actual-release-assets
cd public-release
cd draft-release
test "$(wc -l <SHA256SUMS | tr -d ' ')" = "9"
awk '{print $2}' SHA256SUMS | sort >/tmp/checksummed-release-assets
grep -v '^SHA256SUMS$' /tmp/expected-release-assets \
@@ -991,6 +1069,35 @@ jobs:
cmp /tmp/expected-checksummed-release-assets /tmp/checksummed-release-assets
sha256sum --check SHA256SUMS
- name: Verify GitHub Release channel metadata
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
metadata="$(
gh release view "${GITHUB_REF_NAME}" \
--repo "${GITHUB_REPOSITORY}" \
--json targetCommitish,isDraft,isPrerelease
)"
test "$(jq -r '.targetCommitish' <<<"${metadata}")" = "${GITHUB_SHA}"
test "$(jq -r '.isDraft' <<<"${metadata}")" = "true"
test "$(jq -r '.isPrerelease' <<<"${metadata}")" = \
"${{ needs.validate-tag.outputs.prerelease }}"
if latest_tag="$(
gh api \
"repos/${GITHUB_REPOSITORY}/releases/latest" \
--jq '.tag_name' 2>/tmp/latest-release.error
)"; then
test "${latest_tag}" != "${GITHUB_REF_NAME}"
elif ! grep -Eqi \
'HTTP 404|Not Found' \
/tmp/latest-release.error; then
cat /tmp/latest-release.error >&2
exit 1
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
@@ -1048,6 +1155,14 @@ jobs:
test "${latest_digest}" = "${expected_latest_digest}"
test "${latest_digest}" != "${exact_digest}"
if [[ "${{ needs.validate-tag.outputs.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 }}" \
@@ -1097,6 +1212,8 @@ jobs:
if: ${{ always() }}
continue-on-error: true
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Docker Buildx for read-only reconciliation
if: ${{ always() }}
@@ -1191,18 +1308,28 @@ jobs:
read_image_inventory \
"tbphp/gpt-load:${GITHUB_REF_NAME}"
)"
ghcr_beta_inventory="$(
read_image_inventory ghcr.io/tbphp/gpt-load:v2beta
)"
dockerhub_beta_inventory="$(
read_image_inventory tbphp/gpt-load:v2beta
)"
{
printf '## Publication reconciliation\n\n'
printf -- '- preflight: `%s`\n' "${PREFLIGHT_RESULT}"
printf -- '- image writer: `%s`\n' "${IMAGE_WRITER_RESULT}"
printf -- '- GitHub writer: `%s`\n' "${GITHUB_WRITER_RESULT}"
printf -- '- published native smoke: `%s`\n' "${NATIVE_POST_RESULT}"
printf -- '- post-publish verification: `%s`\n' "${VERIFY_POST_RESULT}"
printf -- '- draft native smoke: `%s`\n' "${NATIVE_POST_RESULT}"
printf -- '- draft and image verification: `%s`\n' "${VERIFY_POST_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' \
"${ghcr_beta_inventory}"
printf -- '- Docker Hub v2beta inventory: `%s`\n' \
"${dockerhub_beta_inventory}"
printf '\nmanual recovery: inspect the exact inventories and job logs; '
printf 'do not assume cross-system atomic rollback.\n'
} >>"${GITHUB_STEP_SUMMARY}"
+1 -1
View File
@@ -1,6 +1,6 @@
services:
gpt-load:
image: ghcr.io/tbphp/gpt-load:2
image: ghcr.io/tbphp/gpt-load:v2beta
# build:
# context: .
# dockerfile: Dockerfile
+3 -3
View File
@@ -353,7 +353,7 @@ func TestComposeProjectsHaveIndependentNamesApplicationPortsAndVolumes(t *testin
}
}
func TestComposeResolvesNamedVolumeContainerPathsAndStableImage(t *testing.T) {
func TestComposeResolvesNamedVolumeContainerPathsAndBetaChannelImage(t *testing.T) {
t.Setenv("DATA_DIR", "/host/path/must-not-reach-container")
t.Setenv("DATABASE_DSN", "/host/database/must-not-reach-container.db")
@@ -401,8 +401,8 @@ func TestComposeResolvesNamedVolumeContainerPathsAndStableImage(t *testing.T) {
if !ok {
t.Fatal("resolved Compose lacks gpt-load service")
}
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.Image != "ghcr.io/tbphp/gpt-load:v2beta" {
t.Fatalf("resolved image = %q, want ghcr.io/tbphp/gpt-load:v2beta", service.Image)
}
if service.Environment["DATA_DIR"] != "/app/data" {
t.Fatalf("resolved DATA_DIR = %q, want /app/data", service.Environment["DATA_DIR"])
+249 -9
View File
@@ -161,6 +161,41 @@ func TestWorkflowsPinExternalActionsAndHostedRunners(t *testing.T) {
}
}
func TestBranchWorkflowCancelsSupersededRuns(t *testing.T) {
content := readRepositoryFile(t, ".github/workflows/ci.yml")
concurrency := workflowTopLevelBlock(t, content, "concurrency")
for _, required := range []string{
`group: v2-ci-${{ github.event.pull_request.number || github.ref }}`,
"cancel-in-progress: true",
} {
if !strings.Contains(concurrency, required) {
t.Fatalf("branch CI concurrency does not contain %q:\n%s", required, concurrency)
}
}
}
func TestWorkflowsDisableCheckoutCredentialPersistence(t *testing.T) {
for _, name := range []string{
".github/workflows/ci.yml",
".github/workflows/release.yml",
} {
content := readRepositoryFile(t, name)
checkoutCount := strings.Count(content, "uses: "+checkoutActionRef)
disabledCount := strings.Count(content, "persist-credentials: false")
if checkoutCount == 0 {
t.Fatalf("%s does not contain a checkout action", name)
}
if disabledCount != checkoutCount {
t.Fatalf(
"%s disables checkout credential persistence %d times, want %d",
name,
disabledCount,
checkoutCount,
)
}
}
}
func TestGoFormattingScriptsFailClosed(t *testing.T) {
for _, workflow := range []struct {
name string
@@ -253,7 +288,7 @@ func TestReleaseWorkflowUsesTagOnlyTriggerAndStrictSemverGuard(t *testing.T) {
valid: true,
wantOutput: []string{
"version=v2.0.0", "prerelease=false", "image_exact=v2.0.0",
"image_minor=2.0", "image_major=2",
"image_minor=2.0", "image_major=2", "beta=false",
},
},
{
@@ -261,7 +296,15 @@ func TestReleaseWorkflowUsesTagOnlyTriggerAndStrictSemverGuard(t *testing.T) {
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",
"image_minor=2.0", "image_major=2", "beta=false",
},
},
{
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",
},
},
{
@@ -270,6 +313,7 @@ func TestReleaseWorkflowUsesTagOnlyTriggerAndStrictSemverGuard(t *testing.T) {
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",
},
},
{tag: "v2.0.0.1", valid: false},
@@ -873,6 +917,70 @@ func TestReleaseWorkflowUpdatesAliasesOnlyAfterExactVerification(t *testing.T) {
}
}
func TestReleaseWorkflowMaintainsBetaChannelAlias(t *testing.T) {
content := readRepositoryFile(t, ".github/workflows/release.yml")
validateJob := workflowJobBlock(t, content, "validate-tag")
if !strings.Contains(validateJob, "beta: ${{ steps.tag.outputs.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.beta == 'true'",
"docker buildx imagetools create",
`exact="${repository}:${{ needs.validate-tag.outputs.image_exact }}"`,
`--tag "${repository}:v2beta"`,
} {
if !strings.Contains(betaStep, required) {
t.Fatalf("beta alias update does not contain %q:\n%s", required, betaStep)
}
}
verification := workflowStepBlock(
t,
workflowJobBlock(t, content, "post-publish-verify"),
"Verify published image manifests",
)
for _, required := range []string{
`needs.validate-tag.outputs.beta`,
`"${repository}:v2beta"`,
"beta_digest",
`test "${beta_digest}" = "${exact_digest}"`,
} {
if !strings.Contains(verification, required) {
t.Fatalf("beta alias verification does not contain %q:\n%s", required, verification)
}
}
reconciliation := workflowStepBlock(
t,
workflowJobBlock(t, content, "reconcile-publication"),
"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",
} {
if !strings.Contains(reconciliation, required) {
t.Fatalf("publication reconciliation does not contain %q:\n%s", required, reconciliation)
}
}
}
func TestReleaseWorkflowConsistentRerunIsVerifyOnly(t *testing.T) {
content := readRepositoryFile(t, ".github/workflows/release.yml")
for _, test := range []struct {
@@ -887,7 +995,7 @@ func TestReleaseWorkflowConsistentRerunIsVerifyOnly(t *testing.T) {
},
{
job: "publish-github",
step: "Publish one GitHub Release",
step: "Create or update GitHub Release draft",
call: githubReleaseActionRef,
},
} {
@@ -984,7 +1092,7 @@ func TestReleaseWorkflowReconciliationIsReadOnly(t *testing.T) {
}
}
func TestReleaseWorkflowPublishesStable2xTagsWithoutLatest(t *testing.T) {
func TestReleaseWorkflowPreparesDraftsWithoutLatest(t *testing.T) {
content := readRepositoryFile(t, ".github/workflows/release.yml")
imageJob := workflowJobBlock(t, content, "publish-images")
for _, required := range []string{
@@ -1006,6 +1114,69 @@ func TestReleaseWorkflowPublishesStable2xTagsWithoutLatest(t *testing.T) {
if strings.Contains(strings.ToLower(imageJob), "latest") {
t.Fatalf("image publication job contains latest:\n%s", imageJob)
}
githubJob := workflowJobBlock(t, content, "publish-github")
draftStep := workflowStepBlock(t, githubJob, "Create or update GitHub Release draft")
for _, required := range []string{
"draft: true",
"prerelease: ${{ needs.validate-tag.outputs.prerelease }}",
"make_latest: false",
"generate_release_notes: true",
} {
if !strings.Contains(draftStep, required) {
t.Fatalf("GitHub Release draft does not contain %q:\n%s", required, draftStep)
}
}
for _, forbidden := range []string{
"draft: false",
"Publish one GitHub Release",
} {
if strings.Contains(githubJob, forbidden) {
t.Fatalf("GitHub Release draft job contains direct publication contract %q:\n%s", forbidden, githubJob)
}
}
for _, verification := range []struct {
job string
step string
}{
{job: "publish-github", step: "Verify existing GitHub Release"},
{job: "post-publish-verify", step: "Verify GitHub Release channel metadata"},
} {
step := workflowStepBlock(
t,
workflowJobBlock(t, content, verification.job),
verification.step,
)
for _, required := range []string{
`test "$(jq -r '.isDraft' <<<"${metadata}")" = "true"`,
`test "$(jq -r '.isPrerelease' <<<"${metadata}")" = \`,
`repos/${GITHUB_REPOSITORY}/releases/latest`,
`test "${latest_tag}" != "${GITHUB_REF_NAME}"`,
} {
if !strings.Contains(step, required) {
t.Fatalf("%s does not contain %q:\n%s", verification.step, required, step)
}
}
}
inventoryStep := workflowStepBlock(
t,
workflowJobBlock(t, content, "publication-preflight"),
"Inventory publication channels",
)
for _, required := range []string{
"gh release view",
"--json databaseId,targetCommitish,isDraft,isPrerelease,assets",
`test "${github_draft}" = "true"`,
`test "${github_prerelease}" = \`,
} {
if !strings.Contains(inventoryStep, required) {
t.Fatalf("draft-aware publication inventory does not contain %q:\n%s", required, inventoryStep)
}
}
if strings.Contains(inventoryStep, "/releases/tags/") {
t.Fatalf("publication inventory uses an endpoint that cannot discover drafts:\n%s", inventoryStep)
}
}
func TestReleaseWorkflowIncludesCompleteS5Notes(t *testing.T) {
@@ -1020,15 +1191,18 @@ func TestReleaseWorkflowIncludesCompleteS5Notes(t *testing.T) {
"Stop cleanly",
"`-wal`/`-shm`",
"recovery set",
"v2beta",
"missing",
"partial",
"unpriced",
"compatible upstream",
"encryption key rotation",
"2026-07-27",
"https://developers.openai.com/api/docs/pricing",
"https://platform.claude.com/docs/en/about-claude/pricing",
"https://ai.google.dev/gemini-api/docs/pricing",
"New 2.x deployments",
"supported 2.x installations",
"version-specific release notes",
"embedded official catalog",
"Models.dev",
"explicit user overrides",
"unified data/control dual-plane architecture",
"Groups",
"AccessKeys",
@@ -1041,6 +1215,72 @@ func TestReleaseWorkflowIncludesCompleteS5Notes(t *testing.T) {
if strings.Contains(releaseJob, "app.notion.com") {
t.Fatalf("public release notes depend on a private Notion page:\n%s", releaseJob)
}
for _, stale := range []string{
"Earlier pre-release 2.x databases are not supported",
"Models.dev is the sole automatic price source",
"Built-in prices were reviewed on",
"GPT-Load 2.0.0 does not support encryption key rotation",
} {
if strings.Contains(releaseJob, stale) {
t.Fatalf("release notes contain stale statement %q:\n%s", stale, releaseJob)
}
}
}
func TestCommunityTemplatesCaptureVersionCompatibilityAndSecretSafety(t *testing.T) {
bug := readRepositoryFile(t, ".github/ISSUE_TEMPLATE/bug_report.md")
for _, required := range []string{
"当前主版本线的最新补丁版本",
"GPT-Load 版本",
"1.x 或 2.x",
"部署方式",
"操作系统与架构",
"数据库",
"客户端协议",
"实际结果及脱敏日志",
"AUTH_KEY",
"ENCRYPTION_KEY",
"AccessKey",
} {
if !strings.Contains(bug, required) {
t.Fatalf("bug report template does not contain %q", required)
}
}
feature := readRepositoryFile(t, ".github/ISSUE_TEMPLATE/feature_request.md")
for _, required := range []string{
"当前主版本线的最新补丁版本",
"目标版本线",
"1.x 维护线或 2.x",
"应用场景",
"兼容性、数据与安全影响",
"不要提交任何凭据或令牌",
} {
if !strings.Contains(feature, required) {
t.Fatalf("feature request template does not contain %q", required)
}
}
pullRequest := readRepositoryFile(t, ".github/pull_request_template.md")
for _, heading := range []string{
"### 关联 Issue / Related Issue",
"### 变更内容 / Change Content",
"### 自查清单 / Checklist",
} {
if count := strings.Count(pullRequest, heading); count != 1 {
t.Fatalf("pull request template contains heading %q %d times, want once", heading, count)
}
}
for _, required := range []string{
"make check",
"未包含无关改动",
"敏感信息",
"兼容性或数据迁移",
} {
if !strings.Contains(pullRequest, required) {
t.Fatalf("pull request template does not contain %q", required)
}
}
}
func TestReleaseWorkflowVerifiesDownloadedNativeChecksumsAndGeneratedKeys(t *testing.T) {
@@ -1220,7 +1460,7 @@ func TestReleaseWorkflowPostPublishDownloadsExactAssetsAndRunsFiveNativeSmokes(t
inventoryJob := workflowJobBlock(t, content, "post-publish-verify")
for _, required := range []string{
"gh release download",
"public-release",
"draft-release",
"SHA256SUMS",
"sha256sum --check",
"asset_count",