#!/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 " usage() { cat < Example: release/publish 0.1.4 Before publishing, replace release/notes.md with the new version's notes, starting with "# v". 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" 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")" 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 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 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" # 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 <