Files
codex-proxy-rs/release/publish
T
ZyyandCodex 817b0f2d6d chore(release): commit version and notes together
Allow staged or unstaged release notes and commit their final contents with the version update before tagging. Continue rejecting unrelated tracked changes and require notes to remain uncommitted.

Update the release skill, skip duplicate push CI for release metadata alone, and check the publish script syntax in CI. Validated publication behavior in temporary Git repositories, Bash syntax, and Actionlint.

Co-authored-by: Codex <noreply@openai.com>
2026-09-15 15:14:18 +08:00

173 lines
5.2 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,
starting with "# v<version>". Leave the notes uncommitted; this command commits
them together with release/version.yaml. Commit other tracked changes first.
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 -- . ":(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
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" "$NOTES_FILE"
git commit \
-m "chore(release): prepare $tag" \
-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