diff --git a/.github/scripts/fork-follow-guard.sh b/.github/scripts/fork-follow-guard.sh new file mode 100755 index 0000000000000..e5aa78543e46e --- /dev/null +++ b/.github/scripts/fork-follow-guard.sh @@ -0,0 +1,104 @@ +#!/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). +# +# `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). +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}" +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 "${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." \ + "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 + +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-push.sh b/.github/scripts/fork-follow-push.sh new file mode 100755 index 0000000000000..ebbdc9040e704 --- /dev/null +++ b/.github/scripts/fork-follow-push.sh @@ -0,0 +1,128 @@ +#!/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, 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. +# +# 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() { + 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." \ + "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..aacb431b5fd28 --- /dev/null +++ b/.github/scripts/fork-follow-report.sh @@ -0,0 +1,206 @@ +#!/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, 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 +# 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}" +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 "${GUARD_RESULT:-success}" "${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 + +# 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 |"); } + +# --- 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 "${FINISHED:-}" ]] || row "**Upstream runs that finished before a sweep could stop them**" "${FINISHED//,/, }" +[[ -z "${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="" +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' + 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="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 ;; +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: guard ${GUARD_RESULT:-success}, 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[@]}" + 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 --------------------------------------------------- +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 new file mode 100755 index 0000000000000..227eb438fc6d4 --- /dev/null +++ b/.github/scripts/fork-follow.sh @@ -0,0 +1,321 @@ +#!/usr/bin/env bash +# Plan step of fork-follow.yml: decides what the run does, and rebases the fork's patch series onto +# a newer stable upstream tag when there is one. Run in a full clone with the series checked out. +# 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 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 +# 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 +# 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}" +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 +} + +# 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\`" \ + "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)" +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="" +for tag in $upstream_tags; do + if git merge-base --is-ancestor "$(up "$tag")" HEAD; then + base_tag="$tag" + base="$(up "$tag")" + break + fi +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). +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. 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 + +# 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() { + 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}")" +} + +# 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() { # 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 +# 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. +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 + 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 + else + action=release + released_tag="" + changed="$(changed_release_workflows "$head")" + if [[ -n "$changed" ]]; then + action=stage + 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_list" + echo "new_workflows=" + } >> "$OUTPUT" + exit 0 +fi + +# A newer upstream release: replay the series onto it. Patches upstream now has become empty and +# 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)" + 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:-(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\`, 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 +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 +# 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="" + changed="$(changed_release_workflows "$candidate")" + if [[ -n "$changed" ]]; then + action=stage + stage_report "$candidate" "$target_tag" "$changed" + fi +fi +own_workflows "$target" "$candidate" +new_workflows "$base" "$target" +{ + echo "action=$action" + echo "base_tag=$target_tag" + echo "base_commit=$target" + echo "previous_tag=$base_tag" + echo "previous_head=$head" + echo "candidate=$candidate" + echo "released_tag=${released_tag:-}" + echo "dropped=$dropped" + 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 new file mode 100755 index 0000000000000..eb5a15abecc2e --- /dev/null +++ b/.github/scripts/fork-follow.test.sh @@ -0,0 +1,757 @@ +#!/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 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. 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" +fork_test_gh "$root/bin" << 'GH' +set -euo pipefail +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. + 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?"*) + 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##*/}" + [[ -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 +export 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" + echo '[]' > "$root/api/files.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" ]]' +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 +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" + +# 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 +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 + +# 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"' + +# 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 + +# 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 \ + && 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"' + +# 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 +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"' +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"' + +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" +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 + +# --- 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"' +# 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" \ + 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() { 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":[ + {"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","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 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" && 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: 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 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"' +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"' +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 ]]' +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"' +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 +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)" +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 "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"' +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"' +# 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" \ + 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="$(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"' +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-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-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 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 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 new file mode 100755 index 0000000000000..5bde87d0366c2 --- /dev/null +++ b/.github/scripts/fork-workflows.sh @@ -0,0 +1,137 @@ +#!/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 (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 +# 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, +# 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, 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), NEW_WORKFLOWS (','-separated +# 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 +# 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 +# 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}" +OUTPUT="${GITHUB_OUTPUT:-/dev/stdout}" +TREE_REF="${TREE_REF:-${HEAD_SHA:-${SERIES_BRANCH:-main}}}" + +die() { + printf '%s\n' "$@" > "$REPORT" + 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)." +# 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,"* ]]; } + +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 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 <<< "$listed" + +# Unfinished runs, from any event and for any commit; one listing per unfinished status. +cancelled=() +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 + 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=() +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" && continue + if new "$path"; then unregistered+=("$path"); else unregistered_old+=("$path"); fi +done <<< "$files" + +join() { local IFS=,; echo "$*"; } +{ + echo "disabled=$(join "${disabled[@]}")" + 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 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 +# 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 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" \ + "(\`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 new file mode 100644 index 0000000000000..8be10d65cc509 --- /dev/null +++ b/.github/workflows/fork-follow.yml @@ -0,0 +1,413 @@ +name: Fork follow upstream + +# Daily: keeps this fork's patch series on upstream's newest stable release and publishes it. +# 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: 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 +# read back from it. With Issues enabled it also opens or updates one issue, and closes it again +# on the next good run. +# +# Upstream's workflows stay off: after each push, before the series branch moves, and at the end, +# fork-workflows.sh disables every active workflow the series did not add (a fork-*.yml is always the +# 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 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". +# +# 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, 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 * * *" + workflow_dispatch: + +concurrency: + group: fork-follow + cancel-in-progress: false + +permissions: + contents: read + +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: + # 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 }} + # 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 + 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 + + - 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 + with: + name: fork-follow-report-guard + path: ${{ runner.temp }}/reports + 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 + 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 }} + 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: + - name: Checkout the series + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ env.SERIES_BRANCH }} + fetch-depth: 0 + filter: blob:none + 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 }} + 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')" + 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 + + - id: sweep_plan + name: Disable upstream workflows + 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 + + - 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: + 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 + + # 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: + REPORT: ${{ runner.temp }}/reports/plan.md + run: | + cat "$REPORT" >&2 + exit 1 + + - name: Keep the reason + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: fork-follow-report-plan + path: ${{ runner.temp }}/reports + 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 + # 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 + + advance: + name: Move the series branch + environment: fork-push + 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')) + 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 the scripts + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ needs.plan.outputs.candidate }} + fetch-depth: 0 + 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 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: + 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 + 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: | + 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 }} + 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 + + - 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: [guard, plan, release, advance] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: write + contents: read + issues: write + steps: + - 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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: fork-follow-report-* + merge-multiple: true + path: ${{ runner.temp }}/reports + + # 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 != '' + continue-on-error: true + 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 + run: bash .github/scripts/fork-workflows.sh + + - name: Summary, issue, and the run's status + env: + 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 }} + 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 }} + 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 }} + 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 }} + UNREGISTERED_OLD: ${{ steps.sweep.outputs.unregistered_old }} + REPORTS: ${{ runner.temp }}/reports + run: bash .github/scripts/fork-follow-report.sh