diff --git a/.github/workflows/validate-repo-maintenance.yml b/.github/workflows/validate-repo-maintenance.yml index 5ee2248..771d91d 100644 --- a/.github/workflows/validate-repo-maintenance.yml +++ b/.github/workflows/validate-repo-maintenance.yml @@ -1,5 +1,8 @@ name: Validate Repo Maintenance +# Branch protection should require the Actions check context `validate`. +# GitHub exposes the job check run by this job name, not by the workflow title. + on: pull_request: push: @@ -8,10 +11,11 @@ on: jobs: validate: + name: validate runs-on: macos-latest steps: - uses: actions/checkout@v4 - - name: Install SwiftLint - run: brew install swiftlint + - name: Install Swift repo-maintenance tools + run: brew install swiftformat swiftlint - name: Run repo-maintenance validation run: bash scripts/repo-maintenance/validate-all.sh diff --git a/.swiftlint.yml b/.swiftlint.yml index b33ea4d..715bb5c 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,47 +1,20 @@ -included: - - gmax - - gmaxTests - - gmaxUITests +# Keep SwiftLint focused on non-formatting checks. +# SwiftFormat owns visual shape in this repository. excluded: - - gmax.xcodeproj - - docs - - scripts + - .build + - .local -reporter: xcode - -disabled_rules: - - line_length - - file_length - - type_body_length - - function_body_length - - function_parameter_count - - cyclomatic_complexity - - nesting - - large_tuple - - identifier_name - - type_name - - todo - - trailing_comma - - switch_case_alignment - - implicit_optional_initialization - - multiple_closures_with_trailing_closure - - for_where - -opt_in_rules: +only_rules: + - duplicate_imports - empty_count - - empty_string + - fatal_error_message + - force_try - force_unwrapping - - implicitly_unwrapped_optional + - unused_import force_try: - severity: error - -force_cast: - severity: error + severity: warning force_unwrapping: - severity: error - -implicitly_unwrapped_optional: - severity: error + severity: warning diff --git a/scripts/repo-maintenance/config/profile.env b/scripts/repo-maintenance/config/profile.env index 6340736..302444d 100644 --- a/scripts/repo-maintenance/config/profile.env +++ b/scripts/repo-maintenance/config/profile.env @@ -1,3 +1,3 @@ -# Managed by repo-maintenance-toolkit. Do not hand-edit unless you also control the installer contract. +# Managed by maintain-project-repo. Do not hand-edit unless you also control the installer contract. REPO_MAINTENANCE_PROFILE="xcode-app" REPO_MAINTENANCE_PROFILE_DESCRIPTION="Xcode app repo-maintenance profile for native Apple app repositories." diff --git a/scripts/repo-maintenance/config/release.env b/scripts/repo-maintenance/config/release.env index 1e3f514..50c4868 100644 --- a/scripts/repo-maintenance/config/release.env +++ b/scripts/repo-maintenance/config/release.env @@ -1,2 +1,3 @@ # Repo-maintenance release defaults. REPO_MAINTENANCE_DEFAULT_RELEASE_MODE=standard +REPO_MAINTENANCE_RELEASE_BRANCH=main diff --git a/scripts/repo-maintenance/hooks/pre-commit.sample b/scripts/repo-maintenance/hooks/pre-commit.sample index c4c1fcf..8fc8726 100755 --- a/scripts/repo-maintenance/hooks/pre-commit.sample +++ b/scripts/repo-maintenance/hooks/pre-commit.sample @@ -12,7 +12,7 @@ if ! command -v swiftformat >/dev/null 2>&1; then fi if [ ! -f "$config_file" ]; then - echo "SwiftFormat pre-commit hook expected a checked-in config at $config_file, but it was missing. Restore the managed .swiftformat file or refresh the repo-maintenance toolkit before committing." >&2 + echo "SwiftFormat pre-commit hook expected a checked-in config at $config_file, but it was missing. Restore the managed .swiftformat file or refresh maintain-project-repo before committing." >&2 exit 1 fi diff --git a/scripts/repo-maintenance/lib/common.sh b/scripts/repo-maintenance/lib/common.sh index abfc13d..1e0405b 100755 --- a/scripts/repo-maintenance/lib/common.sh +++ b/scripts/repo-maintenance/lib/common.sh @@ -39,7 +39,7 @@ load_profile_env() { } ensure_git_repo() { - git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1 || die "The repo-maintenance toolkit must run inside a git worktree rooted at $REPO_ROOT." + git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1 || die "maintain-project-repo must run inside a git worktree rooted at $REPO_ROOT." } run_dispatch_dir() { diff --git a/scripts/repo-maintenance/release.sh b/scripts/repo-maintenance/release.sh index 0dbb759..5665b83 100755 --- a/scripts/repo-maintenance/release.sh +++ b/scripts/repo-maintenance/release.sh @@ -12,6 +12,10 @@ mode="${REPO_MAINTENANCE_DEFAULT_RELEASE_MODE:-standard}" release_tag="" skip_validate="false" skip_gh_release="false" +skip_version_bump="false" +base_branch="${REPO_MAINTENANCE_RELEASE_BRANCH:-main}" +review_comments_addressed="false" +skip_branch_cleanup="false" dry_run="false" while [ "$#" -gt 0 ]; do @@ -32,6 +36,22 @@ while [ "$#" -gt 0 ]; do skip_gh_release="true" shift ;; + --skip-version-bump) + skip_version_bump="true" + shift + ;; + --base-branch) + base_branch="${2:-}" + shift 2 + ;; + --review-comments-addressed) + review_comments_addressed="true" + shift + ;; + --skip-branch-cleanup) + skip_branch_cleanup="true" + shift + ;; --dry-run) dry_run="true" shift @@ -39,7 +59,8 @@ while [ "$#" -gt 0 ]; do -h|--help) cat <<'USAGE' Usage: - release.sh --mode --version [--skip-validate] [--skip-gh-release] [--dry-run] + release.sh --mode standard --version [--base-branch main] [--skip-validate] [--skip-version-bump] [--skip-gh-release] [--review-comments-addressed] [--skip-branch-cleanup] [--dry-run] + release.sh --mode submodule --version [--skip-validate] [--skip-gh-release] [--dry-run] USAGE exit 0 ;; @@ -56,6 +77,281 @@ export RELEASE_TAG="$release_tag" export REPO_MAINTENANCE_SKIP_GH_RELEASE="$skip_gh_release" export REPO_MAINTENANCE_DRY_RUN="$dry_run" +ensure_clean_worktree() { + status_output="$(git -C "$REPO_ROOT" status --porcelain)" + [ -z "$status_output" ] || die "Release workflow requires committed changes and a clean worktree before it can continue." +} + +ensure_gh_cli() { + command -v gh >/dev/null 2>&1 || die "Standard release mode requires the GitHub CLI gh so it can create the pull request, watch CI, inspect review comments, merge, and publish the release." +} + +ensure_semver_tag() { + case "$RELEASE_TAG" in + v[0-9]*.[0-9]*.[0-9]*|v[0-9]*.[0-9]*.[0-9]*-*) + ;; + *) + die "Release tag must use vX.Y.Z SemVer syntax." + ;; + esac +} + +current_branch() { + git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD || true +} + +ensure_branch_release_context() { + branch_name="$(current_branch)" + [ -n "$branch_name" ] || die "Standard release mode requires a named feature branch or worktree instead of detached HEAD." + [ "$branch_name" != "$base_branch" ] || die "Standard release mode must run from a release branch or worktree, not protected $base_branch." + printf '%s\n' "$branch_name" +} + +run_version_bump() { + release_version="${RELEASE_TAG#v}" + version_bump_script="$SELF_DIR/version-bump.sh" + + if [ "$skip_version_bump" = "true" ]; then + log "Skipping repo version bump because --skip-version-bump was requested." + return 0 + fi + + [ -x "$version_bump_script" ] || die "Standard release mode expected an executable repo-specific version bump hook at $version_bump_script. Add that hook so the repo's version surfaces move together, or rerun with --skip-version-bump when this release intentionally has no version-bearing files." + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would run $version_bump_script $release_version with RELEASE_TAG=$RELEASE_TAG." + return 0 + fi + + RELEASE_VERSION="$release_version" "$version_bump_script" "$release_version" + + if [ -z "$(git -C "$REPO_ROOT" status --porcelain)" ]; then + die "Version bump hook completed without changing files. Update $version_bump_script to edit the repo's version surfaces, or rerun with --skip-version-bump if this release intentionally has no version bump." + fi + + git -C "$REPO_ROOT" add -A + git -C "$REPO_ROOT" commit -m "release: bump versions for $RELEASE_TAG" + log "Committed version bump for $RELEASE_TAG." +} + +create_release_tag() { + head_sha="$(git -C "$REPO_ROOT" rev-parse HEAD)" + tag_sha="$(git -C "$REPO_ROOT" rev-parse -q --verify "refs/tags/$RELEASE_TAG" 2>/dev/null || true)" + + if [ -n "$tag_sha" ]; then + [ "$tag_sha" = "$head_sha" ] || die "Tag $RELEASE_TAG already exists and does not point at HEAD." + log "Tag $RELEASE_TAG already points at HEAD." + return 0 + fi + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would create annotated tag $RELEASE_TAG at HEAD." + return 0 + fi + + git -C "$REPO_ROOT" tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG" + log "Created annotated tag $RELEASE_TAG." +} + +push_branch_and_tag() { + branch_name="$1" + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would push branch $branch_name and tag $RELEASE_TAG to origin." + return 0 + fi + + git -C "$REPO_ROOT" push -u origin "$branch_name" + git -C "$REPO_ROOT" push origin "$RELEASE_TAG" + log "Pushed branch $branch_name and tag $RELEASE_TAG." +} + +create_or_update_pr() { + branch_name="$1" + PR_NUMBER="" + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would create or update a release PR from $branch_name into $base_branch." + PR_NUMBER="DRY-RUN" + return 0 + fi + + body_file="$(mktemp "${TMPDIR:-/tmp}/repo-maintenance-release-pr.XXXXXX")" + trap 'rm -f "$body_file"' EXIT INT TERM + + cat >"$body_file" </dev/null + log "Updated existing release PR #$pr_number at $pr_url." + else + gh pr create --base "$base_branch" --head "$branch_name" --title "release: prepare $RELEASE_TAG" --body-file "$body_file" >/dev/null + pr_number="$(gh pr list --head "$branch_name" --base "$base_branch" --json number --jq '.[0].number // empty' --limit 1)" + [ -n "$pr_number" ] || die "GitHub CLI did not return a release PR number after creating the pull request." + pr_url="$(gh pr view "$pr_number" --json url --jq '.url')" + log "Created release PR #$pr_number at $pr_url." + PR_NUMBER="$pr_number" + return 0 + fi + + PR_NUMBER="$pr_number" +} + +watch_ci() { + pr_number="$1" + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would watch CI for PR #$pr_number." + return 0 + fi + + log "Watching CI for PR #$pr_number." + if ! gh pr checks "$pr_number" --watch; then + die "CI is not green for PR #$pr_number. Fix the failing checks, push the branch, and rerun release.sh so it can watch CI again." + fi + log "CI is green for PR #$pr_number." +} + +check_pr_comments() { + pr_number="$1" + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would check PR #$pr_number for comments and requested changes." + return 0 + fi + + review_decision="$(gh pr view "$pr_number" --json reviewDecision --jq '.reviewDecision // ""')" + comment_count="$(gh pr view "$pr_number" --json comments,reviews --jq '([.comments[]?, .reviews[]?] | length)')" + + if [ "$review_decision" = "CHANGES_REQUESTED" ]; then + gh pr view "$pr_number" --comments + die "PR #$pr_number has requested changes. Address valid concerns in code, or add out-of-scope concerns to ROADMAP.md, resolve the threads, push, and rerun release.sh." + fi + + if [ "$comment_count" != "0" ] && [ "$review_comments_addressed" != "true" ]; then + gh pr view "$pr_number" --comments + die "PR #$pr_number has review or discussion comments. Address and resolve valid concerns, add out-of-scope concerns to ROADMAP.md, then rerun release.sh with --review-comments-addressed once the comment pass is intentionally complete." + fi + + log "PR #$pr_number has no blocking review state." +} + +merge_pr() { + pr_number="$1" + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would merge PR #$pr_number into $base_branch with a merge commit and delete the remote branch." + return 0 + fi + + gh pr merge "$pr_number" --merge --delete-branch + log "Merged PR #$pr_number into $base_branch." +} + +fast_forward_base_branch() { + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would fast-forward local $base_branch from origin/$base_branch." + return 0 + fi + + git -C "$REPO_ROOT" fetch origin "$base_branch" + if git -C "$REPO_ROOT" switch "$base_branch" 2>/dev/null || git -C "$REPO_ROOT" checkout "$base_branch" 2>/dev/null; then + git -C "$REPO_ROOT" pull --ff-only origin "$base_branch" + log "Fast-forwarded local $base_branch." + else + warn "Could not check out local $base_branch, likely because another worktree owns it. Fast-forward $base_branch from origin/$base_branch in that checkout before cleanup." + fi +} + +create_github_release() { + if [ "$REPO_MAINTENANCE_SKIP_GH_RELEASE" = "true" ]; then + log "Skipping GitHub release creation because --skip-gh-release was requested." + return 0 + fi + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would create a GitHub release for $RELEASE_TAG with gh release create --verify-tag." + return 0 + fi + + if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + log "GitHub release $RELEASE_TAG already exists." + return 0 + fi + + gh release create "$RELEASE_TAG" --verify-tag --generate-notes + log "Created GitHub release $RELEASE_TAG." +} + +cleanup_merged_branches() { + release_branch_name="$1" + + if [ "$skip_branch_cleanup" = "true" ]; then + log "Skipping local merged-branch cleanup because --skip-branch-cleanup was requested." + return 0 + fi + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would prune origin and delete local branches already merged into $base_branch, including $release_branch_name when safe." + return 0 + fi + + git -C "$REPO_ROOT" remote prune origin + for merged_branch in $(git -C "$REPO_ROOT" for-each-ref --format='%(refname:short)' --merged "$base_branch" refs/heads); do + case "$merged_branch" in + "$base_branch") + ;; + *) + git -C "$REPO_ROOT" branch -d "$merged_branch" >/dev/null 2>&1 || warn "Could not delete local merged branch $merged_branch; it may be checked out in another worktree." + ;; + esac + done + log "Cleaned up local branches already merged into $base_branch where safe." +} + +run_standard_release() { + ensure_git_repo + ensure_gh_cli + ensure_semver_tag + branch_name="$(ensure_branch_release_context)" + ensure_clean_worktree + + if [ "$skip_validate" != "true" ]; then + sh "$SELF_DIR/validate-all.sh" + fi + + run_version_bump + ensure_clean_worktree + create_release_tag + push_branch_and_tag "$branch_name" + create_or_update_pr "$branch_name" + pr_number="$PR_NUMBER" + watch_ci "$pr_number" + check_pr_comments "$pr_number" + merge_pr "$pr_number" + fast_forward_base_branch + create_github_release + cleanup_merged_branches "$branch_name" + log "Standard release flow completed successfully for $RELEASE_TAG." +} + +if [ "$mode" = "standard" ]; then + run_standard_release + exit 0 +fi + if [ "$skip_validate" != "true" ]; then sh "$SELF_DIR/validate-all.sh" fi diff --git a/scripts/repo-maintenance/validations/10-toolkit-layout.sh b/scripts/repo-maintenance/validations/10-toolkit-layout.sh index f08227b..7103b20 100755 --- a/scripts/repo-maintenance/validations/10-toolkit-layout.sh +++ b/scripts/repo-maintenance/validations/10-toolkit-layout.sh @@ -12,5 +12,5 @@ for required in \ "$REPO_MAINTENANCE_ROOT/lib/common.sh" \ "$REPO_MAINTENANCE_ROOT/config/profile.env" do - [ -f "$required" ] || die "The repo-maintenance toolkit is missing the required file $required." + [ -f "$required" ] || die "maintain-project-repo is missing the required file $required." done diff --git a/scripts/repo-maintenance/validations/20-agents-guidance.sh b/scripts/repo-maintenance/validations/20-agents-guidance.sh index 75a5eb2..2f775a7 100755 --- a/scripts/repo-maintenance/validations/20-agents-guidance.sh +++ b/scripts/repo-maintenance/validations/20-agents-guidance.sh @@ -11,7 +11,7 @@ if [ "${REPO_MAINTENANCE_REQUIRE_AGENTS:-true}" != "true" ]; then fi agents_path="$REPO_ROOT/AGENTS.md" -[ -f "$agents_path" ] || die "Expected $agents_path to exist so the repo-maintenance toolkit has repo guidance to complement." +[ -f "$agents_path" ] || die "Expected $agents_path to exist so maintain-project-repo has repo guidance to complement." [ -s "$agents_path" ] || die "Expected $agents_path to be non-empty." for needle in \