Files
ZyyandCodex b9bcb4e086 ci: streamline builds and release validation
Share binary builds and dependency caches, run Windows platform checks in parallel, and reuse verified CI evidence for version-only releases. Reuse prebuilt CI image inputs and add read-only release preflight.

Co-authored-by: Codex <noreply@openai.com>
2026-11-05 13:03:59 +08:00

192 lines
6.6 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
APP_NAME="codex-proxy-rs"
VERSION_FILE="release/version.yaml"
NOTES_FILE="release/notes.md"
PLUGIN_DEPRECATIONS_FILE="backend/crates/gateway-plugin/runtime/plugin-api-deprecations.json"
PLUGIN_COMPATIBILITY_FILE="backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json"
TAG_PREFIX="v"
SIGNATURE="Co-authored-by: Codex <noreply@openai.com>"
usage() {
cat <<USAGE
Usage:
release/publish <version>
release/publish --check <version>
Example:
release/publish 0.1.4
Before publishing, replace release/notes.md with the new version's notes,
starting with "# v<version>". Leave the notes uncommitted; this command commits
them together with release/version.yaml. Commit other tracked changes first.
Use --check to validate without changing files, Git refs, or remote state.
USAGE
}
die() {
echo "error: $*" >&2
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || die "$1 is required"
}
read_version_key() {
local key="$1"
ruby -ryaml -e '
path, key = ARGV
data = YAML.load_file(path)
value = data[key] if data.is_a?(Hash)
unless value.is_a?(String) && !value.strip.empty?
warn "#{path}: #{key} must be a non-empty string"
exit 1
end
puts value.strip
' "$VERSION_FILE" "$key"
}
update_version_file() {
local version="$1"
ruby - "$VERSION_FILE" "$version" <<'RUBY'
path, version = ARGV
content = File.read(path)
unless content.match?(/^version:/)
warn "#{path}: missing version key"
exit 1
end
content = content.sub(/^version:\s*.*$/, %(version: "#{version}"))
File.write(path, content)
RUBY
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
check_only=false
if [[ "${1:-}" == "--check" ]]; then
check_only=true
# 只读检查也禁止 Git 的可选索引刷新
export GIT_OPTIONAL_LOCKS=0
shift
fi
[[ "$#" -eq 1 ]] || {
usage >&2
exit 2
}
require_command git
require_command gh
require_command ruby
gh auth status >/dev/null 2>&1 || die "gh authentication is required; run gh auth login first"
version="$1"
repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || die "not inside a git repository"
cd "$repo_root"
version="$(ruby .github/scripts/release-metadata.rb validate "$version")"
ruby .github/scripts/toolchains.rb >/dev/null
ruby -r './.github/scripts/release-metadata' -e 'ReleaseMetadata.platforms'
git ls-files --error-unmatch "$VERSION_FILE" >/dev/null 2>&1 || die "$VERSION_FILE is not tracked"
branch="$(git symbolic-ref --quiet --short HEAD)" || die "publishing from detached HEAD is not supported"
remote="$(git config --get "branch.$branch.remote" || true)"
merge_ref="$(git config --get "branch.$branch.merge" || true)"
[[ -n "$remote" && -n "$merge_ref" ]] || die "current branch has no upstream"
remote_branch="${merge_ref#refs/heads/}"
[[ "$remote_branch" != "$merge_ref" && -n "$remote_branch" ]] || die "unsupported upstream ref: $merge_ref"
remote_url="$(git remote get-url "$remote")" || die "failed to resolve remote URL for $remote"
repository="$(gh repo view "$remote_url" --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null)" ||
die "failed to resolve GitHub repository for $remote_url"
if ! git diff --quiet --ignore-submodules -- . ":(exclude)$NOTES_FILE"; then
die "tracked files other than $NOTES_FILE have unstaged changes; commit or stash them first"
fi
if ! git diff --cached --quiet --ignore-submodules -- . ":(exclude)$NOTES_FILE"; then
die "index has staged changes outside $NOTES_FILE; commit or unstage them first"
fi
current_version="$(read_version_key version)"
current_version="${current_version#v}"
[[ "$version" != "$current_version" ]] || die "$VERSION_FILE is already $version"
tag="${TAG_PREFIX}${version}"
git ls-files --error-unmatch "$NOTES_FILE" >/dev/null 2>&1 || die "$NOTES_FILE is not tracked"
if git diff HEAD --quiet -- "$NOTES_FILE"; then
die "$NOTES_FILE must have uncommitted changes so notes and version are committed together"
fi
if git rev-parse --quiet --verify "refs/tags/$tag" >/dev/null; then
die "local tag already exists: $tag"
fi
if [[ "$check_only" == false ]]; then
git fetch "$remote"
fi
upstream="$remote/$remote_branch"
local_head="$(git rev-parse HEAD)"
remote_refs="$(git ls-remote --refs "$remote" "$merge_ref" "refs/tags/$tag")" || die "failed to read remote refs"
upstream_head="$(awk -v ref="$merge_ref" '$2 == ref { print $1 }' <<< "$remote_refs")"
[[ -n "$upstream_head" ]] || die "upstream branch does not exist: $upstream"
if awk -v ref="refs/tags/$tag" '$2 == ref { found = 1 } END { exit !found }' <<< "$remote_refs"; then
die "remote tag already exists: $tag"
fi
git cat-file -e "$upstream_head^{commit}" 2>/dev/null || die "upstream has unseen commits; run git fetch $remote first"
merge_base="$(git merge-base HEAD "$upstream_head")" || die "branch has no common history with $upstream"
if [[ "$local_head" != "$upstream_head" && "$merge_base" != "$upstream_head" ]]; then
if [[ "$merge_base" == "$local_head" ]]; then
die "branch is behind $upstream; pull or rebase first"
fi
die "branch has diverged from $upstream"
fi
if [[ "$check_only" == true ]]; then
# 预检调用与正式更新相同的纯函数,不写回兼容窗口
ruby -r './backend/crates/gateway-plugin/runtime/plugin-api-lifecycle' -e '
deprecations, compatibility, version = ARGV
PluginApiLifecycle.advance(JSON.parse(File.read(deprecations)), JSON.parse(File.read(compatibility)), version)
' "$PLUGIN_DEPRECATIONS_FILE" "$PLUGIN_COMPATIBILITY_FILE" "$version"
echo "Preflight passed: $APP_NAME $version on $branch -> $repository ($remote_branch)"
echo "No files, Git refs, or remote state changed. CI and release artifact validation still run after dispatch."
exit 0
fi
ruby backend/crates/gateway-plugin/runtime/plugin-api-lifecycle.rb "$PLUGIN_DEPRECATIONS_FILE" "$PLUGIN_COMPATIBILITY_FILE" "$version"
update_version_file "$version"
git add -- "$VERSION_FILE" "$NOTES_FILE" "$PLUGIN_DEPRECATIONS_FILE"
git commit \
-m "chore(release): $tag" \
-m "$SIGNATURE"
git tag -a "$tag" -m "$tag"
git push --atomic "$remote" "HEAD:$remote_branch" "refs/tags/$tag"
# 从长期发行分支触发,使 Actions 缓存可跨版本复用
if ! gh workflow run release.yml \
--repo "$repository" \
--ref "$remote_branch" \
--raw-field "tag=$tag"; then
echo "error: commit and tag were pushed, but the Release workflow was not dispatched" >&2
echo "retry: gh workflow run release.yml --repo $repository --ref $remote_branch --raw-field tag=$tag" >&2
exit 1
fi
cat <<DONE
Queued release for $APP_NAME $version
Commit: $(git rev-parse --short HEAD)
Tag: $tag
Release workflow dispatched from: $remote_branch
DONE