From 048fe932dd5bbdc1766d3c1d507e624fd7da70d4 Mon Sep 17 00:00:00 2001 From: t3code-fork Date: Thu, 12 Nov 2026 09:07:27 +0000 Subject: [PATCH 01/12] fork: follow each stable upstream release with the patch series fork-follow.yml runs daily. When upstream has a newer stable tag it rebases the series onto it (patches upstream now has drop out as empty), runs the schema check, tests, builds and publishes through fork-release.yml, then moves the series branch with a lease. When the series has commits no fork release has, it releases them. A conflict, schema change, upstream commits inside the series, a red test or a failed build open or update one issue and publish nothing. Pushing a rebase needs a FORK_PUSH_TOKEN secret, because GITHUB_TOKEN may not push commits that change workflow files. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/fork-follow.sh | 102 +++++++++++++++++ .github/workflows/fork-follow.yml | 176 ++++++++++++++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100755 .github/scripts/fork-follow.sh create mode 100644 .github/workflows/fork-follow.yml diff --git a/.github/scripts/fork-follow.sh b/.github/scripts/fork-follow.sh new file mode 100755 index 0000000000000..b670855de6a3d --- /dev/null +++ b/.github/scripts/fork-follow.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# Decides what the follow-upstream workflow does next, 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. +# Writes key=value lines to $GITHUB_OUTPUT (stdout without it); a failure's reason goes to $REPORT. +set -euo pipefail + +: "${UPSTREAM:?owner/name of the upstream repository}" +REPORT="${REPORT:-fork-follow-report.md}" +OUTPUT="${GITHUB_OUTPUT:-/dev/stdout}" +logs="${RUNNER_TEMP:-$(mktemp -d)}" +upstream_url="${UPSTREAM_URL:-https://github.com/$UPSTREAM.git}" + +fail() { + printf '%s\n' "$@" > "$REPORT" + cat "$REPORT" >&2 + exit 1 +} + +stable_tags() { + git ls-remote --tags --refs "$upstream_url" 'v*' \ + | sed -n 's|.*refs/tags/\(v[0-9]\+\.[0-9]\+\.[0-9]\+\)$|\1|p' | sort -rV +} + +fetch_upstream_tag() { + git fetch --quiet --no-tags "$upstream_url" "+refs/tags/$1:refs/upstream-tags/$1" + git rev-parse "refs/upstream-tags/$1^{commit}" +} + +head="$(git rev-parse HEAD)" +target_tag="$(stable_tags | sed -n 1p)" +[[ -n "$target_tag" ]] || fail "$UPSTREAM has no stable release tag." + +# The series sits on the newest stable upstream tag it contains. +base_tag="" +while read -r tag; do + commit="$(fetch_upstream_tag "$tag")" + if git merge-base --is-ancestor "$commit" HEAD; then + base_tag="$tag" + base="$commit" + break + fi +done < <(stable_tags) +[[ -n "$base_tag" ]] || fail "The series ($head) contains no stable $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). +git fetch --quiet --no-tags "$upstream_url" "+HEAD:refs/upstream-head" +series_count="$(git rev-list --count "$base..HEAD")" +own_count="$(git rev-list --count "$base..HEAD" --not refs/upstream-head)" +if [[ "$series_count" != "$own_count" ]]; then + fail "The series holds $((series_count - own_count)) upstream commits after $base_tag." \ + "Reset it to $base_tag plus the fork's own commits; nothing was rebased or published." +fi + +node .github/scripts/fork-schema-check.cjs "$base" HEAD > "$logs/schema-check.log" 2>&1 \ + || fail "The series changes the database schema:" '```' "$(cat "$logs/schema-check.log")" '```' + +released_head() { + local version="${1#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)" + [[ -z "$last" ]] || git ls-remote origin "refs/tags/v$version-fork.$last^{}" "refs/tags/v$version-fork.$last" \ + | sort -k2 | tail -n1 | cut -f1 +} + +if [[ "$target_tag" == "$base_tag" ]]; then + if [[ "$(released_head "$base_tag")" == "$head" ]]; then + action=none + else + action=release + fi + { + echo "action=$action" + echo "base_tag=$base_tag" + echo "candidate=$head" + } >> "$OUTPUT" + exit 0 +fi + +# A newer upstream release: replay the series onto it. Patches upstream now has become empty and +# are dropped; the release notes list the series, so a dropped patch simply no longer appears. +target="$(fetch_upstream_tag "$target_tag")" +before="$(git log --format=%s "$base..HEAD")" +if ! git -c user.name=t3code-fork -c user.email=noreply@users.noreply.github.com \ + rebase --empty=drop --onto "$target" "$base" > "$logs/rebase.log" 2>&1; then + stopped="$(git log -1 --format='%h %s' REBASE_HEAD 2>/dev/null || echo unknown)" + conflicts="$(git diff --name-only --diff-filter=U | sed 's/^/- /')" + git rebase --abort || true + fail "Rebasing the series from $base_tag onto $target_tag stopped at: $stopped" "" \ + "Conflicting files:" "$conflicts" "" "Nothing was published; the last fork release stays current." +fi +node .github/scripts/fork-schema-check.cjs "$target" HEAD > "$logs/schema-check.log" 2>&1 \ + || fail "After rebasing onto $target_tag the series changes the database schema:" '```' "$(cat "$logs/schema-check.log")" '```' +dropped="$(comm -23 <(sort <<<"$before") <(git log --format=%s "$target..HEAD" | sort) | paste -sd ';' -)" +{ + echo "action=rebase" + echo "base_tag=$target_tag" + echo "previous_tag=$base_tag" + echo "previous_head=$head" + echo "candidate=$(git rev-parse HEAD)" + echo "dropped=$dropped" +} >> "$OUTPUT" diff --git a/.github/workflows/fork-follow.yml b/.github/workflows/fork-follow.yml new file mode 100644 index 0000000000000..00584cc3a57d5 --- /dev/null +++ b/.github/workflows/fork-follow.yml @@ -0,0 +1,176 @@ +name: Fork follow upstream + +# Daily: keeps this fork's patch series on upstream's newest stable release and publishes it. +# - Upstream has a newer stable tag: rebase the series onto it, test, build, publish, then move the +# series branch to the result. +# - Same tag, but the series has commits no fork release has: test, build and publish them. +# - Anything fails (conflict, schema change, red test, failed build): open or update one issue and +# publish nothing, so the last fork release stays current. +# +# Settings: variable FORK_SERIES_BRANCH (default main). Secret FORK_PUSH_TOKEN: a token that may push +# this repository's contents and workflow files (GITHUB_TOKEN may not push commits that change +# .github/workflows); only a rebase needs it. +on: + schedule: + - cron: "23 6 * * *" + workflow_dispatch: + +concurrency: + group: fork-follow + cancel-in-progress: false + +permissions: + contents: read + +env: + SERIES_BRANCH: ${{ vars.FORK_SERIES_BRANCH || 'main' }} + +jobs: + plan: + name: Plan + runs-on: ubuntu-latest + timeout-minutes: 15 + outputs: + action: ${{ steps.plan.outputs.action }} + base_tag: ${{ steps.plan.outputs.base_tag }} + candidate: ${{ steps.plan.outputs.candidate }} + previous_head: ${{ steps.plan.outputs.previous_head }} + dropped: ${{ steps.plan.outputs.dropped }} + steps: + - name: Checkout the series + uses: actions/checkout@v6 + with: + ref: ${{ env.SERIES_BRANCH }} + fetch-depth: 0 + filter: blob:none + token: ${{ secrets.FORK_PUSH_TOKEN || github.token }} + + - id: plan + name: Plan and rebase + env: + GH_TOKEN: ${{ github.token }} + REPORT: ${{ runner.temp }}/fork-follow-report.md + run: | + set -euo pipefail + UPSTREAM="$(gh api "repos/$GITHUB_REPOSITORY" --jq '.parent.full_name // empty')" + if [[ -z "$UPSTREAM" ]]; then + echo "$GITHUB_REPOSITORY is not a fork, so there is no upstream to follow." > "$REPORT" + exit 1 + fi + export UPSTREAM + bash .github/scripts/fork-follow.sh + + - name: Push the rebased series as a candidate + if: steps.plan.outputs.action == 'rebase' + env: + HAS_PUSH_TOKEN: ${{ secrets.FORK_PUSH_TOKEN != '' }} + REPORT: ${{ runner.temp }}/fork-follow-report.md + run: | + set -euo pipefail + if [[ "$HAS_PUSH_TOKEN" != "true" ]]; then + echo "Rebasing onto ${{ steps.plan.outputs.base_tag }} needs the FORK_PUSH_TOKEN secret (contents and workflows write)." > "$REPORT" + exit 1 + fi + git push --force origin "HEAD:refs/heads/fork-follow/candidate" + + - name: Keep the failure report + if: failure() + uses: actions/upload-artifact@v7 + with: + name: fork-follow-report + path: ${{ runner.temp }}/fork-follow-report.md + if-no-files-found: ignore + retention-days: 7 + + release: + name: Release + needs: [plan] + if: needs.plan.outputs.action == 'release' || needs.plan.outputs.action == 'rebase' + permissions: + actions: read + contents: write + uses: ./.github/workflows/fork-release.yml + secrets: inherit + with: + ref: ${{ needs.plan.outputs.candidate }} + publish: true + + advance: + name: Move the series branch + needs: [plan, release] + if: needs.plan.outputs.action == 'rebase' && needs.release.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.plan.outputs.candidate }} + fetch-depth: 0 + filter: blob:none + token: ${{ secrets.FORK_PUSH_TOKEN }} + + # The lease refuses if the branch moved since the plan (a fork PR merged meanwhile); the next + # run rebases again with that change, and the release just published stays valid. + - name: Move the series branch to the released commit + run: | + set -euo pipefail + git push \ + --force-with-lease="refs/heads/$SERIES_BRANCH:${{ needs.plan.outputs.previous_head }}" \ + origin "${{ needs.plan.outputs.candidate }}:refs/heads/$SERIES_BRANCH" + git push origin --delete fork-follow/candidate || true + + report: + name: Report + needs: [plan, release, advance] + if: always() && needs.plan.outputs.action != 'none' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + issues: write + steps: + - name: Download the failure report + if: contains(needs.*.result, 'failure') + continue-on-error: true + uses: actions/download-artifact@v8 + with: + name: fork-follow-report + path: report + + - name: Open, update or close the follow issue + env: + GH_TOKEN: ${{ github.token }} + FAILED: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }} + BASE_TAG: ${{ needs.plan.outputs.base_tag }} + DROPPED: ${{ needs.plan.outputs.dropped }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + title="Fork cannot follow upstream" + issue="$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$title\" in:title" \ + --json number,title --jq ".[] | select(.title == \"$title\") | .number" | head -n1)" + if [[ "$FAILED" == "true" ]]; then + { + echo "Run: $RUN_URL" + echo + if [[ -f report/fork-follow-report.md ]]; then + cat report/fork-follow-report.md + else + echo "A test, build or publish job failed${BASE_TAG:+ for $BASE_TAG}; see the run. Nothing was published." + fi + } > body.md + if [[ -n "$issue" ]]; then + gh issue comment "$issue" --repo "$GITHUB_REPOSITORY" --body-file body.md + else + gh issue create --repo "$GITHUB_REPOSITORY" --title "$title" --body-file body.md + fi + exit 0 + fi + note="Published the fork release on $BASE_TAG: $RUN_URL" + if [[ -n "$DROPPED" ]]; then + note="$note"$'\n\n'"Dropped because upstream now has them: ${DROPPED//;/, }" + fi + echo "$note" >> "$GITHUB_STEP_SUMMARY" + if [[ -n "$issue" ]]; then + gh issue close "$issue" --repo "$GITHUB_REPOSITORY" --comment "$note" + fi From 58e9979ab9c5fd5c235f5a82b31f2c3a46371c1f Mon Sep 17 00:00:00 2001 From: t3code-fork Date: Thu, 12 Nov 2026 11:21:20 +0000 Subject: [PATCH 02/12] fork-follow: report from real state, guarded pushes, upstream workflows off Review round 1 on the follow-upstream workflow. - The plan picks one of five actions: none, release, rebase, advance (the rebased series is already released; only move main) and stage (the rebase changes the release workflow itself, so the candidate is pushed and a person starts its release). - Merge commits on main are refused instead of being flattened by a rebase; deleted or moved upstream tags and failed fetches give a reason and the right advice. - All pushes go through fork-follow-push.sh with FORK_PUSH_TOKEN only. Every run checks the token first (missing, refused, no push access). Moving main uses a lease, treats "already there" as success, and first keeps the old series as tag fork-follow/pre--. - fork-workflows.sh disables upstream's workflows and cancels their runs after each push and at the end of the run. - The report job runs on every run and reads branches, tags and releases back from the repository instead of guessing from job results. Its summary is the report; an issue is opened, commented on or closed only when the repository has Issues enabled. - fork-follow.test.sh covers all of this against local git remotes and a fake gh, plus static checks on the workflow file (secrets passed by name, token only in push steps, persist-credentials false, pinned actions). The plan job runs it first. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/fork-follow-push.sh | 120 ++++++++ .github/scripts/fork-follow-report.sh | 167 +++++++++++ .github/scripts/fork-follow.sh | 201 ++++++++++--- .github/scripts/fork-follow.test.sh | 401 ++++++++++++++++++++++++++ .github/scripts/fork-workflows.sh | 65 +++++ .github/workflows/fork-follow.yml | 275 +++++++++++++----- 6 files changed, 1116 insertions(+), 113 deletions(-) create mode 100755 .github/scripts/fork-follow-push.sh create mode 100755 .github/scripts/fork-follow-report.sh create mode 100755 .github/scripts/fork-follow.test.sh create mode 100755 .github/scripts/fork-workflows.sh diff --git a/.github/scripts/fork-follow-push.sh b/.github/scripts/fork-follow-push.sh new file mode 100755 index 0000000000000..348042bc30c16 --- /dev/null +++ b/.github/scripts/fork-follow-push.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# The pushes of fork-follow.yml, all made with FORK_PUSH_TOKEN, one subcommand each (tests: +# fork-follow.test.sh). Every refusal writes its reason to $REPORT, prints a ::error:: line and +# exits non-zero; nothing falls back to another token. +# +# check Fails unless FORK_PUSH_TOKEN is set, accepted by GitHub and may push here. +# candidate SHA Force-pushes SHA as the branch fork-follow/candidate. +# advance PREV SHA Moves SERIES_BRANCH from PREV to SHA with a lease. First keeps PREV as the tag +# fork-follow/pre--, so the old series stays reachable. +# Already at SHA (a re-run) is success; moved elsewhere is a refusal. +# cleanup Deletes the branch fork-follow/candidate; a failure is only a warning. +# +# Environment: FORK_PUSH_TOKEN, REPOSITORY (owner/name; GITHUB_REPOSITORY in a run), PUSH_URL +# (default https://github.com/$REPOSITORY.git), SERIES_BRANCH (default main), BASE_TAG (advance), +# REPORT. Output (candidate_pushed, moved, backup_tag) goes to $GITHUB_OUTPUT, else stdout. +# +# The token reaches git through GIT_CONFIG_* variables as an HTTP header: never in a URL, a command +# line or .git/config, so git's error messages (copied into reports) cannot contain it. +set -euo pipefail + +REPOSITORY="${REPOSITORY:-${GITHUB_REPOSITORY:?REPOSITORY or GITHUB_REPOSITORY}}" +PUSH_URL="${PUSH_URL:-https://github.com/$REPOSITORY.git}" +SERIES_BRANCH="${SERIES_BRANCH:-main}" +REPORT="${REPORT:-fork-follow-report.md}" +OUTPUT="${GITHUB_OUTPUT:-/dev/stdout}" +CANDIDATE_BRANCH=fork-follow/candidate +err="$(mktemp)" +trap 'rm -f "$err"' EXIT + +die() { + printf '%s\n' "$@" > "$REPORT" + printf '::error::%s\n' "$1" >&2 + [[ $# -lt 2 ]] || printf '%s\n' "${@:2}" >&2 + exit 1 +} + +git_said() { printf '%s\n' '```' "$(tail -n 15 "$err")" '```'; } + +with_token() { + [[ -n "${FORK_PUSH_TOKEN:-}" ]] \ + || die "The secret FORK_PUSH_TOKEN is missing, so this run cannot push." \ + "Add it under Settings > Secrets and variables > Actions: a fine-grained token for $REPOSITORY only, with Contents and Workflows read and write." \ + "GITHUB_TOKEN cannot replace it: it may not push commits that change workflow files." + local header + header="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$FORK_PUSH_TOKEN" | base64 -w0)" + [[ "${GITHUB_ACTIONS:-}" != true ]] || echo "::add-mask::$header" + export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.https://github.com/.extraheader GIT_CONFIG_VALUE_0="$header" + export GIT_TERMINAL_PROMPT=0 +} + +remote_ref() { # prints the remote's commit for a ref, or nothing; fails if the remote cannot be read + git ls-remote "$PUSH_URL" "$1" 2> "$err" | cut -f1 +} + +cmd_check() { + with_token + git ls-remote "$PUSH_URL" HEAD > /dev/null 2> "$err" \ + || die "GitHub refused FORK_PUSH_TOKEN: it is expired, revoked, or not valid for $REPOSITORY." \ + "Renew it (a fine-grained token for $REPOSITORY only, Contents and Workflows read and write) and update the secret. git said:" \ + "$(git_said)" + # A dry run asks for push access (receive-pack) without changing anything. + git push --dry-run "$PUSH_URL" "$(git rev-parse HEAD):refs/heads/fork-follow/token-check" > /dev/null 2> "$err" \ + || die "FORK_PUSH_TOKEN may not push to $REPOSITORY." \ + "Give the token Contents read and write (and Workflows read and write) on $REPOSITORY. git said:" "$(git_said)" + echo "FORK_PUSH_TOKEN is accepted and may push to $REPOSITORY." +} + +cmd_candidate() { + local sha="${1:?SHA}" + with_token + git push --force "$PUSH_URL" "$sha:refs/heads/$CANDIDATE_BRANCH" > /dev/null 2> "$err" \ + || die "Could not push the candidate ${sha:0:12} to $CANDIDATE_BRANCH." \ + "If the rebase brought workflow file changes, the token also needs Workflows read and write. git said:" "$(git_said)" + echo "candidate_pushed=true" >> "$OUTPUT" + echo "Pushed ${sha:0:12} to $CANDIDATE_BRANCH." +} + +cmd_advance() { + local prev="${1:?PREV}" sha="${2:?SHA}" now backup + : "${BASE_TAG:?BASE_TAG}" + with_token + now="$(remote_ref "refs/heads/$SERIES_BRANCH")" \ + || die "Could not read $SERIES_BRANCH of $REPOSITORY, so it was not moved. git said:" "$(git_said)" + if [[ "$now" == "$sha" ]]; then + echo "moved=already" >> "$OUTPUT" + echo "$SERIES_BRANCH is already at ${sha:0:12}." + return 0 + fi + [[ "$now" == "$prev" ]] \ + || die "$SERIES_BRANCH moved since the plan (now ${now:-missing}, planned from ${prev:0:12}), so it was not moved to ${sha:0:12}." \ + "A fork pull request merged meanwhile, or someone pushed it. The next run rebases again with that change." + backup="fork-follow/pre-$BASE_TAG-${prev:0:12}" + git push "$PUSH_URL" "$prev:refs/tags/$backup" > /dev/null 2> "$err" \ + || die "Could not keep the old series ${prev:0:12} as tag $backup, so $SERIES_BRANCH was not moved. git said:" "$(git_said)" + echo "backup_tag=$backup" >> "$OUTPUT" + git push --force-with-lease="refs/heads/$SERIES_BRANCH:$prev" "$PUSH_URL" "$sha:refs/heads/$SERIES_BRANCH" > /dev/null 2> "$err" \ + || die "Could not move $SERIES_BRANCH from ${prev:0:12} to ${sha:0:12}." \ + "If a rule protects $SERIES_BRANCH, the token's user must be allowed to bypass it. The old series is kept as tag $backup. git said:" "$(git_said)" + echo "moved=true" >> "$OUTPUT" + echo "Moved $SERIES_BRANCH from ${prev:0:12} to ${sha:0:12} (old series kept as tag $backup)." +} + +cmd_cleanup() { + with_token + local now + if ! now="$(remote_ref "refs/heads/$CANDIDATE_BRANCH")"; then + echo "::warning::Could not read $CANDIDATE_BRANCH; it may be left over (the next run force-pushes over it)." + elif [[ -n "$now" ]] && ! git push "$PUSH_URL" --delete "refs/heads/$CANDIDATE_BRANCH" > /dev/null 2> "$err"; then + echo "::warning::Could not delete $CANDIDATE_BRANCH (the next run force-pushes over it): $(tail -n 3 "$err" | tr '\n' ' ')" + fi + return 0 +} + +case "${1:-}" in + check) cmd_check ;; + candidate) cmd_candidate "${@:2}" ;; + advance) cmd_advance "${@:2}" ;; + cleanup) cmd_cleanup ;; + *) echo "usage: $0 check | candidate SHA | advance PREV SHA | cleanup" >&2; exit 2 ;; +esac diff --git a/.github/scripts/fork-follow-report.sh b/.github/scripts/fork-follow-report.sh new file mode 100755 index 0000000000000..a255a26c97bcf --- /dev/null +++ b/.github/scripts/fork-follow-report.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# Report job of fork-follow.yml (tests: fork-follow.test.sh). Writes the run's summary and exits +# non-zero when the run needs a person, so trouble shows as a failed run even without issues. +# +# What was pushed and published is read back from the repository (branches, tags, releases), not +# inferred from job results, so the summary never claims a state it did not see. When the +# repository has Issues enabled it also opens or comments on one issue, "Fork cannot follow +# upstream", and closes it once a run succeeds; with Issues disabled it says so and opens none. +# +# Environment (from the run): REPOSITORY, SERIES_BRANCH, RUN_URL, ACTION, PLAN_RESULT, +# RELEASE_RESULT, ADVANCE_RESULT, BASE_TAG, PREVIOUS_TAG, PREVIOUS_HEAD, CANDIDATE, RELEASED_TAG, +# DROPPED, DISABLED, CANCELLED (from the workflow sweeps), SWEEP_RESULT, REPORTS (directory of the jobs' reason +# files), ORIGIN (git URL of this fork, default GitHub). Summary to $GITHUB_STEP_SUMMARY, else stdout. +set -uo pipefail + +REPOSITORY="${REPOSITORY:-${GITHUB_REPOSITORY:?REPOSITORY or GITHUB_REPOSITORY}}" +ORIGIN="${ORIGIN:-https://github.com/$REPOSITORY.git}" +SERIES_BRANCH="${SERIES_BRANCH:-main}" +SUMMARY="${GITHUB_STEP_SUMMARY:-/dev/stdout}" +ACTION="${ACTION:-}" +REPORTS="${REPORTS:-}" +title="Fork cannot follow upstream" +short() { [[ -n "$1" ]] && echo "\`${1:0:12}\`" || echo "(none)"; } + +failed=false +for result in "${PLAN_RESULT:-}" "${RELEASE_RESULT:-skipped}" "${ADVANCE_RESULT:-skipped}"; do + [[ "$result" == success || "$result" == skipped ]] || failed=true +done +[[ "${PLAN_RESULT:-}" == success ]] || failed=true +[[ "$ACTION" != stage ]] || failed=true +[[ "${SWEEP_RESULT:-success}" != failure ]] || failed=true + +# The sweeps' lists arrive joined with ','; drop the empty parts and repeats. +tidy() { tr ',' '\n' <<< "$1" | sed '/^$/d' | awk '!seen[$0]++' | paste -sd, -; } +DISABLED="$(tidy "${DISABLED:-}")" +CANCELLED="$(tidy "${CANCELLED:-}")" + +state=() +row() { state+=("| $1 | $2 |"); } + +# --- what the repository holds now ----------------------------------------------------------- +if refs="$(git ls-remote "$ORIGIN" "refs/heads/$SERIES_BRANCH" refs/heads/fork-follow/candidate 'refs/tags/*' 2>&1)"; then + at() { awk -v ref="$1" '$2 == ref { print $1 }' <<< "$refs"; } + series_now="$(at "refs/heads/$SERIES_BRANCH")" + candidate_now="$(at refs/heads/fork-follow/candidate)" + if [[ -z "${CANDIDATE:-}" ]]; then + row "Series branch \`$SERIES_BRANCH\`" "at $(short "$series_now")" + elif [[ "$series_now" == "$CANDIDATE" && "${PREVIOUS_HEAD:-$CANDIDATE}" != "$CANDIDATE" ]]; then + row "Series branch \`$SERIES_BRANCH\`" "moved to $(short "$CANDIDATE") (was $(short "$PREVIOUS_HEAD"))" + elif [[ "$series_now" == "$CANDIDATE" ]]; then + row "Series branch \`$SERIES_BRANCH\`" "at $(short "$series_now"), unchanged" + else + row "Series branch \`$SERIES_BRANCH\`" "**not moved**: at $(short "$series_now")" + fi + row "Candidate branch \`fork-follow/candidate\`" "$([[ -n "$candidate_now" ]] && echo "at $(short "$candidate_now")" || echo "absent")" + backups="$(awk '$2 ~ "^refs/tags/fork-follow/pre-" && $2 !~ /\^\{\}$/ { sub("refs/tags/", "", $2); print $2 }' <<< "$refs" | paste -sd, -)" + [[ -z "$backups" ]] || row "Backups of earlier series (tags)" "${backups//,/, }" + # Fork release tags that point at the candidate (lightweight, or annotated and peeled). + 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)" + fi + if [[ -z "${CANDIDATE:-}" ]]; then + row "Release" "none: the plan did not pick a commit" + elif [[ -z "$tags" ]]; then + row "Release" "**none published** for $(short "$CANDIDATE")" + else + for tag in $tags; do + if draft="$(gh api "repos/$REPOSITORY/releases/tags/$tag" --jq .draft 2>&1)"; then + if [[ "$draft" == false ]]; then + if [[ "$tag" == "${RELEASED_TAG:-}" && "$ACTION" != release ]]; then + row "Release" "$tag, published earlier" + else + row "Release" "$tag **published** at $(short "$CANDIDATE")" + fi + else + row "Release" "$tag tag exists, release is still a draft (not offered to updaters)" + failed=true + fi + elif [[ "$draft" == *"Not Found"* || "$draft" == *"404"* ]]; then + row "Release" "tag $tag exists without a release" + failed=true + else + row "Release" "unknown: could not read the release of $tag" + failed=true + fi + done + fi +else + row "Repository state" "unknown: could not read the branches and tags of $REPOSITORY" + failed=true +fi +[[ -z "${DISABLED:-}" ]] || row "Upstream workflows disabled" "${DISABLED//,/, }" +[[ -z "${CANCELLED:-}" ]] || row "Their runs cancelled" "${CANCELLED//,/, }" +[[ -z "${DROPPED:-}" ]] || row "Patches dropped (empty on $BASE_TAG: upstream has the change)" "${DROPPED//;/, }" + +reasons="" +if [[ -n "$REPORTS" && -d "$REPORTS" ]]; then + for file in "$REPORTS"/*.md; do + [[ -f "$file" ]] && reasons+="$(cat "$file")"$'\n\n' + done +fi + +case "$ACTION" in + none) what="Nothing to do: the series is released${RELEASED_TAG:+ as $RELEASED_TAG}." ;; + release) what="Release the series on ${BASE_TAG:-?} as it is." ;; + rebase) what="Rebase the series from ${PREVIOUS_TAG:-?} onto ${BASE_TAG:-?}, release it, then move \`$SERIES_BRANCH\`." ;; + advance) what="Move \`$SERIES_BRANCH\` onto ${BASE_TAG:-?}: ${RELEASED_TAG:-?} already has the rebased series." ;; + stage) what="Rebase onto ${BASE_TAG:-?} and push the candidate; its release needs its own workflow files." ;; + *) what="The plan did not finish." ;; +esac + +body="$(mktemp)" +{ + if [[ "$failed" == true ]]; then + echo "## $title" + else + echo "## Fork follows upstream" + fi + echo + echo "Plan: $what Run: ${RUN_URL:-?}" + echo + if [[ "$failed" == true ]]; then + if [[ -n "$reasons" ]]; then + printf '%s' "$reasons" + else + echo "A job failed without writing a reason (results: plan ${PLAN_RESULT:-?}, release ${RELEASE_RESULT:-skipped}, advance ${ADVANCE_RESULT:-skipped}); see the run." + echo + fi + fi + echo "| What | State now |" + echo "|---|---|" + printf '%s\n' "${state[@]}" +} > "$body" + +# --- the issue, when the repository has Issues --------------------------------------------------- +note="" +if ! has_issues="$(gh api "repos/$REPOSITORY" --jq .has_issues 2>&1)"; then + note="Could not read whether $REPOSITORY has Issues, so no issue was opened or closed." + failed=true +elif [[ "$has_issues" != true ]]; then + note="Issues are disabled on $REPOSITORY, so no issue was opened or closed; this summary and the run's status are the report." +elif ! issue="$(gh issue list --repo "$REPOSITORY" --state open --search "\"$title\" in:title" \ + --json number,title --jq ".[] | select(.title == \"$title\") | .number" | head -n1)"; then + note="Could not list the issues of $REPOSITORY, so no issue was opened or closed." + failed=true +elif [[ "$failed" == true && -n "$issue" ]]; then + gh issue comment "$issue" --repo "$REPOSITORY" --body-file "$body" > /dev/null \ + && note="Commented on issue #$issue." || { note="Could not comment on issue #$issue."; } +elif [[ "$failed" == true ]]; then + if url="$(gh issue create --repo "$REPOSITORY" --title "$title" --body-file "$body")"; then + note="Opened $url." + else + note="Could not open an issue." + fi +elif [[ -n "$issue" ]]; then + gh issue close "$issue" --repo "$REPOSITORY" --comment "$(cat "$body")" > /dev/null \ + && note="Closed issue #$issue." || { note="Could not close issue #$issue."; failed=true; } +fi + +{ + cat "$body" + [[ -z "$note" ]] || printf '\n%s\n' "$note" +} >> "$SUMMARY" +rm -f "$body" +[[ "$failed" != true ]] diff --git a/.github/scripts/fork-follow.sh b/.github/scripts/fork-follow.sh index b670855de6a3d..cfd3cf1a7fcba 100755 --- a/.github/scripts/fork-follow.sh +++ b/.github/scripts/fork-follow.sh @@ -1,102 +1,233 @@ #!/usr/bin/env bash -# Decides what the follow-upstream workflow does next, 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. -# Writes key=value lines to $GITHUB_OUTPUT (stdout without it); a failure's reason goes to $REPORT. -set -euo pipefail +# 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. +# 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). +# +# Output, key=value lines to $GITHUB_OUTPUT (stdout without it): +# action none the series head is the newest fork release of its base already +# release same base, but the series has commits no fork release has +# rebase rebased onto a newer tag: release the candidate, then move the series +# advance rebased onto a newer tag, and a published fork release already has the +# candidate's exact files: move the series to that release, build nothing +# stage rebased, but the release workflows changed upstream: push the candidate +# and stop, so it is released with its own workflow files (see $REPORT) +# base_tag, base_commit the upstream tag (and its commit) the candidate sits on +# previous_tag, previous_head where the series was (rebase, advance, stage) +# candidate the commit to release or to move the series to +# released_tag the fork release that already has the candidate's files (none, advance) +# dropped subjects of patches that became empty on the new base (';'-separated) +# own_workflows workflow files the series itself adds (','-separated); every other one is +# upstream's and gets disabled +set -Eeuo pipefail : "${UPSTREAM:?owner/name of the upstream repository}" REPORT="${REPORT:-fork-follow-report.md}" OUTPUT="${GITHUB_OUTPUT:-/dev/stdout}" +ORIGIN="${ORIGIN:-origin}" +REPOSITORY="${REPOSITORY:-${GITHUB_REPOSITORY:-this repository}}" logs="${RUNNER_TEMP:-$(mktemp -d)}" upstream_url="${UPSTREAM_URL:-https://github.com/$UPSTREAM.git}" +reported="" fail() { printf '%s\n' "$@" > "$REPORT" cat "$REPORT" >&2 + reported=1 exit 1 } -stable_tags() { - git ls-remote --tags --refs "$upstream_url" 'v*' \ - | sed -n 's|.*refs/tags/\(v[0-9]\+\.[0-9]\+\.[0-9]\+\)$|\1|p' | sort -rV -} - -fetch_upstream_tag() { - git fetch --quiet --no-tags "$upstream_url" "+refs/tags/$1:refs/upstream-tags/$1" - git rev-parse "refs/upstream-tags/$1^{commit}" +# A command that fails outside fail() (set -e) still leaves a reason that names the step. +on_error() { + local status="$1" line="$2" command="$3" + [[ -z "$reported" ]] || return 0 + reported=1 + printf '%s\n' "The plan stopped at fork-follow.sh line $line (exit $status): \`$command\`" \ + "This is not one of the planned refusals; see the run log." > "$REPORT" + cat "$REPORT" >&2 } +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)" -target_tag="$(stable_tags | sed -n 1p)" -[[ -n "$target_tag" ]] || fail "$UPSTREAM has no stable release tag." +run_sha="${RUN_SHA:-$head}" + +# 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" \ + > "$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 \ + || 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)" +target_tag="$(sed -n 1p <<< "$upstream_tags")" +[[ -n "$target_tag" ]] || fail "$UPSTREAM has no stable release tag (vX.Y.Z)." +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. +# If upstream deleted or moved that tag, the base found below would be an older tag (or upstream's +# rewritten commits would count as the fork's own), so stop and name the tag instead. +last_release="" +for tag in $fork_tags; do + if git merge-base --is-ancestor "refs/fork-tags/$tag^{commit}" HEAD; then + last_release="$tag" + break + fi +done +if [[ -n "$last_release" ]]; then + 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." + fi + if ! git merge-base --is-ancestor "$(up "$built_on")" HEAD; then + fail "$UPSTREAM moved the tag $built_on, which the series was built on (fork release $last_release is in its history), to a commit the series does not contain." \ + "Decide by hand whether to rebase onto the moved tag; the series was left as it is." + fi +fi # The series sits on the newest stable upstream tag it contains. base_tag="" -while read -r tag; do - commit="$(fetch_upstream_tag "$tag")" - if git merge-base --is-ancestor "$commit" HEAD; then +for tag in $upstream_tags; do + if git merge-base --is-ancestor "$(up "$tag")" HEAD; then base_tag="$tag" - base="$commit" + base="$(up "$tag")" break fi -done < <(stable_tags) +done [[ -n "$base_tag" ]] || fail "The series ($head) contains no stable $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). -git fetch --quiet --no-tags "$upstream_url" "+HEAD:refs/upstream-head" series_count="$(git rev-list --count "$base..HEAD")" own_count="$(git rev-list --count "$base..HEAD" --not refs/upstream-head)" if [[ "$series_count" != "$own_count" ]]; then fail "The series holds $((series_count - own_count)) upstream commits after $base_tag." \ - "Reset it to $base_tag plus the fork's own commits; nothing was rebased or published." + "Reset it to $base_tag plus the fork's own commits. The series was left as it is." +fi + +# A rebase flattens merge commits, and a change made in a merge itself (a conflict resolved in the +# merge, an edited merge) would be lost without a trace. The series must be plain commits. +merges="$(git rev-list --merges "$base..HEAD")" +if [[ -n "$merges" ]]; then + fail "The series holds merge commits after $base_tag:" \ + "$(git log --no-walk --format='- %h %s' $merges)" "" \ + "A rebase would flatten them and drop any change made in the merge itself, so the series was left as it is." \ + "Rebuild it as plain commits: check that \`git show --remerge-diff \` prints no change for each merge (or keep that change as its own commit)," \ + "run \`git rebase --onto $base_tag $base_tag \` and force-push it. Merge fork pull requests only with squash or rebase." fi node .github/scripts/fork-schema-check.cjs "$base" HEAD > "$logs/schema-check.log" 2>&1 \ || fail "The series changes the database schema:" '```' "$(cat "$logs/schema-check.log")" '```' -released_head() { - local version="${1#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)" - [[ -z "$last" ]] || git ls-remote origin "refs/tags/v$version-fork.$last^{}" "refs/tags/v$version-fork.$last" \ - | sort -k2 | tail -n1 | cut -f1 +# The newest fork release of a tag: " ", or nothing. +latest_release() { + local last + last="$(grep -E "^${1//./\\.}-fork\.[0-9]+$" <<< "$fork_tags" | sort -V | tail -n1 || true)" + [[ -z "$last" ]] || echo "$last $(git rev-parse "refs/fork-tags/$last^{commit}")" +} + +own_workflows() { + git diff --name-only --diff-filter=A "$1" "$2" -- .github/workflows | paste -sd, - } if [[ "$target_tag" == "$base_tag" ]]; then - if [[ "$(released_head "$base_tag")" == "$head" ]]; then + read -r released_tag released_commit <<< "$(latest_release "$base_tag")" || true + if [[ "${released_commit:-}" == "$head" ]]; then action=none else action=release + released_tag="" fi { echo "action=$action" echo "base_tag=$base_tag" + echo "base_commit=$base" echo "candidate=$head" + echo "released_tag=${released_tag:-}" + echo "own_workflows=$(own_workflows "$base" HEAD)" } >> "$OUTPUT" exit 0 fi # A newer upstream release: replay the series onto it. Patches upstream now has become empty and -# are dropped; the release notes list the series, so a dropped patch simply no longer appears. -target="$(fetch_upstream_tag "$target_tag")" +# are dropped. +target="$(up "$target_tag")" before="$(git log --format=%s "$base..HEAD")" if ! git -c user.name=t3code-fork -c user.email=noreply@users.noreply.github.com \ rebase --empty=drop --onto "$target" "$base" > "$logs/rebase.log" 2>&1; then - stopped="$(git log -1 --format='%h %s' REBASE_HEAD 2>/dev/null || echo unknown)" + stopped="$(git log -1 --format='%h %s' REBASE_HEAD 2> /dev/null || echo unknown)" conflicts="$(git diff --name-only --diff-filter=U | sed 's/^/- /')" git rebase --abort || true fail "Rebasing the series from $base_tag onto $target_tag stopped at: $stopped" "" \ - "Conflicting files:" "$conflicts" "" "Nothing was published; the last fork release stays current." + "Conflicting files:" "${conflicts:-(none listed; see the run log)}" "" \ + "The series was left as it is. To follow by hand: \`git fetch https://github.com/$UPSTREAM.git tag $target_tag\`," \ + "\`git rebase --onto $target_tag $base_tag \`, resolve, run the tests, then push the branch with" \ + "\`--force-with-lease\`. The next run then releases it." fi node .github/scripts/fork-schema-check.cjs "$target" HEAD > "$logs/schema-check.log" 2>&1 \ || fail "After rebasing onto $target_tag the series changes the database schema:" '```' "$(cat "$logs/schema-check.log")" '```' -dropped="$(comm -23 <(sort <<<"$before") <(git log --format=%s "$target..HEAD" | sort) | paste -sd ';' -)" +candidate="$(git rev-parse HEAD)" +dropped="$(comm -23 <(sort <<< "$before") <(git log --format=%s "$target..HEAD" | sort) | paste -sd ';' -)" + +# A published fork release of the target with exactly these files exists (an earlier run published +# it, then could not move the series): move the series to it rather than build it again. +read -r released_tag released_commit <<< "$(latest_release "$target_tag")" || true +action=rebase +if [[ -n "${released_commit:-}" ]] \ + && [[ "$(git rev-parse "$released_commit^{tree}")" == "$(git rev-parse "$candidate^{tree}")" ]]; then + action=advance + candidate="$released_commit" +else + released_tag="" + # The release job runs the reusable workflows of this run's commit (the series before the rebase), + # not the candidate's. If upstream changed them, building would mix two upstream releases. + called() { + local queue=(.github/workflows/fork-release.yml) seen="" file + while ((${#queue[@]})); do + file="${queue[0]}" + queue=("${queue[@]:1}") + grep -qxF "$file" <<< "$seen" && continue + seen+="$file"$'\n' + while read -r used; do + [[ -z "$used" ]] || queue+=("$used") + done <<< "$(git show "$1:$file" 2> /dev/null \ + | sed -n 's|^[[:space:]-]*uses:[[:space:]]*["'\'']\{0,1\}\./\(\.github/workflows/[^[:space:]@#"'\'']*\).*|\1|p')" + done + printf '%s' "$seen" + } + changed="" + for file in $(sort -u <<< "$(called "$run_sha")"$'\n'"$(called "$candidate")"); do + if [[ "$(git rev-parse --quiet --verify "$run_sha:$file" || true)" != "$(git rev-parse --quiet --verify "$candidate:$file" || true)" ]]; then + changed+="- $file"$'\n' + fi + done + if [[ -n "$changed" ]]; then + action=stage + printf '%s\n' \ + "The release workflows differ between this run's commit (${run_sha:0:12}, on $base_tag) and the candidate on $target_tag:" \ + "${changed%$'\n'}" "" \ + "A run uses the reusable workflows of its own commit, so this run would build $target_tag with $base_tag's release steps; it built nothing." \ + "The candidate ${candidate:0:12} is on the branch fork-follow/candidate. Release it with its own workflow files:" \ + "\`gh workflow run fork-release.yml --repo $REPOSITORY --ref fork-follow/candidate -f ref=$candidate -f publish=true\`" \ + "The next follow run then finds that release and moves the series to it." > "$REPORT" + fi +fi { - echo "action=rebase" + echo "action=$action" echo "base_tag=$target_tag" + echo "base_commit=$target" echo "previous_tag=$base_tag" echo "previous_head=$head" - echo "candidate=$(git rev-parse HEAD)" + echo "candidate=$candidate" + echo "released_tag=${released_tag:-}" echo "dropped=$dropped" + echo "own_workflows=$(own_workflows "$target" "$candidate")" } >> "$OUTPUT" diff --git a/.github/scripts/fork-follow.test.sh b/.github/scripts/fork-follow.test.sh new file mode 100755 index 0000000000000..0954814100451 --- /dev/null +++ b/.github/scripts/fork-follow.test.sh @@ -0,0 +1,401 @@ +#!/usr/bin/env bash +# Tests for fork-follow.yml and its scripts against local git repositories and a fake `gh`; no +# network. FORK_FOLLOW_DIR runs the tests against the scripts in another directory. +# bash .github/scripts/fork-follow.test.sh +set -uo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +dir="$(realpath "${FORK_FOLLOW_DIR:-$here}")" +workflow="$(realpath "${FORK_FOLLOW_WORKFLOW:-$here/../workflows/fork-follow.yml}")" +root="$(mktemp -d)" +trap 'rm -rf "$root"' EXIT + +# Upstream "up/t3code" and this fork "me/t3code" live under $root/gh; git reaches them through the +# github.com URLs the scripts use. +export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL="$root/gitconfig" +git config --global user.name test +git config --global user.email test@example.invalid +git config --global init.defaultBranch main +git config --global advice.detachedHead false +git config --global url."$root/gh/".insteadOf https://github.com/ +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 + +# Fake gh, served from $root/api: +# gh api [-X M] PATH [--paginate] [--jq EXPR] gh issue list|comment|create|close ... +# A file $root/api/fail_ makes every call whose path contains fail. +mkdir -p "$root/bin" "$root/api" +cat > "$root/bin/gh" << 'GH' +#!/usr/bin/env bash +set -euo pipefail +api="$FAKE_API" +echo "gh $*" >> "$api/calls" +if [[ "$1" == issue ]]; then + # Like GitHub: with Issues off, every issue command fails. + if [[ "$(cat "$api/has_issues")" != true ]]; then + echo "the 'me/t3code' repository has disabled issues" >&2 + exit 1 + fi + echo "issue $2" >> "$api/writes" + case "$2" in + list) cat "$api/issues" 2> /dev/null || true ;; + create) echo "https://github.com/me/t3code/issues/9" ;; + esac + exit 0 +fi +shift +method=GET path="" jq="" +while (( $# )); do + case "$1" in + -X) method="$2"; shift 2 ;; + --jq) jq="$2"; shift 2 ;; + --paginate) shift ;; + *) path="$1"; shift ;; + esac +done +for f in "$api"/fail_*; do + [[ -e "$f" ]] || continue + [[ "$path" != *"${f##*/fail_}"* ]] || { echo "HTTP 502: Bad Gateway ($path)" >&2; exit 1; } +done +case "$method $path" in + "GET repos/me/t3code") body="{\"has_issues\":$(cat "$api/has_issues"),\"parent\":{\"full_name\":\"up/t3code\"}}" ;; + "GET repos/me/t3code/actions/workflows") body="$(cat "$api/workflows.json")" ;; + "PUT repos/me/t3code/actions/workflows/"*/disable) echo "disable $path" >> "$api/writes"; exit 0 ;; + "GET repos/me/t3code/actions/runs?head_sha="*) body="$(cat "$api/runs.json")" ;; + "POST repos/me/t3code/actions/runs/"*/cancel) echo "cancel $path" >> "$api/writes"; exit 0 ;; + "GET repos/me/t3code/releases/tags/"*) + tag="${path##*/}" + [[ -f "$api/release-$tag" ]] || { echo "gh: Not Found (HTTP 404)" >&2; exit 1; } + body="$(cat "$api/release-$tag")" ;; + *) echo "fake gh: no route for $method $path" >&2; exit 1 ;; +esac +if [[ -n "$jq" ]]; then jq -r "$jq" <<< "$body"; else echo "$body"; fi +GH +chmod +x "$root/bin/gh" +export PATH="$root/bin:$PATH" FAKE_API="$root/api" + +failures=0 +pass() { echo "ok $1"; } +fail() { + echo "FAIL $1" + sed 's/^/ | /' "$root/out" 2> /dev/null || true + failures=$((failures + 1)) +} +check() { # check NAME CONDITION...: passes when the condition (a command) succeeds + local name="$1" + shift + if "$@"; then pass "$name"; else fail "$name"; fi +} +has() { grep -q -- "$1" "$2" 2> /dev/null; } +hasnt() { ! grep -q -- "$1" "$2" 2> /dev/null; } + +reset_api() { + rm -rf "$root/api" && mkdir -p "$root/api" + echo "${1:-false}" > "$root/api/has_issues" + echo '{"workflows":[]}' > "$root/api/workflows.json" + echo '{"workflow_runs":[]}' > "$root/api/runs.json" +} + +# --- repositories -------------------------------------------------------------------------------- +up_git() { (cd "$root/upwork" && "$@" > /dev/null 2>&1); } +up_push() { up_git git push -q --force https://github.com/up/t3code.git "$@"; } + +# Upstream: u1 (v0.1.8) - u2 (v0.1.9) - u3 (v0.1.10) - u4 (upstream main). Each adds a file, and +# upstream's release-desktop.yml is the same in all of them unless a test changes it. +new_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" + mkdir -p .github/workflows + echo "on: workflow_call" > .github/workflows/release-desktop.yml + echo "on: push" > .github/workflows/ci.yml + echo base > shared.txt + git add -A && git commit -q -m u1 && git tag v0.1.8 + echo u2 > u2.txt && git add -A && git commit -q -m u2 && git tag -a -m v0.1.9 v0.1.9 + echo u3 > u3.txt && git add -A && git commit -q -m u3 && git tag v0.1.10 + echo u4 > u4.txt && git add -A && git commit -q -m u4 + git push -q https://github.com/up/t3code.git main --tags + ) +} + +# The fork's checkout in $root/work: upstream rev $1 plus the fork's own commit (its workflow +# files and scripts, and feature.txt), pushed as the fork's main. +new_fork() { + rm -rf "$root/work" + git clone -q https://github.com/up/t3code.git "$root/work" 2> /dev/null + ( + cd "$root/work" + git remote set-url origin https://github.com/me/t3code.git + git checkout -q -B main "$1" + mkdir -p .github/scripts .github/workflows + cp "$here/fork-schema-check.cjs" .github/scripts/ + printf 'jobs:\n desktop:\n uses: ./.github/workflows/release-desktop.yml\n' > .github/workflows/fork-release.yml + echo "on: workflow_dispatch" > .github/workflows/fork-follow.yml + echo feature > feature.txt + git add -A && git commit -q -m "fork: own change" + git push -q --force origin main 2> /dev/null + ) +} +work() { (cd "$root/work" && "$@" > /dev/null 2>&1); } +fork_tag() { work git tag -f "$1" "${2:-HEAD}" && work git push -q --force origin "refs/tags/$1"; } + +plan() { # runs the plan in $root/work; output in $root/plan.out, report in $root/report.md + rm -f "$root/report.md" "$root/plan.out" + (cd "$root/work" && REPORT="$root/report.md" GITHUB_OUTPUT="$root/plan.out" bash "$dir/fork-follow.sh") > "$root/out" 2>&1 +} +out() { sed -n "s/^$1=//p" "$root/plan.out" 2> /dev/null | tail -n1; } +action_is() { [[ "$(out action)" == "$1" ]]; } +series() { git -C "$root/gh/me/t3code.git" rev-parse "refs/heads/${1:-main}" 2> /dev/null; } +work_head() { git -C "$root/work" rev-parse HEAD; } + +# --- plan: fork-follow.sh --------------------------------------------------------------------- +new_upstream +reset_api +new_fork v0.1.10 +plan +check "plan: same base, no fork release yet -> release" action_is release +fork_tag v0.1.10-fork.1 +plan +check "plan: the series head is the newest release -> none, naming it" \ + eval 'action_is none && [[ "$(out released_tag)" == v0.1.10-fork.1 ]]' +check "plan: own_workflows lists only the files the series adds" \ + eval '[[ "$(out own_workflows)" == .github/workflows/fork-follow.yml,.github/workflows/fork-release.yml ]]' + +new_upstream +new_fork v0.1.9 +before="$(work_head)" +plan +check "plan: a newer tag with a clean rebase -> rebase onto it" \ + eval 'action_is rebase && [[ "$(out base_tag)" == v0.1.10 && "$(out previous_head)" == "$before" ]]' + +new_fork v0.1.9 +(cd "$root/work" && echo fork > u3.txt && git add -A && git commit -q -m "fork: edit u3" && git push -q origin main) > /dev/null 2>&1 +plan +check "plan: a conflict names the stopped patch and its file and aborts the rebase" \ + eval 'has "fork: edit u3" "$root/report.md" && has "u3.txt" "$root/report.md" && [[ ! -d "$root/work/.git/rebase-merge" ]]' + +new_fork main +plan +check "plan: upstream commits inside the series are refused" has "upstream commits after" "$root/report.md" + +# Major: merge commits. A fork PR merged with a merge commit that carries its own change. +new_fork v0.1.9 +( + cd "$root/work" + git checkout -q -b side HEAD~0 && echo side > side.txt && git add -A && git commit -q -m "fork: side" + git checkout -q main && echo other > other.txt && git add -A && git commit -q -m "fork: other" + git merge -q --no-ff --no-commit side && echo evil > evil.txt && git add -A && git commit -q -m "Merge side" + git push -q origin main +) > /dev/null 2>&1 +before="$(work_head)" +plan +check "plan: merge commits in the series are refused and named, the series left as it is" \ + eval '! action_is rebase && has "merge commits" "$root/report.md" && has "Merge side" "$root/report.md" && [[ "$(work_head)" == "$before" ]]' + +# Minor: a failed fetch leaves a reason that names it. +new_fork v0.1.10 +(cd "$root/work" && UPSTREAM_URL="$root/nowhere.git" REPORT="$root/report.md" GITHUB_OUTPUT="$root/plan.out" \ + bash "$dir/fork-follow.sh") > "$root/out" 2>&1 +check "plan: a failed upstream fetch writes a reason naming it" has "Could not fetch" "$root/report.md" + +# Minor: any other failing command still writes a reason (fake git fails `rev-list --merges`). +cat > "$root/bin/git-fail-merges" << 'G' +#!/usr/bin/env bash +for a in "$@"; do [[ "$a" != --merges ]] || { echo "fatal: simulated" >&2; exit 128; }; done +exec "$REAL_GIT" "$@" +G +chmod +x "$root/bin/git-fail-merges" +mkdir -p "$root/failgit" && ln -sf "$root/bin/git-fail-merges" "$root/failgit/git" +(cd "$root/work" && REAL_GIT="$(command -v git)" PATH="$root/failgit:$PATH" REPORT="$root/report.md" \ + GITHUB_OUTPUT="$root/plan.out" bash "$dir/fork-follow.sh") > "$root/out" 2>&1 +check "plan: an unexpected failure writes a reason naming the line" has "fork-follow.sh line" "$root/report.md" + +# Minor: upstream deletes or moves the tag the released series sits on. +new_upstream +new_fork v0.1.10 +fork_tag v0.1.10-fork.1 +git -C "$root/gh/up/t3code.git" tag -d v0.1.10 > /dev/null +plan +check "plan: upstream deleted the tag the series was built on -> named, not 'reset to v0.1.9'" \ + eval 'has "no longer has the tag v0.1.10" "$root/report.md" && hasnt "Reset it to v0.1.9" "$root/report.md"' +new_upstream +new_fork v0.1.10 +fork_tag v0.1.10-fork.1 +(cd "$root/upwork" && git checkout -q -b moved v0.1.9 && echo again > u3.txt && git add -A && git commit -q -m u3b \ + && git tag -f v0.1.10 && git push -q --force https://github.com/up/t3code.git refs/tags/v0.1.10) > /dev/null 2>&1 +plan +check "plan: upstream moved the tag the series was built on -> named" has "moved the tag v0.1.10" "$root/report.md" + +# Major: a release was published, then the series branch did not move. The next plan moves the +# branch to that release instead of building and publishing another one. +new_upstream +new_fork v0.1.9 +plan +released="$(out candidate)" +work git push -q origin "$released:refs/heads/fork-follow/candidate" +fork_tag v0.1.10-fork.1 "$released" +work git checkout -q main && work git reset -q --hard "$(series)" +plan +check "plan: a published release of the rebased files exists -> advance to it, build nothing" \ + eval 'action_is advance && [[ "$(out candidate)" == "$released" && "$(out released_tag)" == v0.1.10-fork.1 ]]' +work git reset -q --hard "$(series)" +(cd "$root/work" && echo more > more.txt && git add -A && git commit -q -m "fork: more" && git push -q origin main) > /dev/null 2>&1 +plan +check "plan: a release of different files does not count -> rebase" action_is rebase + +# Minor: upstream changed a reusable workflow the release calls; the run's own copy is older. +new_upstream +(cd "$root/upwork" && git checkout -q main && echo "on: workflow_call # v2" > .github/workflows/release-desktop.yml \ + && git add -A && git commit -q -m u5 && git tag v0.1.11 && git push -q https://github.com/up/t3code.git main --tags) > /dev/null 2>&1 +new_fork v0.1.10 +(cd "$root/work" && RUN_SHA="$(git rev-parse HEAD)" REPORT="$root/report.md" GITHUB_OUTPUT="$root/plan.out" \ + bash "$dir/fork-follow.sh") > "$root/out" 2>&1 +check "plan: changed release workflows -> stage, with the command that releases the candidate" \ + eval 'action_is stage && has "release-desktop.yml" "$root/report.md" && has "gh workflow run fork-release.yml" "$root/report.md"' + +# --- pushes: fork-follow-push.sh ------------------------------------------------------------- +new_upstream +new_fork v0.1.10 +push() { (cd "$root/work" && REPORT="$root/report.md" GITHUB_OUTPUT="$root/push.out" PUSH_URL="${PUSH_URL:-$root/gh/me/t3code.git}" \ + BASE_TAG=v0.1.10 bash "$dir/fork-follow-push.sh" "$@") > "$root/out" 2>&1; } +rm -f "$root/report.md" +check "push: a missing FORK_PUSH_TOKEN fails and says so" eval '! push check && has "FORK_PUSH_TOKEN is missing" "$root/report.md"' +export FORK_PUSH_TOKEN=test-token +check "push: a token GitHub refuses (expired) fails and says so" \ + eval '! PUSH_URL="$root/gh/me/gone.git" push check && has "refused FORK_PUSH_TOKEN" "$root/report.md"' + +check "push: a working token passes the check" push check + +prev="$(series)" +work git commit -q --allow-empty -m "fork: next" +next="$(work_head)" +push candidate "$next" +check "push: the candidate goes to fork-follow/candidate" eval '[[ "$(series fork-follow/candidate)" == "$next" ]] && has candidate_pushed=true "$root/push.out"' +push advance "$prev" "$next" +check "push: advance moves the series and keeps the old one as a tag" \ + eval '[[ "$(series)" == "$next" && "$(git -C "$root/gh/me/t3code.git" rev-parse "refs/tags/fork-follow/pre-v0.1.10-${prev:0:12}")" == "$prev" ]]' +push advance "$prev" "$next" +check "push: advance again after it moved (re-run) succeeds without a push" has moved=already "$root/push.out" +work git commit -q --allow-empty -m "fork: merged meanwhile" +work git push -q origin HEAD:main +moved="$(series)" +rm -f "$root/report.md" +push advance "$next" "$prev" +check "push: advance refuses when the branch moved since the plan, and leaves it" \ + eval '[[ "$(series)" == "$moved" ]] && has "moved since the plan" "$root/report.md"' +push cleanup +check "push: cleanup deletes the candidate branch" eval '! series fork-follow/candidate > /dev/null' +unset FORK_PUSH_TOKEN + +# --- workflows: fork-workflows.sh ------------------------------------------------------------ +sweep() { REPORT="$root/report.md" GITHUB_OUTPUT="$root/sweep.out" bash "$dir/fork-workflows.sh" > "$root/out" 2>&1; } +reset_api +cat > "$root/api/workflows.json" << 'J' +{"workflows":[ + {"id":1,"state":"active","path":".github/workflows/fork-follow.yml"}, + {"id":2,"state":"active","path":".github/workflows/web-preview.yml"}, + {"id":3,"state":"disabled_manually","path":".github/workflows/ci.yml"}, + {"id":4,"state":"active","path":"dynamic/pages/pages-build-deployment"}]} +J +cat > "$root/api/runs.json" << 'J' +{"workflow_runs":[ + {"id":11,"status":"queued","path":".github/workflows/web-preview.yml"}, + {"id":12,"status":"in_progress","path":".github/workflows/fork-tests.yml"}, + {"id":13,"status":"completed","path":".github/workflows/relay-preview.yml"}]} +J +OWN_WORKFLOWS=.github/workflows/fork-follow.yml,.github/workflows/fork-tests.yml HEAD_SHA=abc sweep +check "workflows: an active upstream workflow is disabled and named; own, disabled and dynamic ones are left" \ + eval 'has "workflows/2/disable" "$root/api/writes" && [[ $(grep -c disable "$root/api/writes") == 1 ]] && has "disabled=.github/workflows/web-preview.yml" "$root/sweep.out"' +check "workflows: unfinished runs of upstream workflows are cancelled, own and finished ones are not" \ + eval 'has "runs/11/cancel" "$root/api/writes" && [[ $(grep -c cancel "$root/api/writes") == 1 ]]' +reset_api +OWN_WORKFLOWS="" sweep +check "workflows: an empty own list disables nothing (refusing to guess)" eval '[[ ! -e "$root/api/writes" ]] && has "refusing to guess" "$root/report.md"' +touch "$root/api/fail_workflows" +OWN_WORKFLOWS=.github/workflows/fork-follow.yml sweep +check "workflows: a failed listing fails with a reason" has "Could not list the workflows" "$root/report.md" + +# --- report: fork-follow-report.sh ----------------------------------------------------------- +new_upstream +new_fork v0.1.10 +report() { # report KEY=VALUE...: the summary in $root/summary.md, status in $root/status + rm -f "$root/summary.md" + (cd "$root/work" && env GITHUB_STEP_SUMMARY="$root/summary.md" RUN_URL=run REPORTS="$root/reports" ORIGIN="$root/gh/me/t3code.git" "$@" \ + bash "$dir/fork-follow-report.sh") > "$root/out" 2>&1 + echo $? > "$root/status" +} +failed_run() { [[ "$(cat "$root/status")" != 0 ]]; } +rm -rf "$root/reports" && mkdir -p "$root/reports" +echo "Rebasing the series from v0.1.9 onto v0.1.10 stopped at: abc fork: x" > "$root/reports/plan.md" +reset_api false +report ACTION= PLAN_RESULT=failure +check "report: Issues disabled -> the reason is in the summary, no issue call, the run fails" \ + eval 'failed_run && has "stopped at" "$root/summary.md" && has "Issues are disabled" "$root/summary.md" && hasnt "issue" "$root/api/writes"' +check "report: Issues enabled -> one issue is opened on failure" \ + eval 'reset_api true; report ACTION= PLAN_RESULT=failure; failed_run && has "issue create" "$root/api/writes"' + +# Major: release published, then the series branch did not move. +rm -rf "$root/reports" && mkdir -p "$root/reports" +echo "main moved since the plan" > "$root/reports/advance.md" +prev="$(series)" +work git commit -q --allow-empty -m "fork: rebased" +cand="$(work_head)" +fork_tag v0.1.10-fork.2 "$cand" +reset_api false +echo '{"draft":false}' > "$root/api/release-v0.1.10-fork.2" +report ACTION=rebase PLAN_RESULT=success RELEASE_RESULT=success ADVANCE_RESULT=failure \ + 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"' +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)" +check "report: a failed release with no tag -> 'none published', read from the repository" \ + eval 'failed_run && has "none published" "$root/summary.md" && has "A job failed without writing a reason" "$root/summary.md"' + +# Minor: an issue opened by a failure closes once a run finds nothing to do (fixed by hand). +reset_api true +echo 7 > "$root/api/issues" +echo '{"draft":false}' > "$root/api/release-v0.1.10-fork.2" +work git push -q --force origin "$cand:refs/heads/main" +report ACTION=none PLAN_RESULT=success RELEASE_RESULT=skipped ADVANCE_RESULT=skipped BASE_TAG=v0.1.10 \ + CANDIDATE="$cand" RELEASED_TAG=v0.1.10-fork.2 +check "report: action none closes an open follow issue and the run passes" \ + eval '! failed_run && has "issue close" "$root/api/writes"' +reset_api false +echo "The release workflows differ" > "$root/reports/plan.md" +report ACTION=stage PLAN_RESULT=failure CANDIDATE="$cand" +check "report: stage fails the run with the reason" eval 'failed_run && has "release workflows differ" "$root/summary.md"' +reset_api false +report ACTION=none PLAN_RESULT=success CANDIDATE="$cand" DISABLED=",.github/workflows/web-preview.yml,,.github/workflows/web-preview.yml" SWEEP_RESULT=success +check "report: names each disabled upstream workflow once" \ + eval '[[ $(grep -o "web-preview.yml" "$root/summary.md" | wc -l) == 1 ]]' + +# --- the workflow file --------------------------------------------------------------------------- +desktop="$here/../workflows/release-desktop.yml" +declared="$(sed -n '/^ workflow_call:/,/^ inputs:/p' "$desktop" | sed -n 's/^ \([A-Z_]*\):$/\1/p' | sort)" +passed="$(sed -n '/^ release:/,/^ [a-z_]*:$/p' "$workflow" | sed -n 's/^ \([A-Z_]*\): \${{ secrets\.\([A-Z_]*\) }}$/\1=\2/p' | sort)" +check "workflow: release gets exactly the signing secrets by name, no 'secrets: inherit'" \ + eval '! grep -q "secrets: inherit" "$workflow" && [[ -n "$declared" && "$passed" == "$(sed "s/.*/&=&/" <<< "$declared")" ]]' +check "workflow: FORK_PUSH_TOKEN reaches only fork-follow-push.sh steps, never a checkout" \ + eval '! grep -q "token: \${{ secrets.FORK_PUSH_TOKEN" "$workflow" && [[ $(grep -c "secrets.FORK_PUSH_TOKEN" "$workflow") == $(grep -cE "^\s*(run: )?bash \.github/scripts/fork-follow-push\.sh" "$workflow") ]]' +check "workflow: every checkout drops its credentials" \ + eval '[[ $(grep -c "actions/checkout@" "$workflow") == $(grep -c "persist-credentials: false" "$workflow") ]]' +check "workflow: every action is pinned by commit" \ + eval '! grep -E "^\s*-?\s*uses: [^.][^@]*@" "$workflow" | grep -vqE "@[0-9a-f]{40}( |$)"' +check "workflow: the report runs on every run (also action none)" \ + eval 'sed -n "/^ report:/,\$p" "$workflow" | grep -q "^ if: always()$"' +check "workflow: upstream workflows are swept after each push" \ + eval '[[ $(grep -c "fork-workflows.sh" "$workflow") -ge 3 ]]' + +echo +if (( failures )); then + echo "$failures failed" + exit 1 +fi +echo "all passed" diff --git a/.github/scripts/fork-workflows.sh b/.github/scripts/fork-workflows.sh new file mode 100755 index 0000000000000..3bd2d6ef280e4 --- /dev/null +++ b/.github/scripts/fork-workflows.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Keeps upstream's workflows off in this fork (tests: fork-follow.test.sh). Disables every active +# workflow under .github/workflows that is not one of the fork's own, then cancels the unfinished +# runs that such workflows started for HEAD_SHA. +# +# GitHub lists a workflow only once an event has reached its file, so a workflow that arrives with a +# rebase can only be disabled after the push that brings it. fork-follow.yml therefore runs this +# right after each of its pushes and again at the end of the run, and cancels what already started. +# +# Environment: REPOSITORY (owner/name; GITHUB_REPOSITORY in a run), OWN_WORKFLOWS (','-separated +# paths the fork's own commits add; required, nothing is guessed), HEAD_SHA (optional), REPORT +# (receives the reason on failure). Output: disabled= and cancelled= (','-separated paths and run +# ids) to $GITHUB_OUTPUT, else stdout; a summary line on stderr. +set -euo pipefail + +REPOSITORY="${REPOSITORY:-${GITHUB_REPOSITORY:?REPOSITORY or GITHUB_REPOSITORY}}" +REPORT="${REPORT:-fork-workflows-report.md}" +OUTPUT="${GITHUB_OUTPUT:-/dev/stdout}" + +die() { + printf '%s\n' "$@" > "$REPORT" + printf '::error::%s\n' "$*" >&2 + exit 1 +} + +[[ -n "${OWN_WORKFLOWS:-}" ]] || die "The list of the fork's own workflows is empty, so no workflow was disabled (refusing to guess)." +own() { [[ ",$OWN_WORKFLOWS," == *",$1,"* ]]; } +upstream() { [[ "$1" == .github/workflows/* ]] && ! own "$1"; } + +active="$(gh api "repos/$REPOSITORY/actions/workflows" --paginate \ + --jq '.workflows[] | select(.state == "active") | "\(.id) \(.path)"')" \ + || die "Could not list the workflows of $REPOSITORY, so upstream workflows may still be enabled." + +disabled=() +while read -r id path; do + [[ -n "${id:-}" ]] && upstream "$path" || continue + gh api -X PUT "repos/$REPOSITORY/actions/workflows/$id/disable" > /dev/null \ + || die "Could not disable the upstream workflow $path ($id)." + disabled+=("$path") +done <<< "$active" + +cancelled=() +if [[ -n "${HEAD_SHA:-}" ]]; then + runs="$(gh api "repos/$REPOSITORY/actions/runs?head_sha=$HEAD_SHA&per_page=100" --paginate \ + --jq '.workflow_runs[] | select(.status != "completed") | "\(.id) \(.path)"')" \ + || die "Could not list the runs for ${HEAD_SHA:0:12}, so runs of upstream workflows may still be going." + while read -r id path; do + path="${path%%@*}" + [[ -n "${id:-}" ]] && upstream "$path" || continue + if ! gh api -X POST "repos/$REPOSITORY/actions/runs/$id/cancel" > /dev/null; then + status="$(gh api "repos/$REPOSITORY/actions/runs/$id" --jq .status)" \ + || die "Could not cancel run $id of $path, nor read its state." + [[ "$status" == completed ]] || die "Could not cancel run $id of $path (state: $status)." + continue + fi + cancelled+=("$id ($path)") + done <<< "$runs" +fi + +join() { local IFS=,; echo "$*"; } +{ + echo "disabled=$(join "${disabled[@]}")" + echo "cancelled=$(join "${cancelled[@]}")" +} >> "$OUTPUT" +echo "Upstream workflows disabled: ${#disabled[@]}; their runs cancelled: ${#cancelled[@]}." >&2 diff --git a/.github/workflows/fork-follow.yml b/.github/workflows/fork-follow.yml index 00584cc3a57d5..818ff7c6e9a31 100644 --- a/.github/workflows/fork-follow.yml +++ b/.github/workflows/fork-follow.yml @@ -1,15 +1,34 @@ name: Fork follow upstream # Daily: keeps this fork's patch series on upstream's newest stable release and publishes it. -# - Upstream has a newer stable tag: rebase the series onto it, test, build, publish, then move the -# series branch to the result. -# - Same tag, but the series has commits no fork release has: test, build and publish them. -# - Anything fails (conflict, schema change, red test, failed build): open or update one issue and -# publish nothing, so the last fork release stays current. +# 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 +# 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. +# - stage: upstream changed the release workflows. A run uses the reusable workflows of its own +# commit, so the candidate is pushed and the run stops with the command that releases it with +# its own files; the next run then advances to that release. +# 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. # -# Settings: variable FORK_SERIES_BRANCH (default main). Secret FORK_PUSH_TOKEN: a token that may push -# this repository's contents and workflow files (GITHUB_TOKEN may not push commits that change -# .github/workflows); only a rebase needs it. +# Upstream's workflows stay off: after each push and at the end, fork-workflows.sh disables every +# active workflow the series did not add and cancels their runs for the pushed commit. +# +# Settings: variable FORK_SERIES_BRANCH (default main). Secret FORK_PUSH_TOKEN: a fine-grained token +# for this repository only, Contents and Workflows read and write (GITHUB_TOKEN may not push commits +# that change .github/workflows). Every run checks it first and fails when it is missing, expired or +# may not push. It reaches only the steps that run fork-follow-push.sh; the release workflow gets the +# signing secrets by name and never this token. If a rule protects the series branch, the token's +# user must be allowed to bypass it. +# +# A conflict is resolved by hand: rebase the series branch onto the new upstream tag, resolve, test, +# push it with --force-with-lease; the next run then releases it (the plan's report has the commands). on: schedule: - cron: "23 6 * * *" @@ -30,26 +49,46 @@ jobs: name: Plan runs-on: ubuntu-latest timeout-minutes: 15 + permissions: + actions: write + contents: read outputs: action: ${{ steps.plan.outputs.action }} base_tag: ${{ steps.plan.outputs.base_tag }} + previous_tag: ${{ steps.plan.outputs.previous_tag }} candidate: ${{ steps.plan.outputs.candidate }} previous_head: ${{ steps.plan.outputs.previous_head }} + released_tag: ${{ steps.plan.outputs.released_tag }} dropped: ${{ steps.plan.outputs.dropped }} + own_workflows: ${{ steps.plan.outputs.own_workflows }} + disabled: ${{ steps.sweep_plan.outputs.disabled }},${{ steps.sweep_push.outputs.disabled }} + cancelled: ${{ steps.sweep_push.outputs.cancelled }} steps: - name: Checkout the series - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ env.SERIES_BRANCH }} fetch-depth: 0 filter: blob:none - token: ${{ secrets.FORK_PUSH_TOKEN || github.token }} + persist-credentials: false + + - name: Test the follow scripts + run: bash .github/scripts/fork-follow.test.sh + + - name: Check FORK_PUSH_TOKEN + env: + FORK_PUSH_TOKEN: ${{ secrets.FORK_PUSH_TOKEN }} + REPORT: ${{ runner.temp }}/reports/token.md + run: | + mkdir -p "$RUNNER_TEMP/reports" + bash .github/scripts/fork-follow-push.sh check - id: plan name: Plan and rebase env: GH_TOKEN: ${{ github.token }} - REPORT: ${{ runner.temp }}/fork-follow-report.md + RUN_SHA: ${{ github.sha }} + REPORT: ${{ runner.temp }}/reports/plan.md run: | set -euo pipefail UPSTREAM="$(gh api "repos/$GITHUB_REPOSITORY" --jq '.parent.full_name // empty')" @@ -60,25 +99,48 @@ jobs: export UPSTREAM bash .github/scripts/fork-follow.sh - - name: Push the rebased series as a candidate - if: steps.plan.outputs.action == 'rebase' + - id: sweep_plan + name: Disable upstream workflows + env: + GH_TOKEN: ${{ github.token }} + OWN_WORKFLOWS: ${{ steps.plan.outputs.own_workflows }} + REPORT: ${{ runner.temp }}/reports/plan-workflows.md + run: bash .github/scripts/fork-workflows.sh + + - id: push + name: Push the rebased series as a candidate + if: steps.plan.outputs.action == 'rebase' || steps.plan.outputs.action == 'stage' + env: + FORK_PUSH_TOKEN: ${{ secrets.FORK_PUSH_TOKEN }} + CANDIDATE: ${{ steps.plan.outputs.candidate }} + REPORT: ${{ runner.temp }}/reports/push.md + run: bash .github/scripts/fork-follow-push.sh candidate "$CANDIDATE" + + # The candidate branch is a push: an upstream workflow it brings is listed only from now on. + - id: sweep_push + name: Disable upstream workflows the candidate brought, cancel their runs + if: steps.push.outcome == 'success' env: - HAS_PUSH_TOKEN: ${{ secrets.FORK_PUSH_TOKEN != '' }} - REPORT: ${{ runner.temp }}/fork-follow-report.md + GH_TOKEN: ${{ github.token }} + OWN_WORKFLOWS: ${{ steps.plan.outputs.own_workflows }} + HEAD_SHA: ${{ steps.plan.outputs.candidate }} + REPORT: ${{ runner.temp }}/reports/push-workflows.md + run: bash .github/scripts/fork-workflows.sh + + - name: Stop for a release from the candidate's own workflow files + if: steps.plan.outputs.action == 'stage' + env: + REPORT: ${{ runner.temp }}/reports/plan.md run: | - set -euo pipefail - if [[ "$HAS_PUSH_TOKEN" != "true" ]]; then - echo "Rebasing onto ${{ steps.plan.outputs.base_tag }} needs the FORK_PUSH_TOKEN secret (contents and workflows write)." > "$REPORT" - exit 1 - fi - git push --force origin "HEAD:refs/heads/fork-follow/candidate" + cat "$REPORT" >&2 + exit 1 - - name: Keep the failure report - if: failure() - uses: actions/upload-artifact@v7 + - name: Keep the reason + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: fork-follow-report - path: ${{ runner.temp }}/fork-follow-report.md + name: fork-follow-report-plan + path: ${{ runner.temp }}/reports if-no-files-found: ignore retention-days: 7 @@ -90,7 +152,21 @@ jobs: actions: read contents: write uses: ./.github/workflows/fork-release.yml - secrets: inherit + # By name, only what fork-release.yml declares (release-desktop.yml's signing secrets). + secrets: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + AZURE_TRUSTED_SIGNING_PUBLISHER_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_PUBLISHER_NAME }} with: ref: ${{ needs.plan.outputs.candidate }} publish: true @@ -98,79 +174,122 @@ jobs: advance: name: Move the series branch needs: [plan, release] - if: needs.plan.outputs.action == 'rebase' && needs.release.result == 'success' + if: >- + !cancelled() && needs.plan.result == 'success' && + (needs.plan.outputs.action == 'advance' || + (needs.plan.outputs.action == 'rebase' && needs.release.result == 'success' && + needs.release.outputs.published == 'true')) runs-on: ubuntu-latest timeout-minutes: 10 + permissions: + actions: write + contents: read + outputs: + disabled: ${{ steps.sweep.outputs.disabled }} + cancelled: ${{ steps.sweep.outputs.cancelled }} steps: - - name: Checkout - uses: actions/checkout@v6 + - name: Checkout the scripts + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ needs.plan.outputs.candidate }} fetch-depth: 0 filter: blob:none - token: ${{ secrets.FORK_PUSH_TOKEN }} + persist-credentials: false - # The lease refuses if the branch moved since the plan (a fork PR merged meanwhile); the next - # run rebases again with that change, and the release just published stays valid. + # Refuses if the branch moved since the plan (a fork PR merged meanwhile); the next run + # rebases again with that change. A re-run after a successful move finds it done. - name: Move the series branch to the released commit + env: + FORK_PUSH_TOKEN: ${{ secrets.FORK_PUSH_TOKEN }} + BASE_TAG: ${{ needs.plan.outputs.base_tag }} + PREVIOUS_HEAD: ${{ needs.plan.outputs.previous_head }} + CANDIDATE: ${{ needs.plan.outputs.candidate }} + REPORT: ${{ runner.temp }}/reports/advance.md run: | - set -euo pipefail - git push \ - --force-with-lease="refs/heads/$SERIES_BRANCH:${{ needs.plan.outputs.previous_head }}" \ - origin "${{ needs.plan.outputs.candidate }}:refs/heads/$SERIES_BRANCH" - git push origin --delete fork-follow/candidate || true + mkdir -p "$RUNNER_TEMP/reports" + bash .github/scripts/fork-follow-push.sh advance "$PREVIOUS_HEAD" "$CANDIDATE" + + # The push to the series branch fires push workflows; an upstream one it brought is disabled + # and its run cancelled here. + - id: sweep + name: Disable upstream workflows, cancel their runs + if: always() + env: + GH_TOKEN: ${{ github.token }} + OWN_WORKFLOWS: ${{ needs.plan.outputs.own_workflows }} + HEAD_SHA: ${{ needs.plan.outputs.candidate }} + REPORT: ${{ runner.temp }}/reports/advance-workflows.md + run: bash .github/scripts/fork-workflows.sh + + - name: Delete the candidate branch + env: + FORK_PUSH_TOKEN: ${{ secrets.FORK_PUSH_TOKEN }} + run: bash .github/scripts/fork-follow-push.sh cleanup + + - name: Keep the reason + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: fork-follow-report-advance + path: ${{ runner.temp }}/reports + if-no-files-found: ignore + retention-days: 7 report: name: Report needs: [plan, release, advance] - if: always() && needs.plan.outputs.action != 'none' + if: always() runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 permissions: + actions: write + contents: read issues: write steps: - - name: Download the failure report - if: contains(needs.*.result, 'failure') + - name: Checkout the scripts + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ github.sha }} + persist-credentials: false + sparse-checkout: .github/scripts + sparse-checkout-cone-mode: true + + - name: Download the jobs' reasons continue-on-error: true - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: fork-follow-report - path: report + pattern: fork-follow-report-* + merge-multiple: true + path: ${{ runner.temp }}/reports - - name: Open, update or close the follow issue + # Last sweep: runs that a push started after the earlier sweeps looked. + - id: sweep + name: Disable upstream workflows, cancel their runs + if: needs.plan.outputs.own_workflows != '' + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + OWN_WORKFLOWS: ${{ needs.plan.outputs.own_workflows }} + HEAD_SHA: ${{ needs.plan.outputs.candidate }} + REPORT: ${{ runner.temp }}/reports/report-workflows.md + run: bash .github/scripts/fork-workflows.sh + + - name: Summary, issue, and the run's status env: GH_TOKEN: ${{ github.token }} - FAILED: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + ACTION: ${{ needs.plan.outputs.action }} + PLAN_RESULT: ${{ needs.plan.result }} + RELEASE_RESULT: ${{ needs.release.result }} + ADVANCE_RESULT: ${{ needs.advance.result }} + SWEEP_RESULT: ${{ steps.sweep.outcome }} BASE_TAG: ${{ needs.plan.outputs.base_tag }} + PREVIOUS_TAG: ${{ needs.plan.outputs.previous_tag }} + PREVIOUS_HEAD: ${{ needs.plan.outputs.previous_head }} + CANDIDATE: ${{ needs.plan.outputs.candidate }} + RELEASED_TAG: ${{ needs.plan.outputs.released_tag }} DROPPED: ${{ needs.plan.outputs.dropped }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: | - set -euo pipefail - title="Fork cannot follow upstream" - issue="$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$title\" in:title" \ - --json number,title --jq ".[] | select(.title == \"$title\") | .number" | head -n1)" - if [[ "$FAILED" == "true" ]]; then - { - echo "Run: $RUN_URL" - echo - if [[ -f report/fork-follow-report.md ]]; then - cat report/fork-follow-report.md - else - echo "A test, build or publish job failed${BASE_TAG:+ for $BASE_TAG}; see the run. Nothing was published." - fi - } > body.md - if [[ -n "$issue" ]]; then - gh issue comment "$issue" --repo "$GITHUB_REPOSITORY" --body-file body.md - else - gh issue create --repo "$GITHUB_REPOSITORY" --title "$title" --body-file body.md - fi - exit 0 - fi - note="Published the fork release on $BASE_TAG: $RUN_URL" - if [[ -n "$DROPPED" ]]; then - note="$note"$'\n\n'"Dropped because upstream now has them: ${DROPPED//;/, }" - fi - echo "$note" >> "$GITHUB_STEP_SUMMARY" - if [[ -n "$issue" ]]; then - gh issue close "$issue" --repo "$GITHUB_REPOSITORY" --comment "$note" - fi + DISABLED: ${{ needs.plan.outputs.disabled }},${{ needs.advance.outputs.disabled }},${{ steps.sweep.outputs.disabled }} + CANCELLED: ${{ needs.plan.outputs.cancelled }},${{ needs.advance.outputs.cancelled }},${{ steps.sweep.outputs.cancelled }} + REPORTS: ${{ runner.temp }}/reports + run: bash .github/scripts/fork-follow-report.sh From 63b604da4ba1b5253f29f331b435071148ce4c7c Mon Sep 17 00:00:00 2001 From: t3code-fork Date: Thu, 12 Nov 2026 11:23:56 +0000 Subject: [PATCH 03/12] fork-follow: the pushing jobs run in the Environment fork-push A repository secret can be read by a workflow file on any pushed branch. An Environment secret limited to the series branch reaches only runs from that branch, so the plan and advance jobs (the only ones that read FORK_PUSH_TOKEN) now run in the Environment fork-push. When the token is empty there, the check fails and its reason (in the run's summary) names the Environment, how to limit it to the series branch, and why the token should not stay a repository secret. A test checks that exactly the jobs that read the token use the Environment. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/fork-follow-push.sh | 10 +++++++++- .github/scripts/fork-follow.test.sh | 8 ++++++++ .github/workflows/fork-follow.yml | 10 ++++++++-- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/.github/scripts/fork-follow-push.sh b/.github/scripts/fork-follow-push.sh index 348042bc30c16..ebbdc9040e704 100755 --- a/.github/scripts/fork-follow-push.sh +++ b/.github/scripts/fork-follow-push.sh @@ -10,7 +10,8 @@ # Already at SHA (a re-run) is success; moved elsewhere is a refusal. # cleanup Deletes the branch fork-follow/candidate; a failure is only a warning. # -# Environment: FORK_PUSH_TOKEN, REPOSITORY (owner/name; GITHUB_REPOSITORY in a run), PUSH_URL +# Environment: FORK_PUSH_TOKEN, FORK_PUSH_ENVIRONMENT (the GitHub Environment that should hold the +# token; named when it is missing), REPOSITORY (owner/name; GITHUB_REPOSITORY in a run), PUSH_URL # (default https://github.com/$REPOSITORY.git), SERIES_BRANCH (default main), BASE_TAG (advance), # REPORT. Output (candidate_pushed, moved, backup_tag) goes to $GITHUB_OUTPUT, else stdout. # @@ -37,6 +38,13 @@ die() { git_said() { printf '%s\n' '```' "$(tail -n 15 "$err")" '```'; } with_token() { + if [[ -z "${FORK_PUSH_TOKEN:-}" && -n "${FORK_PUSH_ENVIRONMENT:-}" ]]; then + die "The secret FORK_PUSH_TOKEN is missing in the Environment $FORK_PUSH_ENVIRONMENT (or the Environment does not exist yet), so this run cannot push." \ + "Under Settings > Environments, create $FORK_PUSH_ENVIRONMENT (a run that names a missing Environment makes an empty one with no rules)," \ + "limit its deployment branches to $SERIES_BRANCH, and add the secret FORK_PUSH_TOKEN there: a fine-grained token for $REPOSITORY only, with Contents and Workflows read and write." \ + "Keep it out of the repository secrets: a workflow file on any pushed branch can read those, while an Environment limited to $SERIES_BRANCH gives it only to runs from $SERIES_BRANCH." \ + "GITHUB_TOKEN cannot replace it: it may not push commits that change workflow files." + fi [[ -n "${FORK_PUSH_TOKEN:-}" ]] \ || die "The secret FORK_PUSH_TOKEN is missing, so this run cannot push." \ "Add it under Settings > Secrets and variables > Actions: a fine-grained token for $REPOSITORY only, with Contents and Workflows read and write." \ diff --git a/.github/scripts/fork-follow.test.sh b/.github/scripts/fork-follow.test.sh index 0954814100451..f9ff48abc5621 100755 --- a/.github/scripts/fork-follow.test.sh +++ b/.github/scripts/fork-follow.test.sh @@ -265,6 +265,9 @@ push() { (cd "$root/work" && REPORT="$root/report.md" GITHUB_OUTPUT="$root/push. BASE_TAG=v0.1.10 bash "$dir/fork-follow-push.sh" "$@") > "$root/out" 2>&1; } rm -f "$root/report.md" check "push: a missing FORK_PUSH_TOKEN fails and says so" eval '! push check && has "FORK_PUSH_TOKEN is missing" "$root/report.md"' +rm -f "$root/report.md" +check "push: a missing FORK_PUSH_TOKEN names the Environment that should hold it" \ + eval '! FORK_PUSH_ENVIRONMENT=fork-push push check && has "missing in the Environment fork-push" "$root/report.md" && has "deployment branches to main" "$root/report.md"' export FORK_PUSH_TOKEN=test-token check "push: a token GitHub refuses (expired) fails and says so" \ eval '! PUSH_URL="$root/gh/me/gone.git" push check && has "refused FORK_PUSH_TOKEN" "$root/report.md"' @@ -384,6 +387,11 @@ check "workflow: release gets exactly the signing secrets by name, no 'secrets: eval '! grep -q "secrets: inherit" "$workflow" && [[ -n "$declared" && "$passed" == "$(sed "s/.*/&=&/" <<< "$declared")" ]]' check "workflow: FORK_PUSH_TOKEN reaches only fork-follow-push.sh steps, never a checkout" \ eval '! grep -q "token: \${{ secrets.FORK_PUSH_TOKEN" "$workflow" && [[ $(grep -c "secrets.FORK_PUSH_TOKEN" "$workflow") == $(grep -cE "^\s*(run: )?bash \.github/scripts/fork-follow-push\.sh" "$workflow") ]]' +# Each job that reads FORK_PUSH_TOKEN runs in the Environment that holds it, named as in env. +token_jobs="$(awk '/^ [a-z_-]+:$/ { job = $1 } /secrets\.FORK_PUSH_TOKEN/ { print job }' "$workflow" | sort -u)" +env_jobs="$(awk '/^ [a-z_-]+:$/ { job = $1 } /^ environment: fork-push$/ { print job }' "$workflow" | sort -u)" +check "workflow: the jobs that read FORK_PUSH_TOKEN, and only they, run in the Environment fork-push" \ + eval '[[ -n "$token_jobs" && "$token_jobs" == "$env_jobs" ]] && grep -qx " FORK_PUSH_ENVIRONMENT: fork-push" "$workflow"' check "workflow: every checkout drops its credentials" \ eval '[[ $(grep -c "actions/checkout@" "$workflow") == $(grep -c "persist-credentials: false" "$workflow") ]]' check "workflow: every action is pinned by commit" \ diff --git a/.github/workflows/fork-follow.yml b/.github/workflows/fork-follow.yml index 818ff7c6e9a31..190042de14698 100644 --- a/.github/workflows/fork-follow.yml +++ b/.github/workflows/fork-follow.yml @@ -20,8 +20,10 @@ name: Fork follow upstream # Upstream's workflows stay off: after each push and at the end, fork-workflows.sh disables every # active workflow the series did not add and cancels their runs for the pushed commit. # -# Settings: variable FORK_SERIES_BRANCH (default main). Secret FORK_PUSH_TOKEN: a fine-grained token -# for this repository only, Contents and Workflows read and write (GITHUB_TOKEN may not push commits +# Settings: variable FORK_SERIES_BRANCH (default main). Secret FORK_PUSH_TOKEN in the Environment +# fork-push, whose deployment branches are limited to the series branch (a repository secret could +# be read by a workflow file on any pushed branch; the jobs that push run in that Environment and no +# other job sees the secret): a fine-grained token for this repository only, Contents and Workflows read and write (GITHUB_TOKEN may not push commits # that change .github/workflows). Every run checks it first and fails when it is missing, expired or # may not push. It reaches only the steps that run fork-follow-push.sh; the release workflow gets the # signing secrets by name and never this token. If a rule protects the series branch, the token's @@ -43,10 +45,13 @@ permissions: env: SERIES_BRANCH: ${{ vars.FORK_SERIES_BRANCH || 'main' }} + # Must match `environment:` of the jobs that push (an expression there cannot read env). + FORK_PUSH_ENVIRONMENT: fork-push jobs: plan: name: Plan + environment: fork-push runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -173,6 +178,7 @@ jobs: advance: name: Move the series branch + environment: fork-push needs: [plan, release] if: >- !cancelled() && needs.plan.result == 'success' && From b660f931547234f3288d95b161ab7dbc4e5f0d1f Mon Sep 17 00:00:00 2001 From: t3code-fork Date: Thu, 12 Nov 2026 11:25:37 +0000 Subject: [PATCH 04/12] fork-follow tests: tagless history and a refused candidate push Pin down two state cases from review round 1: upstream without a stable tag, or a series that contains none, is refused with a reason and plans nothing; a refused candidate push leaves git's reason in the report. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/fork-follow.test.sh | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/scripts/fork-follow.test.sh b/.github/scripts/fork-follow.test.sh index f9ff48abc5621..788b1058abc1d 100755 --- a/.github/scripts/fork-follow.test.sh +++ b/.github/scripts/fork-follow.test.sh @@ -231,6 +231,22 @@ fork_tag v0.1.10-fork.1 plan check "plan: upstream moved the tag the series was built on -> named" has "moved the tag v0.1.10" "$root/report.md" +# State: empty or tagless history. No stable tag upstream, or none inside the series. +new_upstream +new_fork v0.1.10 +for t in v0.1.8 v0.1.9 v0.1.10; do git -C "$root/gh/up/t3code.git" tag -d "$t" > /dev/null; done +work git tag -d v0.1.8 v0.1.9 v0.1.10 +plan +check "plan: upstream has no stable tag -> refused with a reason, nothing planned" \ + eval 'has "no stable release tag" "$root/report.md" && [[ -z "$(out action)" ]]' +new_upstream +new_fork v0.1.8 +git -C "$root/gh/up/t3code.git" tag -d v0.1.8 > /dev/null +work git tag -d v0.1.8 +plan +check "plan: the series contains no stable upstream tag -> refused with a reason" \ + eval 'has "contains no stable" "$root/report.md" && [[ -z "$(out action)" ]]' + # Major: a release was published, then the series branch did not move. The next plan moves the # branch to that release instead of building and publishing another one. new_upstream @@ -277,6 +293,13 @@ check "push: a working token passes the check" push check prev="$(series)" work git commit -q --allow-empty -m "fork: next" next="$(work_head)" +# Minor: a refused candidate push (say, a token without Workflows write) leaves a reason. +hook="$root/gh/me/t3code.git/hooks/pre-receive" +printf '#!/bin/sh\necho "refusing to allow a token without workflows permission" >&2\nexit 1\n' > "$hook" && chmod +x "$hook" +rm -f "$root/report.md" +check "push: a refused candidate push fails with git's reason" \ + eval '! push candidate "$next" && has "Could not push the candidate" "$root/report.md" && has "workflows permission" "$root/report.md"' +rm -f "$hook" push candidate "$next" check "push: the candidate goes to fork-follow/candidate" eval '[[ "$(series fork-follow/candidate)" == "$next" ]] && has candidate_pushed=true "$root/push.out"' push advance "$prev" "$next" From 8b641dfdf69989122614dd017aa7d0a465563fdd Mon Sep 17 00:00:00 2001 From: t3code-fork Date: Thu, 12 Nov 2026 11:56:52 +0000 Subject: [PATCH 05/12] fork-follow: guard the token's isolation, compare release workflows on every path The Guard job (no Environment) runs before any job gets FORK_PUSH_TOKEN and refuses unless the repository holds no repository or organisation secret of that name (it sees only whether `secrets.FORK_PUSH_TOKEN` is empty there) and the Environment fork-push exists with exactly one deployment rule: the branch main. Each refusal says what to change. The plan compares the release workflows of the run's commit with the candidate's on the release path too, so a re-run or queued run of an older commit stages instead of building with other release steps. A commit missing from the fork is refused. The sweep cancels unfinished upstream runs from any event, names upstream runs that finished since the run began, and names upstream workflow files GitHub does not list yet. The report shows both. Nits: a crashing schema check is reported as a crash; the conflict advice says to start the follow workflow after the manual push; `while read` instead of an unquoted `for`; the ERR trap reports only from the main shell; action none deletes a leftover candidate branch. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/fork-follow-guard.sh | 87 ++++++++++++++ .github/scripts/fork-follow-report.sh | 24 ++-- .github/scripts/fork-follow.sh | 106 +++++++++++------ .github/scripts/fork-follow.test.sh | 158 ++++++++++++++++++++++++-- .github/scripts/fork-workflows.sh | 93 ++++++++++----- .github/workflows/fork-follow.yml | 89 ++++++++++++--- 6 files changed, 462 insertions(+), 95 deletions(-) create mode 100755 .github/scripts/fork-follow-guard.sh diff --git a/.github/scripts/fork-follow-guard.sh b/.github/scripts/fork-follow-guard.sh new file mode 100755 index 0000000000000..494173a417368 --- /dev/null +++ b/.github/scripts/fork-follow-guard.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Guard job of fork-follow.yml (tests: fork-follow.test.sh). It runs before any job that gets +# FORK_PUSH_TOKEN and refuses unless the token's isolation holds as the repository is set up now: +# +# 1. No repository or organisation secret named FORK_PUSH_TOKEN. A workflow file on any pushed +# branch can read such a secret, and in a job with an Environment GitHub falls back to it when +# the Environment has none. The job runs without an Environment, so `secrets.FORK_PUSH_TOKEN` +# there can only be a repository or organisation secret; the workflow passes only whether it is +# empty (REPO_SECRET_PRESENT=true|false), never the value. GITHUB_TOKEN has no permission that +# lists secrets, so this is the check GitHub allows. +# 2. The Environment FORK_PUSH_ENVIRONMENT exists, and only SERIES_BRANCH may deploy to it: its +# deployment branches are "Selected branches and tags" with exactly one rule, the branch +# SERIES_BRANCH, written without wildcards. "Protected branches only" is refused, because the +# API cannot prove that no other branch is protected (or becomes protected later). +# +# Environment: REPOSITORY (owner/name; GITHUB_REPOSITORY in a run), FORK_PUSH_ENVIRONMENT, +# SERIES_BRANCH (default main), REPO_SECRET_PRESENT, REPORT (receives the reason on refusal). Reads +# the Environment with `gh api` (GH_TOKEN: GITHUB_TOKEN with actions: read is enough). +set -euo pipefail + +REPOSITORY="${REPOSITORY:-${GITHUB_REPOSITORY:?REPOSITORY or GITHUB_REPOSITORY}}" +: "${FORK_PUSH_ENVIRONMENT:?FORK_PUSH_ENVIRONMENT}" +SERIES_BRANCH="${SERIES_BRANCH:-main}" +REPORT="${REPORT:-fork-follow-report.md}" +env_name="$FORK_PUSH_ENVIRONMENT" +settings="Settings > Environments > $env_name > Deployment branches and tags" +err="$(mktemp)" +trap 'rm -f "$err"' EXIT + +die() { + printf '%s\n' "$@" > "$REPORT" + printf '::error::%s\n' "$1" >&2 + [[ $# -lt 2 ]] || printf '%s\n' "${@:2}" >&2 + exit 1 +} + +case "${REPO_SECRET_PRESENT:-}" in + false) ;; + true) + die "A repository or organisation secret named FORK_PUSH_TOKEN exists, so this run does not use the token." \ + "A workflow file on any pushed branch can read a repository or organisation secret, and a job in the Environment $env_name falls back to it when the Environment has none." \ + "Delete it under Settings > Secrets and variables > Actions > Repository secrets (or, for an organisation secret, remove $REPOSITORY from its repository access)." \ + "Keep the token only as an Environment secret of $env_name." ;; + *) + die "The run could not tell whether a repository secret FORK_PUSH_TOKEN exists (REPO_SECRET_PRESENT='${REPO_SECRET_PRESENT:-}'), so it does not use the token." \ + "This is a fault in fork-follow.yml: its guard job must pass \`secrets.FORK_PUSH_TOKEN != ''\`." ;; +esac + +if ! environment="$(gh api "repos/$REPOSITORY/environments/$env_name" 2> "$err")"; then + if grep -q "404" "$err"; then + die "The Environment $env_name does not exist, so this run does not use the token." \ + "Create it under Settings > Environments > New environment, named $env_name." \ + "Under Deployment branches and tags pick \"Selected branches and tags\" and add one rule: branch $SERIES_BRANCH (nothing else)." \ + "Then add the secret FORK_PUSH_TOKEN there: a fine-grained token for $REPOSITORY only, with Contents and Workflows read and write." + fi + die "Could not read the Environment $env_name of $REPOSITORY, so this run does not use the token. gh said:" \ + '```' "$(tail -n 5 "$err")" '```' +fi + +policy="$(jq -r 'if .deployment_branch_policy == null then "all" + elif .deployment_branch_policy.protected_branches then "protected" + elif .deployment_branch_policy.custom_branch_policies then "custom" + else "unknown" end' <<< "$environment")" +case "$policy" in + all) + die "Any branch may deploy to the Environment $env_name, so a workflow file on any pushed branch could get FORK_PUSH_TOKEN; this run does not use it." \ + "Under $settings pick \"Selected branches and tags\" and add one rule: branch $SERIES_BRANCH (nothing else)." ;; + protected) + die "The Environment $env_name allows \"Protected branches only\", which the API cannot prove means only $SERIES_BRANCH; this run does not use FORK_PUSH_TOKEN." \ + "Under $settings pick \"Selected branches and tags\" instead and add one rule: branch $SERIES_BRANCH (nothing else)." ;; + custom) ;; + *) + die "The deployment branch rule of the Environment $env_name is not one this guard knows, so this run does not use FORK_PUSH_TOKEN." \ + "Under $settings pick \"Selected branches and tags\" and add one rule: branch $SERIES_BRANCH (nothing else)." ;; +esac + +rules="$(gh api "repos/$REPOSITORY/environments/$env_name/deployment-branch-policies?per_page=100" --paginate \ + --jq '.branch_policies[] | "\(.type // "branch") \(.name)"' 2> "$err")" \ + || die "Could not read the deployment branch rules of the Environment $env_name, so this run does not use FORK_PUSH_TOKEN. gh said:" \ + '```' "$(tail -n 5 "$err")" '```' +if [[ "$rules" != "branch $SERIES_BRANCH" ]] || [[ "$SERIES_BRANCH" == *[][*?]* ]]; then + die "The Environment $env_name must let only the branch $SERIES_BRANCH deploy, so this run does not use FORK_PUSH_TOKEN. Its rules now:" \ + "$(if [[ -n "$rules" ]]; then sed 's/^\([a-z]*\) \(.*\)$/- \1 `\2`/' <<< "$rules"; else echo "- (none)"; fi)" "" \ + "Under $settings keep exactly one rule, branch $SERIES_BRANCH, and delete the others (a tag rule or a pattern lets other refs deploy too)." +fi + +echo "FORK_PUSH_TOKEN is only an Environment secret, and only $SERIES_BRANCH may deploy to $env_name." diff --git a/.github/scripts/fork-follow-report.sh b/.github/scripts/fork-follow-report.sh index a255a26c97bcf..f8fb32da9c089 100755 --- a/.github/scripts/fork-follow-report.sh +++ b/.github/scripts/fork-follow-report.sh @@ -7,10 +7,11 @@ # repository has Issues enabled it also opens or comments on one issue, "Fork cannot follow # upstream", and closes it once a run succeeds; with Issues disabled it says so and opens none. # -# Environment (from the run): REPOSITORY, SERIES_BRANCH, RUN_URL, ACTION, PLAN_RESULT, +# Environment (from the run): REPOSITORY, SERIES_BRANCH, RUN_URL, ACTION, GUARD_RESULT, PLAN_RESULT, # RELEASE_RESULT, ADVANCE_RESULT, BASE_TAG, PREVIOUS_TAG, PREVIOUS_HEAD, CANDIDATE, RELEASED_TAG, -# DROPPED, DISABLED, CANCELLED (from the workflow sweeps), SWEEP_RESULT, REPORTS (directory of the jobs' reason -# files), ORIGIN (git URL of this fork, default GitHub). Summary to $GITHUB_STEP_SUMMARY, else stdout. +# DROPPED, DISABLED, CANCELLED, FINISHED, UNREGISTERED (from the workflow sweeps), SWEEP_RESULT, +# REPORTS (directory of the jobs' reason files), ORIGIN (git URL of this fork, default GitHub). +# Summary to $GITHUB_STEP_SUMMARY, else stdout. set -uo pipefail REPOSITORY="${REPOSITORY:-${GITHUB_REPOSITORY:?REPOSITORY or GITHUB_REPOSITORY}}" @@ -23,7 +24,7 @@ title="Fork cannot follow upstream" short() { [[ -n "$1" ]] && echo "\`${1:0:12}\`" || echo "(none)"; } failed=false -for result in "${PLAN_RESULT:-}" "${RELEASE_RESULT:-skipped}" "${ADVANCE_RESULT:-skipped}"; do +for result in "${GUARD_RESULT:-success}" "${PLAN_RESULT:-}" "${RELEASE_RESULT:-skipped}" "${ADVANCE_RESULT:-skipped}"; do [[ "$result" == success || "$result" == skipped ]] || failed=true done [[ "${PLAN_RESULT:-}" == success ]] || failed=true @@ -34,6 +35,8 @@ done tidy() { tr ',' '\n' <<< "$1" | sed '/^$/d' | awk '!seen[$0]++' | paste -sd, -; } DISABLED="$(tidy "${DISABLED:-}")" CANCELLED="$(tidy "${CANCELLED:-}")" +FINISHED="$(tidy "${FINISHED:-}")" +UNREGISTERED="$(tidy "${UNREGISTERED:-}")" state=() row() { state+=("| $1 | $2 |"); } @@ -93,6 +96,9 @@ else fi [[ -z "${DISABLED:-}" ]] || row "Upstream workflows disabled" "${DISABLED//,/, }" [[ -z "${CANCELLED:-}" ]] || row "Their runs cancelled" "${CANCELLED//,/, }" +[[ -z "${FINISHED:-}" ]] || row "**Upstream runs that finished before a sweep could stop them**" "${FINISHED//,/, }" +[[ -z "${UNREGISTERED:-}" ]] \ + || row "**Upstream workflow files GitHub does not list yet, so not disabled** (the sweep after their first run disables them)" "${UNREGISTERED//,/, }" [[ -z "${DROPPED:-}" ]] || row "Patches dropped (empty on $BASE_TAG: upstream has the change)" "${DROPPED//;/, }" reasons="" @@ -107,8 +113,12 @@ case "$ACTION" in release) what="Release the series on ${BASE_TAG:-?} as it is." ;; rebase) what="Rebase the series from ${PREVIOUS_TAG:-?} onto ${BASE_TAG:-?}, release it, then move \`$SERIES_BRANCH\`." ;; advance) what="Move \`$SERIES_BRANCH\` onto ${BASE_TAG:-?}: ${RELEASED_TAG:-?} already has the rebased series." ;; - stage) what="Rebase onto ${BASE_TAG:-?} and push the candidate; its release needs its own workflow files." ;; - *) what="The plan did not finish." ;; + stage) what="Push the candidate on ${BASE_TAG:-?}; its release needs its own workflow files." ;; + *) if [[ "${GUARD_RESULT:-success}" != success ]]; then + what="None: the guard refused, so no job got FORK_PUSH_TOKEN." + else + what="The plan did not finish." + fi ;; esac body="$(mktemp)" @@ -125,7 +135,7 @@ body="$(mktemp)" if [[ -n "$reasons" ]]; then printf '%s' "$reasons" else - echo "A job failed without writing a reason (results: plan ${PLAN_RESULT:-?}, release ${RELEASE_RESULT:-skipped}, advance ${ADVANCE_RESULT:-skipped}); see the run." + echo "A job failed without writing a reason (results: guard ${GUARD_RESULT:-success}, plan ${PLAN_RESULT:-?}, release ${RELEASE_RESULT:-skipped}, advance ${ADVANCE_RESULT:-skipped}); see the run." echo fi fi diff --git a/.github/scripts/fork-follow.sh b/.github/scripts/fork-follow.sh index cfd3cf1a7fcba..73e82c45ddc06 100755 --- a/.github/scripts/fork-follow.sh +++ b/.github/scripts/fork-follow.sh @@ -14,8 +14,9 @@ # rebase rebased onto a newer tag: release the candidate, then move the series # advance rebased onto a newer tag, and a published fork release already has the # candidate's exact files: move the series to that release, build nothing -# stage rebased, but the release workflows changed upstream: push the candidate -# and stop, so it is released with its own workflow files (see $REPORT) +# stage the release workflows of this run's commit differ from the candidate's +# (upstream changed them, or this is a re-run of an older run): push the +# candidate and stop, so it is released with its own files (see $REPORT) # base_tag, base_commit the upstream tag (and its commit) the candidate sits on # previous_tag, previous_head where the series was (rebase, advance, stage) # candidate the commit to release or to move the series to @@ -44,6 +45,9 @@ fail() { # A command that fails outside fail() (set -e) still leaves a reason that names the step. on_error() { local status="$1" line="$2" command="$3" + # Only the script's own shell reports: a command substitution inherits the trap (set -E), and a + # failure there that matters fails the line that uses it, which is reported here. + ((BASH_SUBSHELL == 0)) || return 0 [[ -z "$reported" ]] || return 0 reported=1 printf '%s\n' "The plan stopped at fork-follow.sh line $line (exit $status): \`$command\`" \ @@ -124,8 +128,19 @@ if [[ -n "$merges" ]]; then "run \`git rebase --onto $base_tag $base_tag \` and force-push it. Merge fork pull requests only with squash or rebase." fi -node .github/scripts/fork-schema-check.cjs "$base" HEAD > "$logs/schema-check.log" 2>&1 \ - || fail "The series changes the database schema:" '```' "$(cat "$logs/schema-check.log")" '```' +# The schema check exits 1 both when it refuses and when node crashes; only a refusal prints this. +schema_refusal="Fork changes must not change the database schema" +schema_check() { # schema_check BASE WHAT: fails with the check's output, a refusal or a crash + local status=0 + node .github/scripts/fork-schema-check.cjs "$1" HEAD > "$logs/schema-check.log" 2>&1 || status=$? + ((status != 0)) || return 0 + if ((status == 1)) && grep -qF "$schema_refusal" "$logs/schema-check.log"; then + fail "$2 changes the database schema:" '```' "$(cat "$logs/schema-check.log")" '```' + fi + fail "The schema check crashed (exit $status), so it did not say whether the series touches the database schema:" \ + '```' "$(tail -n 20 "$logs/schema-check.log")" '```' "The series was left as it is." +} +schema_check "$base" "The series" # The newest fork release of a tag: " ", or nothing. latest_release() { @@ -138,6 +153,47 @@ own_workflows() { git diff --name-only --diff-filter=A "$1" "$2" -- .github/workflows | paste -sd, - } +# The release job runs the reusable workflows of this run's commit (RUN_SHA), not the candidate's. +# They differ after upstream changed them, and on a re-run (or a queued run) of a run whose commit +# is older than the series. Building then would mix two versions of the release steps. +if ! git cat-file -e "$run_sha^{commit}" 2> /dev/null; then + git fetch --quiet --no-tags "$ORIGIN" "$run_sha" > "$logs/fetch.log" 2>&1 \ + || fail "This run's commit $run_sha is not in the fork, so the plan cannot compare its release workflows with the candidate's." \ + "Start a new run (\`gh workflow run fork-follow.yml --repo $REPOSITORY\`) instead of re-running this one." +fi +called() { # the release workflow and every local workflow it calls, at commit $1, one per line + local queue=(.github/workflows/fork-release.yml) seen="" file used + while ((${#queue[@]})); do + file="${queue[0]}" + queue=("${queue[@]:1}") + grep -qxF -- "$file" <<< "$seen" && continue + seen+="$file"$'\n' + while read -r used; do + [[ -z "$used" ]] || queue+=("$used") + done <<< "$({ git show "$1:$file" 2> /dev/null || true; } \ + | sed -n 's|^[[:space:]-]*uses:[[:space:]]*["'\'']\{0,1\}\./\(\.github/workflows/[^[:space:]@#"'\'']*\).*|\1|p')" + done + printf '%s' "$seen" +} +changed_release_workflows() { # lists the release workflow files that differ between RUN_SHA and $1 + local file + while read -r file; do + [[ -n "$file" ]] || continue + if [[ "$(git rev-parse --quiet --verify "$run_sha:$file" || true)" != "$(git rev-parse --quiet --verify "$1:$file" || true)" ]]; then + printf -- '- %s\n' "$file" + fi + done <<< "$(sort -u <<< "$(called "$run_sha")"$'\n'"$(called "$1")")" +} +stage_report() { # stage_report CANDIDATE TAG CHANGED + printf '%s\n' \ + "The release workflows differ between this run's commit (${run_sha:0:12}) and the candidate ${1:0:12} on $2:" \ + "${3%$'\n'}" "" \ + "A run uses the reusable workflows of its own commit, so this run would build the candidate with other release steps than its own; it built nothing." \ + "The candidate ${1:0:12} is on the branch fork-follow/candidate. Release it with its own workflow files:" \ + "\`gh workflow run fork-release.yml --repo $REPOSITORY --ref fork-follow/candidate -f ref=$1 -f publish=true\`" \ + "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 read -r released_tag released_commit <<< "$(latest_release "$base_tag")" || true if [[ "${released_commit:-}" == "$head" ]]; then @@ -145,6 +201,11 @@ if [[ "$target_tag" == "$base_tag" ]]; then else action=release released_tag="" + changed="$(changed_release_workflows "$head")" + if [[ -n "$changed" ]]; then + action=stage + stage_report "$head" "$base_tag" "$changed" + fi fi { echo "action=$action" @@ -170,10 +231,10 @@ if ! git -c user.name=t3code-fork -c user.email=noreply@users.noreply.github.com "Conflicting files:" "${conflicts:-(none listed; see the run log)}" "" \ "The series was left as it is. To follow by hand: \`git fetch https://github.com/$UPSTREAM.git tag $target_tag\`," \ "\`git rebase --onto $target_tag $base_tag \`, resolve, run the tests, then push the branch with" \ - "\`--force-with-lease\`. The next run then releases it." + "\`--force-with-lease\`, and start the follow workflow once (\`gh workflow run fork-follow.yml --repo $REPOSITORY\`):" \ + "it releases the series and disables the upstream workflows that push started." fi -node .github/scripts/fork-schema-check.cjs "$target" HEAD > "$logs/schema-check.log" 2>&1 \ - || fail "After rebasing onto $target_tag the series changes the database schema:" '```' "$(cat "$logs/schema-check.log")" '```' +schema_check "$target" "After rebasing onto $target_tag, the series" candidate="$(git rev-parse HEAD)" dropped="$(comm -23 <(sort <<< "$before") <(git log --format=%s "$target..HEAD" | sort) | paste -sd ';' -)" @@ -187,37 +248,10 @@ if [[ -n "${released_commit:-}" ]] \ candidate="$released_commit" else released_tag="" - # The release job runs the reusable workflows of this run's commit (the series before the rebase), - # not the candidate's. If upstream changed them, building would mix two upstream releases. - called() { - local queue=(.github/workflows/fork-release.yml) seen="" file - while ((${#queue[@]})); do - file="${queue[0]}" - queue=("${queue[@]:1}") - grep -qxF "$file" <<< "$seen" && continue - seen+="$file"$'\n' - while read -r used; do - [[ -z "$used" ]] || queue+=("$used") - done <<< "$(git show "$1:$file" 2> /dev/null \ - | sed -n 's|^[[:space:]-]*uses:[[:space:]]*["'\'']\{0,1\}\./\(\.github/workflows/[^[:space:]@#"'\'']*\).*|\1|p')" - done - printf '%s' "$seen" - } - changed="" - for file in $(sort -u <<< "$(called "$run_sha")"$'\n'"$(called "$candidate")"); do - if [[ "$(git rev-parse --quiet --verify "$run_sha:$file" || true)" != "$(git rev-parse --quiet --verify "$candidate:$file" || true)" ]]; then - changed+="- $file"$'\n' - fi - done + changed="$(changed_release_workflows "$candidate")" if [[ -n "$changed" ]]; then action=stage - printf '%s\n' \ - "The release workflows differ between this run's commit (${run_sha:0:12}, on $base_tag) and the candidate on $target_tag:" \ - "${changed%$'\n'}" "" \ - "A run uses the reusable workflows of its own commit, so this run would build $target_tag with $base_tag's release steps; it built nothing." \ - "The candidate ${candidate:0:12} is on the branch fork-follow/candidate. Release it with its own workflow files:" \ - "\`gh workflow run fork-release.yml --repo $REPOSITORY --ref fork-follow/candidate -f ref=$candidate -f publish=true\`" \ - "The next follow run then finds that release and moves the series to it." > "$REPORT" + stage_report "$candidate" "$target_tag" "$changed" fi fi { diff --git a/.github/scripts/fork-follow.test.sh b/.github/scripts/fork-follow.test.sh index 788b1058abc1d..8b75d8202cbf8 100755 --- a/.github/scripts/fork-follow.test.sh +++ b/.github/scripts/fork-follow.test.sh @@ -20,11 +20,13 @@ git config --global advice.detachedHead false git config --global url."$root/gh/".insteadOf https://github.com/ 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 + SERIES_BRANCH FORK_PUSH_TOKEN OWN_WORKFLOWS HEAD_SHA TREE_REF SINCE REPO_SECRET_PRESENT \ + FORK_PUSH_ENVIRONMENT # Fake gh, served from $root/api: # gh api [-X M] PATH [--paginate] [--jq EXPR] gh issue list|comment|create|close ... -# A file $root/api/fail_ makes every call whose path contains fail. +# A file $root/api/fail_ makes every call whose path contains fail. Run listings are +# filtered by their head_sha= and status= query like GitHub's (created= is ignored). mkdir -p "$root/bin" "$root/api" cat > "$root/bin/gh" << 'GH' #!/usr/bin/env bash @@ -62,7 +64,17 @@ case "$method $path" in "GET repos/me/t3code") body="{\"has_issues\":$(cat "$api/has_issues"),\"parent\":{\"full_name\":\"up/t3code\"}}" ;; "GET repos/me/t3code/actions/workflows") body="$(cat "$api/workflows.json")" ;; "PUT repos/me/t3code/actions/workflows/"*/disable) echo "disable $path" >> "$api/writes"; exit 0 ;; - "GET repos/me/t3code/actions/runs?head_sha="*) body="$(cat "$api/runs.json")" ;; + "GET repos/me/t3code/actions/runs?"*) + query="${path#*\?}" sha="" st="" + [[ "&$query" != *"&head_sha="* ]] || { sha="${query#*head_sha=}"; sha="${sha%%&*}"; } + [[ "&$query" != *"&status="* ]] || { st="${query#*status=}"; st="${st%%&*}"; } + body="$(jq --arg sha "$sha" --arg st "$st" \ + '.workflow_runs |= map(select(($sha == "" or .head_sha == $sha) and ($st == "" or .status == $st)))' "$api/runs.json")" ;; + "GET repos/me/t3code/contents/.github/workflows?ref="*) body="$(cat "$api/files.json")" ;; + "GET repos/me/t3code/environments/fork-push") + [[ -f "$api/environment.json" ]] || { echo "gh: Not Found (HTTP 404)" >&2; exit 1; } + body="$(cat "$api/environment.json")" ;; + "GET repos/me/t3code/environments/fork-push/deployment-branch-policies"*) body="$(cat "$api/branch-policies.json")" ;; "POST repos/me/t3code/actions/runs/"*/cancel) echo "cancel $path" >> "$api/writes"; exit 0 ;; "GET repos/me/t3code/releases/tags/"*) tag="${path##*/}" @@ -95,6 +107,7 @@ reset_api() { echo "${1:-false}" > "$root/api/has_issues" echo '{"workflows":[]}' > "$root/api/workflows.json" echo '{"workflow_runs":[]}' > "$root/api/runs.json" + echo '[]' > "$root/api/files.json" } # --- repositories -------------------------------------------------------------------------------- @@ -178,6 +191,8 @@ new_fork v0.1.9 plan check "plan: a conflict names the stopped patch and its file and aborts the rebase" \ eval 'has "fork: edit u3" "$root/report.md" && has "u3.txt" "$root/report.md" && [[ ! -d "$root/work/.git/rebase-merge" ]]' +check "plan: the conflict advice says to start the follow workflow once after the manual push" \ + has "gh workflow run fork-follow.yml" "$root/report.md" new_fork main plan @@ -274,6 +289,53 @@ new_fork v0.1.10 check "plan: changed release workflows -> stage, with the command that releases the candidate" \ eval 'action_is stage && has "release-desktop.yml" "$root/report.md" && has "gh workflow run fork-release.yml" "$root/report.md"' +# Round 2: the same comparison on the release path. A re-run (or queued run) of an older run: its +# commit is the series on v0.1.10, while the series already sits on v0.1.11, whose +# release-desktop.yml differs. Releasing would build v0.1.11 with v0.1.10's release steps. +# The stage plan above left the rebased series (on v0.1.11) checked out; the fork's main is still +# the series on v0.1.10, the older run's commit. Make the rebased series the fork's main. +old_run="$(series)" +work git push -q --force origin HEAD:main +(cd "$root/work" && RUN_SHA="$old_run" REPORT="$root/report.md" GITHUB_OUTPUT="$root/plan.out" \ + bash "$dir/fork-follow.sh") > "$root/out" 2>&1 +check "plan: release path, the run's commit has other release workflows -> stage, not release" \ + eval 'action_is stage && [[ "$(out base_tag)" == v0.1.11 && "$(out candidate)" == "$(work_head)" ]] && has "release-desktop.yml" "$root/report.md"' +(cd "$root/work" && RUN_SHA="$(git rev-parse HEAD)" REPORT="$root/report.md" GITHUB_OUTPUT="$root/plan.out" \ + bash "$dir/fork-follow.sh") > "$root/out" 2>&1 +check "plan: release path, the run's own commit -> release" action_is release +rm -f "$root/plan.out" +(cd "$root/work" && RUN_SHA=1234567890123456789012345678901234567890 REPORT="$root/report.md" GITHUB_OUTPUT="$root/plan.out" \ + bash "$dir/fork-follow.sh") > "$root/out" 2>&1 +check "plan: the run's commit is not in the fork -> refused with a reason, nothing planned" \ + eval 'has "is not in the fork" "$root/report.md" && [[ -z "$(out action)" ]]' + +# Nit: a release workflow that names a file missing at a commit makes `git show` fail inside a +# command substitution; that must not leave a stray "plan stopped" reason while the plan goes on. +new_upstream +new_fork v0.1.9 +(cd "$root/work" && printf ' uses: ./.github/workflows/missing.yml\n' >> .github/workflows/fork-release.yml \ + && git add -A && git commit -q --amend --no-edit && git push -q --force origin main) > /dev/null 2>&1 +plan +check "plan: a release workflow naming a missing file leaves no stray reason" \ + eval 'action_is rebase && [[ ! -e "$root/report.md" ]]' + +# Nit: the schema check crashing is not reported as a schema change; a refusal still is. +new_upstream +new_fork v0.1.10 +mkdir -p "$root/fakenode" +printf '#!/bin/sh\necho "Error: Cannot find module x (MODULE_NOT_FOUND)" >&2\nexit 1\n' > "$root/fakenode/node" +chmod +x "$root/fakenode/node" +(cd "$root/work" && PATH="$root/fakenode:$PATH" REPORT="$root/report.md" GITHUB_OUTPUT="$root/plan.out" \ + bash "$dir/fork-follow.sh") > "$root/out" 2>&1 +rm -f "$root/plan.out" +check "plan: a crashing schema check is reported as a crash, not a schema change" \ + eval 'has "schema check crashed (exit 1)" "$root/report.md" && hasnt "changes the database schema" "$root/report.md" && [[ -z "$(out action)" ]]' +printf '#!/bin/sh\necho "Fork changes must not change the database schema (no down-migrations exist):" >&2\necho "- a.sql" >&2\nexit 1\n' > "$root/fakenode/node" +(cd "$root/work" && PATH="$root/fakenode:$PATH" REPORT="$root/report.md" GITHUB_OUTPUT="$root/plan.out" \ + bash "$dir/fork-follow.sh") > "$root/out" 2>&1 +check "plan: a schema check refusal is reported as a schema change" \ + eval 'has "The series changes the database schema" "$root/report.md" && has "a.sql" "$root/report.md"' + # --- pushes: fork-follow-push.sh ------------------------------------------------------------- new_upstream new_fork v0.1.10 @@ -318,6 +380,44 @@ push cleanup check "push: cleanup deletes the candidate branch" eval '! series fork-follow/candidate > /dev/null' unset FORK_PUSH_TOKEN +# --- guard: fork-follow-guard.sh ----------------------------------------------------------------- +guard() { REPORT="$root/report.md" FORK_PUSH_ENVIRONMENT=fork-push bash "$dir/fork-follow-guard.sh" > "$root/out" 2>&1; } +good_env() { + reset_api + echo '{"name":"fork-push","deployment_branch_policy":{"protected_branches":false,"custom_branch_policies":true}}' > "$root/api/environment.json" + echo '{"total_count":1,"branch_policies":[{"id":1,"name":"main","type":"branch"}]}' > "$root/api/branch-policies.json" +} +good_env +rm -f "$root/report.md" +check "guard: a repository or organisation secret FORK_PUSH_TOKEN -> refused, says to delete it" \ + eval '! REPO_SECRET_PRESENT=true guard && has "repository or organisation secret named FORK_PUSH_TOKEN exists" "$root/report.md" && has "Repository secrets" "$root/report.md"' +check "guard: the secret check's answer is missing -> refused" \ + eval '! REPO_SECRET_PRESENT= guard && has "could not tell" "$root/report.md"' +export REPO_SECRET_PRESENT=false +check "guard: only the Environment holds it and only main may deploy -> passes" guard +rm -f "$root/api/environment.json" +check "guard: the Environment does not exist -> refused, says how to create it" \ + eval '! guard && has "does not exist" "$root/report.md" && has "Selected branches and tags" "$root/report.md" && has "branch main" "$root/report.md"' +echo '{"name":"fork-push","deployment_branch_policy":null}' > "$root/api/environment.json" +check "guard: any branch may deploy (no rule, as GitHub auto-creates it) -> refused" \ + eval '! guard && has "Any branch may deploy" "$root/report.md" && has "Selected branches and tags" "$root/report.md"' +echo '{"name":"fork-push","deployment_branch_policy":{"protected_branches":true,"custom_branch_policies":false}}' > "$root/api/environment.json" +check "guard: protected branches only (not provably main alone) -> refused" \ + eval '! guard && has "Protected branches only" "$root/report.md"' +good_env +echo '{"total_count":2,"branch_policies":[{"id":1,"name":"main","type":"branch"},{"id":2,"name":"v*","type":"tag"}]}' > "$root/api/branch-policies.json" +check "guard: an extra tag rule -> refused, the rules listed" \ + eval '! guard && has "tag \`v\*\`" "$root/report.md" && has "exactly one rule" "$root/report.md"' +echo '{"total_count":1,"branch_policies":[{"id":1,"name":"main*","type":"branch"}]}' > "$root/api/branch-policies.json" +check "guard: a pattern rule (main*) -> refused" eval '! guard && has "main\*" "$root/report.md"' +echo '{"total_count":0,"branch_policies":[]}' > "$root/api/branch-policies.json" +check "guard: no rule at all -> refused" eval '! guard && has "(none)" "$root/report.md"' +good_env +touch "$root/api/fail_environments" +check "guard: the Environment cannot be read -> refused with gh's reason" \ + eval '! guard && has "Could not read the Environment" "$root/report.md" && has "502" "$root/report.md"' +unset REPO_SECRET_PRESENT + # --- workflows: fork-workflows.sh ------------------------------------------------------------ sweep() { REPORT="$root/report.md" GITHUB_OUTPUT="$root/sweep.out" bash "$dir/fork-workflows.sh" > "$root/out" 2>&1; } reset_api @@ -330,15 +430,32 @@ cat > "$root/api/workflows.json" << 'J' J cat > "$root/api/runs.json" << 'J' {"workflow_runs":[ - {"id":11,"status":"queued","path":".github/workflows/web-preview.yml"}, - {"id":12,"status":"in_progress","path":".github/workflows/fork-tests.yml"}, - {"id":13,"status":"completed","path":".github/workflows/relay-preview.yml"}]} + {"id":11,"status":"queued","head_sha":"abc","event":"push","path":".github/workflows/web-preview.yml"}, + {"id":12,"status":"in_progress","head_sha":"abc","event":"push","path":".github/workflows/fork-tests.yml"}, + {"id":13,"status":"completed","conclusion":"success","head_sha":"abc","event":"push","path":".github/workflows/relay-preview.yml"}, + {"id":14,"status":"in_progress","head_sha":"def","event":"pull_request_target","path":".github/workflows/relay-preview-cleanup.yml@refs/heads/main"}, + {"id":15,"status":"completed","conclusion":"cancelled","head_sha":"abc","event":"push","path":".github/workflows/ci.yml"}, + {"id":16,"status":"completed","conclusion":"success","head_sha":"def","event":"schedule","path":".github/workflows/fork-tests.yml"}]} +J +cat > "$root/api/files.json" << 'J' +[{"type":"file","path":".github/workflows/fork-follow.yml"}, + {"type":"file","path":".github/workflows/web-preview.yml"}, + {"type":"file","path":".github/workflows/ci.yml"}, + {"type":"file","path":".github/workflows/release.yml"}, + {"type":"file","path":".github/workflows/README.md"}, + {"type":"dir","path":".github/workflows/scripts"}] J -OWN_WORKFLOWS=.github/workflows/fork-follow.yml,.github/workflows/fork-tests.yml HEAD_SHA=abc sweep +OWN_WORKFLOWS=.github/workflows/fork-follow.yml,.github/workflows/fork-tests.yml HEAD_SHA=abc SINCE=2026-11-12T06:23:00Z sweep check "workflows: an active upstream workflow is disabled and named; own, disabled and dynamic ones are left" \ eval 'has "workflows/2/disable" "$root/api/writes" && [[ $(grep -c disable "$root/api/writes") == 1 ]] && has "disabled=.github/workflows/web-preview.yml" "$root/sweep.out"' check "workflows: unfinished runs of upstream workflows are cancelled, own and finished ones are not" \ - eval 'has "runs/11/cancel" "$root/api/writes" && [[ $(grep -c cancel "$root/api/writes") == 1 ]]' + eval 'has "runs/11/cancel" "$root/api/writes" && hasnt "runs/12/cancel" "$root/api/writes" && hasnt "runs/13/cancel" "$root/api/writes"' +check "workflows: an upstream run from another event and commit (pull_request_target) is cancelled too" \ + eval 'has "runs/14/cancel" "$root/api/writes" && [[ $(grep -c cancel "$root/api/writes") == 2 ]]' +check "workflows: upstream runs that finished before the sweep are named with event and conclusion; own and cancelled ones are not" \ + eval '[[ "$(sed -n "s/^finished=//p" "$root/sweep.out")" == "13 (.github/workflows/relay-preview.yml: success after push)" ]]' +check "workflows: upstream workflow files GitHub does not list yet are named, not called disabled" \ + eval '[[ "$(sed -n "s/^unregistered=//p" "$root/sweep.out")" == ".github/workflows/release.yml" ]] && has "does not list yet" "$root/out"' reset_api OWN_WORKFLOWS="" sweep check "workflows: an empty own list disables nothing (refusing to guess)" eval '[[ ! -e "$root/api/writes" ]] && has "refusing to guess" "$root/report.md"' @@ -401,6 +518,16 @@ reset_api false report ACTION=none PLAN_RESULT=success CANDIDATE="$cand" DISABLED=",.github/workflows/web-preview.yml,,.github/workflows/web-preview.yml" SWEEP_RESULT=success check "report: names each disabled upstream workflow once" \ eval '[[ $(grep -o "web-preview.yml" "$root/summary.md" | wc -l) == 1 ]]' +reset_api false +report ACTION=none PLAN_RESULT=success CANDIDATE="$cand" SWEEP_RESULT=success \ + FINISHED="13 (.github/workflows/relay-preview.yml: success after push)" UNREGISTERED=".github/workflows/release.yml" +check "report: names upstream runs that finished first and files GitHub does not list yet" \ + eval 'has "finished before a sweep could stop them" "$root/summary.md" && has "relay-preview.yml: success after push" "$root/summary.md" && has "does not list yet" "$root/summary.md" && has "release.yml" "$root/summary.md"' +rm -rf "$root/reports" && mkdir -p "$root/reports" +echo "A repository or organisation secret named FORK_PUSH_TOKEN exists" > "$root/reports/guard.md" +report ACTION= GUARD_RESULT=failure PLAN_RESULT=skipped +check "report: the guard refused -> the run fails with its reason, and says no job got the token" \ + eval 'failed_run && has "secret named FORK_PUSH_TOKEN exists" "$root/summary.md" && has "guard refused" "$root/summary.md"' # --- the workflow file --------------------------------------------------------------------------- desktop="$here/../workflows/release-desktop.yml" @@ -408,10 +535,21 @@ declared="$(sed -n '/^ workflow_call:/,/^ inputs:/p' "$desktop" | sed -n 's/ passed="$(sed -n '/^ release:/,/^ [a-z_]*:$/p' "$workflow" | sed -n 's/^ \([A-Z_]*\): \${{ secrets\.\([A-Z_]*\) }}$/\1=\2/p' | sort)" check "workflow: release gets exactly the signing secrets by name, no 'secrets: inherit'" \ eval '! grep -q "secrets: inherit" "$workflow" && [[ -n "$declared" && "$passed" == "$(sed "s/.*/&=&/" <<< "$declared")" ]]' +# The guard's presence check is the only other mention, and it yields true/false, never the value. +presence="REPO_SECRET_PRESENT: \${{ secrets.FORK_PUSH_TOKEN != '' }}" check "workflow: FORK_PUSH_TOKEN reaches only fork-follow-push.sh steps, never a checkout" \ - eval '! grep -q "token: \${{ secrets.FORK_PUSH_TOKEN" "$workflow" && [[ $(grep -c "secrets.FORK_PUSH_TOKEN" "$workflow") == $(grep -cE "^\s*(run: )?bash \.github/scripts/fork-follow-push\.sh" "$workflow") ]]' + eval '! grep -q "token: \${{ secrets.FORK_PUSH_TOKEN" "$workflow" && [[ $(grep "secrets.FORK_PUSH_TOKEN" "$workflow" | grep -cvF "$presence") == $(grep -cE "^\s*(run: )?bash \.github/scripts/fork-follow-push\.sh" "$workflow") ]]' +guard_job="$(sed -n '/^ guard:$/,/^ [a-z_-]*:$/p' "$workflow" | sed '$d')" +check "workflow: a Guard job without an Environment sees only whether a repository/organisation secret exists, and runs the guard" \ + eval '[[ -n "$guard_job" ]] && ! grep -q "environment:" <<< "$guard_job" && [[ $(grep -cF "$presence" "$workflow") == 1 ]] && grep -qF "$presence" <<< "$guard_job" && grep -q "fork-follow-guard.sh" <<< "$guard_job" && grep -q "actions: read" <<< "$guard_job"' +check "workflow: the plan (the first job with the token) waits for the guard; advance waits for the plan" \ + eval 'sed -n "/^ plan:$/,/^ steps:$/p" "$workflow" | grep -qx " needs: \[guard\]" && sed -n "/^ advance:$/,/^ steps:$/p" "$workflow" | grep -q "^ needs: \[plan"' +check "workflow: action none deletes a leftover candidate branch" \ + eval 'grep -B2 -A3 "action == .none." "$workflow" | grep -q "fork-follow-push.sh cleanup"' +check "workflow: the last sweep reports runs since the run began" \ + eval 'sed -n "/^ report:/,\$p" "$workflow" | grep -q "SINCE: \${{ needs.guard.outputs.started }}"' # Each job that reads FORK_PUSH_TOKEN runs in the Environment that holds it, named as in env. -token_jobs="$(awk '/^ [a-z_-]+:$/ { job = $1 } /secrets\.FORK_PUSH_TOKEN/ { print job }' "$workflow" | sort -u)" +token_jobs="$(grep -vF "$presence" "$workflow" | awk '/^ [a-z_-]+:$/ { job = $1 } /secrets\.FORK_PUSH_TOKEN/ { print job }' | sort -u)" env_jobs="$(awk '/^ [a-z_-]+:$/ { job = $1 } /^ environment: fork-push$/ { print job }' "$workflow" | sort -u)" check "workflow: the jobs that read FORK_PUSH_TOKEN, and only they, run in the Environment fork-push" \ eval '[[ -n "$token_jobs" && "$token_jobs" == "$env_jobs" ]] && grep -qx " FORK_PUSH_ENVIRONMENT: fork-push" "$workflow"' diff --git a/.github/scripts/fork-workflows.sh b/.github/scripts/fork-workflows.sh index 3bd2d6ef280e4..654d580537ce0 100755 --- a/.github/scripts/fork-workflows.sh +++ b/.github/scripts/fork-workflows.sh @@ -1,21 +1,28 @@ #!/usr/bin/env bash # Keeps upstream's workflows off in this fork (tests: fork-follow.test.sh). Disables every active -# workflow under .github/workflows that is not one of the fork's own, then cancels the unfinished -# runs that such workflows started for HEAD_SHA. +# workflow under .github/workflows that is not one of the fork's own, cancels every unfinished run +# of such a workflow (whatever event started it: push, pull_request, schedule ...), and names what +# it could not stop: +# finished runs of upstream workflows that completed since SINCE (they ran before a sweep +# could stop them), with their conclusion +# unregistered upstream workflow files in the tree at TREE_REF that GitHub does not list yet. +# GitHub lists a workflow only once an event has reached its file, so these can be +# neither disabled nor cancelled until then; the next sweep after that event does. # -# GitHub lists a workflow only once an event has reached its file, so a workflow that arrives with a -# rebase can only be disabled after the push that brings it. fork-follow.yml therefore runs this -# right after each of its pushes and again at the end of the run, and cancels what already started. +# fork-follow.yml runs this right after each of its pushes and again at the end of the run. # # Environment: REPOSITORY (owner/name; GITHUB_REPOSITORY in a run), OWN_WORKFLOWS (','-separated -# paths the fork's own commits add; required, nothing is guessed), HEAD_SHA (optional), REPORT -# (receives the reason on failure). Output: disabled= and cancelled= (','-separated paths and run -# ids) to $GITHUB_OUTPUT, else stdout; a summary line on stderr. +# paths the fork's own commits add; required, nothing is guessed), TREE_REF (commit or branch whose +# workflow files are checked; default HEAD_SHA, else SERIES_BRANCH, else main), HEAD_SHA, SINCE +# (ISO 8601 time; optional), REPORT (receives the reason on failure). Output: disabled=, cancelled=, +# finished= and unregistered= (','-separated) to $GITHUB_OUTPUT, else stdout; a summary line on +# stderr, and a ::warning:: line for finished runs and unregistered files. set -euo pipefail REPOSITORY="${REPOSITORY:-${GITHUB_REPOSITORY:?REPOSITORY or GITHUB_REPOSITORY}}" REPORT="${REPORT:-fork-workflows-report.md}" OUTPUT="${GITHUB_OUTPUT:-/dev/stdout}" +TREE_REF="${TREE_REF:-${HEAD_SHA:-${SERIES_BRANCH:-main}}}" die() { printf '%s\n' "$@" > "$REPORT" @@ -27,39 +34,71 @@ die() { own() { [[ ",$OWN_WORKFLOWS," == *",$1,"* ]]; } upstream() { [[ "$1" == .github/workflows/* ]] && ! own "$1"; } -active="$(gh api "repos/$REPOSITORY/actions/workflows" --paginate \ - --jq '.workflows[] | select(.state == "active") | "\(.id) \(.path)"')" \ +listed="$(gh api "repos/$REPOSITORY/actions/workflows" --paginate \ + --jq '.workflows[] | "\(.id) \(.state) \(.path)"')" \ || die "Could not list the workflows of $REPOSITORY, so upstream workflows may still be enabled." disabled=() -while read -r id path; do - [[ -n "${id:-}" ]] && upstream "$path" || continue +while read -r id state path; do + [[ -n "${id:-}" && "$state" == active ]] && upstream "$path" || continue gh api -X PUT "repos/$REPOSITORY/actions/workflows/$id/disable" > /dev/null \ || die "Could not disable the upstream workflow $path ($id)." disabled+=("$path") -done <<< "$active" +done <<< "$listed" +# Unfinished runs, from any event and for any commit; one listing per unfinished status. cancelled=() -if [[ -n "${HEAD_SHA:-}" ]]; then - runs="$(gh api "repos/$REPOSITORY/actions/runs?head_sha=$HEAD_SHA&per_page=100" --paginate \ - --jq '.workflow_runs[] | select(.status != "completed") | "\(.id) \(.path)"')" \ - || die "Could not list the runs for ${HEAD_SHA:0:12}, so runs of upstream workflows may still be going." - while read -r id path; do +runs="" +for status in requested queued pending waiting in_progress; do + runs+="$(gh api "repos/$REPOSITORY/actions/runs?status=$status&per_page=100" --paginate \ + --jq '.workflow_runs[] | select(.status != "completed") | "\(.id) \(.path)"')"$'\n' \ + || die "Could not list the $status runs of $REPOSITORY, so runs of upstream workflows may still be going." +done +while read -r id path; do + path="${path%%@*}" + [[ -n "${id:-}" ]] && upstream "$path" || continue + if ! gh api -X POST "repos/$REPOSITORY/actions/runs/$id/cancel" > /dev/null; then + status="$(gh api "repos/$REPOSITORY/actions/runs/$id" --jq .status)" \ + || die "Could not cancel run $id of $path, nor read its state." + [[ "$status" == completed ]] || die "Could not cancel run $id of $path (state: $status)." + continue + fi + cancelled+=("$id ($path)") +done <<< "$(sort -u <<< "$runs")" + +# Runs that finished before a sweep could stop them. Cancelled ones are left out: a sweep stopped +# them (named under cancelled) or a person did. +finished=() +if [[ -n "${SINCE:-}" ]]; then + done_runs="$(gh api "repos/$REPOSITORY/actions/runs?created=%3E%3D$SINCE&status=completed&per_page=100" --paginate \ + --jq '.workflow_runs[] | select(.status == "completed" and .conclusion != "cancelled") | "\(.id) \(.conclusion) \(.event) \(.path)"')" \ + || die "Could not list the runs of $REPOSITORY since $SINCE, so upstream runs that already finished are not known." + while read -r id conclusion event path; do path="${path%%@*}" [[ -n "${id:-}" ]] && upstream "$path" || continue - if ! gh api -X POST "repos/$REPOSITORY/actions/runs/$id/cancel" > /dev/null; then - status="$(gh api "repos/$REPOSITORY/actions/runs/$id" --jq .status)" \ - || die "Could not cancel run $id of $path, nor read its state." - [[ "$status" == completed ]] || die "Could not cancel run $id of $path (state: $status)." - continue - fi - cancelled+=("$id ($path)") - done <<< "$runs" + finished+=("$id ($path: $conclusion after $event)") # no ',': lists are ','-joined + done <<< "$(sort -u <<< "$done_runs")" fi +# Upstream workflow files GitHub has not registered (any state counts as registered). +unregistered=() +files="$(gh api "repos/$REPOSITORY/contents/.github/workflows?ref=$TREE_REF" \ + --jq '.[] | select(.type == "file") | .path')" \ + || die "Could not list the workflow files of $REPOSITORY at $TREE_REF, so unregistered upstream workflows are not known." +while read -r path; do + [[ "$path" == *.yml || "$path" == *.yaml ]] && upstream "$path" || continue + awk -v p="$path" '$3 == p { found = 1 } END { exit !found }' <<< "$listed" || unregistered+=("$path") +done <<< "$files" + join() { local IFS=,; echo "$*"; } { echo "disabled=$(join "${disabled[@]}")" echo "cancelled=$(join "${cancelled[@]}")" + echo "finished=$(join "${finished[@]}")" + echo "unregistered=$(join "${unregistered[@]}")" } >> "$OUTPUT" -echo "Upstream workflows disabled: ${#disabled[@]}; their runs cancelled: ${#cancelled[@]}." >&2 +((${#finished[@]} == 0)) \ + || echo "::warning::Upstream workflow runs finished before they could be stopped: $(join "${finished[@]}")" >&2 +((${#unregistered[@]} == 0)) \ + || echo "::warning::Upstream workflow files GitHub does not list yet, so not disabled: $(join "${unregistered[@]}")" >&2 +echo "Upstream workflows disabled: ${#disabled[@]}; runs cancelled: ${#cancelled[@]}; finished before a sweep: ${#finished[@]}; not registered yet: ${#unregistered[@]}." >&2 diff --git a/.github/workflows/fork-follow.yml b/.github/workflows/fork-follow.yml index 190042de14698..5cbd915c027d4 100644 --- a/.github/workflows/fork-follow.yml +++ b/.github/workflows/fork-follow.yml @@ -8,9 +8,10 @@ name: Fork follow upstream # 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. -# - stage: upstream changed the release workflows. A run uses the reusable workflows of its own -# commit, so the candidate is pushed and the run stops with the command that releases it with -# its own files; the next run then advances to that release. +# - stage: the release workflows of this run's commit differ from the candidate's (upstream changed +# them, or this is a re-run or queued run of an older commit). A run uses the reusable workflows +# of its own commit, so the candidate is pushed and the run stops with the command that releases +# it with its own files; the next run then finds that release (and advances to 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 @@ -18,19 +19,23 @@ name: Fork follow upstream # on the next good run. # # Upstream's workflows stay off: after each push and at the end, fork-workflows.sh disables every -# active workflow the series did not add and cancels their runs for the pushed commit. +# active workflow the series did not add, cancels every unfinished run of one (any event), and +# names the runs that finished before it could stop them and the files GitHub has not listed yet. # -# Settings: variable FORK_SERIES_BRANCH (default main). Secret FORK_PUSH_TOKEN in the Environment -# fork-push, whose deployment branches are limited to the series branch (a repository secret could -# be read by a workflow file on any pushed branch; the jobs that push run in that Environment and no -# other job sees the secret): a fine-grained token for this repository only, Contents and Workflows read and write (GITHUB_TOKEN may not push commits -# that change .github/workflows). Every run checks it first and fails when it is missing, expired or -# may not push. It reaches only the steps that run fork-follow-push.sh; the release workflow gets the -# signing secrets by name and never this token. If a rule protects the series branch, the token's -# user must be allowed to bypass it. +# Settings: variable FORK_SERIES_BRANCH (default main). 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 +# both before any job gets the token (fork-follow-guard.sh) and refuses, saying what to change. +# The token: a fine-grained token for this repository only, Contents and Workflows read and write +# (GITHUB_TOKEN may not push commits that change .github/workflows). The plan checks it first and +# fails when it is missing, expired or may not push. It reaches only the steps that run +# fork-follow-push.sh; the release workflow gets the signing secrets by name and never this token. +# If a rule protects the series branch, the token's user must be allowed to bypass it. # # A conflict is resolved by hand: rebase the series branch onto the new upstream tag, resolve, test, -# push it with --force-with-lease; the next run then releases it (the plan's report has the commands). +# push it with --force-with-lease, then start this workflow once: it releases the series and disables +# the upstream workflows that push started (the plan's report has the commands). on: schedule: - cron: "23 6 * * *" @@ -49,8 +54,50 @@ env: FORK_PUSH_ENVIRONMENT: fork-push jobs: + # No Environment here, so the FORK_PUSH_TOKEN secret seen here can only be a repository or + # organisation secret. Only whether it is empty reaches the step, never the value. + guard: + name: Guard the token + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: read + contents: read + outputs: + started: ${{ steps.started.outputs.at }} + steps: + - id: started + name: Note the start + run: echo "at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + + - name: Checkout the scripts + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false + sparse-checkout: .github/scripts + sparse-checkout-cone-mode: true + + - name: Refuse unless only the Environment holds FORK_PUSH_TOKEN, for the series branch only + env: + GH_TOKEN: ${{ github.token }} + REPO_SECRET_PRESENT: ${{ secrets.FORK_PUSH_TOKEN != '' }} + REPORT: ${{ runner.temp }}/reports/guard.md + run: | + mkdir -p "$RUNNER_TEMP/reports" + bash .github/scripts/fork-follow-guard.sh + + - name: Keep the reason + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: fork-follow-report-guard + path: ${{ runner.temp }}/reports + if-no-files-found: ignore + retention-days: 7 + plan: name: Plan + needs: [guard] environment: fork-push runs-on: ubuntu-latest timeout-minutes: 15 @@ -132,6 +179,13 @@ jobs: REPORT: ${{ runner.temp }}/reports/push-workflows.md run: bash .github/scripts/fork-workflows.sh + # Nothing waits for a candidate when the series head is already released. + - name: Delete a leftover candidate branch + if: steps.plan.outputs.action == 'none' + env: + FORK_PUSH_TOKEN: ${{ secrets.FORK_PUSH_TOKEN }} + run: bash .github/scripts/fork-follow-push.sh cleanup + - name: Stop for a release from the candidate's own workflow files if: steps.plan.outputs.action == 'stage' env: @@ -243,7 +297,7 @@ jobs: report: name: Report - needs: [plan, release, advance] + needs: [guard, plan, release, advance] if: always() runs-on: ubuntu-latest timeout-minutes: 10 @@ -268,7 +322,8 @@ jobs: merge-multiple: true path: ${{ runner.temp }}/reports - # Last sweep: runs that a push started after the earlier sweeps looked. + # Last sweep: runs that a push started after the earlier sweeps looked, the upstream runs that + # finished since the run began, and the upstream files GitHub has not listed yet. - id: sweep name: Disable upstream workflows, cancel their runs if: needs.plan.outputs.own_workflows != '' @@ -277,6 +332,7 @@ jobs: GH_TOKEN: ${{ github.token }} OWN_WORKFLOWS: ${{ needs.plan.outputs.own_workflows }} HEAD_SHA: ${{ needs.plan.outputs.candidate }} + SINCE: ${{ needs.guard.outputs.started }} REPORT: ${{ runner.temp }}/reports/report-workflows.md run: bash .github/scripts/fork-workflows.sh @@ -285,6 +341,7 @@ jobs: GH_TOKEN: ${{ github.token }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} ACTION: ${{ needs.plan.outputs.action }} + GUARD_RESULT: ${{ needs.guard.result }} PLAN_RESULT: ${{ needs.plan.result }} RELEASE_RESULT: ${{ needs.release.result }} ADVANCE_RESULT: ${{ needs.advance.result }} @@ -297,5 +354,7 @@ jobs: DROPPED: ${{ needs.plan.outputs.dropped }} DISABLED: ${{ needs.plan.outputs.disabled }},${{ needs.advance.outputs.disabled }},${{ steps.sweep.outputs.disabled }} CANCELLED: ${{ needs.plan.outputs.cancelled }},${{ needs.advance.outputs.cancelled }},${{ steps.sweep.outputs.cancelled }} + FINISHED: ${{ steps.sweep.outputs.finished }} + UNREGISTERED: ${{ steps.sweep.outputs.unregistered }} REPORTS: ${{ runner.temp }}/reports run: bash .github/scripts/fork-follow-report.sh From 70b9612ab079eac8b2d7b0ef9593211ad774a86f Mon Sep 17 00:00:00 2001 From: t3code-fork Date: Thu, 12 Nov 2026 12:17:27 +0000 Subject: [PATCH 06/12] fork-follow: guard per run attempt, refuse the branch move while a new upstream workflow is unlisted "Re-run failed jobs" and "Re-run job" keep the Guard job's result from an earlier attempt without running it again. The guard now outputs the attempt it passed in (set only after the guard script), and plan and advance run only when that is this attempt: a job-level if, so a skipped job never reaches the Environment. The report says to re-run all jobs. Before the branch move, advance checks the Environment's rule again (fork-follow-guard.sh --environment-only), because the release can take an hour or more. The Environment ties the token to the branch main, not to fork-follow.yml, so any workflow file on main may ask for it. The plan names the upstream workflow files that are new on the target release (a rename counts as new). Advance sweeps before the push, and refuses to move main while one of them is in the candidate and GitHub does not list it, since it cannot be disabled first. The reason says how a person moves main, or that a fork patch can delete the files. The sweep names only the new files as unregistered (with a warning). Unlisted files from earlier releases, most of which only run on events the fork never sends, go to unregistered_old and into a collapsed note in the summary. Tests: the plan's only job-level if is this attempt's guard (always(), !cancelled() and failure() turn it red); advance needs this attempt's guard; a pattern series branch with the matching rule is refused; new vs old unlisted files; the refusal before the move; the report on a stale guard. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/fork-follow-guard.sh | 19 ++++++- .github/scripts/fork-follow-report.sh | 35 ++++++++++-- .github/scripts/fork-follow.sh | 9 +++ .github/scripts/fork-follow.test.sh | 79 +++++++++++++++++++++++++-- .github/scripts/fork-workflows.sh | 54 +++++++++++++----- .github/workflows/fork-follow.yml | 59 ++++++++++++++++++-- 6 files changed, 227 insertions(+), 28 deletions(-) diff --git a/.github/scripts/fork-follow-guard.sh b/.github/scripts/fork-follow-guard.sh index 494173a417368..ab644ea4b07fa 100755 --- a/.github/scripts/fork-follow-guard.sh +++ b/.github/scripts/fork-follow-guard.sh @@ -13,6 +13,11 @@ # SERIES_BRANCH, written without wildcards. "Protected branches only" is refused, because the # API cannot prove that no other branch is protected (or becomes protected later). # +# `fork-follow-guard.sh --environment-only` makes check 2 alone. The job that moves the series +# branch runs it right before the push, because that can be an hour or more after the Guard job +# looked. Check 1 cannot be made there: inside the Environment, `secrets.FORK_PUSH_TOKEN` is the +# Environment's own secret. +# # Environment: REPOSITORY (owner/name; GITHUB_REPOSITORY in a run), FORK_PUSH_ENVIRONMENT, # SERIES_BRANCH (default main), REPO_SECRET_PRESENT, REPORT (receives the reason on refusal). Reads # the Environment with `gh api` (GH_TOKEN: GITHUB_TOKEN with actions: read is enough). @@ -34,7 +39,13 @@ die() { exit 1 } -case "${REPO_SECRET_PRESENT:-}" in +case "${1:-}" in + "") secret_check=true ;; + --environment-only) secret_check=false ;; + *) echo "usage: $0 [--environment-only]" >&2; exit 2 ;; +esac + +[[ "$secret_check" == false ]] || case "${REPO_SECRET_PRESENT:-}" in false) ;; true) die "A repository or organisation secret named FORK_PUSH_TOKEN exists, so this run does not use the token." \ @@ -84,4 +95,8 @@ if [[ "$rules" != "branch $SERIES_BRANCH" ]] || [[ "$SERIES_BRANCH" == *[][*?]* "Under $settings keep exactly one rule, branch $SERIES_BRANCH, and delete the others (a tag rule or a pattern lets other refs deploy too)." fi -echo "FORK_PUSH_TOKEN is only an Environment secret, and only $SERIES_BRANCH may deploy to $env_name." +if [[ "$secret_check" == true ]]; then + echo "FORK_PUSH_TOKEN is only an Environment secret, and only $SERIES_BRANCH may deploy to $env_name." +else + echo "Only $SERIES_BRANCH may deploy to $env_name (the repository secret was checked by the Guard job)." +fi diff --git a/.github/scripts/fork-follow-report.sh b/.github/scripts/fork-follow-report.sh index f8fb32da9c089..84a6e3eba9b2a 100755 --- a/.github/scripts/fork-follow-report.sh +++ b/.github/scripts/fork-follow-report.sh @@ -7,9 +7,10 @@ # repository has Issues enabled it also opens or comments on one issue, "Fork cannot follow # upstream", and closes it once a run succeeds; with Issues disabled it says so and opens none. # -# Environment (from the run): REPOSITORY, SERIES_BRANCH, RUN_URL, ACTION, GUARD_RESULT, PLAN_RESULT, -# RELEASE_RESULT, ADVANCE_RESULT, BASE_TAG, PREVIOUS_TAG, PREVIOUS_HEAD, CANDIDATE, RELEASED_TAG, -# DROPPED, DISABLED, CANCELLED, FINISHED, UNREGISTERED (from the workflow sweeps), SWEEP_RESULT, +# Environment (from the run): REPOSITORY, SERIES_BRANCH, RUN_URL, ACTION, GUARD_RESULT, +# GUARD_ATTEMPT (the run attempt the guard passed in), RUN_ATTEMPT, PLAN_RESULT, RELEASE_RESULT, +# ADVANCE_RESULT, BASE_TAG, PREVIOUS_TAG, PREVIOUS_HEAD, CANDIDATE, RELEASED_TAG, DROPPED, DISABLED, +# CANCELLED, FINISHED, UNREGISTERED, UNREGISTERED_OLD (from the workflow sweeps), SWEEP_RESULT, # REPORTS (directory of the jobs' reason files), ORIGIN (git URL of this fork, default GitHub). # Summary to $GITHUB_STEP_SUMMARY, else stdout. set -uo pipefail @@ -31,12 +32,25 @@ done [[ "$ACTION" != stage ]] || failed=true [[ "${SWEEP_RESULT:-success}" != failure ]] || failed=true +# A re-run of failed jobs (or of one job) keeps the guard's result from an earlier attempt; the jobs +# that get FORK_PUSH_TOKEN skip themselves then. Say so whenever one of them was skipped for it. +stale_guard=false +if [[ "${GUARD_RESULT:-success}" == success && -n "${GUARD_ATTEMPT:-}" && -n "${RUN_ATTEMPT:-}" \ + && "$GUARD_ATTEMPT" != "$RUN_ATTEMPT" ]]; then + if [[ "${PLAN_RESULT:-}" == skipped ]] \ + || [[ "${ADVANCE_RESULT:-skipped}" == skipped && ( "$ACTION" == advance || "$ACTION" == rebase ) ]]; then + stale_guard=true + failed=true + fi +fi + # The sweeps' lists arrive joined with ','; drop the empty parts and repeats. tidy() { tr ',' '\n' <<< "$1" | sed '/^$/d' | awk '!seen[$0]++' | paste -sd, -; } DISABLED="$(tidy "${DISABLED:-}")" CANCELLED="$(tidy "${CANCELLED:-}")" FINISHED="$(tidy "${FINISHED:-}")" UNREGISTERED="$(tidy "${UNREGISTERED:-}")" +UNREGISTERED_OLD="$(tidy "${UNREGISTERED_OLD:-}")" state=() row() { state+=("| $1 | $2 |"); } @@ -98,10 +112,13 @@ fi [[ -z "${CANCELLED:-}" ]] || row "Their runs cancelled" "${CANCELLED//,/, }" [[ -z "${FINISHED:-}" ]] || row "**Upstream runs that finished before a sweep could stop them**" "${FINISHED//,/, }" [[ -z "${UNREGISTERED:-}" ]] \ - || row "**Upstream workflow files GitHub does not list yet, so not disabled** (the sweep after their first run disables them)" "${UNREGISTERED//,/, }" + || row "**Upstream workflow files new on this release that GitHub does not list yet, so not disabled** (the sweep after an event reaches them disables them; the branch move waits for that)" "${UNREGISTERED//,/, }" [[ -z "${DROPPED:-}" ]] || row "Patches dropped (empty on $BASE_TAG: upstream has the change)" "${DROPPED//;/, }" reasons="" +if [[ "$stale_guard" == true ]]; then + reasons+="This is attempt $RUN_ATTEMPT of the run, but the guard passed in attempt $GUARD_ATTEMPT: \"Re-run failed jobs\" and \"Re-run job\" keep the guard's earlier result without checking again, so the jobs that get FORK_PUSH_TOKEN were skipped. Use \"Re-run all jobs\", or start a new run (\`gh workflow run fork-follow.yml --repo $REPOSITORY\`)."$'\n\n' +fi if [[ -n "$REPORTS" && -d "$REPORTS" ]]; then for file in "$REPORTS"/*.md; do [[ -f "$file" ]] && reasons+="$(cat "$file")"$'\n\n' @@ -116,6 +133,8 @@ case "$ACTION" in stage) what="Push the candidate on ${BASE_TAG:-?}; its release needs its own workflow files." ;; *) if [[ "${GUARD_RESULT:-success}" != success ]]; then what="None: the guard refused, so no job got FORK_PUSH_TOKEN." + elif [[ "$stale_guard" == true ]]; then + what="None: the guard did not run in this attempt, so no job got FORK_PUSH_TOKEN." else what="The plan did not finish." fi ;; @@ -142,6 +161,14 @@ body="$(mktemp)" echo "| What | State now |" echo "|---|---|" printf '%s\n' "${state[@]}" + if [[ -n "$UNREGISTERED_OLD" ]]; then + echo + echo "
Upstream workflow files from earlier releases that GitHub does not list (not a warning)" + echo + echo "Most of them run only on events this fork never sends (workflow_call, workflow_dispatch, workflow_run), so they stay unlisted. A sweep disables one once an event reaches it: ${UNREGISTERED_OLD//,/, }" + echo + echo "
" + fi } > "$body" # --- the issue, when the repository has Issues --------------------------------------------------- diff --git a/.github/scripts/fork-follow.sh b/.github/scripts/fork-follow.sh index 73e82c45ddc06..cf9baf689af16 100755 --- a/.github/scripts/fork-follow.sh +++ b/.github/scripts/fork-follow.sh @@ -24,6 +24,9 @@ # dropped subjects of patches that became empty on the new base (';'-separated) # own_workflows workflow files the series itself adds (','-separated); every other one is # upstream's and gets disabled +# new_workflows upstream workflow files added between the series' old base and the new one +# (','-separated; empty without a new base). The branch move refuses while one of +# them is not listed by GitHub yet, so it cannot be disabled before main has it. set -Eeuo pipefail : "${UPSTREAM:?owner/name of the upstream repository}" @@ -152,6 +155,10 @@ latest_release() { own_workflows() { git diff --name-only --diff-filter=A "$1" "$2" -- .github/workflows | paste -sd, - } +new_workflows() { # upstream workflow files added from upstream commit $1 to $2 (a rename counts as new) + git diff --no-renames --name-only --diff-filter=A "$1" "$2" -- .github/workflows \ + | { grep -E '\.ya?ml$' || true; } | paste -sd, - +} # The release job runs the reusable workflows of this run's commit (RUN_SHA), not the candidate's. # They differ after upstream changed them, and on a re-run (or a queued run) of a run whose commit @@ -214,6 +221,7 @@ if [[ "$target_tag" == "$base_tag" ]]; then echo "candidate=$head" echo "released_tag=${released_tag:-}" echo "own_workflows=$(own_workflows "$base" HEAD)" + echo "new_workflows=" } >> "$OUTPUT" exit 0 fi @@ -264,4 +272,5 @@ fi echo "released_tag=${released_tag:-}" echo "dropped=$dropped" echo "own_workflows=$(own_workflows "$target" "$candidate")" + echo "new_workflows=$(new_workflows "$base" "$target")" } >> "$OUTPUT" diff --git a/.github/scripts/fork-follow.test.sh b/.github/scripts/fork-follow.test.sh index 8b75d8202cbf8..3ceb7066b7e5e 100755 --- a/.github/scripts/fork-follow.test.sh +++ b/.github/scripts/fork-follow.test.sh @@ -279,6 +279,21 @@ work git reset -q --hard "$(series)" plan check "plan: a release of different files does not count -> rebase" action_is rebase +# Round 3: upstream workflow files new on the target release are named, so the branch move can +# refuse while GitHub does not list one. A rename counts as new; a same-base plan names none. +new_upstream +(cd "$root/upwork" && git checkout -q main && echo "on: push" > .github/workflows/nightly.yml \ + && git mv .github/workflows/ci.yml .github/workflows/ci-v2.yml && git add -A && git commit -q -m u5 \ + && git tag v0.1.11 && git push -q https://github.com/up/t3code.git main --tags) > /dev/null 2>&1 +new_fork v0.1.10 +plan +check "plan: a rebase names the upstream workflow files new on the target release (a rename too)" \ + eval 'action_is rebase && [[ "$(out new_workflows)" == .github/workflows/ci-v2.yml,.github/workflows/nightly.yml ]]' +new_fork v0.1.11 +plan +check "plan: same base -> no new upstream workflow files" \ + eval 'action_is release && grep -qx "new_workflows=" "$root/plan.out"' + # Minor: upstream changed a reusable workflow the release calls; the run's own copy is older. new_upstream (cd "$root/upwork" && git checkout -q main && echo "on: workflow_call # v2" > .github/workflows/release-desktop.yml \ @@ -412,6 +427,16 @@ echo '{"total_count":1,"branch_policies":[{"id":1,"name":"main*","type":"branch" check "guard: a pattern rule (main*) -> refused" eval '! guard && has "main\*" "$root/report.md"' echo '{"total_count":0,"branch_policies":[]}' > "$root/api/branch-policies.json" check "guard: no rule at all -> refused" eval '! guard && has "(none)" "$root/report.md"' +# Round 3: a series branch that is itself a pattern, with the matching rule, is still refused. +echo '{"total_count":1,"branch_policies":[{"id":1,"name":"main*","type":"branch"}]}' > "$root/api/branch-policies.json" +check "guard: a series branch that is a pattern (main*), even with the rule 'branch main*' -> refused" \ + eval '! SERIES_BRANCH="main*" guard && has "must let only the branch main\*" "$root/report.md"' +good_env +check "guard: --environment-only (before the branch move) passes without the secret answer" \ + eval 'REPO_SECRET_PRESENT= REPORT="$root/report.md" FORK_PUSH_ENVIRONMENT=fork-push bash "$dir/fork-follow-guard.sh" --environment-only > "$root/out" 2>&1' +echo '{"name":"fork-push","deployment_branch_policy":null}' > "$root/api/environment.json" +check "guard: --environment-only still refuses an Environment any branch may deploy to" \ + eval '! REPORT="$root/report.md" FORK_PUSH_ENVIRONMENT=fork-push bash "$dir/fork-follow-guard.sh" --environment-only > "$root/out" 2>&1 && has "Any branch may deploy" "$root/report.md"' good_env touch "$root/api/fail_environments" check "guard: the Environment cannot be read -> refused with gh's reason" \ @@ -419,7 +444,7 @@ check "guard: the Environment cannot be read -> refused with gh's reason" \ unset REPO_SECRET_PRESENT # --- workflows: fork-workflows.sh ------------------------------------------------------------ -sweep() { REPORT="$root/report.md" GITHUB_OUTPUT="$root/sweep.out" bash "$dir/fork-workflows.sh" > "$root/out" 2>&1; } +sweep() { rm -f "$root/sweep.out"; REPORT="$root/report.md" GITHUB_OUTPUT="$root/sweep.out" bash "$dir/fork-workflows.sh" > "$root/out" 2>&1; } reset_api cat > "$root/api/workflows.json" << 'J' {"workflows":[ @@ -454,8 +479,18 @@ check "workflows: an upstream run from another event and commit (pull_request_ta eval 'has "runs/14/cancel" "$root/api/writes" && [[ $(grep -c cancel "$root/api/writes") == 2 ]]' check "workflows: upstream runs that finished before the sweep are named with event and conclusion; own and cancelled ones are not" \ eval '[[ "$(sed -n "s/^finished=//p" "$root/sweep.out")" == "13 (.github/workflows/relay-preview.yml: success after push)" ]]' -check "workflows: upstream workflow files GitHub does not list yet are named, not called disabled" \ - eval '[[ "$(sed -n "s/^unregistered=//p" "$root/sweep.out")" == ".github/workflows/release.yml" ]] && has "does not list yet" "$root/out"' +check "workflows: an unlisted upstream file from an earlier release is named apart, with no warning" \ + eval '[[ -z "$(sed -n "s/^unregistered=//p" "$root/sweep.out")" && "$(sed -n "s/^unregistered_old=//p" "$root/sweep.out")" == ".github/workflows/release.yml" ]] && hasnt "::warning::.*does not list yet" "$root/out"' +rm -f "$root/api/writes" +OWN_WORKFLOWS=.github/workflows/fork-follow.yml,.github/workflows/fork-tests.yml HEAD_SHA=abc NEW_WORKFLOWS=.github/workflows/release.yml sweep +check "workflows: an unlisted upstream file new on this release is named with a warning, not called disabled" \ + eval '[[ "$(sed -n "s/^unregistered=//p" "$root/sweep.out")" == ".github/workflows/release.yml" && -z "$(sed -n "s/^unregistered_old=//p" "$root/sweep.out")" ]] && has "::warning::.*new on this release.*does not list yet" "$root/out"' +rm -f "$root/api/writes" "$root/report.md" +check "workflows: before the move, a new unlisted upstream file refuses it, after disabling the listed ones" \ + eval '! OWN_WORKFLOWS=.github/workflows/fork-follow.yml HEAD_SHA=abc NEW_WORKFLOWS=.github/workflows/release.yml REQUIRE_NEW_OFF=true sweep && has "main was not moved" "$root/report.md" && has "release.yml" "$root/report.md" && has "A person decides" "$root/report.md" && has "workflows/2/disable" "$root/api/writes"' +rm -f "$root/report.md" +check "workflows: before the move, a new upstream file GitHub lists (now disabled) and older unlisted ones do not refuse it" \ + eval 'OWN_WORKFLOWS=.github/workflows/fork-follow.yml HEAD_SHA=abc NEW_WORKFLOWS=.github/workflows/web-preview.yml REQUIRE_NEW_OFF=true sweep && [[ ! -e "$root/report.md" ]]' reset_api OWN_WORKFLOWS="" sweep check "workflows: an empty own list disables nothing (refusing to guess)" eval '[[ ! -e "$root/api/writes" ]] && has "refusing to guess" "$root/report.md"' @@ -523,6 +558,24 @@ report ACTION=none PLAN_RESULT=success CANDIDATE="$cand" SWEEP_RESULT=success \ FINISHED="13 (.github/workflows/relay-preview.yml: success after push)" UNREGISTERED=".github/workflows/release.yml" check "report: names upstream runs that finished first and files GitHub does not list yet" \ eval 'has "finished before a sweep could stop them" "$root/summary.md" && has "relay-preview.yml: success after push" "$root/summary.md" && has "does not list yet" "$root/summary.md" && has "release.yml" "$root/summary.md"' +reset_api false +echo '{"draft":false}' > "$root/api/release-v0.1.10-fork.2" +report ACTION=none PLAN_RESULT=success CANDIDATE="$cand" SWEEP_RESULT=success UNREGISTERED_OLD=".github/workflows/publish-aur.yml" +check "report: unlisted upstream files from earlier releases go in a collapsed note, not a bold row, and pass" \ + eval '! failed_run && has "
" "$root/summary.md" && has "publish-aur.yml" "$root/summary.md" && hasnt "\*\*.*publish-aur" "$root/summary.md"' +# Round 3: "Re-run failed jobs" keeps the guard's earlier result; the token jobs skip themselves. +rm -rf "$root/reports" && mkdir -p "$root/reports" +reset_api false +report ACTION= GUARD_RESULT=success GUARD_ATTEMPT=1 RUN_ATTEMPT=2 PLAN_RESULT=skipped +check "report: the guard passed in an earlier attempt, the plan skipped -> fails, says to re-run all jobs" \ + eval 'failed_run && has "did not run in this attempt" "$root/summary.md" && has "Re-run all jobs" "$root/summary.md"' +report ACTION=rebase GUARD_RESULT=success GUARD_ATTEMPT=1 RUN_ATTEMPT=2 PLAN_RESULT=success RELEASE_RESULT=success \ + ADVANCE_RESULT=skipped BASE_TAG=v0.1.10 PREVIOUS_HEAD="$prev" CANDIDATE="$cand" +check "report: released, but the move skipped for an earlier attempt's guard -> fails and says why" \ + eval 'failed_run && has "guard passed in attempt 1" "$root/summary.md"' +echo '{"draft":false}' > "$root/api/release-v0.1.10-fork.2" +report ACTION=none GUARD_RESULT=success GUARD_ATTEMPT=1 RUN_ATTEMPT=2 PLAN_RESULT=success CANDIDATE="$cand" RELEASED_TAG=v0.1.10-fork.2 +check "report: a re-run of the report alone after a good run does not cry wolf" eval '! failed_run' rm -rf "$root/reports" && mkdir -p "$root/reports" echo "A repository or organisation secret named FORK_PUSH_TOKEN exists" > "$root/reports/guard.md" report ACTION= GUARD_RESULT=failure PLAN_RESULT=skipped @@ -542,8 +595,24 @@ check "workflow: FORK_PUSH_TOKEN reaches only fork-follow-push.sh steps, never a guard_job="$(sed -n '/^ guard:$/,/^ [a-z_-]*:$/p' "$workflow" | sed '$d')" check "workflow: a Guard job without an Environment sees only whether a repository/organisation secret exists, and runs the guard" \ eval '[[ -n "$guard_job" ]] && ! grep -q "environment:" <<< "$guard_job" && [[ $(grep -cF "$presence" "$workflow") == 1 ]] && grep -qF "$presence" <<< "$guard_job" && grep -q "fork-follow-guard.sh" <<< "$guard_job" && grep -q "actions: read" <<< "$guard_job"' -check "workflow: the plan (the first job with the token) waits for the guard; advance waits for the plan" \ - eval 'sed -n "/^ plan:$/,/^ steps:$/p" "$workflow" | grep -qx " needs: \[guard\]" && sed -n "/^ advance:$/,/^ steps:$/p" "$workflow" | grep -q "^ needs: \[plan"' +plan_head="$(sed -n "/^ plan:$/,/^ steps:$/p" "$workflow")" +advance_head="$(sed -n "/^ advance:$/,/^ steps:$/p" "$workflow")" +attempt_check="needs.guard.outputs.attempt == github.run_attempt" +check "workflow: the plan (the first job with the token) waits for the guard; advance waits for the guard and the plan" \ + eval 'grep -qx " needs: \[guard\]" <<< "$plan_head" && grep -qx " needs: \[guard, plan, release\]" <<< "$advance_head"' +# Round 3: the plan's only job-level condition is this attempt's guard, with the implied success(): +# always(), !cancelled() or failure() there would let it run after the guard refused. +check "workflow: the plan runs only after a guard that passed in this attempt (no always/cancelled/failure)" \ + eval '[[ "$(grep -c "^ if:" <<< "$plan_head")" == 1 ]] && grep -qxF " if: $attempt_check" <<< "$plan_head" && ! grep -qE "always\(\)|cancelled\(\)|failure\(\)" <<< "$plan_head"' +check "workflow: advance needs the plan's success and this attempt's guard" \ + eval 'grep -qF "needs.plan.result == '"'"'success'"'"'" <<< "$advance_head" && grep -qF "$attempt_check" <<< "$advance_head" && ! grep -qE "always\(\)|failure\(\)" <<< "$advance_head"' +check "workflow: the guard's attempt output is set only by a step after the guard script" \ + eval 'grep -qF "attempt: \${{ steps.passed.outputs.attempt }}" <<< "$guard_job" && awk "/fork-follow-guard.sh/ { g = NR } /id: passed/ { p = NR } /attempt=\\\$GITHUB_RUN_ATTEMPT/ { a = NR } END { exit !(g && p > g && a > p) }" <<< "$guard_job"' +advance_job="$(sed -n '/^ advance:$/,/^ [a-z_-]*:$/p' "$workflow" | sed '$d')" +check "workflow: before the move, advance checks the Environment again and sweeps, refusing new unlisted files" \ + eval 'awk "/fork-follow-guard.sh --environment-only/ { g = NR } /REQUIRE_NEW_OFF: \"true\"/ { r = NR } /NEW_WORKFLOWS: .*new_workflows/ && !n { n = NR } /fork-follow-push.sh advance/ { m = NR } END { exit !(g && r && n && m && g < r && r < m && n < m) }" <<< "$advance_job"' +check "workflow: the move step runs only when the checks before it passed (no if:)" \ + eval 'awk "/- name: Move the series branch/ { s = 1; next } s && /^ - / { exit } s && /^ if:/ { bad = 1 } END { exit bad }" <<< "$advance_job"' check "workflow: action none deletes a leftover candidate branch" \ eval 'grep -B2 -A3 "action == .none." "$workflow" | grep -q "fork-follow-push.sh cleanup"' check "workflow: the last sweep reports runs since the run began" \ diff --git a/.github/scripts/fork-workflows.sh b/.github/scripts/fork-workflows.sh index 654d580537ce0..bc4205b2e47db 100755 --- a/.github/scripts/fork-workflows.sh +++ b/.github/scripts/fork-workflows.sh @@ -5,18 +5,27 @@ # it could not stop: # finished runs of upstream workflows that completed since SINCE (they ran before a sweep # could stop them), with their conclusion -# unregistered upstream workflow files in the tree at TREE_REF that GitHub does not list yet. -# GitHub lists a workflow only once an event has reached its file, so these can be -# neither disabled nor cancelled until then; the next sweep after that event does. +# unregistered upstream workflow files in the tree at TREE_REF that GitHub does not list yet +# and that are new on this upstream release (NEW_WORKFLOWS). GitHub lists a +# workflow only once an event has reached its file, so these can be neither +# disabled nor cancelled until then; the next sweep after that event does. +# unregistered_old the other unlisted upstream files, from earlier releases. Most of them run +# only on events the fork never sends (workflow_call, workflow_dispatch, +# workflow_run), so they stay unlisted; they are named, but not as a warning. # -# fork-follow.yml runs this right after each of its pushes and again at the end of the run. +# fork-follow.yml runs this right after each of its pushes, right before it moves the series +# branch (REQUIRE_NEW_OFF=true), and again at the end of the run. # # Environment: REPOSITORY (owner/name; GITHUB_REPOSITORY in a run), OWN_WORKFLOWS (','-separated -# paths the fork's own commits add; required, nothing is guessed), TREE_REF (commit or branch whose -# workflow files are checked; default HEAD_SHA, else SERIES_BRANCH, else main), HEAD_SHA, SINCE -# (ISO 8601 time; optional), REPORT (receives the reason on failure). Output: disabled=, cancelled=, -# finished= and unregistered= (','-separated) to $GITHUB_OUTPUT, else stdout; a summary line on -# stderr, and a ::warning:: line for finished runs and unregistered files. +# paths the fork's own commits add; required, nothing is guessed), NEW_WORKFLOWS (','-separated +# upstream files added since the series' old base; optional), REQUIRE_NEW_OFF (true: fail when one +# of NEW_WORKFLOWS is in the tree and not listed, because then it cannot be disabled before the +# series branch has it), TREE_REF (commit or branch whose workflow files are checked; default +# HEAD_SHA, else SERIES_BRANCH, else main), HEAD_SHA, SERIES_BRANCH, PREVIOUS_HEAD (the series head +# the move starts from, for the advice), SINCE (ISO 8601 time; optional), REPORT (receives the +# reason on failure). Output: disabled=, cancelled=, finished=, unregistered= and unregistered_old= +# (','-separated) to $GITHUB_OUTPUT, else stdout; a summary line on stderr, and a ::warning:: line +# for finished runs and new unregistered files. set -euo pipefail REPOSITORY="${REPOSITORY:-${GITHUB_REPOSITORY:?REPOSITORY or GITHUB_REPOSITORY}}" @@ -26,13 +35,15 @@ TREE_REF="${TREE_REF:-${HEAD_SHA:-${SERIES_BRANCH:-main}}}" die() { printf '%s\n' "$@" > "$REPORT" - printf '::error::%s\n' "$*" >&2 + printf '::error::%s\n' "$1" >&2 + [[ $# -lt 2 ]] || printf '%s\n' "${@:2}" >&2 exit 1 } [[ -n "${OWN_WORKFLOWS:-}" ]] || die "The list of the fork's own workflows is empty, so no workflow was disabled (refusing to guess)." own() { [[ ",$OWN_WORKFLOWS," == *",$1,"* ]]; } upstream() { [[ "$1" == .github/workflows/* ]] && ! own "$1"; } +new() { [[ ",${NEW_WORKFLOWS:-}," == *",$1,"* ]]; } listed="$(gh api "repos/$REPOSITORY/actions/workflows" --paginate \ --jq '.workflows[] | "\(.id) \(.state) \(.path)"')" \ @@ -82,12 +93,14 @@ fi # Upstream workflow files GitHub has not registered (any state counts as registered). unregistered=() +unregistered_old=() files="$(gh api "repos/$REPOSITORY/contents/.github/workflows?ref=$TREE_REF" \ --jq '.[] | select(.type == "file") | .path')" \ || die "Could not list the workflow files of $REPOSITORY at $TREE_REF, so unregistered upstream workflows are not known." while read -r path; do [[ "$path" == *.yml || "$path" == *.yaml ]] && upstream "$path" || continue - awk -v p="$path" '$3 == p { found = 1 } END { exit !found }' <<< "$listed" || unregistered+=("$path") + awk -v p="$path" '$3 == p { found = 1 } END { exit !found }' <<< "$listed" && continue + if new "$path"; then unregistered+=("$path"); else unregistered_old+=("$path"); fi done <<< "$files" join() { local IFS=,; echo "$*"; } @@ -96,9 +109,24 @@ join() { local IFS=,; echo "$*"; } echo "cancelled=$(join "${cancelled[@]}")" echo "finished=$(join "${finished[@]}")" echo "unregistered=$(join "${unregistered[@]}")" + echo "unregistered_old=$(join "${unregistered_old[@]}")" } >> "$OUTPUT" ((${#finished[@]} == 0)) \ || echo "::warning::Upstream workflow runs finished before they could be stopped: $(join "${finished[@]}")" >&2 ((${#unregistered[@]} == 0)) \ - || echo "::warning::Upstream workflow files GitHub does not list yet, so not disabled: $(join "${unregistered[@]}")" >&2 -echo "Upstream workflows disabled: ${#disabled[@]}; runs cancelled: ${#cancelled[@]}; finished before a sweep: ${#finished[@]}; not registered yet: ${#unregistered[@]}." >&2 + || echo "::warning::Upstream workflow files new on this release that GitHub does not list yet, so not disabled: $(join "${unregistered[@]}")" >&2 +echo "Upstream workflows disabled: ${#disabled[@]}; runs cancelled: ${#cancelled[@]}; finished before a sweep: ${#finished[@]}; new and not registered yet: ${#unregistered[@]}; older and not registered: ${#unregistered_old[@]}." >&2 + +# Before the series branch moves: any workflow file on it may ask for the Environment that holds +# FORK_PUSH_TOKEN, and the push starts its push workflows before a later sweep can stop them. A +# new upstream file that GitHub does not list cannot be disabled first, so a person decides. +if [[ "${REQUIRE_NEW_OFF:-}" == true ]] && ((${#unregistered[@]})); then + branch="${SERIES_BRANCH:-main}" + die "$branch was not moved: upstream workflow files that are new on this release are not listed by GitHub yet, so they could not be disabled before $branch gets them:" \ + "$(printf -- '- `%s`\n' "${unregistered[@]}")" "" \ + "On $branch, a push starts their push workflows before a sweep can stop them, and any workflow file there may ask for the Environment that holds FORK_PUSH_TOKEN." \ + "A person decides. Read them in the candidate (\`git show ${TREE_REF:0:12}:\`). If they are harmless, move $branch to $TREE_REF yourself" \ + "(\`git push --force-with-lease=refs/heads/$branch:${PREVIOUS_HEAD:-} https://github.com/$REPOSITORY.git $TREE_REF:refs/heads/$branch\`)" \ + "and right after start the follow workflow once (\`gh workflow run fork-follow.yml --repo $REPOSITORY\`): it disables them and cancels their runs." \ + "Otherwise add a fork patch that deletes them; the next run then moves $branch." +fi diff --git a/.github/workflows/fork-follow.yml b/.github/workflows/fork-follow.yml index 5cbd915c027d4..0c91fc8f1a7c3 100644 --- a/.github/workflows/fork-follow.yml +++ b/.github/workflows/fork-follow.yml @@ -18,9 +18,15 @@ name: Fork follow upstream # 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 and at the end, fork-workflows.sh disables every -# active workflow the series did not add, cancels every unfinished run of one (any event), and -# names the runs that finished before it could stop them and the files GitHub has not listed yet. +# 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, cancels every unfinished +# run of one (any event), and names the runs that finished before it could stop them and the files +# GitHub has not listed yet. The branch move is refused while an upstream workflow file that is new +# on this release is not listed (it could not be disabled, and on the branch it could ask for +# fork-push); a person decides then. +# +# 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 # Environment fork-push, whose deployment branches are "Selected branches and tags" with the one @@ -65,6 +71,10 @@ jobs: contents: read outputs: started: ${{ steps.started.outputs.at }} + # Set only once the guard passed. The jobs that get the token run only when it is this run + # attempt: "Re-run failed jobs" or "Re-run job" keeps an earlier attempt's guard result + # without running the guard again, and the settings may have changed since. + attempt: ${{ steps.passed.outputs.attempt }} steps: - id: started name: Note the start @@ -86,6 +96,10 @@ jobs: mkdir -p "$RUNNER_TEMP/reports" bash .github/scripts/fork-follow-guard.sh + - id: passed + name: Note the attempt the guard passed in + run: echo "attempt=$GITHUB_RUN_ATTEMPT" >> "$GITHUB_OUTPUT" + - name: Keep the reason if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -95,9 +109,12 @@ jobs: if-no-files-found: ignore retention-days: 7 + # Job-level if (implied success()): a skipped job never reaches the Environment, so GitHub + # neither hands it the token nor auto-creates the Environment. plan: name: Plan needs: [guard] + if: needs.guard.outputs.attempt == github.run_attempt environment: fork-push runs-on: ubuntu-latest timeout-minutes: 15 @@ -113,6 +130,7 @@ jobs: released_tag: ${{ steps.plan.outputs.released_tag }} dropped: ${{ steps.plan.outputs.dropped }} own_workflows: ${{ steps.plan.outputs.own_workflows }} + new_workflows: ${{ steps.plan.outputs.new_workflows }} disabled: ${{ steps.sweep_plan.outputs.disabled }},${{ steps.sweep_push.outputs.disabled }} cancelled: ${{ steps.sweep_push.outputs.cancelled }} steps: @@ -156,6 +174,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} OWN_WORKFLOWS: ${{ steps.plan.outputs.own_workflows }} + NEW_WORKFLOWS: ${{ steps.plan.outputs.new_workflows }} REPORT: ${{ runner.temp }}/reports/plan-workflows.md run: bash .github/scripts/fork-workflows.sh @@ -175,6 +194,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} OWN_WORKFLOWS: ${{ steps.plan.outputs.own_workflows }} + NEW_WORKFLOWS: ${{ steps.plan.outputs.new_workflows }} HEAD_SHA: ${{ steps.plan.outputs.candidate }} REPORT: ${{ runner.temp }}/reports/push-workflows.md run: bash .github/scripts/fork-workflows.sh @@ -233,9 +253,10 @@ jobs: advance: name: Move the series branch environment: fork-push - needs: [plan, release] + needs: [guard, plan, release] if: >- !cancelled() && needs.plan.result == 'success' && + needs.guard.outputs.attempt == github.run_attempt && (needs.plan.outputs.action == 'advance' || (needs.plan.outputs.action == 'rebase' && needs.release.result == 'success' && needs.release.outputs.published == 'true')) @@ -256,6 +277,31 @@ jobs: filter: blob:none persist-credentials: false + # The release can take an hour or more, so the Environment's rule is checked again. (The + # repository secret cannot be checked from inside the Environment; the Guard job did.) + - name: Check again that only the series branch may deploy to fork-push + env: + GH_TOKEN: ${{ github.token }} + REPORT: ${{ runner.temp }}/reports/advance-guard.md + run: | + mkdir -p "$RUNNER_TEMP/reports" + bash .github/scripts/fork-follow-guard.sh --environment-only + + # Any workflow file on the series branch may ask for fork-push, and the move fires its push + # workflows. So upstream workflows are disabled first, and the move is refused while an + # upstream file new on this release is not listed by GitHub (it cannot be disabled yet). + - id: sweep_before + name: Disable upstream workflows before the move, refuse while a new one cannot be + env: + GH_TOKEN: ${{ github.token }} + OWN_WORKFLOWS: ${{ needs.plan.outputs.own_workflows }} + NEW_WORKFLOWS: ${{ needs.plan.outputs.new_workflows }} + REQUIRE_NEW_OFF: "true" + HEAD_SHA: ${{ needs.plan.outputs.candidate }} + PREVIOUS_HEAD: ${{ needs.plan.outputs.previous_head }} + REPORT: ${{ runner.temp }}/reports/advance-before.md + run: bash .github/scripts/fork-workflows.sh + # Refuses if the branch moved since the plan (a fork PR merged meanwhile); the next run # rebases again with that change. A re-run after a successful move finds it done. - name: Move the series branch to the released commit @@ -277,6 +323,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} OWN_WORKFLOWS: ${{ needs.plan.outputs.own_workflows }} + NEW_WORKFLOWS: ${{ needs.plan.outputs.new_workflows }} HEAD_SHA: ${{ needs.plan.outputs.candidate }} REPORT: ${{ runner.temp }}/reports/advance-workflows.md run: bash .github/scripts/fork-workflows.sh @@ -331,6 +378,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} OWN_WORKFLOWS: ${{ needs.plan.outputs.own_workflows }} + NEW_WORKFLOWS: ${{ needs.plan.outputs.new_workflows }} HEAD_SHA: ${{ needs.plan.outputs.candidate }} SINCE: ${{ needs.guard.outputs.started }} REPORT: ${{ runner.temp }}/reports/report-workflows.md @@ -342,6 +390,8 @@ jobs: RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} ACTION: ${{ needs.plan.outputs.action }} GUARD_RESULT: ${{ needs.guard.result }} + GUARD_ATTEMPT: ${{ needs.guard.outputs.attempt }} + RUN_ATTEMPT: ${{ github.run_attempt }} PLAN_RESULT: ${{ needs.plan.result }} RELEASE_RESULT: ${{ needs.release.result }} ADVANCE_RESULT: ${{ needs.advance.result }} @@ -356,5 +406,6 @@ jobs: CANCELLED: ${{ needs.plan.outputs.cancelled }},${{ needs.advance.outputs.cancelled }},${{ steps.sweep.outputs.cancelled }} FINISHED: ${{ steps.sweep.outputs.finished }} UNREGISTERED: ${{ steps.sweep.outputs.unregistered }} + UNREGISTERED_OLD: ${{ steps.sweep.outputs.unregistered_old }} REPORTS: ${{ runner.temp }}/reports run: bash .github/scripts/fork-follow-report.sh From 8deff417c192f423caafe55fbe1c54cf7b5afaed Mon Sep 17 00:00:00 2001 From: t3code-fork Date: Thu, 12 Nov 2026 13:13:09 +0000 Subject: [PATCH 07/12] fork-follow: scripts reach gh only in a run or under the tests' stub A test run against other scripts made a real gh API sweep and disabled a workflow of the live fork. Now every script that calls gh sources fork-gh-guard.sh: outside a GitHub Actions run it refuses unless a test runs it (FORK_TEST_MODE) with the stub as the gh on PATH, no GitHub token and a .invalid GH_HOST. It exits before any gh call, and every later gh call checks again. The tests install the stub through fork-test-gh.sh, which also empties the gh config, so even a script without the guard finds nothing to log in to. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/fork-follow-guard.sh | 2 + .github/scripts/fork-follow-report.sh | 2 + .github/scripts/fork-follow.test.sh | 56 ++++++++++++++++++++++++--- .github/scripts/fork-gh-guard.sh | 51 ++++++++++++++++++++++++ .github/scripts/fork-test-gh.sh | 28 ++++++++++++++ .github/scripts/fork-workflows.sh | 2 + 6 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 .github/scripts/fork-gh-guard.sh create mode 100644 .github/scripts/fork-test-gh.sh diff --git a/.github/scripts/fork-follow-guard.sh b/.github/scripts/fork-follow-guard.sh index ab644ea4b07fa..e5aa78543e46e 100755 --- a/.github/scripts/fork-follow-guard.sh +++ b/.github/scripts/fork-follow-guard.sh @@ -22,6 +22,8 @@ # SERIES_BRANCH (default main), REPO_SECRET_PRESENT, REPORT (receives the reason on refusal). Reads # the Environment with `gh api` (GH_TOKEN: GITHUB_TOKEN with actions: read is enough). 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" REPOSITORY="${REPOSITORY:-${GITHUB_REPOSITORY:?REPOSITORY or GITHUB_REPOSITORY}}" : "${FORK_PUSH_ENVIRONMENT:?FORK_PUSH_ENVIRONMENT}" diff --git a/.github/scripts/fork-follow-report.sh b/.github/scripts/fork-follow-report.sh index 84a6e3eba9b2a..aad63d4abdaf3 100755 --- a/.github/scripts/fork-follow-report.sh +++ b/.github/scripts/fork-follow-report.sh @@ -14,6 +14,8 @@ # REPORTS (directory of the jobs' reason files), ORIGIN (git URL of this fork, default GitHub). # Summary to $GITHUB_STEP_SUMMARY, else stdout. 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" REPOSITORY="${REPOSITORY:-${GITHUB_REPOSITORY:?REPOSITORY or GITHUB_REPOSITORY}}" ORIGIN="${ORIGIN:-https://github.com/$REPOSITORY.git}" diff --git a/.github/scripts/fork-follow.test.sh b/.github/scripts/fork-follow.test.sh index 3ceb7066b7e5e..01471d6cb599b 100755 --- a/.github/scripts/fork-follow.test.sh +++ b/.github/scripts/fork-follow.test.sh @@ -27,11 +27,13 @@ unset GITHUB_OUTPUT GITHUB_STEP_SUMMARY GITHUB_ACTIONS UPSTREAM_URL ORIGIN RUN_S # gh api [-X M] PATH [--paginate] [--jq EXPR] gh issue list|comment|create|close ... # A file $root/api/fail_ makes every call whose path contains fail. Run listings are # filtered by their head_sha= and status= query like GitHub's (created= is ignored). +# fork_test_gh (fork-test-gh.sh) installs it first on PATH, with no token, a .invalid GH_HOST and +# FORK_TEST_MODE, so the scripts refuse to run if any other gh would answer. +. "$here/fork-test-gh.sh" mkdir -p "$root/bin" "$root/api" -cat > "$root/bin/gh" << 'GH' -#!/usr/bin/env bash +fork_test_gh "$root/bin" << 'GH' set -euo pipefail -api="$FAKE_API" +api="${FAKE_API:?the gh stub needs FAKE_API}" echo "gh $*" >> "$api/calls" if [[ "$1" == issue ]]; then # Like GitHub: with Issues off, every issue command fails. @@ -84,8 +86,7 @@ case "$method $path" in esac if [[ -n "$jq" ]]; then jq -r "$jq" <<< "$body"; else echo "$body"; fi GH -chmod +x "$root/bin/gh" -export PATH="$root/bin:$PATH" FAKE_API="$root/api" +export FAKE_API="$root/api" failures=0 pass() { echo "ok $1"; } @@ -582,6 +583,51 @@ report ACTION= GUARD_RESULT=failure PLAN_RESULT=skipped check "report: the guard refused -> the run fails with its reason, and says no job got the token" \ eval 'failed_run && has "secret named FORK_PUSH_TOKEN exists" "$root/summary.md" && has "guard refused" "$root/summary.md"' +# --- gh: no script reaches GitHub outside a run (fork-gh-guard.sh) ------------------------------ +# A gh that is not the stub (a test file run without it, an older test, a person running a script +# by hand) answers like GitHub and records every call. Each script that calls gh must refuse before +# it calls any gh: neither this one nor the stub. +mkdir -p "$root/decoy" "$root/nomarker" "$root/reports" +cat > "$root/decoy/gh" << 'D' +#!/usr/bin/env bash +echo "gh $*" >> "$DECOY_CALLS" +echo '{"workflows":[{"id":9,"state":"active","path":".github/workflows/fork-tests.yml"}]}' +D +chmod +x "$root/decoy/gh" +grep -vxF "$FORK_GH_STUB_MARKER" "$root/bin/gh" > "$root/nomarker/gh" && chmod +x "$root/nomarker/gh" +gh_scripts=(fork-workflows.sh fork-follow-guard.sh fork-follow-report.sh) +refuses() { # refuses ENV...: every gh script, run with ENV, fails, says why, and calls no gh + local s + for s in "${gh_scripts[@]}"; do + rm -f "$root/decoy.calls" "$root/api/calls" "$root/report.md" + if (cd "$root/work" && env DECOY_CALLS="$root/decoy.calls" OWN_WORKFLOWS=.github/workflows/fork-follow.yml \ + REPORT="$root/report.md" REPO_SECRET_PRESENT=false FORK_PUSH_ENVIRONMENT=fork-push REPORTS="$root/reports" \ + ACTION=none PLAN_RESULT=success "$@" bash "$dir/$s") > "$root/out" 2>&1; then + echo "$s passed" >> "$root/out" + return 1 + fi + [[ ! -e "$root/decoy.calls" && ! -e "$root/api/calls" ]] || { echo "$s called gh" >> "$root/out"; return 1; } + grep -q "Refusing to run $s" "$root/out" || { echo "$s gave no refusal" >> "$root/out"; return 1; } + done +} +reset_api +check "gh: a test run without the stub first on PATH fails closed and never calls gh" \ + refuses PATH="$root/decoy:$PATH" +check "gh: run by hand (no test mode, not in a GitHub Actions run) refuses before any gh call" \ + refuses FORK_TEST_MODE= PATH="$root/decoy:$PATH" +check "gh: test mode with a GitHub token set refuses, even with the stub" refuses GH_TOKEN=x +check "gh: test mode with GITHUB_TOKEN set refuses, even with the stub" refuses GITHUB_TOKEN=x +check "gh: test mode with GH_HOST github.com refuses" refuses GH_HOST=github.com +check "gh: a gh named as the stub but without its marker line refuses" \ + refuses PATH="$root/nomarker:$PATH" FORK_GH_STUB="$root/nomarker/gh" +rm -f "$root/api/calls" +check "gh: in a GitHub Actions run (not a test) the scripts use gh" \ + eval '(cd "$root/work" && env -u FORK_TEST_MODE GITHUB_ACTIONS=true OWN_WORKFLOWS=.github/workflows/fork-follow.yml \ + REPORT="$root/report.md" GITHUB_OUTPUT="$root/sweep.out" bash "$dir/fork-workflows.sh") > "$root/out" 2>&1 && has "actions/workflows" "$root/api/calls"' +# A script that changes PATH after it started still cannot reach another gh: every call checks. +check "gh: each gh call checks again (PATH changed after the start) and refuses" \ + eval '! (. "$here/fork-gh-guard.sh"; PATH="$root/decoy:$PATH"; DECOY_CALLS="$root/decoy.calls" gh api user) > "$root/out" 2>&1 && [[ ! -e "$root/decoy.calls" ]] && has "Refusing to call gh" "$root/out"' + # --- the workflow file --------------------------------------------------------------------------- desktop="$here/../workflows/release-desktop.yml" declared="$(sed -n '/^ workflow_call:/,/^ inputs:/p' "$desktop" | sed -n 's/^ \([A-Z_]*\):$/\1/p' | sort)" diff --git a/.github/scripts/fork-gh-guard.sh b/.github/scripts/fork-gh-guard.sh new file mode 100644 index 0000000000000..cbee67444e3f9 --- /dev/null +++ b/.github/scripts/fork-gh-guard.sh @@ -0,0 +1,51 @@ +# shellcheck shell=bash +# Sourced by every fork script that calls gh (fork-workflows.sh, fork-follow-guard.sh, +# fork-follow-report.sh, fork-release.sh). Those scripts change the live repository (they disable +# workflows, cancel runs, publish releases), so they reach gh only: +# - in a GitHub Actions run (GITHUB_ACTIONS=true) that is not a test, or +# - in a test (FORK_TEST_MODE set, by fork-test-gh.sh) whose gh is the tests' stub: the gh on +# PATH is the file FORK_GH_STUB names and carries the stub's marker line, no GitHub token is +# set, and GH_HOST is a .invalid host. +# Anything else refuses: the script exits 1 when it sources this file, before any gh call, and +# every later call goes through the gh function below, which checks again (a script may change +# PATH). The marker must match FORK_GH_STUB_MARKER in fork-test-gh.sh. Tests: fork-follow.test.sh +# and fork-release.test.sh. + +FORK_GH_STUB_MARKER="# fork-gh-stub: a test stand-in for gh that never reaches GitHub" + +fork_gh_refusal() { # prints why gh may not be called here, or nothing + local stub + if [[ -z "${FORK_TEST_MODE:-}" ]]; then + [[ "${GITHUB_ACTIONS:-}" == true ]] \ + || echo "this is neither a GitHub Actions run nor a test under the gh stub (FORK_TEST_MODE). These scripts change the live repository, so they run only in its workflows." + return 0 + fi + stub="$(type -P gh || true)" + if [[ -z "${FORK_GH_STUB:-}" || -z "$stub" || "$(realpath "$stub")" != "$(realpath "$FORK_GH_STUB")" ]]; then + echo "test mode, but the gh on PATH (${stub:-none}) is not the stub FORK_GH_STUB (${FORK_GH_STUB:-unset})." + elif ! grep -qxF "$FORK_GH_STUB_MARKER" "$stub"; then + echo "test mode, but $stub has no stub marker line." + elif [[ -n "${GH_TOKEN:-}${GITHUB_TOKEN:-}${GH_ENTERPRISE_TOKEN:-}${GITHUB_ENTERPRISE_TOKEN:-}" ]]; then + echo "test mode, but a GitHub token is set (GH_TOKEN, GITHUB_TOKEN or an enterprise token)." + elif [[ "${GH_HOST:-}" != *.invalid ]]; then + echo "test mode, but GH_HOST (${GH_HOST:-unset}) is not a .invalid host." + fi +} + +gh() { + local why + why="$(fork_gh_refusal)" + if [[ -n "$why" ]]; then + printf '::error::Refusing to call gh: %s\n' "$why" >&2 + return 97 + fi + command gh "$@" +} + +fork_gh_why="$(fork_gh_refusal)" +if [[ -n "$fork_gh_why" ]]; then + printf '::error::Refusing to run %s: %s\n' "$(basename "$0")" "$fork_gh_why" >&2 + [[ -z "${REPORT:-}" ]] || printf 'Refusing to run %s: %s\n' "$(basename "$0")" "$fork_gh_why" > "$REPORT" + exit 1 +fi +unset fork_gh_why diff --git a/.github/scripts/fork-test-gh.sh b/.github/scripts/fork-test-gh.sh new file mode 100644 index 0000000000000..15d59ed594837 --- /dev/null +++ b/.github/scripts/fork-test-gh.sh @@ -0,0 +1,28 @@ +# shellcheck shell=bash +# Sourced first by every fork test that runs a script calling gh (fork-follow.test.sh, +# fork-release.test.sh). `fork_test_gh DIR < STUB` cuts the test off from GitHub before any script +# runs: no GitHub token, GH_HOST a .invalid host and an empty gh config (so even a real gh has +# nothing to reach and no login), and the stub read from stdin as DIR/gh, first on PATH, with the +# marker line fork-gh-guard.sh looks for. FORK_TEST_MODE then makes the scripts refuse whenever +# that stub is not the gh they would call. The test stops at once if gh still resolves elsewhere. + +FORK_GH_STUB_MARKER="# fork-gh-stub: a test stand-in for gh that never reaches GitHub" + +fork_test_gh() { + local dir="$1" + unset GH_TOKEN GITHUB_TOKEN GH_ENTERPRISE_TOKEN GITHUB_ENTERPRISE_TOKEN GITHUB_ACTIONS + mkdir -p "$dir" "$dir/gh-config" + export GH_HOST=gh-stub.invalid GH_CONFIG_DIR="$dir/gh-config" GH_PROMPT_DISABLED=1 \ + GH_NO_UPDATE_NOTIFIER=1 FORK_TEST_MODE=1 + { + echo '#!/usr/bin/env bash' + echo "$FORK_GH_STUB_MARKER" + cat + } > "$dir/gh" + chmod +x "$dir/gh" + export PATH="$dir:$PATH" FORK_GH_STUB="$dir/gh" + if [[ "$(type -P gh)" != "$dir/gh" ]]; then + echo "fork_test_gh: gh resolves to $(type -P gh), not the stub $dir/gh; stopping before any script runs" >&2 + exit 1 + fi +} diff --git a/.github/scripts/fork-workflows.sh b/.github/scripts/fork-workflows.sh index bc4205b2e47db..c170fc79f98cd 100755 --- a/.github/scripts/fork-workflows.sh +++ b/.github/scripts/fork-workflows.sh @@ -27,6 +27,8 @@ # (','-separated) to $GITHUB_OUTPUT, else stdout; a summary line on stderr, and a ::warning:: line # for finished runs and new unregistered files. 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" REPOSITORY="${REPOSITORY:-${GITHUB_REPOSITORY:?REPOSITORY or GITHUB_REPOSITORY}}" REPORT="${REPORT:-fork-workflows-report.md}" From 51af29d394383c71e310ef205490c43bd2d0a3ba Mon Sep 17 00:00:00 2001 From: t3code-fork Date: Thu, 12 Nov 2026 13:13:09 +0000 Subject: [PATCH 08/12] fork-release: reach gh only in a run or under the tests' stub fork-release.sh sources fork-gh-guard.sh like the follow scripts, and fork-release.test.sh installs its stub through fork-test-gh.sh, with tests that a run without the stub, by hand, with a token or with a real GH_HOST refuses before any gh call. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/fork-release.sh | 2 ++ .github/scripts/fork-release.test.sh | 37 ++++++++++++++++++++++++---- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/.github/scripts/fork-release.sh b/.github/scripts/fork-release.sh index 62e62a4816736..f0ff3eee17d38 100755 --- a/.github/scripts/fork-release.sh +++ b/.github/scripts/fork-release.sh @@ -14,6 +14,8 @@ # (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. 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" REPOSITORY="${REPOSITORY:-${GITHUB_REPOSITORY:?REPOSITORY or GITHUB_REPOSITORY}}" ORIGIN="${ORIGIN:-origin}" diff --git a/.github/scripts/fork-release.test.sh b/.github/scripts/fork-release.test.sh index 2a1e2240ecba1..aae82c37ded49 100755 --- a/.github/scripts/fork-release.test.sh +++ b/.github/scripts/fork-release.test.sh @@ -20,11 +20,13 @@ export REPOSITORY=me/t3code GITHUB_REPOSITORY=me/t3code unset GITHUB_OUTPUT GITHUB_STEP_SUMMARY UPSTREAM UPSTREAM_URL ORIGIN # Fake gh: `gh api [-X M] PATH [--paginate] [--jq EXPR] [-f/-F k=v]...` served from $root/api. +# fork_test_gh (fork-test-gh.sh) installs it first on PATH, with no token, a .invalid GH_HOST and +# FORK_TEST_MODE, so the script refuses to run if any other gh would answer. +. "$here/fork-test-gh.sh" mkdir -p "$root/bin" "$root/api" -cat > "$root/bin/gh" <<'GH' -#!/usr/bin/env bash +fork_test_gh "$root/bin" <<'GH' set -euo pipefail -api="$FAKE_API" +api="${FAKE_API:?the gh stub needs FAKE_API}" echo "gh $*" >> "$api/calls" [[ "$1" == api ]] || { echo "fake gh: only api" >&2; exit 2; } shift @@ -55,8 +57,7 @@ case "$method $path" in esac if [[ -n "$jq" ]]; then jq -r "$jq" <<< "$body"; else echo "$body"; fi GH -chmod +x "$root/bin/gh" -export PATH="$root/bin:$PATH" FAKE_API="$root/api" +export FAKE_API="$root/api" failures=0 pass() { echo "ok $1"; } @@ -287,6 +288,32 @@ check_gt 0.1.9-fork.2 0.1.9-fork.2 not check_gt 0.1.9-fork.2 0.2.0-fork.1 not check_gt v1.0.0 v0.1.10 gt +# --- gh: never GitHub outside a run (fork-gh-guard.sh) ------------------------------------------ +# A gh that is not the stub answers like GitHub and records every call; the script must refuse +# before it calls any gh: neither this one nor the stub. +mkdir -p "$root/decoy" +printf '#!/usr/bin/env bash\necho "gh $*" >> "%s"\necho "[]"\n' "$root/decoy.calls" > "$root/decoy/gh" +chmod +x "$root/decoy/gh" +new_upstream +new_fork v0.1.9 +reset_api v0.1.9 +refuses() { # refuses NAME ENV...: resolve and remove-drafts, run with ENV, fail, say why, call no gh + local name="$1" sub ok=true + shift + for sub in resolve "remove-drafts v0.1.9-fork.1"; do + rm -f "$root/decoy.calls" "$root/api/calls" + # shellcheck disable=SC2086 + if (cd "$root/work" && env "$@" "$script" $sub) > "$root/out" 2>&1; then ok=false; fi + [[ ! -e "$root/decoy.calls" && ! -e "$root/api/calls" ]] || ok=false + grep -q "Refusing to run fork-release.sh" "$root/out" || ok=false + done + if $ok; then pass "$name"; else fail "$name"; fi +} +refuses "gh: a test run without the stub first on PATH fails closed and never calls gh" PATH="$root/decoy:$PATH" +refuses "gh: run by hand (no test mode, not in a GitHub Actions run) refuses" FORK_TEST_MODE= PATH="$root/decoy:$PATH" +refuses "gh: test mode with a GitHub token set refuses, even with the stub" GH_TOKEN=x +refuses "gh: test mode with GH_HOST github.com refuses" GH_HOST=github.com + if (( failures )); then echo "$failures failed" exit 1 From 2e10b37a6348bb0ede9377a796fc1ad4d632023d Mon Sep 17 00:00:00 2001 From: t3code-fork Date: Thu, 12 Nov 2026 13:14:45 +0000 Subject: [PATCH 09/12] fork-follow: a fork-*.yml is the fork's own even before main has it The sweep took only the files the series adds as the fork's own, so a fork pull request that adds a fork-*.yml had that workflow disabled (and its runs cancelled) by the next sweep. Now every .github/workflows/fork-*.y[a]ml is the fork's own. In return the plan refuses an upstream release that has such a file and the candidate keeps it, since the sweep would never disable it. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/fork-follow.sh | 18 +++++++++++++ .github/scripts/fork-follow.test.sh | 42 +++++++++++++++++++++++++++++ .github/scripts/fork-workflows.sh | 11 +++++--- .github/workflows/fork-follow.yml | 7 ++--- 4 files changed, 71 insertions(+), 7 deletions(-) diff --git a/.github/scripts/fork-follow.sh b/.github/scripts/fork-follow.sh index cf9baf689af16..7b4a059ee3c85 100755 --- a/.github/scripts/fork-follow.sh +++ b/.github/scripts/fork-follow.sh @@ -160,6 +160,22 @@ new_workflows() { # upstream workflow files added from upstream commit $1 to $2 | { grep -E '\.ya?ml$' || true; } | paste -sd, - } +# The sweep (fork-workflows.sh) takes every .github/workflows/fork-*.y[a]ml as the fork's own, even +# one the series does not have yet, and never disables it. A file of that name that the upstream +# release has and the candidate keeps would stay enabled, so the plan refuses it. +fork_named() { # fork_named COMMIT: its .github/workflows/fork-*.y[a]ml files, one per line + git -c core.quotepath=off ls-tree -r -z --name-only "$1" -- .github/workflows \ + | tr '\0' '\n' | { grep -E '^\.github/workflows/fork-[^/]*\.ya?ml$' || true; } +} +refuse_upstream_fork_named() { # refuse_upstream_fork_named UPSTREAM_COMMIT TAG CANDIDATE + local named + named="$(comm -12 <(fork_named "$1" | sort) <(fork_named "$3" | sort))" + [[ -z "$named" ]] || fail "$UPSTREAM $2 has workflow files named like the fork's own (fork-*.yml), and the candidate keeps them:" \ + "$(sed 's/^/- /' <<< "$named")" "" \ + "The workflow sweep never disables a fork-* workflow, so they would run in the fork. The series was left as it is." \ + "Add a fork patch that deletes them (or rename the fork's rule in fork-workflows.sh and fork-follow.sh), then start the follow workflow again." +} + # The release job runs the reusable workflows of this run's commit (RUN_SHA), not the candidate's. # They differ after upstream changed them, and on a re-run (or a queued run) of a run whose commit # is older than the series. Building then would mix two versions of the release steps. @@ -202,6 +218,7 @@ stage_report() { # stage_report CANDIDATE TAG CHANGED } if [[ "$target_tag" == "$base_tag" ]]; then + refuse_upstream_fork_named "$base" "$base_tag" "$head" read -r released_tag released_commit <<< "$(latest_release "$base_tag")" || true if [[ "${released_commit:-}" == "$head" ]]; then action=none @@ -244,6 +261,7 @@ if ! git -c user.name=t3code-fork -c user.email=noreply@users.noreply.github.com fi schema_check "$target" "After rebasing onto $target_tag, the series" candidate="$(git rev-parse HEAD)" +refuse_upstream_fork_named "$target" "$target_tag" "$candidate" dropped="$(comm -23 <(sort <<< "$before") <(git log --format=%s "$target..HEAD" | sort) | paste -sd ';' -)" # A published fork release of the target with exactly these files exists (an earlier run published diff --git a/.github/scripts/fork-follow.test.sh b/.github/scripts/fork-follow.test.sh index 01471d6cb599b..083fee72a559c 100755 --- a/.github/scripts/fork-follow.test.sh +++ b/.github/scripts/fork-follow.test.sh @@ -295,6 +295,24 @@ plan check "plan: same base -> no new upstream workflow files" \ eval 'action_is release && grep -qx "new_workflows=" "$root/plan.out"' +# Round 4: the sweep never disables a fork-*.yml, so an upstream release that has one is refused, +# on the rebase path and on the same base, unless a fork patch deletes it. +new_upstream +(cd "$root/upwork" && git checkout -q main && echo "on: push" > .github/workflows/fork-sneaky.yml \ + && git add -A && git commit -q -m u5 && git tag v0.1.11 && git push -q https://github.com/up/t3code.git main --tags) > /dev/null 2>&1 +new_fork v0.1.10 +plan +check "plan: rebasing onto a release with an upstream fork-*.yml is refused and names it" \ + eval '[[ -z "$(out action)" ]] && has "named like the fork.s own" "$root/report.md" && has "fork-sneaky.yml" "$root/report.md"' +new_fork v0.1.11 +plan +check "plan: same base with an upstream fork-*.yml is refused too" \ + eval '[[ -z "$(out action)" ]] && has "fork-sneaky.yml" "$root/report.md"' +(cd "$root/work" && git rm -q .github/workflows/fork-sneaky.yml && git commit -q -m "fork: drop upstream fork-sneaky" \ + && git push -q origin main) > /dev/null 2>&1 +plan +check "plan: a fork patch that deletes the upstream fork-*.yml lets the plan go on" action_is release + # Minor: upstream changed a reusable workflow the release calls; the run's own copy is older. new_upstream (cd "$root/upwork" && git checkout -q main && echo "on: workflow_call # v2" > .github/workflows/release-desktop.yml \ @@ -492,6 +510,30 @@ check "workflows: before the move, a new unlisted upstream file refuses it, afte rm -f "$root/report.md" check "workflows: before the move, a new upstream file GitHub lists (now disabled) and older unlisted ones do not refuse it" \ eval 'OWN_WORKFLOWS=.github/workflows/fork-follow.yml HEAD_SHA=abc NEW_WORKFLOWS=.github/workflows/web-preview.yml REQUIRE_NEW_OFF=true sweep && [[ ! -e "$root/report.md" ]]' +# Round 4: a fork-*.yml that main does not have yet (a fork pull request adds it; GitHub lists it +# from the PR's branch) is the fork's own: never disabled, its runs never cancelled. A name that +# only contains "fork" is upstream's. +reset_api +cat > "$root/api/workflows.json" << 'J' +{"workflows":[ + {"id":1,"state":"active","path":".github/workflows/fork-follow.yml"}, + {"id":5,"state":"active","path":".github/workflows/fork-tests.yml"}, + {"id":6,"state":"active","path":".github/workflows/fork-new.yaml"}, + {"id":7,"state":"active","path":".github/workflows/mobile-eas-preview-fork.yml"}]} +J +cat > "$root/api/runs.json" << 'J' +{"workflow_runs":[ + {"id":21,"status":"in_progress","head_sha":"pr","event":"pull_request","path":".github/workflows/fork-tests.yml"}, + {"id":22,"status":"queued","head_sha":"pr","event":"pull_request","path":".github/workflows/fork-new.yaml@refs/pull/9/merge"}, + {"id":23,"status":"queued","head_sha":"abc","event":"push","path":".github/workflows/mobile-eas-preview-fork.yml"}]} +J +echo '[{"type":"file","path":".github/workflows/fork-follow.yml"},{"type":"file","path":".github/workflows/mobile-eas-preview-fork.yml"}]' > "$root/api/files.json" +OWN_WORKFLOWS=.github/workflows/fork-follow.yml sweep +check "workflows: a fork-*.yml/.yaml absent from main (a fork PR adds it) is never disabled, its runs never cancelled" \ + eval 'hasnt "workflows/5/disable" "$root/api/writes" && hasnt "workflows/6/disable" "$root/api/writes" && hasnt "runs/21/cancel" "$root/api/writes" && hasnt "runs/22/cancel" "$root/api/writes"' +check "workflows: an upstream name that only contains 'fork' is still disabled and its runs cancelled" \ + eval 'has "workflows/7/disable" "$root/api/writes" && has "runs/23/cancel" "$root/api/writes" && [[ $(grep -c . "$root/api/writes") == 2 ]]' + reset_api OWN_WORKFLOWS="" sweep check "workflows: an empty own list disables nothing (refusing to guess)" eval '[[ ! -e "$root/api/writes" ]] && has "refusing to guess" "$root/report.md"' diff --git a/.github/scripts/fork-workflows.sh b/.github/scripts/fork-workflows.sh index c170fc79f98cd..1b42db6a0e9cc 100755 --- a/.github/scripts/fork-workflows.sh +++ b/.github/scripts/fork-workflows.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Keeps upstream's workflows off in this fork (tests: fork-follow.test.sh). Disables every active -# workflow under .github/workflows that is not one of the fork's own, cancels every unfinished run -# of such a workflow (whatever event started it: push, pull_request, schedule ...), and names what -# it could not stop: +# workflow under .github/workflows that is not one of the fork's own (OWN_WORKFLOWS, and every +# .github/workflows/fork-*.y[a]ml), cancels every unfinished run of such a workflow (whatever event +# started it: push, pull_request, schedule ...), and names what it could not stop: # finished runs of upstream workflows that completed since SINCE (they ran before a sweep # could stop them), with their conclusion # unregistered upstream workflow files in the tree at TREE_REF that GitHub does not list yet @@ -43,7 +43,10 @@ die() { } [[ -n "${OWN_WORKFLOWS:-}" ]] || die "The list of the fork's own workflows is empty, so no workflow was disabled (refusing to guess)." -own() { [[ ",$OWN_WORKFLOWS," == *",$1,"* ]]; } +# A fork-*.yml or fork-*.yaml file is the fork's own even when the series does not have it yet (a +# fork pull request adding it runs it from its branch): it is never disabled, nor its runs +# cancelled. The plan refuses an upstream release that has such a file (fork-follow.sh). +own() { [[ ",$OWN_WORKFLOWS," == *",$1,"* || "$1" =~ ^\.github/workflows/fork-[^/]*\.ya?ml$ ]]; } upstream() { [[ "$1" == .github/workflows/* ]] && ! own "$1"; } new() { [[ ",${NEW_WORKFLOWS:-}," == *",$1,"* ]]; } diff --git a/.github/workflows/fork-follow.yml b/.github/workflows/fork-follow.yml index 0c91fc8f1a7c3..e31d2a36419bb 100644 --- a/.github/workflows/fork-follow.yml +++ b/.github/workflows/fork-follow.yml @@ -19,9 +19,10 @@ name: Fork follow upstream # 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, cancels every unfinished -# run of one (any event), and names the runs that finished before it could stop them and the files -# GitHub has not listed yet. The branch move is refused while an upstream workflow file that is new +# fork-workflows.sh disables every active workflow the series did not add (a fork-*.yml is always the +# fork's own, also before main has it; the plan refuses an upstream release that has one), cancels +# every unfinished run of one (any event), and names the runs that finished before it could stop +# them and the files GitHub has not listed yet. The branch move is refused while an upstream workflow file that is new # on this release is not listed (it could not be disabled, and on the branch it could ask for # fork-push); a person decides then. # From 717ea028df8a6f490afbf2bf379e367a5802dc6b Mon Sep 17 00:00:00 2001 From: t3code-fork Date: Thu, 12 Nov 2026 13:16:47 +0000 Subject: [PATCH 10/12] fork-follow: changed and non-ASCII upstream workflows hold the move too new_workflows read `git diff --name-only` with git's quoting, so a new upstream workflow with a non-ASCII name came out quoted, failed the .yml filter and slipped past the branch-move refusal. It now reads the diff NUL-separated with core.quotepath=off, and refuses a name with ',' or a newline (the lists are ','-joined). It also takes changed files (--diff-filter=AM): an unlisted upstream workflow that now runs on push holds the move like a new one. The lists are built in the plan's own shell, so a refusal stops the plan. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/fork-follow-report.sh | 2 +- .github/scripts/fork-follow.sh | 49 +++++++++++++++++++++------ .github/scripts/fork-follow.test.sh | 20 ++++++++++- .github/scripts/fork-workflows.sh | 10 +++--- .github/workflows/fork-follow.yml | 9 ++--- 5 files changed, 68 insertions(+), 22 deletions(-) diff --git a/.github/scripts/fork-follow-report.sh b/.github/scripts/fork-follow-report.sh index aad63d4abdaf3..aacb431b5fd28 100755 --- a/.github/scripts/fork-follow-report.sh +++ b/.github/scripts/fork-follow-report.sh @@ -114,7 +114,7 @@ fi [[ -z "${CANCELLED:-}" ]] || row "Their runs cancelled" "${CANCELLED//,/, }" [[ -z "${FINISHED:-}" ]] || row "**Upstream runs that finished before a sweep could stop them**" "${FINISHED//,/, }" [[ -z "${UNREGISTERED:-}" ]] \ - || row "**Upstream workflow files new on this release that GitHub does not list yet, so not disabled** (the sweep after an event reaches them disables them; the branch move waits for that)" "${UNREGISTERED//,/, }" + || row "**Upstream workflow files new or changed on this release that GitHub does not list yet, so not disabled** (the sweep after an event reaches them disables them; the branch move waits for that)" "${UNREGISTERED//,/, }" [[ -z "${DROPPED:-}" ]] || row "Patches dropped (empty on $BASE_TAG: upstream has the change)" "${DROPPED//;/, }" reasons="" diff --git a/.github/scripts/fork-follow.sh b/.github/scripts/fork-follow.sh index 7b4a059ee3c85..227eb438fc6d4 100755 --- a/.github/scripts/fork-follow.sh +++ b/.github/scripts/fork-follow.sh @@ -24,9 +24,10 @@ # dropped subjects of patches that became empty on the new base (';'-separated) # own_workflows workflow files the series itself adds (','-separated); every other one is # upstream's and gets disabled -# new_workflows upstream workflow files added between the series' old base and the new one -# (','-separated; empty without a new base). The branch move refuses while one of -# them is not listed by GitHub yet, so it cannot be disabled before main has it. +# new_workflows upstream workflow files added or changed between the series' old base and the +# new one (','-separated; empty without a new base). The branch move refuses while +# one of them is not listed by GitHub yet, so it cannot be disabled before main has +# it (a changed file may now run on push). set -Eeuo pipefail : "${UPSTREAM:?owner/name of the upstream repository}" @@ -152,12 +153,35 @@ latest_release() { [[ -z "$last" ]] || echo "$last $(git rev-parse "refs/fork-tags/$last^{commit}")" } -own_workflows() { - git diff --name-only --diff-filter=A "$1" "$2" -- .github/workflows | paste -sd, - +# Workflow paths, read NUL-separated with core.quotepath=off, so a name with non-ASCII bytes, a +# quote or a backslash comes out as GitHub lists it. The lists are ','-joined, so a name with a ',' +# or a newline cannot be named and the plan refuses it. These run in the plan's own shell (no +# command substitution), so a refusal stops the plan. +workflow_paths() { # workflow_paths FILTER FROM TO: sets paths, the ','-joined files FROM..TO shows as FILTER + local path + paths="" + git -c core.quotepath=off diff -z --no-renames --name-only --diff-filter="$1" "$2" "$3" -- .github/workflows \ + > "$logs/paths" || fail "Could not list the workflow files that differ between ${2:0:12} and ${3:0:12}." + while IFS= read -r -d '' path; do + if [[ "$path" == *[,$'\n']* ]]; then + fail "The workflow file \`$path\` (between ${2:0:12} and ${3:0:12}) has a ',' or a newline in its name, so it cannot be named to the workflow sweep." \ + "The series was left as it is. Rename or delete it with a fork patch." + fi + paths+="${paths:+,}$path" + done < "$logs/paths" +} +own_workflows() { # own_workflows UPSTREAM_COMMIT TIP: sets own_list, the workflow files the series adds + workflow_paths A "$1" "$2" + own_list="$paths" } -new_workflows() { # upstream workflow files added from upstream commit $1 to $2 (a rename counts as new) - git diff --no-renames --name-only --diff-filter=A "$1" "$2" -- .github/workflows \ - | { grep -E '\.ya?ml$' || true; } | paste -sd, - +new_workflows() { # new_workflows FROM TO: sets new_list, the upstream workflow files added or changed + local path list=() + workflow_paths AM "$1" "$2" # a rename counts as new + IFS=, read -r -a list <<< "$paths" + new_list="" + for path in "${list[@]}"; do + [[ "$path" != *.yml && "$path" != *.yaml ]] || new_list+="${new_list:+,}$path" + done } # The sweep (fork-workflows.sh) takes every .github/workflows/fork-*.y[a]ml as the fork's own, even @@ -231,13 +255,14 @@ if [[ "$target_tag" == "$base_tag" ]]; then stage_report "$head" "$base_tag" "$changed" fi fi + own_workflows "$base" HEAD { echo "action=$action" echo "base_tag=$base_tag" echo "base_commit=$base" echo "candidate=$head" echo "released_tag=${released_tag:-}" - echo "own_workflows=$(own_workflows "$base" HEAD)" + echo "own_workflows=$own_list" echo "new_workflows=" } >> "$OUTPUT" exit 0 @@ -280,6 +305,8 @@ else stage_report "$candidate" "$target_tag" "$changed" fi fi +own_workflows "$target" "$candidate" +new_workflows "$base" "$target" { echo "action=$action" echo "base_tag=$target_tag" @@ -289,6 +316,6 @@ fi echo "candidate=$candidate" echo "released_tag=${released_tag:-}" echo "dropped=$dropped" - echo "own_workflows=$(own_workflows "$target" "$candidate")" - echo "new_workflows=$(new_workflows "$base" "$target")" + echo "own_workflows=$own_list" + echo "new_workflows=$new_list" } >> "$OUTPUT" diff --git a/.github/scripts/fork-follow.test.sh b/.github/scripts/fork-follow.test.sh index 083fee72a559c..cd9be4ff96b18 100755 --- a/.github/scripts/fork-follow.test.sh +++ b/.github/scripts/fork-follow.test.sh @@ -313,6 +313,24 @@ check "plan: same base with an upstream fork-*.yml is refused too" \ plan check "plan: a fork patch that deletes the upstream fork-*.yml lets the plan go on" action_is release +# Round 4: a changed upstream workflow counts like a new one (an unlisted file that now runs on push +# must hold the move too), and a non-ASCII name comes out as GitHub lists it, not quoted by git. +new_upstream +(cd "$root/upwork" && git checkout -q main && echo "on: [push] # changed" > .github/workflows/ci.yml \ + && echo "on: push" > .github/workflows/dé.yml && git add -A && git commit -q -m u5 \ + && git tag v0.1.11 && git push -q https://github.com/up/t3code.git main --tags) > /dev/null 2>&1 +new_fork v0.1.10 +plan +check "plan: a changed upstream workflow and a non-ASCII new one are both named, unquoted" \ + eval 'action_is rebase && [[ "$(out new_workflows)" == ".github/workflows/ci.yml,.github/workflows/dé.yml" ]]' +new_upstream +(cd "$root/upwork" && git checkout -q main && echo "on: push" > ".github/workflows/a,b.yml" && git add -A \ + && git commit -q -m u5 && git tag v0.1.11 && git push -q https://github.com/up/t3code.git main --tags) > /dev/null 2>&1 +new_fork v0.1.10 +plan +check "plan: a new upstream workflow with ',' in its name (cannot be listed) is refused, nothing planned" \ + eval '[[ -z "$(out action)" ]] && has "a,b.yml" "$root/report.md" && has "cannot be named" "$root/report.md" && hasnt "plan stopped at" "$root/report.md"' + # Minor: upstream changed a reusable workflow the release calls; the run's own copy is older. new_upstream (cd "$root/upwork" && git checkout -q main && echo "on: workflow_call # v2" > .github/workflows/release-desktop.yml \ @@ -503,7 +521,7 @@ check "workflows: an unlisted upstream file from an earlier release is named apa rm -f "$root/api/writes" OWN_WORKFLOWS=.github/workflows/fork-follow.yml,.github/workflows/fork-tests.yml HEAD_SHA=abc NEW_WORKFLOWS=.github/workflows/release.yml sweep check "workflows: an unlisted upstream file new on this release is named with a warning, not called disabled" \ - eval '[[ "$(sed -n "s/^unregistered=//p" "$root/sweep.out")" == ".github/workflows/release.yml" && -z "$(sed -n "s/^unregistered_old=//p" "$root/sweep.out")" ]] && has "::warning::.*new on this release.*does not list yet" "$root/out"' + eval '[[ "$(sed -n "s/^unregistered=//p" "$root/sweep.out")" == ".github/workflows/release.yml" && -z "$(sed -n "s/^unregistered_old=//p" "$root/sweep.out")" ]] && has "::warning::.*new or changed on this release.*does not list yet" "$root/out"' rm -f "$root/api/writes" "$root/report.md" check "workflows: before the move, a new unlisted upstream file refuses it, after disabling the listed ones" \ eval '! OWN_WORKFLOWS=.github/workflows/fork-follow.yml HEAD_SHA=abc NEW_WORKFLOWS=.github/workflows/release.yml REQUIRE_NEW_OFF=true sweep && has "main was not moved" "$root/report.md" && has "release.yml" "$root/report.md" && has "A person decides" "$root/report.md" && has "workflows/2/disable" "$root/api/writes"' diff --git a/.github/scripts/fork-workflows.sh b/.github/scripts/fork-workflows.sh index 1b42db6a0e9cc..5bde87d0366c2 100755 --- a/.github/scripts/fork-workflows.sh +++ b/.github/scripts/fork-workflows.sh @@ -6,8 +6,8 @@ # finished runs of upstream workflows that completed since SINCE (they ran before a sweep # could stop them), with their conclusion # unregistered upstream workflow files in the tree at TREE_REF that GitHub does not list yet -# and that are new on this upstream release (NEW_WORKFLOWS). GitHub lists a -# workflow only once an event has reached its file, so these can be neither +# and that are new or changed on this upstream release (NEW_WORKFLOWS). GitHub +# lists a workflow only once an event has reached its file, so these can be neither # disabled nor cancelled until then; the next sweep after that event does. # unregistered_old the other unlisted upstream files, from earlier releases. Most of them run # only on events the fork never sends (workflow_call, workflow_dispatch, @@ -18,7 +18,7 @@ # # Environment: REPOSITORY (owner/name; GITHUB_REPOSITORY in a run), OWN_WORKFLOWS (','-separated # paths the fork's own commits add; required, nothing is guessed), NEW_WORKFLOWS (','-separated -# upstream files added since the series' old base; optional), REQUIRE_NEW_OFF (true: fail when one +# upstream files added or changed since the series' old base; optional), REQUIRE_NEW_OFF (true: fail when one # of NEW_WORKFLOWS is in the tree and not listed, because then it cannot be disabled before the # series branch has it), TREE_REF (commit or branch whose workflow files are checked; default # HEAD_SHA, else SERIES_BRANCH, else main), HEAD_SHA, SERIES_BRANCH, PREVIOUS_HEAD (the series head @@ -119,7 +119,7 @@ join() { local IFS=,; echo "$*"; } ((${#finished[@]} == 0)) \ || echo "::warning::Upstream workflow runs finished before they could be stopped: $(join "${finished[@]}")" >&2 ((${#unregistered[@]} == 0)) \ - || echo "::warning::Upstream workflow files new on this release that GitHub does not list yet, so not disabled: $(join "${unregistered[@]}")" >&2 + || echo "::warning::Upstream workflow files new or changed on this release that GitHub does not list yet, so not disabled: $(join "${unregistered[@]}")" >&2 echo "Upstream workflows disabled: ${#disabled[@]}; runs cancelled: ${#cancelled[@]}; finished before a sweep: ${#finished[@]}; new and not registered yet: ${#unregistered[@]}; older and not registered: ${#unregistered_old[@]}." >&2 # Before the series branch moves: any workflow file on it may ask for the Environment that holds @@ -127,7 +127,7 @@ echo "Upstream workflows disabled: ${#disabled[@]}; runs cancelled: ${#cancelled # new upstream file that GitHub does not list cannot be disabled first, so a person decides. if [[ "${REQUIRE_NEW_OFF:-}" == true ]] && ((${#unregistered[@]})); then branch="${SERIES_BRANCH:-main}" - die "$branch was not moved: upstream workflow files that are new on this release are not listed by GitHub yet, so they could not be disabled before $branch gets them:" \ + die "$branch was not moved: upstream workflow files that are new or changed on this release are not listed by GitHub yet, so they could not be disabled before $branch gets them:" \ "$(printf -- '- `%s`\n' "${unregistered[@]}")" "" \ "On $branch, a push starts their push workflows before a sweep can stop them, and any workflow file there may ask for the Environment that holds FORK_PUSH_TOKEN." \ "A person decides. Read them in the candidate (\`git show ${TREE_REF:0:12}:\`). If they are harmless, move $branch to $TREE_REF yourself" \ diff --git a/.github/workflows/fork-follow.yml b/.github/workflows/fork-follow.yml index e31d2a36419bb..8be10d65cc509 100644 --- a/.github/workflows/fork-follow.yml +++ b/.github/workflows/fork-follow.yml @@ -22,9 +22,9 @@ name: Fork follow upstream # fork-workflows.sh disables every active workflow the series did not add (a fork-*.yml is always the # fork's own, also before main has it; the plan refuses an upstream release that has one), cancels # every unfinished run of one (any event), and names the runs that finished before it could stop -# them and the files GitHub has not listed yet. The branch move is refused while an upstream workflow file that is new -# on this release is not listed (it could not be disabled, and on the branch it could ask for -# fork-push); a person decides then. +# them and the files GitHub has not listed yet. The branch move is refused while an upstream +# workflow file that is new or changed on this release is not listed (it could not be disabled, and +# on the branch it could ask for fork-push); a person decides then. # # 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". @@ -290,7 +290,8 @@ jobs: # Any workflow file on the series branch may ask for fork-push, and the move fires its push # workflows. So upstream workflows are disabled first, and the move is refused while an - # upstream file new on this release is not listed by GitHub (it cannot be disabled yet). + # upstream file new or changed on this release is not listed by GitHub (it cannot be + # disabled yet). - id: sweep_before name: Disable upstream workflows before the move, refuse while a new one cannot be env: From c3ee3546190b1248491f9c4ded2844c8a79252d2 Mon Sep 17 00:00:00 2001 From: t3code-fork Date: Thu, 12 Nov 2026 13:18:16 +0000 Subject: [PATCH 11/12] fork-follow tests: an || in advance's if:, continue-on-error on a refusal Three breaks left every test green: `||` after the attempt check in advance's if:, and continue-on-error on the Environment re-check or on the sweep before the move (the refusal turns into a warning and the branch moves). The tests now read advance's if: as one conjunction (no `||` outside parentheses) and allow continue-on-error only in the Report job. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/fork-follow.test.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/scripts/fork-follow.test.sh b/.github/scripts/fork-follow.test.sh index cd9be4ff96b18..eb5a15abecc2e 100755 --- a/.github/scripts/fork-follow.test.sh +++ b/.github/scripts/fork-follow.test.sh @@ -717,6 +717,18 @@ check "workflow: the guard's attempt output is set only by a step after the guar advance_job="$(sed -n '/^ advance:$/,/^ [a-z_-]*:$/p' "$workflow" | sed '$d')" check "workflow: before the move, advance checks the Environment again and sweeps, refusing new unlisted files" \ eval 'awk "/fork-follow-guard.sh --environment-only/ { g = NR } /REQUIRE_NEW_OFF: \"true\"/ { r = NR } /NEW_WORKFLOWS: .*new_workflows/ && !n { n = NR } /fork-follow-push.sh advance/ { m = NR } END { exit !(g && r && n && m && g < r && r < m && n < m) }" <<< "$advance_job"' +# Round 4: advance's condition is one conjunction: an `||` outside the parentheses (say after the +# attempt check) would let a re-run with an earlier attempt's guard move the branch. +advance_if="$(awk '/^ if: >-$/ { s = 1; next } s && /^ / { printf "%s ", $0; next } s { exit }' <<< "$advance_head")" +top_level="$advance_if" +while [[ "$top_level" =~ \([^()]*\) ]]; do top_level="${top_level//"${BASH_REMATCH[0]}"/_}"; done +check "workflow: advance's if: is a conjunction (no top-level ||) of the plan's success and this attempt's guard" \ + eval '[[ -n "$advance_if" && "$top_level" != *"||"* && "$top_level" == *"$attempt_check &&"* && "$top_level" == *"needs.plan.result == '"'"'success'"'"' &&"* ]]' +# A refusal that only warns is no refusal: continue-on-error is allowed in the Report job alone (the +# guard, the Environment re-check and the sweep before the move must stop their job). +report_start="$(grep -n "^ report:$" "$workflow" | cut -d: -f1)" +check "workflow: continue-on-error appears only in the Report job (never on the guard, the re-check or the sweep before the move)" \ + eval '[[ -n "$report_start" ]] && ! awk -v r="$report_start" "/continue-on-error/ && NR < r { bad = 1 } END { exit !bad }" "$workflow"' check "workflow: the move step runs only when the checks before it passed (no if:)" \ eval 'awk "/- name: Move the series branch/ { s = 1; next } s && /^ - / { exit } s && /^ if:/ { bad = 1 } END { exit bad }" <<< "$advance_job"' check "workflow: action none deletes a leftover candidate branch" \ From c861d84277419ac8bdbe9d4db9678a6ed93b8fe0 Mon Sep 17 00:00:00 2001 From: t3code-fork Date: Thu, 12 Nov 2026 13:21:45 +0000 Subject: [PATCH 12/12] fork-secret-scan: fail when gitleaks logs an error and still exits 0 gitleaks 8.30.1 logs `ERR error="stderr is not empty"` when git writes to stderr while it reads the log, may drop the rest of the diffs, and exits 0. The scan now fails (2) on any ERR line in gitleaks' log, before the commit count. Test: a git wrapper that writes a stray stderr line; with a 3,000-file commit the real gitleaks misses a key in the last file and passes. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/fork-secret-scan.sh | 9 ++++++ .github/scripts/fork-secret-scan.test.sh | 38 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/.github/scripts/fork-secret-scan.sh b/.github/scripts/fork-secret-scan.sh index b6411894d226a..cfafbb16e848b 100755 --- a/.github/scripts/fork-secret-scan.sh +++ b/.github/scripts/fork-secret-scan.sh @@ -110,6 +110,15 @@ scanned="$(sed -E 's/\x1b\[[0-9;]*m//g' "$log" | sed -nE 's/.* ([0-9]+) commits echo "gitleaks scanned ${scanned:-no} commit(s); $expected commit(s) in range have changes it reads" summary "### Secret scan" "" if [[ -n "$warning" ]]; then summary "Warning: $warning." ""; fi +# gitleaks 8.30.1 logs git's stderr as ERR (and `ERR error="stderr is not empty"`), stops reading +# diffs once git exits, and still exits 0; the count above misses that when the dropped files all +# belong to a commit it already counted. Any ERR line with exit 0 fails the job. +errors="$(sed -E 's/\x1b\[[0-9;]*m//g' "$log" | grep -E '^([^[:space:]]+[[:space:]]+)?ERR[[:space:]]' || true)" +if [[ $status -eq 0 && -n "$errors" ]]; then + echo "::error::Secret scan: gitleaks logged an error and still passed, so it may not have read every change: $(head -n 1 <<< "$errors")" >&2 + summary "Failed: gitleaks logged an error while scanning $what (see the job log), so the scan may be incomplete." + exit 2 +fi if [[ $status -eq 0 && ( -z "$scanned" || "$scanned" -lt "$expected" ) ]]; then echo "::error::Secret scan: gitleaks scanned ${scanned:-no} commit(s), but $expected commit(s) in range have changes (a git error stopped it?)" >&2 summary "Failed: gitleaks scanned ${scanned:-no} of the $expected commit(s) with changes in $what, so the rest were not checked." diff --git a/.github/scripts/fork-secret-scan.test.sh b/.github/scripts/fork-secret-scan.test.sh index e5a0c191a1291..f0022f7275e1a 100755 --- a/.github/scripts/fork-secret-scan.test.sh +++ b/.github/scripts/fork-secret-scan.test.sh @@ -38,6 +38,11 @@ if ! git log -p -U0 $opts > "$TMPDIR/fake-gitleaks.log" 2> "$TMPDIR/fake-gitleak echo "no leaks found" exit 0 fi +# Like gitleaks 8.30.1: any stderr line from git is logged as an error, and the scan goes on. +if [[ -s "$TMPDIR/fake-gitleaks.err" ]]; then + echo "ERR [git] $(head -n 1 "$TMPDIR/fake-gitleaks.err")" + echo 'ERR error="stderr is not empty"' +fi # A commit counts when a file it does not delete has a hunk, as in gitleaks. scanned="$(awk '/^commit / { c = $2 } /^diff --git / { d = 0 } /^deleted file mode / { d = 1 } /^@@ / && !d && !(c in s) { s[c] = 1; n++ } END { print n + 0 }' "$TMPDIR/fake-gitleaks.log")" @@ -187,6 +192,39 @@ BASE_SHA="$base" HEAD_SHA="$leaky" \ check "pull request: contents that cannot be fetched after the clone fail" 2 "cannot read the commits" mv "$root/origin-gone.git" "$root/origin.git" +# git writes a stray line to stderr while gitleaks reads the log (in gitleaks 8.30.1 that is +# `ERR error="stderr is not empty"`, possibly with diffs dropped, and still exit 0): the scan fails. +mkdir -p "$root/straygit" +cat > "$root/straygit/git" <&2 +exit \$status +STRAY +chmod +x "$root/straygit/git" +clone="$root/clone" +PATH="$root/straygit:$PATH" BASE_SHA="$base" HEAD_SHA="$clean" \ + check "pull request: gitleaks logging a git error (stderr not empty) and passing fails" 2 "gitleaks logged an error|gitleaks scanned" +BASE_SHA="$base" HEAD_SHA="$clean" check "pull request: without the stray line the same scan passes" 0 +# The case the count misses (gitleaks 8.30.1): one commit adds 3,000 files and a key in the last +# one; the stray line stops gitleaks before that file, with the commit already counted. The real +# gitleaks then exits 0, so the scan must fail on its ERR lines (2); the stand-in reads every file +# and finds the key (1). +cd "$root/work" +git checkout -q -b wide "$base" +mkdir -p wide && for n in $(seq -w 1 3000); do echo "line $n" > "wide/f$n.txt"; done +printf '%s\n' "aws_access_key_id=$key" > wide/zzz.txt +date=$((date + 60)) +git add -A && GIT_AUTHOR_DATE="@$date" GIT_COMMITTER_DATE="@$date" git commit -qm "PR W: 3,000 files, a key last" +git push -q origin wide +wide=$(git rev-parse wide) +git -C "$root/clone" fetch -q origin wide +cd "$root" +wide_status=1; [[ -z "${GITLEAKS_BIN:-}" ]] || wide_status=2 +PATH="$root/straygit:$PATH" BASE_SHA="$base" HEAD_SHA="$wide" \ + check "pull request: a stray git stderr line that hides a key in a wide commit fails" "$wide_status" + if [[ "$failures" -ne 0 ]]; then echo "$failures check(s) failed" exit 1