Files
codex-proxy-rs/release/publish
T
ZyyandCodex 9b19cf083a feat(release): publish release notes from Markdown
Require committed, non-empty notes matching the target release version before
creating a tag. Read notes from the tagged source and place them before the
installation guide and generated release changelog.

Add the 3.3.31 feature summary and keep maintainer instructions in the release
script help and repository skill instead of the deployment README.

Validation: shell syntax and workflow lint passed. Nine isolated checks cover
local publication, stale or missing notes, and release-body rendering.

Co-authored-by: Codex <noreply@openai.com>
2026-09-11 03:40:00 +08:00

169 lines
4.8 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"
TAG_PREFIX="v"
SEMVER='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'
SIGNATURE="Co-authored-by: Codex <noreply@openai.com>"
usage() {
cat <<USAGE
Usage:
release/publish <version>
Example:
release/publish 0.1.4
Before publishing, replace release/notes.md with the new version's notes and
commit it. Its first line must be "# v<version>".
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
[[ "$#" -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#v}"
[[ "$version" =~ $SEMVER ]] || die "version must be semver, for example 0.1.4"
repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || die "not inside a git repository"
cd "$repo_root"
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 --; then
die "tracked files have unstaged changes; commit or stash them first"
fi
if ! git diff --cached --quiet --ignore-submodules --; then
die "index has staged changes; 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"
ruby - "$NOTES_FILE" "$tag" <<'RUBY'
path, tag = ARGV
lines = File.readlines(path)
unless lines.first&.strip == "# #{tag}" && !lines.drop(1).join.strip.empty?
warn "#{path}: replace the contents with notes for #{tag}, starting with '# #{tag}'"
exit 1
end
RUBY
if git rev-parse --quiet --verify "refs/tags/$tag" >/dev/null; then
die "local tag already exists: $tag"
fi
git fetch "$remote"
upstream="$remote/$remote_branch"
local_head="$(git rev-parse HEAD)"
upstream_head="$(git rev-parse "$upstream")"
merge_base="$(git merge-base HEAD "$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 git ls-remote --exit-code --tags "$remote" "refs/tags/$tag" >/dev/null 2>&1; then
die "remote tag already exists: $tag"
fi
update_version_file "$version"
git add "$VERSION_FILE"
git commit \
-m "chore: bump version to $version" \
-m "$SIGNATURE"
git tag -a "$tag" -m "$tag"
git push --atomic "$remote" "HEAD:$remote_branch" "refs/tags/$tag"
# Dispatch from the long-lived release branch instead of the tag so GitHub
# Actions caches remain reusable across releases.
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
Published $APP_NAME $version
Commit: $(git rev-parse --short HEAD)
Tag: $tag
Release workflow dispatched from: $remote_branch
DONE