#!/usr/bin/env bash set -euo pipefail DEV_BRANCH="dev" MAIN_BRANCH="main" ORIGIN_REMOTE="origin" DEFAULT_AGENT_DEVELOPMENT_PATHS=( "AGENTS.md" "SCRATCHPAD.md" "guidance" "requirements" "prompts" "process" ) AGENT_DEVELOPMENT_PATHS=("${DEFAULT_AGENT_DEVELOPMENT_PATHS[@]}") CURRENT_BRANCH="${DEV_BRANCH}" ASSUME_YES=0 DRY_RUN=0 SKIP_TESTS=0 usage() { cat <&2 exit 1 } cleanup() { local exit_code="$1" trap - EXIT if git rev-parse -q --verify MERGE_HEAD >/dev/null 2>&1; then printf 'Merge is incomplete; aborting merge on %s...\n' "${CURRENT_BRANCH}" >&2 git merge --abort >/dev/null 2>&1 || true fi if [ "${CURRENT_BRANCH}" != "${DEV_BRANCH}" ]; then printf 'Switching back to %s...\n' "${DEV_BRANCH}" >&2 git switch "${DEV_BRANCH}" >/dev/null 2>&1 || true CURRENT_BRANCH="${DEV_BRANCH}" fi exit "${exit_code}" } load_cleanup_paths() { if [ -n "${FFX_RELEASE_CLEAN_PATHS:-}" ]; then IFS=':' read -r -a AGENT_DEVELOPMENT_PATHS <<< "${FFX_RELEASE_CLEAN_PATHS}" fi if [ "${#AGENT_DEVELOPMENT_PATHS[@]}" -eq 0 ]; then fail "Release cleanup path list is empty." fi } require_repo_state() { if ! git rev-parse --show-toplevel >/dev/null 2>&1; then fail "This helper must be run inside a git repository." fi if ! git show-ref --verify --quiet "refs/heads/${DEV_BRANCH}"; then fail "Local branch '${DEV_BRANCH}' does not exist." fi if ! git show-ref --verify --quiet "refs/heads/${MAIN_BRANCH}"; then fail "Local branch '${MAIN_BRANCH}' does not exist." fi if ! git remote get-url "${ORIGIN_REMOTE}" >/dev/null 2>&1; then fail "Remote '${ORIGIN_REMOTE}' is not configured." fi } require_dev_checkout() { CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)" if [ "${CURRENT_BRANCH}" != "${DEV_BRANCH}" ]; then fail "Current branch is '${CURRENT_BRANCH}', but '${DEV_BRANCH}' is required." fi } require_clean_worktree() { if [ -n "$(git status --porcelain)" ]; then fail "Local '${DEV_BRANCH}' branch is dirty. Commit, stash, or clean changes first." fi } fetch_remote_state() { printf 'Fetching %s branch and tag state...\n' "${ORIGIN_REMOTE}" git fetch "${ORIGIN_REMOTE}" "${DEV_BRANCH}" "${MAIN_BRANCH}" --tags >/dev/null } require_branch_matches_remote() { local branch="$1" local local_sha="" local remote_sha="" if ! git show-ref --verify --quiet "refs/remotes/${ORIGIN_REMOTE}/${branch}"; then fail "Remote branch '${ORIGIN_REMOTE}/${branch}' does not exist." fi local_sha="$(git rev-parse "refs/heads/${branch}")" remote_sha="$(git rev-parse "refs/remotes/${ORIGIN_REMOTE}/${branch}")" if [ "${local_sha}" != "${remote_sha}" ]; then fail "Local branch '${branch}' is not up to date with '${ORIGIN_REMOTE}/${branch}'. Pull, rebase, or push first." fi } resolve_release_version() { local version_from_pyproject="" local version_from_constants="" version_from_pyproject="$( sed -n 's/^version = "\(.*\)"$/\1/p' pyproject.toml | head -n 1 )" version_from_constants="$( sed -n "s/^VERSION='\(.*\)'$/\1/p" src/ffx/constants.py | head -n 1 )" if [ -z "${version_from_pyproject}" ]; then fail "Could not resolve release version from pyproject.toml." fi if [ -z "${version_from_constants}" ]; then fail "Could not resolve release version from src/ffx/constants.py." fi if [ "${version_from_pyproject}" != "${version_from_constants}" ]; then fail "Version mismatch: pyproject.toml=${version_from_pyproject}, src/ffx/constants.py=${version_from_constants}." fi printf '%s\n' "${version_from_pyproject}" } require_release_tag_available() { local release_version="$1" local release_tag="v${release_version}" if git rev-parse -q --verify "refs/tags/${release_tag}" >/dev/null 2>&1; then fail "Tag '${release_tag}' already exists." fi if git rev-parse -q --verify "refs/tags/${release_version}" >/dev/null 2>&1; then fail "Bare tag '${release_version}' already exists; refusing to create ambiguous release tags." fi } run_pre_release_tests() { if [ "${SKIP_TESTS}" -eq 1 ]; then printf 'Skipping pre-release tests.\n' return 0 fi if [ ! -x "./tools/test.sh" ]; then fail "Missing executable test runner at ./tools/test.sh." fi printf 'Running pre-release tests via ./tools/test.sh...\n' ./tools/test.sh } print_release_plan() { local release_version="$1" local release_tag="v${release_version}" local release_commit_message="Release ${release_tag}" printf 'Dry run only. Planned steps:\n' printf '1. Ensure current branch is %s and the worktree is clean.\n' "${DEV_BRANCH}" printf '2. Fetch %s and verify local %s and %s exactly match %s/%s and %s/%s.\n' \ "${ORIGIN_REMOTE}" \ "${DEV_BRANCH}" \ "${MAIN_BRANCH}" \ "${ORIGIN_REMOTE}" \ "${DEV_BRANCH}" \ "${ORIGIN_REMOTE}" \ "${MAIN_BRANCH}" if [ "${SKIP_TESTS}" -eq 1 ]; then printf '3. Skip the pre-release test gate.\n' else printf '3. Run ./tools/test.sh as the pre-release test gate.\n' fi printf '4. Switch to %s and merge %s with --no-ff --no-commit.\n' "${MAIN_BRANCH}" "${DEV_BRANCH}" printf '5. Remove release-cleanup paths from %s:\n' "${MAIN_BRANCH}" local cleanup_path="" for cleanup_path in "${AGENT_DEVELOPMENT_PATHS[@]}"; do printf ' - %s\n' "${cleanup_path}" done printf '6. Create merge commit: %s\n' "${release_commit_message}" printf '7. Create annotated tag: %s\n' "${release_tag}" printf '8. Push %s to %s/%s with --follow-tags.\n' "${MAIN_BRANCH}" "${ORIGIN_REMOTE}" "${MAIN_BRANCH}" printf '9. Switch back to %s.\n' "${DEV_BRANCH}" } trap 'cleanup $?' EXIT while [ "$#" -gt 0 ]; do case "$1" in --yes) ASSUME_YES=1 ;; --dry-run) DRY_RUN=1 ;; --skip-tests) SKIP_TESTS=1 ;; --help|-h) usage exit 0 ;; *) usage >&2 fail "Unknown option: $1" ;; esac shift done load_cleanup_paths require_repo_state require_dev_checkout require_clean_worktree fetch_remote_state require_branch_matches_remote "${DEV_BRANCH}" require_branch_matches_remote "${MAIN_BRANCH}" RELEASE_VERSION="$(resolve_release_version)" RELEASE_TAG="v${RELEASE_VERSION}" RELEASE_COMMIT_MESSAGE="Release ${RELEASE_TAG}" require_release_tag_available "${RELEASE_VERSION}" printf 'This will merge %s into %s, remove agent-development files on %s,\n' "${DEV_BRANCH}" "${MAIN_BRANCH}" "${MAIN_BRANCH}" printf 'run the pre-release gate%s, create %s, push to %s/%s, and switch back to %s.\n' \ "$([ "${SKIP_TESTS}" -eq 1 ] && printf ' (skipped)' || printf '')" \ "${RELEASE_TAG}" \ "${ORIGIN_REMOTE}" \ "${MAIN_BRANCH}" \ "${DEV_BRANCH}" if [ "${ASSUME_YES}" -ne 1 ]; then printf 'Are you sure? [y/N] ' read -r confirmation case "${confirmation}" in y|Y|yes|YES) ;; *) fail "Aborted by user." ;; esac fi if [ "${DRY_RUN}" -eq 1 ]; then print_release_plan "${RELEASE_VERSION}" exit 0 fi run_pre_release_tests require_clean_worktree fetch_remote_state require_branch_matches_remote "${DEV_BRANCH}" require_branch_matches_remote "${MAIN_BRANCH}" require_release_tag_available "${RELEASE_VERSION}" git switch "${MAIN_BRANCH}" >/dev/null CURRENT_BRANCH="${MAIN_BRANCH}" printf 'Merging %s into %s...\n' "${DEV_BRANCH}" "${MAIN_BRANCH}" if ! git merge --no-ff --no-commit "${DEV_BRANCH}"; then fail "Merge from '${DEV_BRANCH}' into '${MAIN_BRANCH}' failed." fi if ! git rev-parse -q --verify MERGE_HEAD >/dev/null 2>&1; then fail "'${MAIN_BRANCH}' is already up to date with '${DEV_BRANCH}'. Nothing to merge." fi printf 'Removing agent-development files from %s...\n' "${MAIN_BRANCH}" git rm -r --ignore-unmatch "${AGENT_DEVELOPMENT_PATHS[@]}" >/dev/null if git diff --cached --quiet; then fail "No staged changes are present after merging '${DEV_BRANCH}' into '${MAIN_BRANCH}'." fi printf 'Creating release merge commit: %s\n' "${RELEASE_COMMIT_MESSAGE}" git commit -m "${RELEASE_COMMIT_MESSAGE}" printf 'Creating annotated tag: %s\n' "${RELEASE_TAG}" git tag -a "${RELEASE_TAG}" -m "FFX ${RELEASE_VERSION}" printf 'Pushing %s and annotated tags to %s...\n' "${MAIN_BRANCH}" "${ORIGIN_REMOTE}" git push "${ORIGIN_REMOTE}" "${MAIN_BRANCH}" --follow-tags printf 'Switching back to %s...\n' "${DEV_BRANCH}" git switch "${DEV_BRANCH}" >/dev/null CURRENT_BRANCH="${DEV_BRANCH}" printf 'Release merge complete: %s pushed to %s/%s and tagged as %s.\n' \ "${RELEASE_COMMIT_MESSAGE}" \ "${ORIGIN_REMOTE}" \ "${MAIN_BRANCH}" \ "${RELEASE_TAG}"