diff --git a/.codex/environments/swift-package.toml b/.codex/environments/swift-package.toml new file mode 100644 index 0000000..0d26432 --- /dev/null +++ b/.codex/environments/swift-package.toml @@ -0,0 +1,21 @@ +# Copy to .codex/environments/swift-package.toml and adjust action names if needed. +version = 1 +name = "swift-package" + +[setup] +script = "swift package resolve" + +[[actions]] +name = "resolve" +icon = "tool" +command = "swift package resolve" + +[[actions]] +name = "build" +icon = "tool" +command = "swift build" + +[[actions]] +name = "test" +icon = "tool" +command = "swift test" diff --git a/.github/workflows/validate-repo-maintenance.yml b/.github/workflows/validate-repo-maintenance.yml index b33210e..7012827 100644 --- a/.github/workflows/validate-repo-maintenance.yml +++ b/.github/workflows/validate-repo-maintenance.yml @@ -9,19 +9,20 @@ on: branches: - main -permissions: - contents: read - -concurrency: - group: validate-repo-maintenance-${{ github.ref }} - cancel-in-progress: true - jobs: validate: name: validate - runs-on: ubuntu-latest - timeout-minutes: 5 + runs-on: macos-26 steps: - - uses: actions/checkout@v5 + # This is a validated floor, not a ceiling; update to newer stable official versions when validated. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + - name: Report selected Xcode + run: xcode-select --print-path + - name: Report Swift toolchain + run: xcrun swift --version + - 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/AGENTS.md b/AGENTS.md index abda5d7..9713f7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,3 +173,42 @@ xcodebuild docbuild -scheme SwiftASB -destination generic/platform=macOS -derive - Keep `CodexWireInitializeResponse` hand-owned in its own dedicated Swift file next to the promoted generated v2 snapshot until the upstream v2 schema exposes that type directly. - Do not reintroduce a promoted generated v1 batch unless Gale explicitly asks for that compatibility surface again. - Treat the generated wire layer as an internal scaffolding surface, not the final public Swift API. + +## Swift Package Workflow + +- Use `swift build` and `swift test` as the default first-pass validation commands for this package. +- Use `bootstrap-swift-package` when a new Swift package repo still needs to be created from scratch. +- Use `sync-swift-package-guidance` when the repo guidance for this package drifts and needs to be refreshed or merged forward. +- Re-run `sync-swift-package-guidance` after substantial package-workflow or plugin updates so local guidance stays aligned. +- Use `swift-package-build-run-workflow` for manifest, dependency, plugin, resource, Metal-distribution, build, and run work when `Package.swift` is the source of truth. +- Use `swift-package-testing-workflow` for Swift Testing, XCTest holdouts, `.xctestplan`, fixtures, and package test diagnosis. +- Use `scripts/repo-maintenance/validate-all.sh` for local maintainer validation and `scripts/repo-maintenance/sync-shared.sh` for repo-local sync steps. +- Use `scripts/repo-maintenance/release.sh --mode standard --version vX.Y.Z` from a feature branch or worktree only when the task is actually a protected-main release, publish, merge, tag, or release-PR preparation. +- Do not run the standard release workflow from `main`; when a protected-main release is explicitly requested, let it validate, bump versions, tag, push the branch and tag, open the release PR, watch CI, address valid PR comments or record out-of-scope concerns in `ROADMAP.md`, merge to protected `main`, fast-forward local `main`, and clean up stale branches. +- Treat `scripts/repo-maintenance/config/profile.env` as the installed `maintain-project-repo` profile marker, and keep it on the `swift-package` profile for plain package repos. +- Read relevant SwiftPM, Swift, and Apple documentation before proposing package-structure, dependency, manifest, concurrency, or architecture changes. +- Prefer Dash or local Swift docs first, then official Swift or Apple docs when local docs are insufficient. +- When SwiftPM behavior, manifest syntax, package plugins, resources, products, targets, or dependency rules matter, prefer the Dash.app docset workflow with the `swiftlang/swift-package-manager` docset first; fall back to the canonical `swiftlang/swift-package-manager` GitHub repository only when the local docset is unavailable or insufficient. +- Prefer the simplest correct Swift that is easiest to read and reason about. +- Prefer synthesized and framework-provided behavior over extra wrappers and boilerplate. +- For public Swift APIs, treat streamlined, compact, ergonomic call sites as the only acceptable default; prefer optional parameters with explicit default values over additional methods or overloads when the difference is optional behavior on the same operation. +- When a public function, initializer, or method reaches four or more arguments or parameters, strongly prefer a named typed `struct` request, options, or configuration value so call sites stay readable and future additions do not multiply overloads. +- Prefer enums, enum cases with associated values, and narrow typed values over strings, booleans, sentinel values, or parallel parameters whenever the domain has a closed or meaningful set of choices. +- Keep data flow straight and dependency direction unidirectional. +- Treat `Package.swift` as the source of truth for package structure, targets, products, and dependencies. +- Prefer `swift package` subcommands for structural package edits before manually editing `Package.swift`. +- Edit `Package.swift` intentionally and keep it readable; agents may modify it when package structure, targets, products, or dependencies need to change, and should try to keep package graph updates consolidated in one change when possible. +- Keep `Package.swift` explicit about its package-wide Swift language mode. On current Swift 6-era manifests, prefer `swiftLanguageModes: [.v6]` as the default declaration, treat `swiftLanguageVersions` as a legacy alias used only when an older manifest surface requires it, and keep the supported Swift toolchain window focused on the latest stable minor and previous stable minor. Treat Swift `6.2` as the current minimum floor for trait-enabled manifests, not as a ceiling; use newer stable Swift toolchains when available and validated, and refresh this guidance when the maintained floor or window changes. Do not lower `// swift-tools-version:` below `6.2` without an explicit repo policy and a matching guidance update. +- Keep `swift-configuration` as the default configuration dependency for Swift packages unless the package has a concrete reason to remove it. The preferred manifest shape depends on `https://github.com/apple/swift-configuration` from `1.2.0`, enables the `.defaults`, `Reloading`, `YAML`, and `CommandLineArguments` package traits, and adds the `Configuration` product to the primary target. Add the `PropertyList` trait when the package should parse property-list configuration, and add the `Logging` trait when configuration access should integrate with `SwiftLog.Logger`. +- Keep dependency provenance concise but explicit enough for another contributor to fetch the same package: use package-manager, package-registry, GitHub URL, or other real remote repository requirements, and do not commit machine-local dependency paths such as `/Users/...`, `~/...`, `../...`, local worktrees, or private checkout paths. Avoid branch- or revision-based requirements unless the user explicitly asks for that level of control. +- Treat `Package.resolved` and similar package-manager outputs as generated files; do not hand-edit them. +- Prefer Swift Testing by default unless an external constraint requires XCTest. +- Use `apple-ui-accessibility-workflow` when the package work crosses into SwiftUI accessibility semantics, Apple UI accessibility review, or UIKit/AppKit accessibility bridge behavior. +- Keep package resources under the owning target tree, declare them intentionally with `Resource.process(...)`, `Resource.copy(...)`, `Resource.embedInCode(...)`, and load them through `Bundle.module`. +- Keep test fixtures as test-target resources instead of relying on the working directory. +- Bundle precompiled Metal artifacts such as `.metallib` files as explicit resources when they ship with the package, and prefer `xcode-build-run-workflow` when shader compilation or Apple-managed Metal toolchain behavior matters. +- Prefer normal SwiftPM parallel test execution for ordinary Swift Testing and XCTest runs. Do not serialize regular package tests just because they use Swift, XCTest, async tests, fixtures, or test plans. +- Treat tests that load large local AI or ML models, especially models over 500 million parameters, as heavy system-resource tests. Run those tests sequentially, one at a time, and call `unload_models` on Gale's live TTS service before the heavy run and `reload_models` after it ends, even when the run fails or is interrupted. +- Validate both Debug and Release paths when optimization or packaging differences matter, and treat tagged releases as a cue to verify the Release artifact path before publishing. +- Prefer `xcode-build-run-workflow` or `xcode-testing-workflow` only when package work needs Xcode-managed SDK, toolchain, or test behavior. +- Keep runtime UI accessibility verification and XCUITest follow-through in `xcode-testing-workflow` rather than treating package-side testing as a substitute for live UI verification. diff --git a/Sources/SwiftASB/SwiftASB.docc/CodexThread.md b/Sources/SwiftASB/SwiftASB.docc/CodexThread.md index aed5ba8..6352609 100644 --- a/Sources/SwiftASB/SwiftASB.docc/CodexThread.md +++ b/Sources/SwiftASB/SwiftASB.docc/CodexThread.md @@ -29,7 +29,11 @@ Use ``startPlanningTurn(_:approvalPolicy:approvalsReviewer:currentDirectoryPath: Use ``makeAgenda()`` when a UI wants current goal and plan state for this thread. Agenda also exposes UI-friendly goal actions. Use ``readGoal()``, ``setGoal(_:)``, and ``clearGoal()`` when a non-UI caller needs direct goal reads or mutation. -Plan and goal actions are separate controls. Use plan mode first to shape complex or ambiguous work, then set a goal from the accepted objective when a user or host app is ready to track execution. SwiftASB does not currently auto-create goals from plan prompts or auto-promote completed plans into goals. +Goals, plans, and plan collaboration mode are related but separate concepts. A Codex goal is a durable objective and progress container. It can span turns, can optionally have a token budget, and is explicitly marked complete or blocked when that status is known. A Codex plan is the active working checklist for a task or turn. It is lower-level than a goal and can be updated as work progresses. Plan collaboration mode is a separate turn-start mode that changes the planning and decision-making flow, including structured user-input prompts during planning. + +These controls compose, but they are not fused. A goal can exist without a current plan, a plan can exist without a goal, and plan collaboration mode can be active or inactive independently of both. A useful mental model is: goal is the durable mission, plan is the active checklist, and plan collaboration mode is the planning cadence. + +Plan and goal actions are separate controls. Use plan mode first to shape complex or ambiguous work, then set a goal from the accepted objective when a user or host app is ready to track execution. SwiftASB does not currently auto-create goals from plan prompts or auto-promote completed plans into goals. Keep goal creation explicit; plans may still be created or updated when an agent needs a checklist for multi-step work. Use ``startReview(against:placement:)`` to ask the app-server to review repository state associated with this thread. The `against` subject can be uncommitted changes, a base branch, one commit, or custom instructions. ``ReviewPlacement/inline`` runs the review turn on this thread. ``ReviewPlacement/detached`` runs the review turn on a new review thread returned in ``CodexReviewHandle/reviewThreadID``. diff --git a/docs/maintainers/thread-plan-goal-companion-plan.md b/docs/maintainers/thread-plan-goal-companion-plan.md index 23dde96..f09ad55 100644 --- a/docs/maintainers/thread-plan-goal-companion-plan.md +++ b/docs/maintainers/thread-plan-goal-companion-plan.md @@ -51,6 +51,31 @@ That is useful but too raw for routine app UI. Consumers should not need to reconcile thread-goal reads with live goal notifications, and they should not need to treat experimental plan deltas as a stable user-facing data source. +## Concept Boundary + +Use these distinctions when naming APIs, docs, tests, and UI affordances: + +- A Codex Goal is a durable objective and progress container. It can span + multiple turns, can optionally carry a token budget, and should be marked + complete or blocked only when that state is explicit. +- A Codex Plan is the current working checklist for a task or turn. It is + lower-level than a goal, can be updated as work progresses, and can exist + whether plan collaboration mode is active. +- Plan collaboration mode is a separate collaboration flow. It changes how + planning and decisions are handled, especially by allowing structured + user-input prompts during planning. It is not the same thing as a plan. + +These concepts compose, but they are not fused. A goal can exist without a +current plan, a plan can exist without a goal, and plan collaboration mode can +be active or inactive independently of both. The short mental model is: goal is +the durable mission, plan is the active checklist, and plan collaboration mode is +the planning and decision-making cadence. + +Operationally, SwiftASB should create or mutate goals only when the user or host +app explicitly asks for persistent objective tracking. Agent-facing plans may be +created or updated whenever they help organize multi-step work, but a plan is +not a request to persist a goal. + ## Proposed Public Shape Add one thread-scoped observable companion that owns plan and goal current state. diff --git a/scripts/repo-maintenance/config/release.env b/scripts/repo-maintenance/config/release.env index b24cbc6..5cb1e1c 100644 --- a/scripts/repo-maintenance/config/release.env +++ b/scripts/repo-maintenance/config/release.env @@ -1,5 +1,16 @@ # Repo-maintenance release defaults. REPO_MAINTENANCE_DEFAULT_RELEASE_MODE=standard REPO_MAINTENANCE_RELEASE_BRANCH=main -REPO_MAINTENANCE_CI_REGISTRATION_TIMEOUT_SECONDS=120 -REPO_MAINTENANCE_CI_REGISTRATION_POLL_SECONDS=5 +REPO_MAINTENANCE_REMOTE_CI_MODE=full + +# GitHub can accept branch, tag, PR, check, review, and release mutations before +# those surfaces are immediately readable. These defaults keep release scripts +# explicit about intentional waits instead of failing on transient indexing gaps. +REPO_MAINTENANCE_GH_WAIT_TIMEOUT_SECONDS=120 +REPO_MAINTENANCE_GH_WAIT_POLL_SECONDS=5 + +# Keep full local validation as the default release gate. For repositories whose +# GitHub CI is intentionally heavy, use --remote-ci-mode defer so release.sh +# pauses after branch push, PR creation, and initial check discovery. Codex can +# then use a native thread Timer/Wakeup or heartbeat automation to resume later +# instead of leaving a long-running shell process open just to poll GitHub. diff --git a/scripts/repo-maintenance/lib/common.sh b/scripts/repo-maintenance/lib/common.sh index 1e0405b..b0afa95 100755 --- a/scripts/repo-maintenance/lib/common.sh +++ b/scripts/repo-maintenance/lib/common.sh @@ -38,6 +38,148 @@ load_profile_env() { load_env_file "$REPO_MAINTENANCE_ROOT/config/profile.env" } +positive_integer_or_default() { + value="$1" + default_value="$2" + + case "$value" in + ''|*[!0-9]*) + printf '%s\n' "$default_value" + ;; + 0) + printf '%s\n' "$default_value" + ;; + *) + printf '%s\n' "$value" + ;; + esac +} + +is_semver_prerelease_tag() { + tag_name="$1" + case "$tag_name" in + v[0-9]*.[0-9]*.[0-9]*-*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +expected_github_prerelease_value() { + tag_name="$1" + if is_semver_prerelease_tag "$tag_name"; then + printf '%s\n' "true" + else + printf '%s\n' "false" + fi +} + +github_release_create_prerelease_flag() { + tag_name="$1" + if is_semver_prerelease_tag "$tag_name"; then + printf '%s\n' "--prerelease" + fi +} + +verify_github_release_prerelease_metadata() { + tag_name="$1" + expected_value="$(expected_github_prerelease_value "$tag_name")" + + actual_value="$(gh release view "$tag_name" --json isPrerelease --jq .isPrerelease 2>/dev/null || true)" + case "$actual_value" in + true|false) + ;; + *) + die "GitHub release $tag_name exists, but its prerelease metadata was not readable. Confirm gh can read release JSON metadata before rerunning release.sh." + ;; + esac + + [ "$actual_value" = "$expected_value" ] || die "GitHub release $tag_name prerelease metadata mismatch: tag implies isPrerelease=$expected_value but GitHub reports isPrerelease=$actual_value. Update the release metadata or delete and recreate the release before rerunning release.sh." +} + +github_wait_timeout() { + value="$1" + default_timeout="$(positive_integer_or_default "${REPO_MAINTENANCE_GH_WAIT_TIMEOUT_SECONDS:-120}" 120)" + positive_integer_or_default "$value" "$default_timeout" +} + +github_wait_poll_seconds() { + value="$1" + default_poll_seconds="$(positive_integer_or_default "${REPO_MAINTENANCE_GH_WAIT_POLL_SECONDS:-5}" 5)" + positive_integer_or_default "$value" "$default_poll_seconds" +} + +wait_for_remote_branch() { + branch_name="$1" + timeout_seconds="$(github_wait_timeout "${REPO_MAINTENANCE_REMOTE_BRANCH_TIMEOUT_SECONDS:-}")" + poll_seconds="$(github_wait_poll_seconds "${REPO_MAINTENANCE_REMOTE_BRANCH_POLL_SECONDS:-}")" + elapsed_seconds="0" + + log "Waiting up to ${timeout_seconds}s for remote branch origin/$branch_name to become visible." + + while :; do + if git -C "$REPO_ROOT" ls-remote --exit-code --heads origin "$branch_name" >/dev/null 2>&1; then + log "Remote branch origin/$branch_name is visible." + return 0 + fi + + if [ "$elapsed_seconds" -ge "$timeout_seconds" ]; then + die "Remote branch origin/$branch_name was not visible after ${timeout_seconds}s. Confirm the branch push succeeded and that the origin remote is reachable before rerunning release.sh." + fi + + sleep "$poll_seconds" + elapsed_seconds=$((elapsed_seconds + poll_seconds)) + done +} + +wait_for_remote_tag() { + tag_name="$1" + timeout_seconds="$(github_wait_timeout "${REPO_MAINTENANCE_REMOTE_TAG_TIMEOUT_SECONDS:-}")" + poll_seconds="$(github_wait_poll_seconds "${REPO_MAINTENANCE_REMOTE_TAG_POLL_SECONDS:-}")" + elapsed_seconds="0" + + log "Waiting up to ${timeout_seconds}s for remote tag $tag_name to become visible." + + while :; do + if git -C "$REPO_ROOT" ls-remote --exit-code --tags origin "refs/tags/$tag_name" >/dev/null 2>&1; then + log "Remote tag $tag_name is visible." + return 0 + fi + + if [ "$elapsed_seconds" -ge "$timeout_seconds" ]; then + die "Remote tag $tag_name was not visible after ${timeout_seconds}s. Confirm the tag push succeeded and that GitHub has indexed the tag before rerunning release.sh." + fi + + sleep "$poll_seconds" + elapsed_seconds=$((elapsed_seconds + poll_seconds)) + done +} + +wait_for_github_release() { + tag_name="$1" + timeout_seconds="$(github_wait_timeout "${REPO_MAINTENANCE_GH_RELEASE_TIMEOUT_SECONDS:-}")" + poll_seconds="$(github_wait_poll_seconds "${REPO_MAINTENANCE_GH_RELEASE_POLL_SECONDS:-}")" + elapsed_seconds="0" + + log "Waiting up to ${timeout_seconds}s for GitHub release $tag_name to become readable." + + while :; do + if gh release view "$tag_name" >/dev/null 2>&1; then + log "GitHub release $tag_name is readable." + return 0 + fi + + if [ "$elapsed_seconds" -ge "$timeout_seconds" ]; then + die "GitHub release $tag_name was not readable after ${timeout_seconds}s. Confirm release creation succeeded and GitHub has indexed the release before rerunning release.sh." + fi + + sleep "$poll_seconds" + elapsed_seconds=$((elapsed_seconds + poll_seconds)) + done +} + ensure_git_repo() { 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." } diff --git a/scripts/repo-maintenance/release.sh b/scripts/repo-maintenance/release.sh index 4402859..12856e7 100755 --- a/scripts/repo-maintenance/release.sh +++ b/scripts/repo-maintenance/release.sh @@ -18,6 +18,7 @@ base_branch="${REPO_MAINTENANCE_RELEASE_BRANCH:-main}" review_comments_addressed="false" skip_branch_cleanup="false" dry_run="false" +remote_ci_mode="${REPO_MAINTENANCE_REMOTE_CI_MODE:-full}" while [ "$#" -gt 0 ]; do case "$1" in @@ -53,6 +54,10 @@ while [ "$#" -gt 0 ]; do review_comments_addressed="true" shift ;; + --remote-ci-mode) + remote_ci_mode="${2:-}" + shift 2 + ;; --skip-branch-cleanup) skip_branch_cleanup="true" shift @@ -64,7 +69,7 @@ while [ "$#" -gt 0 ]; do -h|--help) cat <<'USAGE' Usage: - release.sh --mode standard --version [--base-branch main] [--skip-validate] [--skip-local-release-gate] [--skip-version-bump] [--skip-gh-release] [--review-comments-addressed] [--skip-branch-cleanup] [--dry-run] + release.sh --mode standard --version [--base-branch main] [--skip-validate] [--skip-local-release-gate] [--skip-version-bump] [--skip-gh-release] [--review-comments-addressed] [--remote-ci-mode full|defer] [--skip-branch-cleanup] [--dry-run] release.sh --mode submodule --version [--skip-validate] [--skip-gh-release] [--dry-run] USAGE exit 0 @@ -81,6 +86,7 @@ export REPO_MAINTENANCE_RELEASE_MODE="$mode" export RELEASE_TAG="$release_tag" export REPO_MAINTENANCE_SKIP_GH_RELEASE="$skip_gh_release" export REPO_MAINTENANCE_DRY_RUN="$dry_run" +export REPO_MAINTENANCE_REMOTE_CI_MODE="$remote_ci_mode" ensure_clean_worktree() { status_output="$(git -C "$REPO_ROOT" status --porcelain)" @@ -101,6 +107,16 @@ ensure_semver_tag() { esac } +ensure_remote_ci_mode() { + case "$REPO_MAINTENANCE_REMOTE_CI_MODE" in + full|defer) + ;; + *) + die "Remote CI mode must be either full or defer. Use full to watch GitHub checks in this script, or defer to pause after initial check discovery and continue from a Codex wakeup." + ;; + esac +} + current_branch() { git -C "$REPO_ROOT" symbolic-ref --quiet --short HEAD || true } @@ -115,12 +131,18 @@ ensure_branch_release_context() { run_version_bump() { release_version="${RELEASE_TAG#v}" version_bump_script="$SELF_DIR/version-bump.sh" + head_subject="$(git -C "$REPO_ROOT" log -1 --format=%s 2>/dev/null || true)" if [ "$skip_version_bump" = "true" ]; then log "Skipping repo version bump because --skip-version-bump was requested." return 0 fi + if [ "$head_subject" = "release: bump versions for $RELEASE_TAG" ]; then + log "Version bump commit for $RELEASE_TAG is already at HEAD; continuing the release resume path." + 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 @@ -179,17 +201,28 @@ create_release_tag() { log "Created annotated tag $RELEASE_TAG." } -push_branch_and_tag() { +push_release_branch() { branch_name="$1" if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then - log "Would push branch $branch_name and tag $RELEASE_TAG to origin." + log "Would push branch $branch_name to origin." return 0 fi git -C "$REPO_ROOT" push -u origin "$branch_name" + log "Pushed branch $branch_name." + wait_for_remote_branch "$branch_name" +} + +push_release_tag() { + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would push tag $RELEASE_TAG to origin." + return 0 + fi + git -C "$REPO_ROOT" push origin "$RELEASE_TAG" - log "Pushed branch $branch_name and tag $RELEASE_TAG." + log "Pushed tag $RELEASE_TAG." + wait_for_remote_tag "$RELEASE_TAG" } create_or_update_pr() { @@ -210,11 +243,11 @@ create_or_update_pr() { - prepares $RELEASE_TAG from branch \`$branch_name\` - keeps protected \`$base_branch\` updates behind pull request review and CI -- release tag \`$RELEASE_TAG\` was created locally before this PR so the reviewed release candidate is preserved exactly +- release tag \`$RELEASE_TAG\` will be created after CI and the review-comment gate pass, so failed or still-discussed release candidates do not get tagged ## Review Loop -Before merge, \`scripts/repo-maintenance/release.sh\` watches CI and stops on review comments unless the maintainer has already addressed or resolved them and reruns with \`--review-comments-addressed\`. +Before merge and tagging, \`scripts/repo-maintenance/release.sh\` watches CI and stops on review comments unless the maintainer has already addressed or resolved them and reruns with \`--review-comments-addressed\`. EOF pr_number="$(gh pr list --head "$branch_name" --base "$base_branch" --json number --jq '.[0].number // empty' --limit 1)" @@ -244,48 +277,87 @@ watch_ci() { fi log "Watching CI for PR #$pr_number." - wait_for_ci_registration "$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." } -wait_for_ci_registration() { +defer_remote_ci_if_requested() { pr_number="$1" - timeout_seconds="${REPO_MAINTENANCE_CI_REGISTRATION_TIMEOUT_SECONDS:-120}" - poll_seconds="${REPO_MAINTENANCE_CI_REGISTRATION_POLL_SECONDS:-5}" - elapsed_seconds=0 + branch_name="$2" - case "$timeout_seconds" in - ''|*[!0-9]*) - die "REPO_MAINTENANCE_CI_REGISTRATION_TIMEOUT_SECONDS must be a non-negative integer." - ;; - esac + [ "$REPO_MAINTENANCE_REMOTE_CI_MODE" = "defer" ] || return 1 - case "$poll_seconds" in - ''|*[!0-9]*) - die "REPO_MAINTENANCE_CI_REGISTRATION_POLL_SECONDS must be a positive integer." - ;; - esac + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would defer remote CI after PR #$pr_number reports initial checks." + return 0 + fi - [ "$poll_seconds" -gt 0 ] || die "REPO_MAINTENANCE_CI_REGISTRATION_POLL_SECONDS must be greater than zero." + pr_url="$(gh pr view "$pr_number" --json url --jq '.url')" + log "Remote CI mode is defer, so release.sh is pausing after local validation, branch push, PR creation, and initial check discovery." + log "Release is not complete yet. Let GitHub finish CI for PR #$pr_number, then continue from branch $branch_name with:" + log " bash scripts/repo-maintenance/release.sh --mode standard --version $RELEASE_TAG" + log "Codex should use a native thread Timer/Wakeup or heartbeat automation for this wait when available, then resume by checking $pr_url and rerunning the command above instead of leaving a shell script open to poll GitHub." + return 0 +} + +wait_for_initial_pr_checks() { + pr_number="$1" + timeout_seconds="$(github_wait_timeout "${REPO_MAINTENANCE_INITIAL_CHECK_TIMEOUT_SECONDS:-}")" + poll_seconds="$(github_wait_poll_seconds "${REPO_MAINTENANCE_INITIAL_CHECK_POLL_SECONDS:-}")" + elapsed_seconds="0" + last_state="no check data returned yet" + + log "Waiting up to ${timeout_seconds}s for GitHub to report initial checks on PR #$pr_number." while :; do - check_count="$( - gh pr view "$pr_number" --json statusCheckRollup --jq '.statusCheckRollup | length' - )" + last_state="$(gh pr checks "$pr_number" --json name,state,workflow --jq 'map(.name + ":" + .state) | join(", ")' 2>/dev/null || printf 'no checks reported')" + check_count="$(gh pr checks "$pr_number" --json name,state,workflow --jq 'length' 2>/dev/null || printf '0')" + case "$check_count" in + ''|*[!0-9]*) + check_count="0" + ;; + esac - if [ "$check_count" != "0" ]; then - log "CI has reported $check_count check(s) for PR #$pr_number." + if [ "$check_count" -gt 0 ]; then + log "Found $check_count initial check(s) for PR #$pr_number." return 0 fi - [ "$elapsed_seconds" -lt "$timeout_seconds" ] || { - die "No CI checks were reported for PR #$pr_number after ${timeout_seconds}s. Confirm the branch pushed correctly, GitHub Actions is enabled, and the workflow trigger matches this PR." - } + if [ "$elapsed_seconds" -ge "$timeout_seconds" ]; then + die "No checks were reported for PR #$pr_number after ${timeout_seconds}s. Last observed state: $last_state. Confirm the GitHub Actions workflow triggers for the release branch, Actions is enabled, and the branch push succeeded before rerunning release.sh." + fi + + sleep "$poll_seconds" + elapsed_seconds=$((elapsed_seconds + poll_seconds)) + done +} + +wait_for_pr_review_state() { + pr_number="$1" + timeout_seconds="$(github_wait_timeout "${REPO_MAINTENANCE_PR_REVIEW_TIMEOUT_SECONDS:-}")" + poll_seconds="$(github_wait_poll_seconds "${REPO_MAINTENANCE_PR_REVIEW_POLL_SECONDS:-}")" + elapsed_seconds="0" + last_state="PR review/comment state has not been read yet" + + log "Waiting up to ${timeout_seconds}s for GitHub review/comment state on PR #$pr_number." + + while :; do + last_state="$(gh pr view "$pr_number" --json reviewDecision,comments,reviews --jq '"reviewDecision=" + (.reviewDecision // "") + ", comments=" + ((.comments | length) | tostring) + ", reviews=" + ((.reviews | length) | tostring)' 2>/dev/null || printf 'GitHub did not return PR review/comment state')" + case "$last_state" in + "GitHub did not return PR review/comment state") + ;; + *) + log "GitHub review/comment state is readable for PR #$pr_number: $last_state." + return 0 + ;; + esac + + if [ "$elapsed_seconds" -ge "$timeout_seconds" ]; then + die "GitHub review/comment state for PR #$pr_number was not readable after ${timeout_seconds}s. Last observed state: $last_state. Confirm the PR exists and GitHub is returning review data before rerunning release.sh." + fi - log "No CI checks reported for PR #$pr_number yet; waiting ${poll_seconds}s." sleep "$poll_seconds" elapsed_seconds=$((elapsed_seconds + poll_seconds)) done @@ -299,8 +371,10 @@ check_pr_comments() { return 0 fi + wait_for_pr_review_state "$pr_number" + 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)')" + comment_count="$(gh pr view "$pr_number" --json comments,reviews --jq '([.comments[]?, (.reviews[]? | select(.state == "COMMENTED"))] | length)')" if [ "$review_decision" = "CHANGES_REQUESTED" ]; then gh pr view "$pr_number" --comments @@ -315,6 +389,28 @@ check_pr_comments() { log "PR #$pr_number has no blocking review state." } +ensure_base_branch_checkout_available() { + branch_name="$1" + + if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then + log "Would verify local $base_branch can be checked out before merging the release PR." + 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 + if git -C "$REPO_ROOT" switch "$branch_name" 2>/dev/null || git -C "$REPO_ROOT" checkout "$branch_name" 2>/dev/null; then + log "Verified local $base_branch can be checked out for post-merge fast-forward and tagging." + return 0 + fi + + die "Release workflow could check out $base_branch but could not return to $branch_name. Restore the release branch checkout before rerunning release.sh." + fi + + die "Could not check out local $base_branch before merging, likely because another worktree owns it. Fast-forward $base_branch from origin/$base_branch in that checkout or release that worktree before rerunning release.sh." +} + merge_pr() { pr_number="$1" @@ -338,7 +434,7 @@ fast_forward_base_branch() { 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." + die "Could not check out local $base_branch, likely because another worktree owns it. Fast-forward $base_branch from origin/$base_branch in that checkout, then rerun release.sh so the release tag is created from the reviewed base branch." fi } @@ -349,17 +445,23 @@ create_github_release() { fi if [ "$REPO_MAINTENANCE_DRY_RUN" = "true" ]; then - log "Would create a GitHub release for $RELEASE_TAG with gh release create --verify-tag." + prerelease_flag="$(github_release_create_prerelease_flag "$RELEASE_TAG")" + log "Would create a GitHub release for $RELEASE_TAG with gh release create --verify-tag${prerelease_flag:+ $prerelease_flag}." return 0 fi if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + verify_github_release_prerelease_metadata "$RELEASE_TAG" log "GitHub release $RELEASE_TAG already exists." return 0 fi - gh release create "$RELEASE_TAG" --verify-tag --generate-notes + prerelease_flag="$(github_release_create_prerelease_flag "$RELEASE_TAG")" + # shellcheck disable=SC2086 + gh release create "$RELEASE_TAG" --verify-tag --generate-notes $prerelease_flag log "Created GitHub release $RELEASE_TAG." + wait_for_github_release "$RELEASE_TAG" + verify_github_release_prerelease_metadata "$RELEASE_TAG" } cleanup_merged_branches() { @@ -392,6 +494,7 @@ run_standard_release() { ensure_git_repo ensure_gh_cli ensure_semver_tag + ensure_remote_ci_mode branch_name="$(ensure_branch_release_context)" ensure_clean_worktree @@ -403,14 +506,21 @@ run_standard_release() { ensure_clean_worktree run_local_release_gate ensure_clean_worktree - create_release_tag - push_branch_and_tag "$branch_name" + push_release_branch "$branch_name" create_or_update_pr "$branch_name" pr_number="$PR_NUMBER" + wait_for_initial_pr_checks "$pr_number" + if defer_remote_ci_if_requested "$pr_number" "$branch_name"; then + log "Standard release flow paused before remote CI watch for $RELEASE_TAG." + return 0 + fi watch_ci "$pr_number" check_pr_comments "$pr_number" + ensure_base_branch_checkout_available "$branch_name" merge_pr "$pr_number" fast_forward_base_branch + create_release_tag + push_release_tag create_github_release cleanup_merged_branches "$branch_name" log "Standard release flow completed successfully for $RELEASE_TAG." diff --git a/scripts/repo-maintenance/release/30-push-release.sh b/scripts/repo-maintenance/release/30-push-release.sh index 81d22d0..54de388 100755 --- a/scripts/repo-maintenance/release/30-push-release.sh +++ b/scripts/repo-maintenance/release/30-push-release.sh @@ -13,5 +13,7 @@ if [ "${REPO_MAINTENANCE_DRY_RUN:-false}" = "true" ]; then fi git -C "$REPO_ROOT" push -u origin "$branch_name" +wait_for_remote_branch "$branch_name" git -C "$REPO_ROOT" push origin "$RELEASE_TAG" +wait_for_remote_tag "$RELEASE_TAG" log "Pushed branch $branch_name and tag $RELEASE_TAG." diff --git a/scripts/repo-maintenance/release/40-github-release.sh b/scripts/repo-maintenance/release/40-github-release.sh index 348cfcc..2220e09 100755 --- a/scripts/repo-maintenance/release/40-github-release.sh +++ b/scripts/repo-maintenance/release/40-github-release.sh @@ -16,14 +16,20 @@ if ! command -v gh >/dev/null 2>&1; then fi if [ "${REPO_MAINTENANCE_DRY_RUN:-false}" = "true" ]; then - log "Would create a GitHub release for $RELEASE_TAG with gh release create --verify-tag." + prerelease_flag="$(github_release_create_prerelease_flag "$RELEASE_TAG")" + log "Would create a GitHub release for $RELEASE_TAG with gh release create --verify-tag${prerelease_flag:+ $prerelease_flag}." exit 0 fi if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + verify_github_release_prerelease_metadata "$RELEASE_TAG" log "GitHub release $RELEASE_TAG already exists." exit 0 fi -gh release create "$RELEASE_TAG" --verify-tag --generate-notes +prerelease_flag="$(github_release_create_prerelease_flag "$RELEASE_TAG")" +# shellcheck disable=SC2086 +gh release create "$RELEASE_TAG" --verify-tag --generate-notes $prerelease_flag log "Created GitHub release $RELEASE_TAG." +wait_for_github_release "$RELEASE_TAG" +verify_github_release_prerelease_metadata "$RELEASE_TAG"