diff --git a/CHANGELOG.md b/CHANGELOG.md index 03da7704..5f291fd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p - Workspace colors are now remembered by local folder and automatically reused when that folder opens in a new workspace. - New setting, off by default: open a browser split beside the terminal whenever a new agent workspace is created (⌘⇧C, `programa` helper agents, and `race`). Also `automation.openBrowserWithAgentSplits` in settings.json. +### Changed +- Each ship now deletes promoted release candidates older than the two most recent, so the releases page stops accumulating 110 MB prereleases. + ### Fixed - Release publishing accepts the previous ten-asset candidate manifests again, so the first ship after the remote daemon removal no longer fails. - Remote and local CLI clients now share the v2 JSON-RPC and `programa-relay-auth` contracts, password-protected sockets work through MCP, and remote bootstrap files, tmux wait signals, relay diagnostics, and downloaded daemon artifacts have bounded ownership and lifetime. diff --git a/CLAUDE.md b/CLAUDE.md index 189f7d66..8e457454 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -238,7 +238,10 @@ build time, never committed. They publish to a single, reused `rolling` GitHub r the releases page stays clean (one `rolling` entry plus permanent milestone `v*` tags) and `releases/latest/download/*` always resolves to the newest green build. Every ship is therefore distinguishable in the about box and on the releases page; only minor/major bumps -remain manual milestones. +remain manual milestones. Each ship also promotes one sealed `rolling-candidate-` +prerelease to become that ship's permanent archive tag, then deletes older promoted +candidates, keeping only the two newest for rollback so the releases page does not +accumulate a growing pile of ~110 MB prereleases. Milestone marketing-version bumps (e.g. `0.15.0` → `0.16.0`) are still done manually and can optionally be tagged as a `vX.Y.Z` marker, which the same `release.yml` also builds on tag push: diff --git a/scripts/publish_rolling_release.sh b/scripts/publish_rolling_release.sh index edcf51a0..4e4b0880 100755 --- a/scripts/publish_rolling_release.sh +++ b/scripts/publish_rolling_release.sh @@ -147,6 +147,10 @@ build_is_at_most() { node -e 'process.exit(BigInt(process.argv[1]) <= BigInt(process.argv[2]) ? 0 : 1)' "$1" "$2" } +build_is_less_than() { + node -e 'process.exit(BigInt(process.argv[1]) < BigInt(process.argv[2]) ? 0 : 1)' "$1" "$2" +} + require_selected_target_is_current_main() { local checkpoint="$1" current_main current_main="$("${GH_BIN}" api \ @@ -181,6 +185,41 @@ prune_candidates() { done < "${RELEASE_LIST}" } +# Deletes published (non-draft) candidate archives strictly older than the +# finalized build, keeping the newest PROGRAMA_RETAINED_CANDIDATES (default 2) +# of them for rollback so the releases page stops accumulating every promoted +# candidate forever. The finalized candidate and anything at or above its +# build are never touched here; prune_candidates above still owns draft +# cleanup. +retire_promoted_candidates() { + local finalized_build="$1" keep_tag="$2" + local retained="${PROGRAMA_RETAINED_CANDIDATES:-2}" + [[ "${retained}" =~ ^[1-9][0-9]*$ ]] || \ + fail "PROGRAMA_RETAINED_CANDIDATES must be a positive integer" + + local tag is_draft is_prerelease is_immutable target suffix + local candidates_file="${TEMP_DIR}/retire-candidates.tsv" + : > "${candidates_file}" + while IFS=$'\t' read -r tag is_draft is_prerelease is_immutable target; do + [[ "${is_draft}" == "false" && "${tag}" == "${CANDIDATE_PREFIX}"* ]] || continue + [[ "${tag}" != "${keep_tag}" ]] || continue + [[ "${is_immutable}" == "false" ]] || continue + suffix="${tag#"${CANDIDATE_PREFIX}"}" + [[ "${suffix}" =~ ^[0-9]+$ ]] || continue + suffix="$((10#${suffix}))" + build_is_less_than "${suffix}" "${finalized_build}" || continue + printf '%s\t%s\n' "${suffix}" "${tag}" >> "${candidates_file}" + done < "${RELEASE_LIST}" + + [[ -s "${candidates_file}" ]] || return 0 + + local tag_to_delete + while IFS= read -r tag_to_delete; do + [[ -n "${tag_to_delete}" ]] || continue + "${GH_BIN}" release delete "${tag_to_delete}" --repo "${REPOSITORY}" --yes --cleanup-tag + done < <(LC_ALL=C sort -t $'\t' -k1,1nr "${candidates_file}" | awk -F '\t' -v retained="${retained}" 'NR > retained { print $2 }') +} + snapshot_public_high_water() { local snapshot_name="$1" snapshot_dir releases metadata appcast_paths local release_tag is_draft is_prerelease is_immutable release_target release_index release_metadata release_appcast @@ -765,3 +804,4 @@ cmp -s "${FINAL_BODY}" "${NOTES_FILE}" || fail "rolling release notes did not co [[ "${FINAL_REF}" == "${SELECTED_TARGET}" ]] || fail "rolling ref did not converge" prune_candidates "${SELECTED_BUILD}" "${SELECTED_TAG}" +retire_promoted_candidates "${SELECTED_BUILD}" "${SELECTED_TAG}" diff --git a/tests/test_rolling_release_publication.sh b/tests/test_rolling_release_publication.sh index cf9a4855..0009095c 100755 --- a/tests/test_rolling_release_publication.sh +++ b/tests/test_rolling_release_publication.sh @@ -52,7 +52,10 @@ set -euo pipefail # Metadata and latest status change before the rolling ref moves. Stale drafts # may be deleted after final verification, but the selected archive remains. # Rolling must already exist as the legacy mutable release; missing or immutable -# state fails. +# state fails. After promotion, published candidate archives strictly below the +# finalized build are also retired, keeping only the newest +# PROGRAMA_RETAINED_CANDIDATES (default 2, immutable archives excepted) so the +# releases page does not accumulate every promoted candidate forever. ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" CANDIDATE_HELPER="${ROOT_DIR}/scripts/publish_release_candidate.sh" @@ -470,9 +473,15 @@ release_edit() { fi } release_delete() { - local tag="$1" + local tag="$1"; shift + local yes=false cleanup_tag=false repo="" + while (($#)); do case "$1" in + --repo) repo="$2"; shift 2 ;; --yes) yes=true; shift ;; --cleanup-tag) cleanup_tag=true; shift ;; + *) echo "unsupported fake gh release delete argument: $1" >&2; exit 2 ;; + esac; done + [[ -n "${repo}" && "${yes}" == true ]] || { echo "release delete missing required flags" >&2; exit 2; } [[ "$(cat "$(release_dir "${tag}")/immutable")" == false ]] || { echo "release is immutable" >&2; exit 1; } - rm -rf "$(release_dir "${tag}")"; mutation "delete-release ${tag}" + rm -rf "$(release_dir "${tag}")"; mutation "delete-release ${tag} cleanup-tag=${cleanup_tag}" } attestation_verify() { @@ -972,6 +981,59 @@ seed_rolling 100; invoke_rolling; assert_rolling_converged 103 assert_release_absent rolling-candidate-099; assert_release_absent rolling-candidate-100 assert_release_absent rolling-candidate-101; assert_published_archive 103; assert_release_exists rolling-candidate-104 +# Promotion also retires old published (non-draft) candidate archives: only +# the newest PROGRAMA_RETAINED_CANDIDATES (default 2) below the finalized +# build survive, the finalized build and anything at or above it is never +# touched, an immutable archive is skipped regardless of its build, and +# deletion goes through --cleanup-tag so the tag is removed too. +reset_state +seed_sealed_candidate 103 +write_release rolling-candidate-050 "$(target_sha_for 50)" false false 'Candidate 50' candidate true +write_release rolling-candidate-060 "$(target_sha_for 60)" false false 'Candidate 60' candidate true +write_release rolling-candidate-065 "$(target_sha_for 65)" false false 'Candidate 65' candidate true +printf 'true\n' > "$(release_dir rolling-candidate-065)/immutable" +write_release rolling-candidate-070 "$(target_sha_for 70)" false false 'Candidate 70' candidate true +write_release rolling-candidate-080 "$(target_sha_for 80)" false false 'Candidate 80' candidate true +seed_rolling 100 +: > "${STATE_DIR}/operations.log" +invoke_rolling +assert_rolling_converged 103; assert_published_archive 103 +assert_release_absent rolling-candidate-050 +assert_release_absent rolling-candidate-060 +assert_release_exists rolling-candidate-065 +assert_release_exists rolling-candidate-070 +assert_release_exists rolling-candidate-080 +grep -Fq 'mutation delete-release rolling-candidate-050 cleanup-tag=true' "${STATE_DIR}/operations.log" || \ + fail "retention did not delete the oldest promoted candidate with --cleanup-tag" +grep -Fq 'mutation delete-release rolling-candidate-060 cleanup-tag=true' "${STATE_DIR}/operations.log" || \ + fail "retention did not delete the second-oldest promoted candidate with --cleanup-tag" +! grep -Fq 'mutation delete-release rolling-candidate-065' "${STATE_DIR}/operations.log" || \ + fail "retention deleted an immutable promoted candidate" +! grep -Fq 'mutation delete-release rolling-candidate-070' "${STATE_DIR}/operations.log" || \ + fail "retention deleted a promoted candidate within the default retained window" +! grep -Fq 'mutation delete-release rolling-candidate-080' "${STATE_DIR}/operations.log" || \ + fail "retention deleted a promoted candidate within the default retained window" + +# PROGRAMA_RETAINED_CANDIDATES overrides the default window down to one. +reset_state +seed_sealed_candidate 103 +write_release rolling-candidate-050 "$(target_sha_for 50)" false false 'Candidate 50' candidate true +write_release rolling-candidate-060 "$(target_sha_for 60)" false false 'Candidate 60' candidate true +write_release rolling-candidate-070 "$(target_sha_for 70)" false false 'Candidate 70' candidate true +write_release rolling-candidate-080 "$(target_sha_for 80)" false false 'Candidate 80' candidate true +seed_rolling 100 +: > "${STATE_DIR}/operations.log" +PROGRAMA_RETAINED_CANDIDATES=1 invoke_rolling +assert_rolling_converged 103; assert_published_archive 103 +assert_release_absent rolling-candidate-050 +assert_release_absent rolling-candidate-060 +assert_release_absent rolling-candidate-070 +assert_release_exists rolling-candidate-080 +grep -Fq 'mutation delete-release rolling-candidate-070 cleanup-tag=true' "${STATE_DIR}/operations.log" || \ + fail "PROGRAMA_RETAINED_CANDIDATES=1 did not delete the newly-out-of-window candidate" +! grep -Fq 'mutation delete-release rolling-candidate-080' "${STATE_DIR}/operations.log" || \ + fail "PROGRAMA_RETAINED_CANDIDATES=1 deleted the one retained candidate" + # Lower candidates cannot regress rolling's high-water build. reset_state seed_sealed_candidate 103; seed_rolling 200; : > "${STATE_DIR}/operations.log"; invoke_rolling; assert_rolling_converged 200