diff --git a/.github/scripts/fork-channel.sh b/.github/scripts/fork-channel.sh new file mode 100644 index 0000000000000..fb424e2469c49 --- /dev/null +++ b/.github/scripts/fork-channel.sh @@ -0,0 +1,72 @@ +# Sourced by fork-follow.sh and fork-release.sh: which upstream tags the fork follows, how its own +# releases are named, and the version order both use. Tests: fork-follow.test.sh, fork-release.test.sh. +# +# FORK_FOLLOW_CHANNEL (the repository variable of that name; default nightly): +# nightly upstream tags vX.Y.Z-nightly.YYYYMMDD.N; the fork's release of one is .fork. +# stable upstream tags vX.Y.Z; the fork's release of one is -fork. +# Both fork forms end in something other than -nightly.YYYYMMDD.N, so the app's updaters read every +# fork release as their stable track (packages/shared/src/cliRelease.ts cliReleaseChannelOf): +# the fork has one track, and its builds keep the stable update feed and branding. + +fork_re='^v[0-9]+\.[0-9]+\.[0-9]+(-fork|-nightly\.[0-9]{8}\.[0-9]+\.fork)\.[0-9]+$' + +fork_channel_init() { # sets channel, channel_re and channel_form; fails on an unknown channel + channel="${FORK_FOLLOW_CHANNEL:-nightly}" + case "$channel" in + nightly) channel_re='^v[0-9]+\.[0-9]+\.[0-9]+-nightly\.[0-9]{8}\.[0-9]+$' channel_form=vX.Y.Z-nightly.YYYYMMDD.N ;; + stable) channel_re='^v[0-9]+\.[0-9]+\.[0-9]+$' channel_form=vX.Y.Z ;; + *) return 1 ;; + esac +} + +# The separator before fork. for an upstream tag: "." after a nightly tag (which already has a +# pre-release part, so the fork number extends it), "-" after a stable one. +fork_sep() { if [[ "$1" == *-nightly.* ]]; then printf .; else printf -- -; fi; } + +# version_key VERSION: "X Y Z KIND DATE RUN FORK", numbers only, for vX.Y.Z, vX.Y.Z-fork.N, +# vX.Y.Z-nightly.D.R and vX.Y.Z-nightly.D.R.fork.N (leading v optional); fails on any other form. +# KIND: 1 stable fork, 2 nightly, 3 plain. Within one X.Y.Z this is semver's order, which the app's +# updaters use (packages/shared/src/semver.ts compareSemverVersions): every pre-release sorts below +# X.Y.Z itself, and a nightly sorts above a stable fork release ("nightly" > "fork"). Numbers +# compare as numbers: run 1000 is above run 999 (a plain sort of the names gets that wrong). +# A version is not an age: upstream names a nightly after the release it leads up to, so +# vX.Y.Z-nightly.D.N.fork.M sorts above X.Y.Z-fork.N although its code can be older +# (fork-release.sh refuse_older checks that pair by ancestry). +version_key() { + local re='^v?([0-9]+)\.([0-9]+)\.([0-9]+)(-fork\.([0-9]+)|-nightly\.([0-9]{8})\.([0-9]+)(\.fork\.([0-9]+))?)?$' + [[ "$1" =~ $re ]] || return 1 + local m=("${BASH_REMATCH[@]}") + if [[ -n "${m[5]}" ]]; then + echo "${m[1]} ${m[2]} ${m[3]} 1 0 0 ${m[5]}" + elif [[ -n "${m[6]}" ]]; then + echo "${m[1]} ${m[2]} ${m[3]} 2 ${m[6]} ${m[7]} ${m[9]:-0}" + else + echo "${m[1]} ${m[2]} ${m[3]} 3 0 0 0" + fi +} + +# newest_first: the versions on stdin (forms above only), newest first by version_key, never by +# name or by when a tag was made. A line of another form fails it. +newest_first() { + local t key + while IFS= read -r t; do + [[ -n "$t" ]] || continue + key="$(version_key "$t")" || return 1 + printf '%s %s\n' "$key" "$t" + done | sort -k1,1nr -k2,2nr -k3,3nr -k4,4nr -k5,5nr -k6,6nr -k7,7nr | cut -d' ' -f8 +} + +# version_gt A B: A sorts above B by version_key. Fails (exit 2) on a form it cannot order. +version_gt() { + local -a x y + local i + read -r -a x <<< "$(version_key "$1")" && read -r -a y <<< "$(version_key "$2")" || return 2 + ((${#x[@]} == 7 && ${#y[@]} == 7)) || return 2 + for i in 0 1 2 3 4 5 6; do + if ((10#${x[i]} != 10#${y[i]})); then + ((10#${x[i]} > 10#${y[i]})) + return + fi + done + return 1 +} diff --git a/.github/scripts/fork-follow-report.sh b/.github/scripts/fork-follow-report.sh index aacb431b5fd28..28db46552939b 100755 --- a/.github/scripts/fork-follow-report.sh +++ b/.github/scripts/fork-follow-report.sh @@ -16,6 +16,8 @@ set -uo pipefail # Refuses outside a GitHub Actions run unless a test runs it under the gh stub (fork-gh-guard.sh). . "$(dirname "${BASH_SOURCE[0]}")/fork-gh-guard.sh" +# fork_re: the names of the fork's releases. +. "$(dirname "${BASH_SOURCE[0]}")/fork-channel.sh" REPOSITORY="${REPOSITORY:-${GITHUB_REPOSITORY:?REPOSITORY or GITHUB_REPOSITORY}}" ORIGIN="${ORIGIN:-https://github.com/$REPOSITORY.git}" @@ -78,7 +80,7 @@ if refs="$(git ls-remote "$ORIGIN" "refs/heads/$SERIES_BRANCH" refs/heads/fork-f tags="" if [[ -n "${CANDIDATE:-}" ]]; then tags="$(awk -v sha="$CANDIDATE" '$1 == sha { t = $2; sub("refs/tags/", "", t); sub(/\^\{\}$/, "", t); print t }' <<< "$refs" \ - | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-fork\.[0-9]+$' | sort -uV)" + | grep -E "$fork_re" | sort -uV)" fi if [[ -z "${CANDIDATE:-}" ]]; then row "Release" "none: the plan did not pick a commit" diff --git a/.github/scripts/fork-follow.sh b/.github/scripts/fork-follow.sh index 637780154199b..6c49199c03d5f 100755 --- a/.github/scripts/fork-follow.sh +++ b/.github/scripts/fork-follow.sh @@ -1,12 +1,17 @@ #!/usr/bin/env bash # Plan step of fork-follow.yml: decides what the run does, and rebases the fork's patch series onto -# a newer stable upstream tag when there is one. Run in a full clone with the series checked out. +# a newer upstream tag of the followed channel when there is one (FORK_FOLLOW_CHANNEL, nightly by +# default, or stable; fork-channel.sh). Run in a full clone with the series checked out. # It pushes and publishes nothing itself. Tests: fork-follow.test.sh. # -# Environment: UPSTREAM (owner/name), UPSTREAM_URL (git URL of upstream, default GitHub), ORIGIN -# (git remote or URL of this fork, default origin), RUN_SHA (the commit this run's workflow files -# come from, default HEAD), REPOSITORY (owner/name of this fork, for the advice in reports), REPORT -# (file that receives the reason when the run cannot go on). +# Nightly: the series may sit on a commit of upstream's default branch that no nightly tag marks +# (it was made from that branch). Its base is then that commit, and the plan rebases it onto the +# newest nightly, which must contain it; while no nightly does, the plan refuses and waits. +# +# Environment: FORK_FOLLOW_CHANNEL, UPSTREAM (owner/name), UPSTREAM_URL (git URL of upstream, +# default GitHub), ORIGIN (git remote or URL of this fork, default origin), RUN_SHA (the commit +# this run's workflow files come from, default HEAD), REPOSITORY (owner/name of this fork, for the +# advice in reports), REPORT (file that receives the reason when the run cannot go on). # # Output, key=value lines to $GITHUB_OUTPUT (stdout without it): # action none the series head is the newest fork release of its base already @@ -68,24 +73,25 @@ on_error() { } trap 'on_error "$?" "$LINENO" "$BASH_COMMAND"' ERR -stable_re='^v[0-9]+\.[0-9]+\.[0-9]+$' -fork_re='^v[0-9]+\.[0-9]+\.[0-9]+-fork\.[0-9]+$' head="$(git rev-parse HEAD)" run_sha="${RUN_SHA:-$head}" here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$here/fork-channel.sh" +fork_channel_init || fail "FORK_FOLLOW_CHANNEL is '$FORK_FOLLOW_CHANNEL'; it must be nightly or stable. Nothing was planned." # Read-only fetches into refs this run alone uses: upstream's tags and default branch, and this -# fork's tags (its releases). -git fetch --quiet --no-tags "$upstream_url" "+refs/tags/v*:refs/upstream-tags/v*" "+HEAD:refs/upstream-head" \ +# fork's tags (its releases). Pruned, so a tag deleted since an earlier fetch here is gone too. +git fetch --quiet --prune --no-tags "$upstream_url" "+refs/tags/v*:refs/upstream-tags/v*" "+HEAD:refs/upstream-head" \ > "$logs/fetch.log" 2>&1 \ || fail "Could not fetch the tags and default branch of $UPSTREAM ($upstream_url):" '```' "$(tail -n 20 "$logs/fetch.log")" '```' -git fetch --quiet --no-tags "$ORIGIN" "+refs/tags/v*:refs/fork-tags/v*" > "$logs/fetch.log" 2>&1 \ +git fetch --quiet --prune --no-tags "$ORIGIN" "+refs/tags/v*:refs/fork-tags/v*" > "$logs/fetch.log" 2>&1 \ || fail "Could not fetch the tags of this fork ($ORIGIN):" '```' "$(tail -n 20 "$logs/fetch.log")" '```' -upstream_tags="$(git for-each-ref --format='%(refname:strip=2)' refs/upstream-tags | grep -E "$stable_re" | sort -rV || true)" -fork_tags="$(git for-each-ref --format='%(refname:strip=2)' refs/fork-tags | grep -E "$fork_re" | sort -rV || true)" +# Newest first by version (fork-channel.sh), not by name or by when a tag was made. +upstream_tags="$(git for-each-ref --format='%(refname:strip=2)' refs/upstream-tags | { grep -E "$channel_re" || true; } | newest_first)" +fork_tags="$(git for-each-ref --format='%(refname:strip=2)' refs/fork-tags | { grep -E "$fork_re" || true; } | newest_first)" target_tag="$(sed -n 1p <<< "$upstream_tags")" -[[ -n "$target_tag" ]] || fail "$UPSTREAM has no stable release tag (vX.Y.Z)." +[[ -n "$target_tag" ]] || fail "$UPSTREAM has no $channel release tag ($channel_form)." up() { git rev-parse "refs/upstream-tags/$1^{commit}"; } # The newest fork release in the series' history names the upstream tag the series was built on. @@ -99,7 +105,7 @@ for tag in $fork_tags; do fi done if [[ -n "$last_release" ]]; then - built_on="${last_release%-fork.*}" + built_on="${last_release%[-.]fork.*}" if ! git rev-parse --quiet --verify "refs/upstream-tags/$built_on^{commit}" > /dev/null; then fail "$UPSTREAM no longer has the tag $built_on, which the series was built on (fork release $last_release is in its history)." \ "Upstream deleted or renamed that release. Decide by hand which upstream release the series should sit on; the series was left as it is." @@ -110,7 +116,7 @@ if [[ -n "$last_release" ]]; then fi fi -# The series sits on the newest stable upstream tag it contains. +# The series sits on the newest upstream tag of the channel it contains. base_tag="" for tag in $upstream_tags; do if git merge-base --is-ancestor "$(up "$tag")" HEAD; then @@ -119,7 +125,17 @@ for tag in $upstream_tags; do break fi done -[[ -n "$base_tag" ]] || fail "The series ($head) contains no stable $UPSTREAM tag." +# Nightly: or on a later commit of upstream's default branch that no nightly tag marks. Its base is +# then that commit (base_tag names it by its short hash), and only a rebase can move it onto a tag. +untagged="" +if [[ "$channel" == nightly ]] && fork_point="$(git merge-base HEAD refs/upstream-head)" \ + && [[ "$fork_point" != "${base:-}" ]] \ + && { [[ -z "$base_tag" ]] || git merge-base --is-ancestor "$base" "$fork_point"; }; then + untagged=1 + base="$fork_point" + base_tag="${base:0:12}" +fi +[[ -n "$base_tag" ]] || fail "The series ($head) contains no $channel $UPSTREAM tag." # Every commit between that tag and the series head must be the fork's own. Upstream commits there # mean the branch is not a patch series (for example it still equals upstream's default branch). @@ -217,10 +233,11 @@ refuse_outside_workflows() { # refuse_outside_workflows UPSTREAM_BASE UPSTREAM_T "or, to keep one, rebase onto $3 by hand (see the conflict advice in fork-follow.yml)." } -# The newest fork release of a tag: " ", or nothing. +# The newest fork release of a tag (-fork.N or .fork.N): " ", or nothing. latest_release() { - local last - last="$(grep -E "^${1//./\\.}-fork\.[0-9]+$" <<< "$fork_tags" | sort -V | tail -n1 || true)" + local last sep=- + [[ "$(fork_sep "$1")" != . ]] || sep='\.' + last="$(grep -E "^${1//./\\.}${sep}fork\.[0-9]+$" <<< "$fork_tags" | head -n1 || true)" [[ -z "$last" ]] || echo "$last $(git rev-parse "refs/fork-tags/$last^{commit}")" } @@ -312,7 +329,26 @@ stage_report() { # stage_report CANDIDATE TAG CHANGED "The next follow run then finds that release (and moves the series to it if it is not there yet)." > "$REPORT" } -if [[ "$target_tag" == "$base_tag" ]]; then +# Nightly: the newest nightly must contain the series' base. A nightly tag moved or added to an older +# commit would otherwise take the series backwards, and an untagged base newer than every nightly +# has no tag a release could name yet. +target="$(up "$target_tag")" +if [[ "$channel" == nightly ]] && ! git merge-base --is-ancestor "$base" "$target"; then + if [[ -n "$untagged" ]] && git merge-base --is-ancestor "$target" "$base"; then + fail "The series sits on $UPSTREAM commit $base_tag, which is newer than the newest nightly, $target_tag." \ + "A fork release names the upstream tag it sits on, so the series was left as it is; a run after the next nightly follows it." + fi + fail "$UPSTREAM's newest nightly, $target_tag (${target:0:12}), does not contain the series' base $base_tag (${base:0:12})." \ + "Upstream moved or deleted a nightly tag, or tagged another branch. The series was left as it is; decide by hand which nightly it should sit on." +fi +# Upstream cuts nightlies from its default branch. A nightly-shaped tag on another branch that +# contains the base would pass the check above and bring that branch's code to every install. +if [[ "$channel" == nightly ]] && ! git merge-base --is-ancestor "$target" refs/upstream-head; then + fail "$UPSTREAM's newest nightly, $target_tag (${target:0:12}), is not on its default branch, so it is not one of upstream's nightlies (or upstream rewrote that branch)." \ + "The series was left as it is; decide by hand which nightly it should sit on." +fi + +if [[ -z "$untagged" && "$target_tag" == "$base_tag" ]]; then refuse_upstream_fork_named "$base" "$base_tag" "$head" # The candidate here is the series head itself, so a listed file in it is refused, not deleted. kept="$(present_removed "$head")" @@ -347,7 +383,6 @@ fi # A newer upstream release: replay the series onto it. Patches upstream now has become empty and # are dropped. A stop whose conflicts are all on listed (deleted) workflow files is resolved by # deleting them: upstream edited or brought back a file the fork deletes. -target="$(up "$target_tag")" before="$(git log --format=%s "$base..HEAD")" as_fork() { git -c user.name=t3code-fork -c user.email=noreply@users.noreply.github.com "$@"; } rebasing() { [[ -d "$(git rev-parse --git-path rebase-merge)" || -d "$(git rev-parse --git-path rebase-apply)" ]]; } diff --git a/.github/scripts/fork-follow.test.sh b/.github/scripts/fork-follow.test.sh index 2e6c9a1c3f221..55bb56bb68794 100755 --- a/.github/scripts/fork-follow.test.sh +++ b/.github/scripts/fork-follow.test.sh @@ -87,6 +87,8 @@ export REPOSITORY=me/t3code GITHUB_REPOSITORY=me/t3code UPSTREAM=up/t3code unset GITHUB_OUTPUT GITHUB_STEP_SUMMARY GITHUB_ACTIONS UPSTREAM_URL ORIGIN RUN_SHA PUSH_URL \ SERIES_BRANCH FORK_PUSH_TOKEN OWN_WORKFLOWS HEAD_SHA TREE_REF SINCE REPO_SECRET_PRESENT \ FORK_PUSH_ENVIRONMENT +# The cases below use stable tags; the nightly section sets the channel itself. +export FORK_FOLLOW_CHANNEL=stable export FAKE_API="$root/api" @@ -528,6 +530,141 @@ plan check "plan: a new outside-started upstream workflow that the list names -> deleted, rebase" \ eval 'action_is rebase && ! in_candidate outside.yml' +# --- plan: the nightly channel (fork-channel.sh) ------------------------------------------------- +# Upstream: u1 (v0.1.9) - n1 (v0.1.10-nightly.20261112.999) - n2 (...20261112.1000) - n3 (untagged, +# upstream main). Run 1000 is newer than run 999, which a sort of the names puts first. +nightly_up() { + rm -rf "$root/gh" "$root/upwork" + mkdir -p "$root/gh/up" "$root/gh/me" + git init -q --bare "$root/gh/up/t3code.git" + git init -q --bare "$root/gh/me/t3code.git" + git init -q "$root/upwork" + ( + cd "$root/upwork" + mkdir -p .github/workflows + echo "on: workflow_call" > .github/workflows/release-desktop.yml + echo base > shared.txt + git add -A && git commit -q -m u1 && git tag v0.1.9 + echo n1 > n1.txt && git add -A && git commit -q -m n1 && git tag v0.1.10-nightly.20261112.999 + echo n2 > n2.txt && git add -A && git commit -q -m n2 && git tag v0.1.10-nightly.20261112.1000 + echo n3 > n3.txt && git add -A && git commit -q -m n3 + git push -q https://github.com/up/t3code.git main --tags + ) > /dev/null 2>&1 +} +n4=v0.1.10-nightly.20261113.1004 +nightly_more() { # n4 (tagged $n4) and n5 (untagged upstream main) on top of n3 + up_release "$n4" 'echo n4 > n4.txt' + (cd "$root/upwork" && echo n5 > n5.txt && git add -A && git commit -q -m n5 \ + && git push -q https://github.com/up/t3code.git main) > /dev/null 2>&1 +} +upc() { git -C "$root/upwork" rev-parse "$1"; } +nplan() { FORK_FOLLOW_CHANNEL="$1" plan; } + +nightly_up +reset_api +new_fork v0.1.10-nightly.20261112.999 +nplan nightly +check "nightly: newest by version (run 1000 above run 999) -> rebase onto it" \ + eval 'action_is rebase && [[ "$(out base_tag)" == v0.1.10-nightly.20261112.1000 && "$(out previous_tag)" == v0.1.10-nightly.20261112.999 ]]' +new_fork v0.1.9 +nplan stable +check "nightly: the stable channel ignores nightly tags -> same base v0.1.9, release" \ + eval 'action_is release && [[ "$(out base_tag)" == v0.1.9 ]]' +new_fork v0.1.10-nightly.20261112.999 +nplan beta +check "nightly: an unknown channel is refused, nothing planned" \ + eval '[[ -z "$(out action)" ]] && has "FORK_FOLLOW_CHANNEL is .beta." "$root/report.md"' + +# The fork was made from upstream main at an untagged commit (n3): no nightly contains it yet. +new_fork "$(upc main)" +nplan nightly +check "nightly: an untagged base newer than the newest nightly -> refused, waits for the next" \ + eval '[[ -z "$(out action)" ]] && has "newer than the newest nightly, v0.1.10-nightly.20261112.1000" "$root/report.md"' +n3="$(upc main)" +nightly_more +new_fork "$n3" +before="$(work_head)" +(cd "$root/work" && REPORT="$root/report.md" GITHUB_OUTPUT="$root/plan.out" env -u FORK_FOLLOW_CHANNEL bash "$dir/fork-follow.sh") > "$root/out" 2>&1 +check "nightly: the default channel; the jump from the untagged base onto the first nightly above it" \ + eval 'action_is rebase && [[ "$(out base_tag)" == "$n4" && "$(out previous_tag)" == "${n3:0:12}" && "$(out previous_head)" == "$before" ]]' +check "nightly: the rebased candidate holds the fork's patch on the nightly" \ + eval 'git -C "$root/work" cat-file -e "$(out candidate):feature.txt" && git -C "$root/work" merge-base --is-ancestor "$(upc "$n4")" "$(out candidate)"' + +# Same nightly: release, then none once .fork.1 is the head. +new_fork "$n4" +nplan nightly +check "nightly: same nightly, no fork release yet -> release" eval 'action_is release && [[ "$(out base_tag)" == "$n4" ]]' +fork_tag "$n4.fork.1" +nplan nightly +check "nightly: the head is .fork.1 -> none, naming it" eval 'action_is none && [[ "$(out released_tag)" == "$n4.fork.1" ]]' +fork_commit "fork: more" 'echo more > more.txt' +nplan nightly +check "nightly: a new fork commit on the same nightly -> release" action_is release + +# Upstream deletes or moves the nightly the released series sits on. +git -C "$root/gh/up/t3code.git" tag -d "$n4" > /dev/null +nplan nightly +check "nightly: upstream deleted the nightly the series was built on -> named, nothing planned" \ + eval '[[ -z "$(out action)" ]] && has "no longer has the tag $n4" "$root/report.md"' +(cd "$root/upwork" && git checkout -q -b moved "$n3" && echo again > n4.txt && git add -A && git commit -q -m n4b \ + && git tag -f "$n4" && git push -q --force https://github.com/up/t3code.git "refs/tags/$n4") > /dev/null 2>&1 +nplan nightly +check "nightly: upstream moved the nightly the series was built on -> named" has "moved the tag $n4" "$root/report.md" + +# A newest nightly that does not contain the series' base (tagged on another line). +nightly_up +nightly_more +(cd "$root/upwork" && git checkout -q -b side v0.1.10-nightly.20261112.1000 && echo side > side.txt && git add -A \ + && git commit -q -m side && git tag v0.1.10-nightly.20261114.1010 \ + && git push -q https://github.com/up/t3code.git refs/tags/v0.1.10-nightly.20261114.1010) > /dev/null 2>&1 +new_fork "$n4" +before="$(work_head)" +nplan nightly +check "nightly: a newest nightly that does not contain the base -> refused, the series left as it is" \ + eval '[[ -z "$(out action)" ]] && has "does not contain the series. base $n4" "$root/report.md" && [[ "$(work_head)" == "$before" ]]' + +# A newest nightly tagged on another upstream branch that does contain the base: not one of +# upstream's nightlies (they are cut from its default branch). +nightly_up +nightly_more +(cd "$root/upwork" && git checkout -q -b side "$n4" && echo side > side.txt && git add -A \ + && git commit -q -m side && git tag v0.1.10-nightly.20261114.1010 \ + && git push -q https://github.com/up/t3code.git refs/tags/v0.1.10-nightly.20261114.1010) > /dev/null 2>&1 +new_fork "$n4" +before="$(work_head)" +nplan nightly +check "nightly: a newest nightly off upstream's default branch that contains the base -> refused, left as it is" \ + eval '[[ -z "$(out action)" ]] && has "v0.1.10-nightly.20261114.1010 (.*), is not on its default branch" "$root/report.md" && [[ "$(work_head)" == "$before" ]]' + +# Upstream rewrote main past the series' nightly: main restarts from nightly 1000 without n3 and +# n4, and a newer nightly is tagged on the new line. The merge-base with upstream main (nightly +# 1000) must not become an untagged base: n3 and n4, gone from upstream, would then count as the +# fork's own commits and be replayed into the next release. +nightly_up +nightly_more +new_fork "$n4" +(cd "$root/upwork" && git checkout -q -B main v0.1.10-nightly.20261112.1000 && echo n3b > n3b.txt && git add -A \ + && git commit -q -m n3b && git tag v0.1.10-nightly.20261114.1006 \ + && git push -q --force https://github.com/up/t3code.git main refs/tags/v0.1.10-nightly.20261114.1006) > /dev/null 2>&1 +before="$(work_head)" +nplan nightly +check "nightly: upstream rewrote main past the series' nightly -> refused, nothing replayed" \ + eval '[[ -z "$(out action)" && -z "$(out candidate)" ]] && has "does not contain the series. base $n4" "$root/report.md" \ + && [[ "$(work_head)" == "$before" && ! -d "$root/work/.git/rebase-merge" ]]' + +# A published .fork.1 of the rebased files: advance to it. +nightly_up +nightly_more +new_fork v0.1.10-nightly.20261112.1000 +nplan nightly +released="$(out candidate)" +work git push -q origin "$released:refs/heads/fork-follow/candidate" +fork_tag "$n4.fork.1" "$released" +work git checkout -q main && work git reset -q --hard "$(series)" +nplan nightly +check "nightly: a published .fork.1 of the rebased files -> advance to it" \ + eval 'action_is advance && [[ "$(out candidate)" == "$released" && "$(out released_tag)" == "$n4.fork.1" ]]' + # --- pushes: fork-follow-push.sh ------------------------------------------------------------- new_upstream new_fork v0.1.10 @@ -731,6 +868,15 @@ report ACTION=rebase PLAN_RESULT=success RELEASE_RESULT=success ADVANCE_RESULT=f BASE_TAG=v0.1.10 PREVIOUS_TAG=v0.1.9 PREVIOUS_HEAD="$prev" CANDIDATE="$cand" check "report: published but not moved -> says both, never 'nothing was published'" \ eval 'failed_run && has "v0.1.10-fork.2 \*\*published\*\*" "$root/summary.md" && has "not moved" "$root/summary.md" && hasnt -i "nothing was published" "$root/summary.md"' +work git commit -q --allow-empty -m "fork: rebased on a nightly" +ncand="$(work_head)" +fork_tag v0.1.10-nightly.20261113.1004.fork.1 "$ncand" +echo '{"draft":false}' > "$root/api/release-v0.1.10-nightly.20261113.1004.fork.1" +report ACTION=rebase PLAN_RESULT=success RELEASE_RESULT=success ADVANCE_RESULT=failure \ + BASE_TAG=v0.1.10-nightly.20261113.1004 PREVIOUS_TAG=v0.1.10-nightly.20261112.1000 PREVIOUS_HEAD="$prev" CANDIDATE="$ncand" +check "report: a published .fork.1 at the candidate is named as published" \ + has "v0.1.10-nightly.20261113.1004.fork.1 \*\*published\*\*" "$root/summary.md" +work git reset -q --hard "$cand" rm -rf "$root/reports" && mkdir -p "$root/reports" report ACTION=rebase PLAN_RESULT=success RELEASE_RESULT=failure ADVANCE_RESULT=skipped \ BASE_TAG=v0.1.11 PREVIOUS_HEAD="$prev" CANDIDATE="$(git -C "$root/work" rev-parse HEAD~1)" diff --git a/.github/scripts/fork-release.sh b/.github/scripts/fork-release.sh index f0ff3eee17d38..9077f90354bec 100755 --- a/.github/scripts/fork-release.sh +++ b/.github/scripts/fork-release.sh @@ -2,20 +2,25 @@ # The guards of fork-release.yml, one subcommand each, so they can be tested without a run # (fork-release.test.sh). Every refusal exits non-zero with a ::error:: line; nothing falls back. # -# resolve Preflight: picks base tag, version and tag for HEAD (key=value lines). +# resolve Preflight: picks base tag, version and tag for HEAD (key=value lines): +# -fork.N on a stable base, .fork.N on a nightly one. # remove-drafts TAG Refuses a published TAG, then deletes drafts with TAG left by earlier runs. -# publish ID TAG VERSION Refuses a published TAG or a published release that is not below -# VERSION, then publishes draft ID as the latest release. +# publish ID TAG VERSION Refuses a published TAG, a published release that is not below +# VERSION, or an upstream base that is no longer released (a nightly +# withdrawn during the build), then publishes draft ID as the latest release. # check-bundle DIR The server bundle DIR (apps/server/dist) outside DIR/client must name # REPOSITORY as its release repository. # check-archives DIR The `t3` program of every CLI archive in DIR must name REPOSITORY. # -# Environment: REPOSITORY (owner/name of this fork; GITHUB_REPOSITORY in a run), UPSTREAM -# (owner/name, resolve looks it up), UPSTREAM_URL (git URL of upstream, default GitHub), ORIGIN -# (git remote or URL of this fork, default origin). Output goes to $GITHUB_OUTPUT, else stdout. +# Environment: FORK_FOLLOW_CHANNEL (nightly or stable, default nightly: which upstream tags a base +# may be; fork-channel.sh), REPOSITORY (owner/name of this fork; GITHUB_REPOSITORY in a run), UPSTREAM +# (owner/name; this fork's parent when unset), UPSTREAM_URL (git URL of upstream, default GitHub), +# ORIGIN (git remote or URL of this fork, default origin). Output goes to $GITHUB_OUTPUT, else stdout. set -euo pipefail # Refuses outside a GitHub Actions run unless a test runs it under the gh stub (fork-gh-guard.sh). . "$(dirname "${BASH_SOURCE[0]}")/fork-gh-guard.sh" +# The channel, the fork's release names and version_gt. +. "$(dirname "${BASH_SOURCE[0]}")/fork-channel.sh" REPOSITORY="${REPOSITORY:-${GITHUB_REPOSITORY:?REPOSITORY or GITHUB_REPOSITORY}}" ORIGIN="${ORIGIN:-origin}" @@ -27,23 +32,11 @@ die() { } stable_version_re='^v[0-9]+\.[0-9]+\.[0-9]+$' -fork_version_re='^v?[0-9]+\.[0-9]+\.[0-9]+-fork\.[0-9]+$' - -# version_gt A B: A sorts above B. Takes X.Y.Z or X.Y.Z-fork.N (leading v allowed). A fork -# release of X.Y.Z sorts above X.Y.Z itself here, which is what this fork's ordering needs. -version_gt() { - local a="${1#v}" b="${2#v}" i - local -a x y - IFS=. read -r -a x <<< "${a/-fork./.}" - IFS=. read -r -a y <<< "${b/-fork./.}" - for i in 0 1 2 3; do - if (( 10#${x[i]:-0} != 10#${y[i]:-0} )); then - (( 10#${x[i]:-0} > 10#${y[i]:-0} )) - return - fi - done - return 1 -} +# A fork version, with or without the leading v (fork_re in fork-channel.sh). +fork_version_re="^v?${fork_re#^v}" +# The updaters' own rule for a release on another track (cliReleaseChannelOf): the version ends in +# -nightly.YYYYMMDD.N or -preview.YYYYMMDD.N. A fork release of a nightly does not, so it counts. +other_track_re='^v[^-+]+-(nightly|preview)\.[0-9]{8}\.[0-9]+$' # Prints the releases of this fork through a jq filter; a failed listing stops everything. releases() { @@ -67,66 +60,114 @@ refuse_published() { fi } +upstream_repo() { # prints UPSTREAM, or this fork's parent + local upstream="${UPSTREAM:-}" + if [[ -z "$upstream" ]]; then + upstream="$(gh api "repos/$REPOSITORY" --jq '.parent.full_name // empty')" + [[ -n "$upstream" ]] || die "$REPOSITORY is not a fork, so there is no upstream release to build on." + fi + printf '%s\n' "$upstream" +} + +# The upstream tag a fork version is built on: X.Y.Z-fork.N on vX.Y.Z, X.Y.Z-nightly.D.R.fork.N on +# vX.Y.Z-nightly.D.R. +fork_base_tag() { local v="v${1#v}"; if [[ "$v" == *-nightly.* ]]; then printf '%s\n' "${v%.fork.*}"; else printf '%s\n' "${v%-fork.*}"; fi; } + +# Only released upstream versions, checked by preflight and again right before publishing (a +# nightly upstream withdraws during the build must not reach auto-updating installs). +# Stable tag: never above the release upstream marks latest. Nightly tag: upstream published a +# release of that tag itself (a nightly is a pre-release, which "latest" never names; a tag whose +# release is missing or still a draft was not released; this fork's token cannot see another +# repository's drafts, so a draft reads as missing). +require_released() { # require_released UPSTREAM TAG + local upstream="$1" tag="$2" draft upstream_latest + if [[ "$tag" == *-nightly.* ]]; then + draft="$(gh api "repos/$upstream/releases/tags/$tag" --jq '.draft')" \ + || die "Could not read $upstream's release of $tag (it has none, or the lookup failed). Only released upstream versions are built and published." + [[ "$draft" == false ]] || die "$upstream's release of $tag is not published (draft: $draft). Only released upstream versions are built and published." + else + upstream_latest="$(gh api "repos/$upstream/releases/latest" --jq '.tag_name')" + [[ "$upstream_latest" =~ $stable_version_re ]] \ + || die "$upstream's latest release is '$upstream_latest', not a stable vX.Y.Z tag." + if version_gt "$tag" "$upstream_latest"; then + die "$tag sorts above $upstream's latest release $upstream_latest. Only released upstream versions are built and published." + fi + fi +} + # The server's updater (packages/shared/src/cliRelease.ts newestCliReleaseVersion) takes the # newest-created non-draft release on its channel, not the highest version. So every new release # must be above every published one, or `t3 update` would see a downgrade and stall until a higher -# build exists. Stable channel = any vX.Y.Z[-...] tag except -nightly./-preview. builds. -refuse_older() { - local version="$1" other published +# build exists. Stable channel = any vX.Y.Z[-...] tag except upstream-style nightly and preview builds. +# +# A higher version is not newer code for one pair: upstream names a nightly after the release it +# leads up to, so vX.Y.Z-nightly.D.R (older than vX.Y.Z) still sorts above a published X.Y.Z-fork.N. +# Its fork build would install older upstream code over the stable one, which may lack migrations +# that build already ran. So a nightly build of the same X.Y.Z as a published stable fork release +# must contain that release's upstream tag (GitHub's compare of the two upstream tags). +refuse_older() { # refuse_older VERSION UPSTREAM + local version="$1" upstream="$2" other published mine status + mine="v${version#v}" published="$(releases '.[] | select(.draft | not) | .tag_name')" for other in $published; do [[ "$other" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-.*)?$ ]] || continue - [[ ! "$other" =~ -(nightly|preview)\. ]] || continue + [[ ! "$other" =~ $other_track_re ]] || continue [[ "$other" =~ $stable_version_re || "$other" =~ $fork_version_re ]] \ - || die "Published release $other is on the server's stable channel but has no X.Y.Z or X.Y.Z-fork.N version, so $version cannot be ordered against it; nothing was changed." + || die "Published release $other is on the server's stable channel but has no X.Y.Z, X.Y.Z-fork.N or X.Y.Z-nightly.D.N.fork.N version, so $version cannot be ordered against it; nothing was changed." version_gt "$version" "$other" \ || die "$other is published and not below $version. The server updates to the newest-created release, so publishing $version would show every server a downgrade. Build a newer upstream release instead; to go back, install the older release with t3 update --version X --allow-downgrade. Nothing was changed." + if [[ "$mine" == *-nightly.* && "$other" != *-nightly.* && "$other" == *-fork.* \ + && "${mine%%-*}" == "${other%%-*}" ]]; then + status="$(gh api "repos/$upstream/compare/$(fork_base_tag "$other")...$(fork_base_tag "$mine")" --jq '.status')" \ + || die "Could not compare $upstream $(fork_base_tag "$other") with $(fork_base_tag "$mine"), so $version cannot be ordered against the published $other; nothing was changed." + [[ "$status" == ahead || "$status" == identical ]] \ + || die "$other is published and built on $upstream $(fork_base_tag "$other"), which $(fork_base_tag "$mine") does not contain ($status). Upstream names a nightly after the release it leads up to, so $version sorts above $other but holds older upstream code; every install would update to it. Build a nightly that contains $(fork_base_tag "$other") instead. Nothing was changed." + fi done } cmd_resolve() { - local upstream="${UPSTREAM:-}" - if [[ -z "$upstream" ]]; then - upstream="$(gh api "repos/$REPOSITORY" --jq '.parent.full_name // empty')" - [[ -n "$upstream" ]] || die "$REPOSITORY is not a fork, so there is no upstream release to build on." - fi + local upstream + upstream="$(upstream_repo)" local url="${UPSTREAM_URL:-https://github.com/$upstream.git}" - local head + local head channel channel_re channel_form + fork_channel_init || die "FORK_FOLLOW_CHANNEL is '$FORK_FOLLOW_CHANNEL'; it must be nightly or stable." head="$(git rev-parse HEAD)" # Read-only fetch of upstream's tags and default branch into refs this run alone uses. - git fetch --quiet --no-tags "$url" "+refs/tags/v*:refs/upstream-tags/v*" "+HEAD:refs/upstream-head" + git fetch --quiet --prune --no-tags "$url" "+refs/tags/v*:refs/upstream-tags/v*" "+HEAD:refs/upstream-head" # Lists are read into variables first: a failure inside <(...) would go unnoticed. local refs tag local -a contained=() refs="$(git for-each-ref --format='%(refname:strip=2)' refs/upstream-tags)" - for tag in $(grep -E "$stable_version_re" <<< "$refs" | sort -rV || true); do + refs="$({ grep -E "$channel_re" <<< "$refs" || true; } | newest_first)" + for tag in $refs; do if git merge-base --is-ancestor "refs/upstream-tags/$tag^{commit}" HEAD; then contained+=("$tag") fi done - (( ${#contained[@]} > 0 )) || die "$head contains no stable $upstream release tag." + (( ${#contained[@]} > 0 )) || die "$head contains no $channel $upstream release tag ($channel_form)." local base_tag="${contained[0]}" local base base="$(git rev-parse "refs/upstream-tags/$base_tag^{commit}")" - # The base must be the newest stable tag in the series' history, not just the highest name: a + # The base must be the newest tag of the channel in the series' history, not just the highest: a # stray tag on an old commit (say v1.0.0) would otherwise outrank every later real release. for tag in "${contained[@]:1}"; do git merge-base --is-ancestor "refs/upstream-tags/$tag^{commit}" "$base" \ - || die "$base_tag sorts highest but is not the newest stable $upstream tag in this history ($tag is not part of it). Delete or fix the stray tag; nothing was built." + || die "$base_tag sorts highest but is not the newest $channel $upstream tag in this history ($tag is not part of it). Delete or fix the stray tag; nothing was built." done - # Upstream's own ordering: never above the release upstream marks latest. - local upstream_latest - upstream_latest="$(gh api "repos/$upstream/releases/latest" --jq '.tag_name')" - [[ "$upstream_latest" =~ $stable_version_re ]] \ - || die "$upstream's latest release is '$upstream_latest', not a stable vX.Y.Z tag." - if version_gt "$base_tag" "$upstream_latest"; then - die "$base_tag sorts above $upstream's latest release $upstream_latest. Only released upstream versions are built." + # Nightly: upstream cuts nightlies from its default branch. A nightly-shaped tag on another + # branch (which the checks above accept when it is the newest one in this history) is not one. + if [[ "$channel" == nightly ]]; then + git merge-base --is-ancestor "$base" refs/upstream-head \ + || die "$base_tag is not on $upstream's default branch, so it is not one of upstream's nightlies (or upstream rewrote that branch). Only nightlies of the default branch are built; nothing was built." fi + require_released "$upstream" "$base_tag" + # Releases only: every commit after the base tag must be the fork's own. Upstream commits there # are unreleased upstream code (for example a branch that still equals upstream's default branch). local total own @@ -136,11 +177,13 @@ cmd_resolve() { die "$head holds $((total - own)) unreleased $upstream commits after $base_tag. Build a branch of $base_tag plus the fork's own commits." fi - local version="${base_tag#v}" last - last="$(git ls-remote --tags --refs "$ORIGIN" "v$version-fork.*" \ - | sed -n "s|.*refs/tags/v$version-fork\.\([0-9]\+\)$|\1|p" | sort -n | tail -n1)" - version="$version-fork.$(( ${last:-0} + 1 ))" - refuse_older "$version" + local version="${base_tag#v}" sep sep_re=- last + sep="$(fork_sep "$base_tag")" + [[ "$sep" != . ]] || sep_re='\.' + last="$(git ls-remote --tags --refs "$ORIGIN" "v${version}${sep}fork.*" \ + | sed -n "s|.*refs/tags/v${version//./\\.}${sep_re}fork\.\([0-9]\+\)$|\1|p" | sort -n | tail -n1)" + version="${version}${sep}fork.$(( ${last:-0} + 1 ))" + refuse_older "$version" "$upstream" { echo "ref=$head" echo "upstream=$upstream" @@ -168,9 +211,14 @@ cmd_publish() { local id="${1:?release id}" tag="${2:?tag}" version="${3:?version}" [[ "$version" =~ $fork_version_re ]] || die "'$version' is not a fork version." refuse_published "$tag" + local upstream + upstream="$(upstream_repo)" # Checked again here: a release published since preflight must not end up above this one. After # this check VERSION is the highest release, so the server and the desktop updater agree on it. - refuse_older "$version" + refuse_older "$version" "$upstream" + # And upstream must still have released the base: a nightly withdrawn during the build (release + # deleted or set back to draft) is not published to auto-updating installs. + require_released "$upstream" "$(fork_base_tag "$version")" gh api -X PATCH "repos/$REPOSITORY/releases/$id" -F draft=false -f make_latest=true > /dev/null echo "Published $tag (latest: true)." { diff --git a/.github/scripts/fork-release.test.sh b/.github/scripts/fork-release.test.sh index 0ae10f0da7f01..f1be8599b82da 100755 --- a/.github/scripts/fork-release.test.sh +++ b/.github/scripts/fork-release.test.sh @@ -31,6 +31,19 @@ done case "$method $path" in "GET repos/me/t3code") body='{"parent":{"full_name":"up/t3code"}}' ;; "GET repos/up/t3code/releases/latest") body="{\"tag_name\":\"$(cat "$api/upstream_latest")\"}" ;; + "GET repos/up/t3code/releases/tags/"*) + [[ -f "$api/up-release-${path##*/}" ]] || { echo "gh: Not Found (HTTP 404)" >&2; exit 1; } + body="$(cat "$api/up-release-${path##*/}")" ;; + "GET repos/up/t3code/compare/"*) # A...B: how upstream tag B relates to tag A, from the local upstream + range="${path#*/compare/}" up_git="$api/../gh/up/t3code.git" + a="$(git -C "$up_git" rev-parse -q --verify "refs/tags/${range%%...*}^{commit}")" \ + && b="$(git -C "$up_git" rev-parse -q --verify "refs/tags/${range#*...}^{commit}")" \ + || { echo "gh: Not Found (HTTP 404)" >&2; exit 1; } + if [[ "$a" == "$b" ]]; then status=identical + elif git -C "$up_git" merge-base --is-ancestor "$a" "$b"; then status=ahead + elif git -C "$up_git" merge-base --is-ancestor "$b" "$a"; then status=behind + else status=diverged; fi + body="{\"status\":\"$status\"}" ;; "GET repos/me/t3code/releases") [[ ! -e "$api/fail_list" ]] || { echo "HTTP 502: Bad Gateway" >&2; exit 1; } body="$(cat "$api/releases.json")" ;; @@ -58,6 +71,8 @@ git config --global advice.detachedHead false git config --global url."$root/gh/".insteadOf https://github.com/ export REPOSITORY=me/t3code GITHUB_REPOSITORY=me/t3code unset GITHUB_OUTPUT GITHUB_STEP_SUMMARY UPSTREAM UPSTREAM_URL ORIGIN +# The cases below use stable tags; the nightly section sets the channel itself. +export FORK_FOLLOW_CHANNEL=stable export FAKE_API="$root/api" @@ -151,6 +166,162 @@ expect_refused "resolve: refuses a build below a published release (older ref af echo '[{"id":5,"draft":true,"prerelease":false,"tag_name":"v0.1.10-fork.3"}]' > "$root/api/releases.json" expect_resolved "resolve: a higher draft does not block (updaters skip drafts)" 0.1.9-fork.1 +# --- the nightly channel (fork-channel.sh) ---------------------------------------------------- +# Upstream: u1 (v0.1.9) - n1 (v0.1.10-nightly.20261113.4630) - u3 (untagged, upstream main). +nt=v0.1.10-nightly.20261113.4630 +nightly_upstream() { + rm -rf "$root/gh" "$root/upwork" + mkdir -p "$root/gh/up" "$root/gh/me" + git init -q --bare "$root/gh/up/t3code.git" + git init -q --bare "$root/gh/me/t3code.git" + git init -q "$root/upwork" + ( + cd "$root/upwork" + git commit -q --allow-empty -m u1 && git tag v0.1.9 + git commit -q --allow-empty -m n1 && git tag "$nt" + git commit -q --allow-empty -m u3 + git push -q https://github.com/up/t3code.git main --tags + ) +} +up_released() { echo "{\"tag_name\":\"$1\",\"draft\":${2:-false},\"prerelease\":true}" > "$root/api/up-release-$1"; } +nightly_upstream +reset_api +up_released "$nt" +new_fork "$nt" +export FORK_FOLLOW_CHANNEL=nightly +expect_resolved "nightly: a published nightly plus a fork commit is .fork.1" "${nt#v}.fork.1" +grep -qx "tag=$nt.fork.1" "$root/out" && pass "nightly: its tag is v.fork.1" || fail "nightly: its tag is v.fork.1" +fork_tag "$nt.fork.1" +expect_resolved "nightly: the next build counts the .fork.N tags" "${nt#v}.fork.2" +unset FORK_FOLLOW_CHANNEL +expect_resolved "nightly: the default channel is nightly" "${nt#v}.fork.2" +export FORK_FOLLOW_CHANNEL=nightly +up_released "$nt" true +expect_refused "nightly: a nightly whose upstream release is a draft is refused" "not published" "$root/work" resolve +rm "$root/api/up-release-$nt" +expect_refused "nightly: a nightly with no upstream release is refused" "Could not read up/t3code's release of $nt" "$root/work" resolve +up_released "$nt" +new_fork main +expect_refused "nightly: untagged upstream commits after the nightly are refused" "unreleased" "$root/work" resolve +new_fork v0.1.9 +expect_refused "nightly: a series with no nightly tag is refused" "contains no nightly" "$root/work" resolve +FORK_FOLLOW_CHANNEL=beta expect_refused "nightly: an unknown channel is refused" "FORK_FOLLOW_CHANNEL is 'beta'" "$root/work" resolve + +# Ordering: a published fork release of a nightly is on the updaters' stable track, so it blocks +# a lower build; an upstream-style nightly name (another track) does not. +export FORK_FOLLOW_CHANNEL=stable +reset_api v0.1.9 +echo "[{\"id\":5,\"draft\":false,\"prerelease\":false,\"tag_name\":\"$nt.fork.1\"}]" > "$root/api/releases.json" +expect_refused "nightly: a published .fork.1 blocks a lower build" "$nt.fork.1 is published and not below 0.1.9-fork.1" "$root/work" resolve +echo '[{"id":5,"draft":false,"prerelease":true,"tag_name":"v0.1.11-nightly.20261114.1"}]' > "$root/api/releases.json" +expect_resolved "nightly: an upstream-style nightly release (another track) does not block" 0.1.9-fork.1 +export FORK_FOLLOW_CHANNEL=nightly +nightly_upstream +reset_api +up_released "$nt" +new_fork "$nt" +echo '[{"id":5,"draft":false,"prerelease":false,"tag_name":"v0.1.10-fork.4"}]' > "$root/api/releases.json" +expect_refused "nightly: a published v0.1.10-fork.4 whose upstream v0.1.10 is gone cannot be ordered -> refused" \ + "Could not compare up/t3code v0.1.10 with $nt" "$root/work" resolve +(cd "$root/upwork" && git tag v0.1.10 "$nt" && git push -q https://github.com/up/t3code.git v0.1.10) > /dev/null 2>&1 +expect_resolved "nightly: .fork.1 is above a stable fork release of the same X.Y.Z it contains" "${nt#v}.fork.1" +echo "[{\"id\":5,\"draft\":false,\"prerelease\":false,\"tag_name\":\"v0.1.10-nightly.20261113.4631.fork.1\"}]" > "$root/api/releases.json" +expect_refused "nightly: a newer published nightly release blocks an older one" "4631.fork.1 is published and not below" "$root/work" resolve +# A plain X.Y.Z sorts above every pre-release of it, in the app as in semver (compareSemverVersions). +echo '[{"id":5,"draft":false,"prerelease":false,"tag_name":"v0.1.10"}]' > "$root/api/releases.json" +expect_refused "nightly: a published plain v0.1.10 blocks a <0.1.10 nightly>.fork.1 (the app ranks it lower)" \ + "v0.1.10 is published and not below ${nt#v}.fork.1" "$root/work" resolve + +# A nightly tag on another upstream branch than the default one, even one that is the newest in +# the series' history, is not one of upstream's nightlies. +side=v0.1.10-nightly.20261114.4700 +(cd "$root/upwork" && git checkout -q -b side "$nt" && git commit -q --allow-empty -m side && git tag "$side" \ + && git push -q https://github.com/up/t3code.git "refs/tags/$side" && git checkout -q main) > /dev/null 2>&1 +echo '[]' > "$root/api/releases.json" +up_released "$side" +new_fork "$side" +expect_refused "nightly: a nightly tag that is not on upstream's default branch is refused" \ + "$side is not on up/t3code's default branch" "$root/work" resolve + +# Upstream names a nightly after the release it leads up to: v0.1.9-nightly.20261109.4443 is older +# than v0.1.9, yet its fork build sorts above a published v0.1.9-fork.1. Nightly 4515 is the +# v0.1.9 commit itself. Upstream: o1 (4443) - s1 (v0.1.9 = 4515) - main. +old_n=v0.1.9-nightly.20261109.4443 same_n=v0.1.9-nightly.20261110.4515 +rm -rf "$root/gh" "$root/upwork" +mkdir -p "$root/gh/up" "$root/gh/me" +git init -q --bare "$root/gh/up/t3code.git" +git init -q --bare "$root/gh/me/t3code.git" +git init -q "$root/upwork" +(cd "$root/upwork" && git commit -q --allow-empty -m o1 && git tag "$old_n" \ + && git commit -q --allow-empty -m s1 && git tag v0.1.9 && git tag "$same_n" \ + && git commit -q --allow-empty -m u3 && git push -q https://github.com/up/t3code.git main --tags) > /dev/null 2>&1 +reset_api v0.1.9 +up_released "$old_n" +up_released "$same_n" +echo '[{"id":5,"draft":false,"prerelease":false,"tag_name":"v0.1.9-fork.1"}]' > "$root/api/releases.json" +new_fork "$old_n" +expect_refused "nightly: a build of a nightly older than a published v0.1.9-fork.1's v0.1.9 is refused" \ + "v0.1.9-fork.1 is published and built on up/t3code v0.1.9, which $old_n does not contain" "$root/work" resolve +new_fork "$same_n" +expect_resolved "nightly: a nightly that contains v0.1.9 goes forward from v0.1.9-fork.1" "${same_n#v}.fork.1" +echo '[{"id":5,"draft":false,"prerelease":false,"tag_name":"v0.1.9-fork.1"}, + {"id":9,"draft":true,"prerelease":false,"tag_name":"'"$old_n"'.fork.1"}]' > "$root/api/releases.json" +expect_refused "nightly: publish re-checks that ancestry (v0.1.9-fork.1 published during the build)" \ + "which $old_n does not contain" "$root/work" publish 9 "$old_n.fork.1" "${old_n#v}.fork.1" +[[ ! -e "$root/api/writes" ]] || fail "nightly: nothing written for an older nightly" + +# Publish re-checks upstream's release of the nightly: one withdrawn during the build (release +# deleted, or set back to draft) is not published to auto-updating installs. +echo '[{"id":9,"draft":true,"prerelease":false,"tag_name":"'"$same_n"'.fork.1"}]' > "$root/api/releases.json" +rm -f "$root/api/writes" +rm "$root/api/up-release-$same_n" +expect_refused "nightly: publish refuses a nightly whose upstream release was deleted during the build" \ + "Could not read up/t3code's release of $same_n" "$root/work" publish 9 "$same_n.fork.1" "${same_n#v}.fork.1" +[[ ! -e "$root/api/writes" ]] || fail "nightly: nothing written for a withdrawn nightly" +rm -f "$root/api/writes" +up_released "$same_n" true +expect_refused "nightly: publish refuses a nightly whose upstream release went back to draft" \ + "release of $same_n is not published" "$root/work" publish 9 "$same_n.fork.1" "${same_n#v}.fork.1" +[[ ! -e "$root/api/writes" ]] || fail "nightly: nothing written for a nightly back in draft" +rm -f "$root/api/writes" +up_released "$same_n" +if run "$root/work" publish 9 "$same_n.fork.1" "${same_n#v}.fork.1" \ + && grep -qx "patched 9 draft=false make_latest=true" "$root/api/writes"; then + pass "nightly: publish goes ahead while upstream's release of the nightly is published" +else + fail "nightly: publish goes ahead while upstream's release of the nightly is published" +fi +export FORK_FOLLOW_CHANNEL=stable + +# version_gt / newest_first (fork-channel.sh): numbers compare as numbers, forms rank as semver. +. "$(dirname "$script")/fork-channel.sh" +vcase() { # vcase A B WANT: version_gt A B exits WANT + local got=0 + version_gt "$1" "$2" || got=$? + [[ "$got" == "$3" ]] && pass "version_gt $1 $2 -> $3" || { : > "$root/out"; fail "version_gt $1 $2 -> $3 (got $got)"; } +} +vcase v0.1.10-nightly.20261112.1000 v0.1.10-nightly.20261112.999 0 +vcase v0.1.10-nightly.20261113.1 v0.1.10-nightly.20261112.9999 0 +vcase v0.1.10-nightly.20261113.4630.fork.1 v0.1.10-nightly.20261113.4630 0 +vcase v0.1.10-nightly.20261113.4630.fork.10 v0.1.10-nightly.20261113.4630.fork.9 0 +vcase v0.1.10-nightly.20261113.4630.fork.9 v0.1.10-nightly.20261113.4631 1 +vcase 0.1.10-nightly.20261113.1.fork.1 v0.1.10-fork.3 0 +# Plain X.Y.Z above every pre-release of it, as compareSemverVersions has it (checked with the app: +# -1 for the first two pairs, 1 for the next two). +vcase v0.1.10-fork.1 v0.1.10 1 +vcase v0.1.10-nightly.20261113.1.fork.1 v0.1.10 1 +vcase v0.1.10 v0.1.10-fork.1 0 +vcase v0.1.10 v0.1.10-nightly.20261113.4630.fork.1 0 +vcase v0.1.11 v0.1.10-nightly.20261113.4630.fork.1 0 +vcase v0.1.10 v0.1.10 1 +vcase v0.1.10-rc.1 v0.1.9 2 +got="$(printf '%s\n' v0.1.10-nightly.20261112.999 v0.1.10-nightly.20261112.1000 v0.1.9 v0.1.10-nightly.20261112.1000.fork.2 | newest_first | tr '\n' ' ')" +[[ "$got" == "v0.1.10-nightly.20261112.1000.fork.2 v0.1.10-nightly.20261112.1000 v0.1.10-nightly.20261112.999 v0.1.9 " ]] \ + && pass "newest_first orders by version, not by name" || { echo "$got" > "$root/out"; fail "newest_first orders by version, not by name"; } +printf 'v0.1.9\nv0.1.10-rc.1\n' | newest_first > /dev/null && fail "newest_first fails on a form it cannot order" \ + || pass "newest_first fails on a form it cannot order" +new_upstream + # --- remove-drafts ----------------------------------------------------------------------------- new_upstream reset_api diff --git a/.github/workflows/fork-follow.yml b/.github/workflows/fork-follow.yml index 8761834658dbf..6fe7d2f64aeb5 100644 --- a/.github/workflows/fork-follow.yml +++ b/.github/workflows/fork-follow.yml @@ -1,10 +1,19 @@ name: Fork follow upstream -# Daily: keeps this fork's patch series on upstream's newest stable release and publishes it. +# Daily: keeps this fork's patch series on upstream's newest tag of one channel and publishes it. +# The channel is the variable FORK_FOLLOW_CHANNEL: nightly (the default; tags +# vX.Y.Z-nightly.YYYYMMDD.N, released as .fork.) or stable (vX.Y.Z, released as +# -fork.); .github/scripts/fork-channel.sh. Upstream cuts several nightlies a day: a run +# takes the newest by version (every nightly contains the ones before it), so once a day costs one +# rebase, test and build however many nightlies there were; start a run by hand for one sooner. +# Nightly also starts from a series on an untagged commit of upstream's default branch: it is +# rebased onto the newest nightly that contains that commit. Switching nightly -> stable is by +# hand (rebase onto a stable tag that contains the series' nightly), and the first stable release +# must be on a newer X.Y.Z than the last nightly one, or the release refuses it as a downgrade. # The plan (.github/scripts/fork-follow.sh) picks one action: # - none: the series head is already the newest fork release of its base. # - release: same base, but the series has commits no fork release has: test, build and publish. -# - rebase: upstream has a newer stable tag: rebase the series onto it, test, build, publish, then +# - rebase: upstream has a newer tag: rebase the series onto it, test, build, publish, then # move the series branch to the released commit (the old series is kept as a tag). # - advance: a published fork release already has the rebased files (an earlier run published it, # then could not move the branch): move the series branch to it, build nothing. @@ -17,10 +26,11 @@ name: Fork follow upstream # A missing or malformed list, and a new upstream workflow that someone outside the fork can start # (pull_request_target, workflow_run, issues ...; fork-follow.sh) and the list does not name, stop it. # A conflict, merge commits in the series, a schema change, upstream commits inside the series, a -# moved or deleted upstream tag, a red test, a failed build or push: the run fails, and the Report -# job's summary says why and what the repository holds now (branch heads, release tags, drafts), as -# read back from it. With Issues enabled it also opens or updates one issue, and closes it again -# on the next good run. +# moved or deleted upstream tag, a newest nightly that does not contain the series' base (moved +# backwards, or the series sits past it), a red test, a failed build or push: the run fails, and +# the Report job's summary says why and what the repository holds now (branch heads, release +# tags, drafts), as read back from it. With Issues enabled it also opens or updates one issue, and +# closes it again on the next good run. # # Upstream's workflows stay off: after each push, before the series branch moves, and at the end, # fork-workflows.sh disables every active workflow the series did not add (a fork-*.yml is always the @@ -33,7 +43,8 @@ name: Fork follow upstream # A re-run of failed jobs (or of one job) keeps the Guard job's earlier result, so the jobs with # the token are skipped unless the guard passed in this attempt: use "Re-run all jobs". # -# Settings: variable FORK_SERIES_BRANCH (default main). Secret FORK_PUSH_TOKEN, only in the +# Settings: variables FORK_SERIES_BRANCH (default main) and FORK_FOLLOW_CHANNEL (nightly or stable, +# default nightly; fork-release.yml reads the same one). Secret FORK_PUSH_TOKEN, only in the # Environment fork-push, whose deployment branches are "Selected branches and tags" with the one # rule: branch . A repository secret could be read by a workflow file on any pushed # branch, and an Environment without that rule hands its secret to any branch. The Guard job checks @@ -206,6 +217,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} RUN_SHA: ${{ github.sha }} + FORK_FOLLOW_CHANNEL: ${{ vars.FORK_FOLLOW_CHANNEL || 'nightly' }} REPORT: ${{ runner.temp }}/reports/plan.md run: | set -euo pipefail diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml index d5bc8fb2d93d3..d33953692ec2f 100644 --- a/.github/workflows/fork-release.yml +++ b/.github/workflows/fork-release.yml @@ -1,9 +1,11 @@ name: Fork release -# Builds this fork's release `v-fork.`: upstream's tests, the Linux x64 server archive -# with SHA256SUMS, and the desktop apps (Linux x64, Windows x64, macOS arm64 and x64), all on -# GitHub-hosted runners and unsigned unless signing secrets exist. is the newest stable -# upstream tag the commit contains; counts the fork's earlier builds of it. +# Builds this fork's release `v.fork.` (nightly) or `v-fork.` (stable): +# upstream's tests, the Linux x64 server archive with SHA256SUMS, and the desktop apps (Linux x64, +# Windows x64, macOS arm64 and x64), all on GitHub-hosted runners and unsigned unless signing +# secrets exist. is the newest upstream tag of the channel (variable +# FORK_FOLLOW_CHANNEL, default nightly) the commit contains; counts the fork's earlier builds +# of it. Both names read as the app's stable track, so the builds use the stable feed and branding. # # Builds name this repository as their release repository (scripts/lib/release-repository.ts reads # T3CODE_DESKTOP_UPDATE_REPOSITORY, else GITHUB_REPOSITORY in Actions), so the server's update and @@ -135,12 +137,14 @@ jobs: python3 -c 'import yaml' 2> /dev/null || { sudo apt-get update && sudo apt-get install -y python3-yaml; } python3 .github/scripts/fork-release-workflow.test.py - # Refuses unreleased upstream commits, a stray upstream tag and a base above upstream's latest. + # Refuses unreleased upstream commits, a stray upstream tag, and a base upstream has not + # released (stable: above upstream's latest; nightly: no published release of that tag). - id: meta name: Resolve fork release version shell: bash env: GH_TOKEN: ${{ github.token }} + FORK_FOLLOW_CHANNEL: ${{ vars.FORK_FOLLOW_CHANNEL || 'nightly' }} run: bash .github/scripts/fork-release.sh resolve tests: @@ -599,13 +603,15 @@ jobs: release-assets/*.tar.gz release-assets/SHA256SUMS - # Updaters skip drafts, so nobody sees a release until every file above is uploaded. The tag - # and "nothing published is as high" checks run again right before; it is then made latest. + # Updaters skip drafts, so nobody sees a release until every file above is uploaded. The tag, + # "nothing published is as high" and "upstream still has the base released" checks run again + # right before; it is then made latest. - name: Publish the draft id: publish shell: bash env: GH_TOKEN: ${{ github.token }} + UPSTREAM: ${{ needs.preflight.outputs.upstream }} RELEASE_ID: ${{ steps.draft.outputs.id }} TAG: ${{ needs.preflight.outputs.tag }} VERSION: ${{ needs.preflight.outputs.version }}