Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<build>`
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:
Expand Down
40 changes: 40 additions & 0 deletions scripts/publish_rolling_release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}"
68 changes: 65 additions & 3 deletions tests/test_rolling_release_publication.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down
Loading