diff --git a/.github/fork-ci-tests.txt b/.github/fork-ci-tests.txt index 9947547722dcc..76cefd07c8701 100644 --- a/.github/fork-ci-tests.txt +++ b/.github/fork-ci-tests.txt @@ -14,6 +14,7 @@ .github/scripts/fork-secret-scan.test.sh .github/scripts/fork-test-shards.test.cjs .github/scripts/fork-tests-drift.test.cjs +.github/scripts/fork-workflow-triggers.test.cjs # Upstream's tests, run where fork-tests.yml follows upstream's ci.yml. .github/scripts/check-nightly-release.test.cjs run by .github/workflows/fork-tests.yml diff --git a/.github/fork-removed-workflows.txt b/.github/fork-removed-workflows.txt new file mode 100644 index 0000000000000..8dbe43ec36eda --- /dev/null +++ b/.github/fork-removed-workflows.txt @@ -0,0 +1,14 @@ +# Upstream workflow files the fork deletes and keeps deleted (fork-follow.sh). One path per line; +# '#' starts a comment line. On every follow the plan deletes each listed file that upstream edited +# or brought back (a rebase conflict on a listed file is resolved by deleting it), and it stops red +# when this file is missing or a line is not a .github/workflows/*.yml path. The fork's sweep +# (fork-workflows.sh) disables upstream workflows only once GitHub lists them, which cannot stop the +# first run of one that someone outside the fork can start; these are such files. +# +# pull_request_target: an outside pull request starts it with main's copy and a write token. +.github/workflows/mobile-eas-preview-fork.yml +# Owner decision, 2026-11-12. +.github/workflows/desktop-macos-preview.yml +# workflow_run on a workflow named "CI": an outside pull request can add a workflow of that name. +.github/workflows/thread-transfer-report.yml +.github/workflows/mobile-fingerprint-label.yml diff --git a/.github/scripts/fork-contracts.test.py b/.github/scripts/fork-contracts.test.py index 639dc818b2c27..e1ef7a69bcb41 100644 --- a/.github/scripts/fork-contracts.test.py +++ b/.github/scripts/fork-contracts.test.py @@ -15,6 +15,9 @@ gh-guard every fork script that calls gh sources fork-gh-guard.sh at the top level, after nothing but `set`, before its first call (backslash continuations joined), and every fork shell test sets up the gh stub (fork-test-gh.sh) before anything else. + removed .github/fork-removed-workflows.txt (the upstream workflows the fork deletes, kept + deleted by fork-follow.sh) is there, every line is an upstream workflow path, none of + them is in the tree, and it names the files the owner decided to delete. python3 .github/scripts/fork-contracts.test.py @@ -750,6 +753,46 @@ def test_problems(files): check("gh-guard: a node test spawning gh fails", bool(test_problems({**files, "fork-new.test.cjs": 'execFileSync("gh", ["api", "x"]);\n'}))) +# --- removed ----------------------------------------------------------------------------------- +removed_path = root / ".github/fork-removed-workflows.txt" +REMOVED_LINE = re.compile(r"\.github/workflows/[^/,\s]+\.ya?ml") +# Owner decision 2026-11-12: outside events start these before the sweep can disable them. +OWNER_REMOVED = {".github/workflows/" + n for n in [ + "mobile-eas-preview-fork.yml", "desktop-macos-preview.yml", "thread-transfer-report.yml", + "mobile-fingerprint-label.yml"]} + + +def removed_entries(text): + return [l.rstrip("\r") for l in text.splitlines() if not re.fullmatch(r"\s*(#.*)?", l.rstrip("\r"))] + + +def removed_problems(text, tree): + problems = [] + for line in removed_entries(text): + if not REMOVED_LINE.fullmatch(line) or line.startswith(".github/workflows/fork-"): + problems.append(f"{line!r} is not an upstream workflow path") + elif line in tree: + problems.append(f"{line} is listed as deleted but is in the tree") + return problems + + +tree = {str(p.relative_to(root)) for p in workflows_dir.iterdir()} +removed_text = removed_path.read_text() if removed_path.is_file() else None +check("removed: .github/fork-removed-workflows.txt is there", removed_text is not None) +removed_text = removed_text or "" +check("removed: every line is an upstream workflow path that is not in the tree", + not removed_problems(removed_text, tree), "; ".join(removed_problems(removed_text, tree))) +check("removed: the list names the four outside-started workflows the owner decided to delete", + OWNER_REMOVED <= set(removed_entries(removed_text)), + ", ".join(sorted(OWNER_REMOVED - set(removed_entries(removed_text))))) +for label, (text, extra) in { + "a listed file still in the tree": (removed_text, {".github/workflows/thread-transfer-report.yml"}), + "a bare name": (removed_text + "ci.yml\n", set()), + "a fork-*.yml": (removed_text + ".github/workflows/fork-follow.yml\n", set()), + "a path outside .github/workflows": (removed_text + ".github/workflows/../ci.yml\n", set()), +}.items(): + check(f"removed: {label} fails", bool(removed_problems(text, tree | extra))) + shutil.rmtree(work, ignore_errors=True) if failures: print(f"{len(failures)} failed") diff --git a/.github/scripts/fork-follow.sh b/.github/scripts/fork-follow.sh index 227eb438fc6d4..637780154199b 100755 --- a/.github/scripts/fork-follow.sh +++ b/.github/scripts/fork-follow.sh @@ -28,6 +28,14 @@ # 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). +# +# Removed upstream workflows: .github/fork-removed-workflows.txt (read from the series head) lists +# upstream workflow files the fork deletes. A rebase conflict on listed files only is resolved by +# deleting them; any listed file the rebased candidate still has (upstream re-added it) is deleted +# by one more commit. On the same base the series must not have one. A missing or malformed list +# stops the plan: it never ships the files because it could not read which ones to delete. +# A workflow file the new upstream release adds that someone outside the fork can start (see +# outside_events) and that the list does not delete stops the plan too, naming it. set -Eeuo pipefail : "${UPSTREAM:?owner/name of the upstream repository}" @@ -64,6 +72,7 @@ stable_re='^v[0-9]+\.[0-9]+\.[0-9]+$' fork_re='^v[0-9]+\.[0-9]+\.[0-9]+-fork\.[0-9]+$' head="$(git rev-parse HEAD)" run_sha="${RUN_SHA:-$head}" +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Read-only fetches into refs this run alone uses: upstream's tags and default branch, and this # fork's tags (its releases). @@ -146,6 +155,68 @@ schema_check() { # schema_check BASE WHAT: fails with the check's output, a refu } schema_check "$base" "The series" +# The upstream workflow files the fork deletes, from the series head. Fail closed: without a +# readable list the plan cannot keep them deleted, so it stops instead of shipping them. +removed_file=.github/fork-removed-workflows.txt +removed_re='^\.github/workflows/[^/,[:space:]]+\.ya?ml$' +git cat-file -e "$head:$removed_file" 2> /dev/null \ + || fail "The series has no $removed_file, the list of upstream workflow files the fork deletes." \ + "Without it the plan cannot keep them deleted, so it stopped; the series was left as it is." \ + "Restore the file with a fork patch (an empty list holds only comment lines)." +removed=() +while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%$'\r'}" + [[ "$line" =~ ^[[:space:]]*(#.*)?$ ]] && continue + if [[ ! "$line" =~ $removed_re || "$line" =~ ^\.github/workflows/fork- ]]; then + fail "$removed_file has a line that is not an upstream workflow path (.github/workflows/.yml, not fork-*):" \ + "\`$line\`" "The series was left as it is. Fix the line with a fork patch." + fi + removed+=("$line") +done < <(git show "$head:$removed_file") +is_removed() { local p; for p in "${removed[@]}"; do [[ "$p" != "$1" ]] || return 0; done; return 1; } +present_removed() { # present_removed COMMIT: the listed files COMMIT has, one per line + local p + for p in "${removed[@]}"; do + git cat-file -e "$1:$p" 2> /dev/null && printf '%s\n' "$p" + done + return 0 +} + +# Events someone outside the fork can start a workflow with. GitHub runs such a workflow before +# the sweep can see and disable it, so a new one must not reach the series. Directly: an outside +# pull request (pull_request_target, main's copy), a review or a review comment on a pull request +# from a fork/* branch (pull_request_review, pull_request_review_comment: that branch's copy, with +# the repository's token), an issue or a comment (issues, issue_comment), a discussion +# (discussion, discussion_comment), a star (watch), a fork, a wiki edit if the wiki is open +# (gollum). Indirectly: a workflow of a given name that an outside pull request can add +# (workflow_run), and the checks, commit statuses and deployments that apps post on an outside +# pull request's commits (check_run, check_suite, status, deployment_status). pull_request from +# another fork gets a read-only token and no secrets, so the sweep is enough for it. The rest need +# write access or more. +outside_events='^(pull_request_target|pull_request_review|pull_request_review_comment|issues|issue_comment|discussion|discussion_comment|watch|fork|gollum|workflow_run|check_run|check_suite|status|deployment_status)$' +refuse_outside_workflows() { # refuse_outside_workflows UPSTREAM_BASE UPSTREAM_TARGET TAG CANDIDATE + local path list=() found=() events status + workflow_paths A "$1" "$2" + IFS=, read -r -a list <<< "$paths" + for path in "${list[@]}"; do + [[ "$path" == *.yml || "$path" == *.yaml ]] || continue + git cat-file -e "$4:$path" 2> /dev/null || continue + git show "$4:$path" > "$logs/workflow.yml" + status=0 + events="$(node "$here/fork-workflow-triggers.cjs" "$logs/workflow.yml" 2> "$logs/triggers.log")" || status=$? + if ((status != 0)); then + found+=("- \`$path\`: its triggers could not be read ($(tr '\n' ' ' < "$logs/triggers.log"| sed 's/ *$//'))") + elif grep -qE "$outside_events" <<< "$events" || grep -qwE 'pull_request_target|workflow_run' "$logs/workflow.yml"; then + found+=("- \`$path\`: on $(paste -sd ',' <<< "$events" | sed 's/,/, /g')") + fi + done + ((${#found[@]} == 0)) || fail "$UPSTREAM $3 adds workflow files that someone outside the fork can start, or whose triggers could not be read:" \ + "${found[@]}" "" \ + "The workflow sweep cannot stop the first run of such a file, so the series was left as it is." \ + "Add each one to $removed_file with a fork patch (the next follow then deletes it) and start the follow workflow again;" \ + "or, to keep one, rebase onto $3 by hand (see the conflict advice in fork-follow.yml)." +} + # The newest fork release of a tag: " ", or nothing. latest_release() { local last @@ -243,6 +314,11 @@ stage_report() { # stage_report CANDIDATE TAG CHANGED if [[ "$target_tag" == "$base_tag" ]]; then refuse_upstream_fork_named "$base" "$base_tag" "$head" + # The candidate here is the series head itself, so a listed file in it is refused, not deleted. + kept="$(present_removed "$head")" + [[ -z "$kept" ]] || fail "The series still has workflow files that $removed_file lists as deleted:" \ + "$(sed 's/^/- /' <<< "$kept")" "" \ + "The plan does not release them. Delete them with a fork patch (or take them off the list), then start the follow workflow again." read -r released_tag released_commit <<< "$(latest_release "$base_tag")" || true if [[ "${released_commit:-}" == "$head" ]]; then action=none @@ -269,11 +345,31 @@ if [[ "$target_tag" == "$base_tag" ]]; then fi # A newer upstream release: replay the series onto it. Patches upstream now has become empty and -# are dropped. +# are dropped. A stop whose conflicts are all on listed (deleted) workflow files is resolved by +# deleting them: upstream edited or brought back a file the fork deletes. target="$(up "$target_tag")" before="$(git log --format=%s "$base..HEAD")" -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 +as_fork() { git -c user.name=t3code-fork -c user.email=noreply@users.noreply.github.com "$@"; } +rebasing() { [[ -d "$(git rev-parse --git-path rebase-merge)" || -d "$(git rev-parse --git-path rebase-apply)" ]]; } +rebase_ok=0 +if as_fork rebase --empty=drop --onto "$target" "$base" > "$logs/rebase.log" 2>&1; then + rebase_ok=1 +fi +steps=0 +while ((!rebase_ok)) && rebasing && ((steps++ <= series_count)); do + unmerged=() + while IFS= read -r -d '' path; do unmerged+=("$path"); done \ + < <(git -c core.quotepath=off diff -z --name-only --diff-filter=U) + ((${#unmerged[@]})) || break + for path in "${unmerged[@]}"; do is_removed "$path" || break 2; done + git rm -q --ignore-unmatch -- "${unmerged[@]}" >> "$logs/rebase.log" 2>&1 + if git diff --cached --quiet HEAD; then + GIT_EDITOR=true as_fork rebase --skip >> "$logs/rebase.log" 2>&1 && rebase_ok=1 + else + GIT_EDITOR=true as_fork rebase --continue >> "$logs/rebase.log" 2>&1 && rebase_ok=1 + fi +done +if ((!rebase_ok)); 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 @@ -284,9 +380,19 @@ if ! git -c user.name=t3code-fork -c user.email=noreply@users.noreply.github.com "\`--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 +# Upstream brought back a listed file where no fork patch deletes it any more (the deleting patch +# became empty and was dropped once upstream had deleted the file itself): delete it again. +kept="$(present_removed HEAD)" +if [[ -n "$kept" ]]; then + mapfile -t kept_list <<< "$kept" + git rm -q -- "${kept_list[@]}" + as_fork commit -q -m "fork: delete upstream workflows listed in $removed_file" \ + -m "Upstream $target_tag has them again: $(paste -sd ' ' <<< "$kept")" +fi schema_check "$target" "After rebasing onto $target_tag, the series" candidate="$(git rev-parse HEAD)" refuse_upstream_fork_named "$target" "$target_tag" "$candidate" +refuse_outside_workflows "$base" "$target" "$target_tag" "$candidate" dropped="$(comm -23 <(sort <<< "$before") <(git log --format=%s "$target..HEAD" | sort) | paste -sd ';' -)" # A published fork release of the target with exactly these files exists (an earlier run published diff --git a/.github/scripts/fork-follow.test.sh b/.github/scripts/fork-follow.test.sh index 22ecce78d4395..2e6c9a1c3f221 100755 --- a/.github/scripts/fork-follow.test.sh +++ b/.github/scripts/fork-follow.test.sh @@ -140,7 +140,8 @@ new_upstream() { } # 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. +# files and scripts, an empty list of removed upstream workflows, 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 @@ -152,6 +153,7 @@ new_fork() { 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 "# upstream workflow files the fork deletes" > .github/fork-removed-workflows.txt echo feature > feature.txt git add -A && git commit -q -m "fork: own change" git push -q --force origin main 2> /dev/null @@ -390,6 +392,142 @@ printf '#!/bin/sh\necho "Fork changes must not change the database schema (no do 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"' +# Round 5: upstream workflow files the fork deletes (.github/fork-removed-workflows.txt) stay +# deleted on every follow, and a new upstream workflow that someone outside the fork can start +# stops the plan. Here the fork deletes upstream's ci.yml. +up_release() { # up_release TAG CMD...: one upstream commit on main made by CMD, tagged TAG + (cd "$root/upwork" && git checkout -q main && eval "${*:2}" && git add -A && git commit -q -m "$1" \ + && git tag "$1" && git push -q https://github.com/up/t3code.git main --tags) > /dev/null 2>&1 +} +fork_commit() { # fork_commit MESSAGE CMD...: one fork commit made by CMD, pushed as main + (cd "$root/work" && eval "${*:2}" && git add -A && git commit -q -m "$1" && git push -q origin main) > /dev/null 2>&1 +} +fork_remove() { # fork_remove FILE...: a fork patch that lists and deletes upstream workflow files + local f cmd="" + for f in "$@"; do cmd+="echo .github/workflows/$f >> .github/fork-removed-workflows.txt && rm -f .github/workflows/$f && "; done + fork_commit "fork: delete $*" "${cmd}true" +} +in_candidate() { git -C "$root/work" cat-file -e "$(out candidate):.github/workflows/$1" 2> /dev/null; } + +new_upstream +up_release v0.1.11 'echo "on: [push] # edited" > .github/workflows/ci.yml' +new_fork v0.1.10 +fork_remove ci.yml +plan +check "plan: upstream edits a deleted listed file -> the conflict is resolved by deleting it, rebase" \ + eval 'action_is rebase && [[ "$(out base_tag)" == v0.1.11 ]] && ! in_candidate ci.yml && [[ ! -e "$root/report.md" ]]' +check "plan: the resolved rebase keeps the fork's patches and its list" \ + eval 'action_is rebase && git -C "$root/work" cat-file -e "$(out candidate):feature.txt" && git -C "$root/work" show "$(out candidate):.github/fork-removed-workflows.txt" | grep -qx .github/workflows/ci.yml' +# Re-run: an earlier run published this candidate, then stopped before the branch moved. The +# next run rebuilds the same files and advances to that release instead of building again. +released="$(out candidate)" +fork_tag v0.1.11-fork.1 "$released" +work git checkout -q main && work git reset -q --hard "$(series)" +plan +check "plan: re-run after a resolved deletion -> the same files, advance to the earlier release" \ + eval 'action_is advance && [[ "$(out candidate)" == "$released" ]]' + +new_upstream +up_release v0.1.11 'echo "on: [push] # edited" > .github/workflows/ci.yml' +new_fork v0.1.10 +fork_remove ci.yml +fork_commit "fork: own u4" 'echo mine > u4.txt' +before="$(work_head)" +plan +check "plan: a conflict on a listed file and an unlisted one is refused, naming the unlisted one" \ + eval '[[ -z "$(out action)" ]] && has "u4.txt" "$root/report.md" && hasnt "\.github/workflows/ci.yml" "$root/report.md" && [[ ! -d "$root/work/.git/rebase-merge" && "$(work_head)" == "$before" ]]' + +# Upstream deleted the file itself (the fork's deleting patch has nothing left to do), then brings +# it back: the plan deletes it again with one more commit. +new_upstream +up_release v0.1.11 'git rm -q .github/workflows/ci.yml' +up_release v0.1.12 'echo "on: pull_request_target" > .github/workflows/ci.yml' +new_fork v0.1.11 +fork_commit "fork: list ci.yml" 'echo .github/workflows/ci.yml >> .github/fork-removed-workflows.txt' +plan +check "plan: upstream brings a listed file back -> deleted again on the candidate, rebase" \ + eval 'action_is rebase && [[ "$(out base_tag)" == v0.1.12 ]] && ! in_candidate ci.yml && git -C "$root/work" log -1 --format=%s "$(out candidate)" | grep -q "fork: delete upstream workflows listed"' + +new_upstream +new_fork v0.1.10 +fork_commit "fork: list ci.yml" 'echo .github/workflows/ci.yml >> .github/fork-removed-workflows.txt' +plan +check "plan: same base, the series still has a listed file -> refused, naming it" \ + eval '[[ -z "$(out action)" ]] && has "still has workflow files" "$root/report.md" && has "ci.yml" "$root/report.md"' + +# Fail closed: no list, or a line that is not an upstream workflow path, stops the plan. +new_upstream +new_fork v0.1.10 +fork_commit "fork: lose the list" 'git rm -q .github/fork-removed-workflows.txt' +plan +check "plan: the list of removed workflows is missing -> refused, nothing planned" \ + eval '[[ -z "$(out action)" ]] && has "has no .github/fork-removed-workflows.txt" "$root/report.md"' +for bad in "ci.yml" ".github/workflows/fork-follow.yml" ".github/workflows/a b.yml" "../ci.yml"; do + new_fork v0.1.10 + fork_commit "fork: bad list" "echo '$bad' >> .github/fork-removed-workflows.txt" + plan + check "plan: a list line '$bad' -> refused, nothing planned" \ + eval '[[ -z "$(out action)" ]] && has "not an upstream workflow path" "$root/report.md"' +done +new_upstream +up_release v0.1.11 'echo "on: [push] # edited" > .github/workflows/ci.yml' +new_fork v0.1.10 +fork_commit "fork: comments only" 'printf "# nothing\n\n # still nothing\n" > .github/fork-removed-workflows.txt' +plan +check "plan: a list with comments only deletes nothing, the plan goes on" \ + eval 'action_is rebase && in_candidate ci.yml' +new_upstream +up_release v0.1.11 'echo "on: [push] # edited" > .github/workflows/ci.yml' +new_fork v0.1.10 +fork_commit "fork: delete ci.yml, CRLF list" "printf '# deleted\\r\\n.github/workflows/ci.yml\\r\\n' > .github/fork-removed-workflows.txt && rm .github/workflows/ci.yml" +plan +check "plan: a list with CRLF line ends is read like one with LF, the listed file stays deleted" \ + eval 'action_is rebase && ! in_candidate ci.yml && git -C "$root/work" show "$(out candidate):.github/fork-removed-workflows.txt" | grep -q $'"'"'ci.yml\r$'"'"'' + +# Upstream renames a listed file: git reports a rename/delete conflict on the new name, which the +# list does not name, so the plan stops and names it rather than shipping it under that name. +new_upstream +up_release v0.1.11 'git mv .github/workflows/ci.yml .github/workflows/ci-renamed.yml' +new_fork v0.1.10 +fork_remove ci.yml +plan +check "plan: upstream renames a listed file -> refused, naming the new name, rebase aborted" \ + eval '[[ -z "$(out action)" ]] && has "ci-renamed.yml" "$root/report.md" && hasnt "plan stopped at" "$root/report.md" && [[ ! -d "$root/work/.git/rebase-merge" ]]' + +# A new upstream workflow that someone outside the fork can start is refused unless listed. +for case in "pull_request_target|printf 'on:\n pull_request_target:\n types: [opened]\n'" \ + "workflow_run|printf 'name: x\non:\n workflow_run:\n workflows: [CI]\n'" \ + "issues|printf 'on: [push, issues]\n'" \ + "issue_comment|printf 'on: issue_comment # comments\n'" \ + "pull_request_review|printf 'on:\n pull_request_review:\n types: [submitted]\n'" \ + "pull_request_review_comment|printf 'on: [pull_request_review_comment]\n'" \ + "gollum|printf 'on: gollum\n'" \ + "on push|printf 'on: push\njobs:\n a:\n if: github.event_name == \"pull_request_target\"\n'" \ + "could not be read|printf 'on: {workflow_run: {workflows: [CI]}}\n'" \ + "could not be read|printf 'name: \"x\non: push\n\"\non : issues\n'" \ + "could not be read|printf 'jobs: {}\n'"; do + want="${case%%|*}" + new_upstream + up_release v0.1.11 "${case#*|} > .github/workflows/outside.yml" + new_fork v0.1.10 + plan + check "plan: upstream adds a workflow ($want) -> refused, naming it" \ + eval '[[ -z "$(out action)" ]] && has "outside.yml" "$root/report.md" && has "$want" "$root/report.md" && has "fork-removed-workflows.txt" "$root/report.md"' +done +new_upstream +up_release v0.1.11 "printf 'on:\n push:\n branches: [main]\npermissions:\n issues: write\njobs:\n issues:\n runs-on: x\n' > .github/workflows/inside.yml" +new_fork v0.1.10 +plan +check "plan: a new upstream workflow on push only (issues: as a permission and a job) goes on" \ + eval 'action_is rebase && in_candidate inside.yml' +new_upstream +up_release v0.1.11 "printf 'on: pull_request_target\n' > .github/workflows/outside.yml" +new_fork v0.1.10 +fork_commit "fork: list outside.yml" 'echo .github/workflows/outside.yml >> .github/fork-removed-workflows.txt' +plan +check "plan: a new outside-started upstream workflow that the list names -> deleted, rebase" \ + eval 'action_is rebase && ! in_candidate outside.yml' + # --- pushes: fork-follow-push.sh ------------------------------------------------------------- new_upstream new_fork v0.1.10 diff --git a/.github/scripts/fork-schema-check.test.cjs b/.github/scripts/fork-schema-check.test.cjs index a8e7e9d19e871..5a458afb7d334 100644 --- a/.github/scripts/fork-schema-check.test.cjs +++ b/.github/scripts/fork-schema-check.test.cjs @@ -569,7 +569,7 @@ test("a workflow that pastes PR values into a script or reaches a token fails th // A plain one-line run continued on the next line: YAML folds it into the same script. "an expression on the continuation of a plain run": workflow.replace( /^( +)run: (node --test .*)$/m, - (all, indent, cmd) => `${all}\n${indent} \${{ github.head_ref }}`, + (all, indent) => `${all}\n${indent} \${{ github.head_ref }}`, ), }; for (const [label, text] of Object.entries(mutants)) { diff --git a/.github/scripts/fork-workflow-triggers.cjs b/.github/scripts/fork-workflow-triggers.cjs new file mode 100644 index 0000000000000..3a6f6790603a7 --- /dev/null +++ b/.github/scripts/fork-workflow-triggers.cjs @@ -0,0 +1,184 @@ +// Fork only: `node fork-workflow-triggers.cjs FILE` prints the events a workflow starts on (its +// top-level `on:`), one per line, so fork-follow.sh can refuse new upstream workflows outsiders can +// start. It reads only plain YAML: on any other shape it exits 2 with the reason on stderr, and the +// caller cannot clear the file. No dependencies. Tests: fork-workflow-triggers.test.cjs. +const fs = require("node:fs"); + +const EVENT = /^[a-z_]+$/; +const ON_KEY = /^(?:on|"on"|'on'):(?=[ \t]|$)(.*)$/; +const TOP_KEY = /^(?:[A-Za-z_][A-Za-z0-9_-]*|"on"|'on'):(?=[ \t]|$)/; +const ODD_BREAK = /\r(?!\n)|[\u0085\u2028\u2029]/; +// A line whose value is a block scalar (`run: |`, `- >-`, `key: &a |`): dashes, key, indicator. +const BLOCK_SCALAR = /^((?:-\s+)*)((?:[^\s#][^#]*?:\s+)?)(?:[&!]\S+\s+)*[|>][1-9+-]{0,2}$/; + +const unquote = (s) => s.trim().replace(/^(["'])(.*)\1$/, "$2"); +// Only ASCII spaces indent and only spaces and tabs are blank, as in YAML: a line that starts with +// a BOM or another Unicode space is unindented, so it must be a plain key. +const indentOf = (s) => s.match(/^ */)[0].length; +const blank = (s) => /^[ \t]*$/.test(s); + +// One line, read the way YAML reads it, inside DEPTH open brackets: `code` is the line without its +// comment, `quote` says a quoted scalar is still open at its end, `depth` how many brackets are. +// A quote or bracket starts a scalar or list only where a token starts (`name: it's` is text). +const scan = (line, depth) => { + let quote = null; + let tokenStart = true; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (quote === '"') { + if (c === "\\") i++; + else if (c === '"') [quote, tokenStart] = [null, false]; + continue; + } + if (quote === "'") { + if (c === "'" && line[i + 1] === "'") i++; + else if (c === "'") [quote, tokenStart] = [null, false]; + continue; + } + if (c === "#" && (i === 0 || /[ \t]/.test(line[i - 1]))) { + return { code: line.slice(0, i).trimEnd(), quote: false, depth }; + } + if (/[ \t]/.test(c)) continue; + if (tokenStart && (c === '"' || c === "'")) quote = c; + else if ((tokenStart || depth > 0) && (c === "[" || c === "{")) + [depth, tokenStart] = [depth + 1, true]; + else if (depth > 0 && (c === "]" || c === "}")) [depth, tokenStart] = [depth - 1, false]; + else if (depth > 0 && (c === "," || c === ":")) tokenStart = true; + else if ("-?:".includes(c) && (i + 1 === line.length || /[ \t]/.test(line[i + 1]))) + tokenStart = true; + else tokenStart = false; + } + return { code: line.trimEnd(), quote: quote !== null, depth }; +}; + +// The events TEXT (a workflow file) starts on, sorted. Throws when it cannot tell for sure. +const readTriggers = (text) => { + const unsure = (why) => { + throw new Error(`cannot read the triggers: ${why}`); + }; + // YAML 1.1 also breaks lines at these, which a split on \n would not see. + if (ODD_BREAK.test(text)) + unsure("a line break other than \\n or \\r\\n (a lone \\r, U+0085, U+2028 or U+2029)"); + const lines = text.split(/\r?\n/); + if (lines.some((l) => /^\s*\t/.test(l))) unsure("a line is indented with a tab"); + + // The code of each line (null: blank, a comment, a block scalar's text, a bracket list's later + // lines). A quote left open at a line's end could carry a decoy `on:`, so it is refused. A list + // may go over lines (upstream's `needs:` do) if those lines are indented more than its first; a + // last line of only closing brackets may line up with it. + const codes = []; + let blockAbove = -1; // a block scalar's text is the lines indented more than this + let depth = 0; // brackets open at the end of the last line + let flowFrom = -1; // the line that opened them + lines.forEach((line, i) => { + if (blockAbove !== -1 && (blank(line) || indentOf(line) > blockAbove)) return codes.push(null); + blockAbove = -1; + const scanned = scan(line, depth); + if (scanned.quote) unsure(`a quote is left open at the end of line ${i + 1}`); + if (depth > 0) { + const [here, from] = [indentOf(line), indentOf(lines[flowFrom])]; + const closing = /^[\]}]+$/.test(scanned.code.trim()) && scanned.depth === 0; + if (!blank(scanned.code) && !(here > from || (closing && here === from))) { + unsure( + `line ${i + 1} is inside the bracket opened on line ${flowFrom + 1} but not indented more than that line`, + ); + } + depth = scanned.depth; + return codes.push(null); + } + ({ depth } = scanned); + if (depth > 0) flowFrom = i; + const { code } = scanned; + if (blank(code)) return codes.push(null); + const body = code.trim(); + const block = body.match(BLOCK_SCALAR); + if (block) { + // Its text is indented more than its key, or than its `-` when it has no key. + const [, dashes, key] = block; + blockAbove = indentOf(code) + (key ? dashes.length : dashes ? dashes.lastIndexOf("-") : -1); + } + codes.push(code); + }); + if (depth > 0) unsure(`the bracket opened on line ${flowFrom + 1} is never closed`); + + let found = -1; + let seenContent = false; + codes.forEach((code, i) => { + if (code === null || indentOf(code) !== 0) return; + if (code === "---" || code === "...") { + if (seenContent) unsure("more than one YAML document"); + return; + } + seenContent = true; + // This refuses `on :`, escaped or other quoted keys, `? on`, directives and a root list. + if (!TOP_KEY.test(code)) unsure(`the unindented line \`${code}\` is not a plain \`key:\``); + if (ON_KEY.test(code)) { + if (found !== -1) unsure("`on:` appears twice"); + found = i; + } + }); + if (found === -1) unsure("no top-level `on:`"); + + const events = []; + const name = (raw) => { + const event = unquote(raw); + if (!EVENT.test(event)) unsure(`\`${raw.trim()}\` is not an event name`); + events.push(event); + }; + const rest = codes[found].match(ON_KEY)[1].trim(); + if (/^\[[^[\]{}]*\]$/.test(rest)) { + rest + .slice(1, -1) + .split(",") + .filter((s) => s.trim() !== "") + .forEach(name); + } else if (/^[[{&*|>!]/.test(rest)) { + unsure(`\`on: ${rest}\` is a form this reader does not take`); + } else if (rest !== "") { + name(rest); + } else { + let childIndent = -1; + for (const code of codes.slice(found + 1)) { + if (code === null) continue; + const indent = indentOf(code); + if (indent === 0) break; + if (childIndent === -1) childIndent = indent; + if (indent < childIndent) unsure("the `on:` block is indented unevenly"); + if (indent > childIndent) continue; + const body = code.trim(); + const item = body.match(/^-\s+(.*)$/); + const key = body.match(/^("[^"]*"|'[^']*'|[^\s:"']+)\s*:(?=[ \t]|$)/); + if (item) name(item[1]); + else if (key && key[1] !== "<<") name(key[1]); + else unsure(`\`${body}\` under \`on:\``); + } + if (childIndent === -1) unsure("`on:` is empty"); + } + return [...new Set(events)].sort(); +}; + +const main = () => { + try { + let text; + try { + text = fs.readFileSync(process.argv[2], "utf8"); + } catch (error) { + throw new Error( + `cannot read the triggers: cannot read ${process.argv[2]}: ${error.message}`, + { + cause: error, + }, + ); + } + const events = readTriggers(text); + process.stdout.write(events.map((e) => `${e}\n`).join("")); + } catch (error) { + // Any failure, a bug included, means "cannot tell", which the caller refuses. + process.stderr.write(`${error.message}\n`); + process.exit(2); + } +}; + +module.exports = { readTriggers }; + +if (require.main === module) main(); diff --git a/.github/scripts/fork-workflow-triggers.test.cjs b/.github/scripts/fork-workflow-triggers.test.cjs new file mode 100644 index 0000000000000..0b9373d108ee9 --- /dev/null +++ b/.github/scripts/fork-workflow-triggers.test.cjs @@ -0,0 +1,197 @@ +// Tests for fork-workflow-triggers.cjs: what it reads, and every shape it refuses. +// node --test .github/scripts/fork-workflow-triggers.test.cjs +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const test = require("node:test"); +const { readTriggers } = require("./fork-workflow-triggers.cjs"); + +const SCRIPT = path.join(__dirname, "fork-workflow-triggers.cjs"); +const yml = (...lines) => `${lines.join("\n")}\n`; +const refuses = (text, why) => assert.throws(() => readTriggers(text), why); + +test("reads the top-level on: in each plain form", () => { + assert.deepEqual(readTriggers(yml("on: push")), ["push"]); + assert.deepEqual(readTriggers(yml("on: [push, issues] # two")), ["issues", "push"]); + assert.deepEqual(readTriggers(yml("'on': 'issues'")), ["issues"]); + assert.deepEqual( + readTriggers( + yml('"on":', " push:", " branches: [main]", " - fork", ' "watch":', "jobs: {}"), + ), + ["fork", "push", "watch"], + ); +}); + +test("issues: as a permission or a job is not a trigger", () => { + const text = yml( + "on:", + " push:", + "permissions:", + " issues: write", + "jobs:", + " issues:", + " runs-on: x", + ); + assert.deepEqual(readTriggers(text), ["push"]); +}); + +test("takes what upstream writes: CRLF, block scalars, quotes in text, a needs list over lines", () => { + const text = yml( + 'name: it\'s "fine" # a comment', + "on:", + " push: # on: issues", + "jobs:", + " check:", + ' name: "a #b"', + " needs:", + " [", + " build,", + " test,", + " ]", + " steps:", + " - run: |", + " echo \"it's open [", + "", + " 'it's [ open", + " shell: bash", + " - with:", + " args: >-", + " don't", + " - |", + ' "open', + " - >2", + " [open", + "on_more: x", + ); + assert.deepEqual(readTriggers(text), ["push"]); + assert.deepEqual(readTriggers(text.replace(/\n/g, "\r\n")), ["push"]); +}); + +test("refuses a line break other than \\n or \\r\\n", () => { + for (const nl of ["\r", "\u0085", "\u2028", "\u2029"]) { + refuses(`on:\n push: {}${nl} issues: {}\n`, /line break other than/); + } +}); + +test("refuses a tab in the indent", () => { + refuses("on:\n\tissues:\n", /indented with a tab/); +}); + +test("refuses an unindented line that is not a plain key", () => { + for (const line of [ + "on : issues", + '"o\\x6en": [issues]', + "? on", + "%YAML 1.2", + "- push", + "{on: issues}", + "\uFEFFon: push", + "\u00a0on: push", + "true :", + ]) { + refuses(yml(line, "jobs: {}"), /is not a plain `key:`/); + } + refuses(yml("on:", "- issues"), /is not a plain `key:`/); +}); + +test("refuses a quote left open at the end of a line (a decoy on: inside a string)", () => { + refuses( + yml('name: "x', "on: push", '"', "on : issues"), + /quote is left open at the end of line 1/, + ); + refuses(yml("name: 'x", "on: push", "'"), /quote is left open at the end of line 1/); + refuses( + yml("on: push", "jobs:", " a:", ' name: "x \\"', ' y"'), + /quote is left open at the end of line 4/, + ); +}); + +test("reads the lines after a block scalar's text again", () => { + refuses( + yml( + "on: push", + "jobs:", + " a:", + " steps:", + " - run: |", + " x", + ' name: "open', + ), + /line 7/, + ); + refuses(yml("on: push", "x:", " - |", " y", ' - "open'), /line 5/); + refuses(yml("name: |", " text", "on : issues"), /is not a plain `key:`/); +}); + +test("refuses a bracket list whose lines are not indented more than its first line", () => { + const decoy = yml( + "jobs:", + " a:", + " strategy:", + " matrix: [x,", + "on: push", + "]", + '"on": [issues]', + ); + refuses(decoy, /line 5 is inside the bracket opened on line 4/); + refuses( + yml("on: push", "jobs:", " a:", " needs: [", " a,", " ] x"), + /line 6 is inside the bracket/, + ); + refuses( + yml("on: push", "jobs:", " a:", " needs: [", " a,"), + /bracket opened on line 4 is never closed/, + ); +}); + +test("refuses on: twice and more than one document", () => { + refuses(yml("on: push", "jobs: {}", "on: issues"), /`on:` appears twice/); + refuses(yml("on: push", '"on": issues'), /`on:` appears twice/); + refuses(yml("---", "on: push", "---", "on: issues"), /more than one YAML document/); + refuses(yml("on: push", "...", "on: issues"), /more than one YAML document/); + assert.deepEqual(readTriggers(yml("--- # one document", "on: push")), ["push"]); +}); + +test("refuses no on:, an empty on: and uneven indent under on:", () => { + refuses(yml("jobs: {}"), /no top-level `on:`/); + refuses(yml("name: x", " on: issues"), /no top-level `on:`/); + refuses(yml("on:", "jobs: {}"), /`on:` is empty/); + refuses(yml("on:", " push:", " issues:"), /indented unevenly/); +}); + +test("refuses a list over lines and every other form under on:", () => { + refuses(yml("on: [push,", " issues]"), /form this reader does not take/); + for (const rest of ["{push: {}}", "&a push", "*a", "|", ">-", "!!str push", "[push, [issues]]"]) { + refuses(yml(`on: ${rest}`, " x"), /form this reader does not take/); + } + refuses(yml("on:", " <<: *a"), /`<<: \*a` under `on:`/); + refuses(yml("on:", " ? issues"), /`\? issues` under `on:`/); + refuses(yml("on: Issues"), /`Issues` is not an event name/); + refuses(yml("on: [push, 'iss\\x75es']"), /is not an event name/); + refuses(yml("on:", " - pull_request_target: x"), /is not an event name/); +}); + +test("the command prints one event per line, and exits 2 with the reason when unsure", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fork-triggers-")); + try { + const file = path.join(dir, "w.yml"); + fs.writeFileSync(file, yml("on: [workflow_run, push]")); + let run = spawnSync(process.execPath, [SCRIPT, file], { encoding: "utf8" }); + assert.equal(run.status, 0); + assert.equal(run.stdout, "push\nworkflow_run\n"); + fs.writeFileSync(file, yml("jobs: {}")); + run = spawnSync(process.execPath, [SCRIPT, file], { encoding: "utf8" }); + assert.equal(run.status, 2); + assert.equal(run.stdout, ""); + assert.match(run.stderr, /^cannot read the triggers: no top-level `on:`\n$/); + run = spawnSync(process.execPath, [SCRIPT, path.join(dir, "missing.yml")], { + encoding: "utf8", + }); + assert.equal(run.status, 2); + assert.match(run.stderr, /^cannot read the triggers: cannot read .*missing\.yml/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/.github/workflows/desktop-macos-preview.yml b/.github/workflows/desktop-macos-preview.yml deleted file mode 100644 index 0213f3fce6a99..0000000000000 --- a/.github/workflows/desktop-macos-preview.yml +++ /dev/null @@ -1,108 +0,0 @@ -name: Desktop macOS Preview - -# Untrusted half of the macOS preview. This runs PR code (including fork PRs) -# with a read-only token and no secrets, and only produces the JS bundle. The -# trusted half, desktop-macos-preview-publish.yml, runs on workflow_run from -# main, verifies the PR author is vouched, then packages, signs, notarizes, and -# publishes the bundle without ever executing it. -# -# The label is a one-shot request for the commit it was applied to, not a -# standing subscription: the trusted half removes it once this run completes, -# and later pushes do not build until a maintainer applies it again. Each -# signed preview is therefore an explicit per-commit decision. -# -# Closing the PR is handled by the publish workflow too, since deleting the -# download needs a write token. - -on: - pull_request: - types: [labeled] - -permissions: - contents: read - -concurrency: - group: desktop-macos-preview-${{ github.event.pull_request.number }} - # Adding an unrelated label skips the job and must not cancel a build. - cancel-in-progress: ${{ github.event.label.name == 'preview:mac' }} - -jobs: - build: - name: Build preview JS bundle - if: github.event.label.name == 'preview:mac' - runs-on: blacksmith-32vcpu-ubuntu-2404 - timeout-minutes: 30 - steps: - # This repository is public, so Git needs no credentials. checkout's - # credential cleanup runs submodule foreach even with submodules disabled, - # which fails on the orphaned gitlinks in our vendored .repos tree. - - name: Checkout - shell: bash - env: - CHECKOUT_REF: ${{ github.event.pull_request.head.sha }} - GIT_TERMINAL_PROMPT: "0" - run: | - set -euo pipefail - git init . - git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" - git fetch --no-tags --depth=1 origin "$CHECKOUT_REF" - git sparse-checkout set --no-cone '/*' '!/.repos/' - git checkout --detach FETCH_HEAD - - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 - with: - node-version-file: package.json - cache: true - run-install: false - - - name: Install bundle dependencies - run: vp install --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... - - # The publish workflow derives the same version from this run's number, - # so the version baked into the bundle matches the packaged app. - - name: Set preview version and public configuration - shell: bash - env: - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - set -euo pipefail - - base_version="$(node -p "require('./apps/desktop/package.json').version")" - node scripts/update-release-package-versions.ts "${base_version}-pr.${PR_NUMBER}.${GITHUB_RUN_NUMBER}" - # Public T3 Connect identifiers (Clerk publishable key, relay URL). - cp .env.example .env - - # PR heads from before build:desktop:bundle use the full build, which - # compiles the Linux browser secret helper and needs libsecret. - - name: Check bundle-only build support - id: bundle_build - run: | - script="$(node -p "require('./package.json').scripts['build:desktop:bundle'] ? 'build:desktop:bundle' : 'build:desktop'")" - echo "script=$script" >> "$GITHUB_OUTPUT" - - - if: steps.bundle_build.outputs.script == 'build:desktop' - uses: ./.github/actions/setup-apt-mirrors - - - name: Install browser secret helper build libraries - if: steps.bundle_build.outputs.script == 'build:desktop' - run: sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config - - # desktop-macos-preview-publish.yml packages this bundle as the preview - # channel. The Linux browser secret helper is not part of a macOS app. - - name: Build JS bundle - env: - T3CODE_TELEMETRY_CHANNEL: ${{ github.repository == 'pingdotgg/t3code' && 'preview' || 'dev' }} - run: vp run ${{ steps.bundle_build.outputs.script }} - - # Same layout as release.yml's js-bundle so release-desktop.yml can - # package it unchanged. - - name: Upload JS bundle - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: js-bundle - path: | - apps/server/dist - apps/desktop/dist-electron - if-no-files-found: error - retention-days: 1 diff --git a/.github/workflows/fork-follow.yml b/.github/workflows/fork-follow.yml index 16df6246985b0..8761834658dbf 100644 --- a/.github/workflows/fork-follow.yml +++ b/.github/workflows/fork-follow.yml @@ -12,6 +12,10 @@ name: Fork follow upstream # 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). +# Upstream workflow files listed in .github/fork-removed-workflows.txt stay deleted: the plan +# resolves a rebase conflict on them by deleting them and deletes any that upstream brings back. +# A missing or malformed list, and a new upstream workflow that someone outside the fork can start +# (pull_request_target, workflow_run, issues ...; fork-follow.sh) and the list does not name, stop it. # A conflict, merge commits in the series, a schema change, upstream commits inside the series, a # moved or deleted upstream tag, a red test, a failed build or push: the run fails, and the Report # job's summary says why and what the repository holds now (branch heads, release tags, drafts), as diff --git a/.github/workflows/mobile-eas-preview-fork.yml b/.github/workflows/mobile-eas-preview-fork.yml deleted file mode 100644 index a8bf5725af70c..0000000000000 --- a/.github/workflows/mobile-eas-preview-fork.yml +++ /dev/null @@ -1,355 +0,0 @@ -name: Mobile EAS Preview (Fork) - -# EAS previews for fork PRs. This runs the PR's code with an Expo token: the -# app config and plugins run on this runner, the iOS client builds on EAS and -# the Android one on Blacksmith (android-build), with preview signing credentials. The label is the maintainer's sign-off -# that they reviewed that code, so it covers exactly the commit it was applied -# to: this pins that commit and consumes the label before running anything. A -# later push deploys nothing until the label is applied again. -# -# Same-repo PRs deploy on every push while labeled, via mobile-eas-preview.yml. - -on: - pull_request_target: - types: [labeled] - -permissions: - contents: read - -jobs: - preview: - name: EAS Preview - if: >- - github.event.pull_request.head.repo.full_name != github.repository && - github.event.label.name == '🚀 Mobile Continuous Deployment' - runs-on: blacksmith-8vcpu-ubuntu-2404 - concurrency: - group: mobile-eas-preview-fork-${{ github.event.pull_request.number }} - cancel-in-progress: true - permissions: - contents: read - pull-requests: write - outputs: - build: ${{ steps.client.outputs.build }} - fingerprint: ${{ steps.client.outputs.fingerprint }} - env: - APP_VARIANT: preview - NODE_OPTIONS: --max-old-space-size=8192 - MOBILE_VERSION_POLICY: fingerprint - steps: - # If a maintainer removed the label first, that removal wins. - - id: consume - name: Consume the deployment label - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - name: context.payload.label.name, - }); - core.setOutput("consumed", "true"); - } catch (error) { - if (error.status !== 404) throw error; - core.info("The label was removed before this run could consume it. Skipping."); - core.setOutput("consumed", "false"); - } - - - id: expo-token - name: Check for EXPO_PREVIEW_TOKEN - if: steps.consume.outputs.consumed == 'true' - env: - EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} - run: | - if [ -n "$EXPO_TOKEN" ]; then - echo "present=true" >> "$GITHUB_OUTPUT" - else - echo "present=false" >> "$GITHUB_OUTPUT" - echo "EXPO_PREVIEW_TOKEN is not set; skipping EAS preview." - fi - - # The head SHA from the label event, not the branch, so a push after the - # label cannot swap in unreviewed code. No credentials stay in .git for - # the PR's scripts to read. - - name: Checkout reviewed commit - if: steps.expo-token.outputs.present == 'true' - uses: actions/checkout@v6 - with: - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - # No sparse-checkout: see mobile-eas-preview.yml. - - # The comment script comes from main, not from the PR. - - name: Checkout trusted scripts - if: steps.expo-token.outputs.present == 'true' - uses: actions/checkout@v6 - with: - path: .trusted - sparse-checkout: .github/scripts - - # The steps below run the PR's dependency and build scripts; they get no token. - # checkout keeps it in a file under RUNNER_TEMP that .git/config includes, so both - # go. Asking checkout not to persist it fails here: that cleanup runs `git submodule - # foreach`, which stops at the vendored gitlinks under .repos (no .gitmodules entry). - - name: Drop checkout credentials - if: steps.expo-token.outputs.present == 'true' - run: | - for dir in . .trusted; do - [ -e "$dir/.git" ] || continue - git -C "$dir" config --local --get-regexp '^includeif\..*\.path$' | while read -r key file; do - rm -f "$file" - git -C "$dir" config --local --unset-all "$key" - done - git -C "$dir" config --local --name-only --get-regexp '^http\..*\.extraheader$' | while read -r key; do - git -C "$dir" config --local --unset-all "$key" - done - if git -C "$dir" config --local --get-regexp 'extraheader|includeif'; then - echo "::error::checkout credentials are still configured in $dir" - exit 1 - fi - done - rm -f "$RUNNER_TEMP"/git-credentials-*.config - if ls "$RUNNER_TEMP"/git-credentials-*.config 2>/dev/null; then - echo "::error::a checkout credential file is still present" - exit 1 - fi - - # No caches: pull_request_target saves them under main's scope, where - # trusted workflows with other secrets would restore what PR code wrote. - - name: Setup Vite+ - if: steps.expo-token.outputs.present == 'true' - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: false - run-install: | - args: - - --filter=@t3tools/mobile... - - - name: Expose pnpm - if: steps.expo-token.outputs.present == 'true' - run: | - pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" - vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" - echo "$vp_pnpm_bin" >> "$GITHUB_PATH" - "$vp_pnpm_bin/pnpm" --version - - - name: Setup EAS - if: steps.expo-token.outputs.present == 'true' - uses: expo/expo-github-action@v8 - with: - # Pinned and installed with npm for the same reasons as mobile-eas-preview.yml. - eas-version: 21.8.0 - eas-cache: false - token: ${{ secrets.EXPO_PREVIEW_TOKEN }} - packager: npm - - - name: Pull preview environment variables - if: steps.expo-token.outputs.present == 'true' - working-directory: apps/mobile - env: - EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} - run: | - eas env:pull preview --non-interactive - # See mobile-eas-preview.yml: keeps Android build and OTA fingerprints matching. - node --input-type=module <<'NODE' - import { appendFileSync, readFileSync } from "node:fs"; - import { parseEnv } from "node:util"; - const env = parseEnv(readFileSync(".env.local", "utf8")); - const googleServicesFile = env.T3CODE_ANDROID_GOOGLE_SERVICES_FILE; - if (googleServicesFile) { - appendFileSync(process.env.GITHUB_ENV, `T3CODE_ANDROID_GOOGLE_SERVICES_FILE=${googleServicesFile}\n`); - } - NODE - - - name: Deploy with fingerprint check - if: steps.expo-token.outputs.present == 'true' - uses: expo/expo-github-action/continuous-deploy-fingerprint@main - env: - EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} - RUNNER_DEBUG: "1" - # The action only comments for pull_request events; this hook gives - # it the PR under pull_request_target. Without it, it skips the comment. - EXPO_TEST_GITHUB_PULL: ${{ github.event.pull_request.number }} - with: - profile: preview:dev - branch: pr-${{ github.event.pull_request.number }} - # Android is below: its client builds on Blacksmith (android-build). - platform: ios - environment: preview - working-directory: apps/mobile - github-token: ${{ secrets.GITHUB_TOKEN }} - - # Android, as in mobile-eas-preview.yml: the same fingerprint command the Expo - # action runs. A client built for a same-repo PR (its tag) or by EAS is reused. - - id: client - name: Find the Android client for this fingerprint - if: steps.expo-token.outputs.present == 'true' - working-directory: apps/mobile - shell: bash - env: - EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} - run: | - hash="$(eas env:exec --non-interactive preview "npx expo-updates fingerprint:generate --platform android" | sed -n '/^{/,$p' | jq -er '.hash | select(type == "string" and length > 0)')" - echo "fingerprint=$hash" >> "$GITHUB_OUTPUT" - tag="mobile-android-preview-build/$hash" - link="" - if git fetch --quiet origin "refs/tags/$tag:refs/tags/$tag" 2>/dev/null; then - link="$(git tag -l --format='%(contents:subject)' "$tag")" - else - link="$(eas build:list --platform android --build-profile preview:dev --runtime-version "$hash" --status finished --limit 5 --json --non-interactive \ - | sed -n '/^\[/,$p' \ - | jq -r --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '[.[] | select((.expirationDate // "9999") > $now)][0] | select(. != null) | "https://expo.dev/accounts/pingdotgg/projects/t3-code/builds/\(.id)"')" - fi - echo "link=$link" >> "$GITHUB_OUTPUT" - if [ -z "$link" ]; then echo "build=true" >> "$GITHUB_OUTPUT"; fi - - - id: update - name: Publish the Android update - if: steps.expo-token.outputs.present == 'true' - working-directory: apps/mobile - shell: bash - env: - EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} - PR: ${{ github.event.pull_request.number }} - run: | - group="$(eas update --auto --branch "pr-$PR" --platform android --environment preview --non-interactive --json | sed -n '/^\[/,$p' | jq -er '.[0].group')" - echo "group=$group" >> "$GITHUB_OUTPUT" - - - name: Comment - if: steps.expo-token.outputs.present == 'true' - env: - GH_TOKEN: ${{ github.token }} - PR: ${{ github.event.pull_request.number }} - FINGERPRINT: ${{ steps.client.outputs.fingerprint }} - CLIENT: ${{ steps.client.outputs.link }} - GROUP: ${{ steps.update.outputs.group }} - run: node .trusted/.github/scripts/mobile-preview-comment.cjs - - # The reviewed commit's Android client, built cold: no sticky disk, because PR - # code could leave Gradle or ccache state that trusted builds would restore, and - # no build record tag, so nobody else is served an APK a fork's code built. - android-build: - name: Android preview client - needs: preview - if: needs.preview.outputs.build == 'true' - runs-on: blacksmith-32vcpu-ubuntu-2404 - timeout-minutes: 60 - permissions: - contents: read - pull-requests: write - env: - APP_VARIANT: preview - NODE_OPTIONS: --max-old-space-size=8192 - MOBILE_VERSION_POLICY: fingerprint - FINGERPRINT: ${{ needs.preview.outputs.fingerprint }} - # Phones only: emulators use the development client from the native-client cache. - ORG_GRADLE_PROJECT_reactNativeArchitectures: armeabi-v7a,arm64-v8a - steps: - - name: Checkout reviewed commit - uses: actions/checkout@v6 - with: - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - - # The comment script comes from main, not from the PR. - - name: Checkout trusted scripts - uses: actions/checkout@v6 - with: - path: .trusted - sparse-checkout: .github/scripts - - # The steps below run the PR's dependency and build scripts; they get no token. - # checkout keeps it in a file under RUNNER_TEMP that .git/config includes, so both - # go. Asking checkout not to persist it fails here: that cleanup runs `git submodule - # foreach`, which stops at the vendored gitlinks under .repos (no .gitmodules entry). - - name: Drop checkout credentials - run: | - for dir in . .trusted; do - [ -e "$dir/.git" ] || continue - git -C "$dir" config --local --get-regexp '^includeif\..*\.path$' | while read -r key file; do - rm -f "$file" - git -C "$dir" config --local --unset-all "$key" - done - git -C "$dir" config --local --name-only --get-regexp '^http\..*\.extraheader$' | while read -r key; do - git -C "$dir" config --local --unset-all "$key" - done - if git -C "$dir" config --local --get-regexp 'extraheader|includeif'; then - echo "::error::checkout credentials are still configured in $dir" - exit 1 - fi - done - rm -f "$RUNNER_TEMP"/git-credentials-*.config - if ls "$RUNNER_TEMP"/git-credentials-*.config 2>/dev/null; then - echo "::error::a checkout credential file is still present" - exit 1 - fi - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version-file: package.json - - - name: Enable corepack - run: | - npm install --global corepack@latest - corepack enable - pnpm --version - - - name: Install mobile dependencies - run: pnpm install --frozen-lockfile --filter=@t3tools/mobile... - - - name: Setup Java - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: 17 - - - name: Setup EAS - uses: expo/expo-github-action@v8 - with: - eas-version: latest - eas-cache: false - token: ${{ secrets.EXPO_PREVIEW_TOKEN }} - packager: npm - - - name: Pull preview environment variables - working-directory: apps/mobile - env: - EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} - run: | - eas env:pull preview --non-interactive - node --input-type=module <<'NODE' - import { appendFileSync, readFileSync } from "node:fs"; - import { parseEnv } from "node:util"; - const env = parseEnv(readFileSync(".env.local", "utf8")); - const googleServicesFile = env.T3CODE_ANDROID_GOOGLE_SERVICES_FILE; - if (googleServicesFile) { - appendFileSync(process.env.GITHUB_ENV, `T3CODE_ANDROID_GOOGLE_SERVICES_FILE=${googleServicesFile}\n`); - } - NODE - - - id: build - name: Build and upload - working-directory: apps/mobile - shell: bash - env: - EXPO_TOKEN: ${{ secrets.EXPO_PREVIEW_TOKEN }} - run: | - eas build --local --platform android --profile preview:dev --non-interactive --output "$RUNNER_TEMP/t3code-preview.apk" - eas upload --platform android --build-path "$RUNNER_TEMP/t3code-preview.apk" --fingerprint "$FINGERPRINT" --non-interactive --json | tee "$RUNNER_TEMP/upload.json" - link="$(sed -n '/^{/,$p' "$RUNNER_TEMP/upload.json" | jq -r '.url // empty')" - if [ -z "$link" ]; then - echo "::error::eas upload returned no install link" - exit 1 - fi - echo "link=$link" >> "$GITHUB_OUTPUT" - - - name: Comment - env: - GH_TOKEN: ${{ github.token }} - PR: ${{ github.event.pull_request.number }} - CLIENT: ${{ steps.build.outputs.link }} - run: node .trusted/.github/scripts/mobile-preview-comment.cjs diff --git a/.github/workflows/mobile-fingerprint-label.yml b/.github/workflows/mobile-fingerprint-label.yml deleted file mode 100644 index 45e4c9f2ad42d..0000000000000 --- a/.github/workflows/mobile-fingerprint-label.yml +++ /dev/null @@ -1,189 +0,0 @@ -name: Mobile Fingerprint Label - -# Trusted half of mobile-fingerprint-check.yml. That workflow runs PR code -# (fork PRs included) with a read-only token and records which platforms' -# fingerprints changed. This one runs from main with a write token and syncs -# the "📱 Native Change" label. It never checks out or runs PR code. -# -# A PR can edit the check workflow it runs under, so the recorded result is -# untrusted data: it is validated, and at worst a PR mislabels itself. - -on: - workflow_run: - workflows: [Mobile Fingerprint Check] - types: [completed] - -permissions: - contents: read - -jobs: - label: - name: Sync native change label - # A cancelled run was superseded by a newer push, whose run labels instead. - if: >- - github.event.workflow_run.event == 'pull_request' && - github.event.workflow_run.conclusion == 'success' - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - actions: read - contents: read - issues: write - pull-requests: write - steps: - - id: pr - name: Resolve the pull request behind the check - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const run = context.payload.workflow_run; - const { owner, repo } = context.repo; - - const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { - owner, - repo, - run_id: run.id, - per_page: 100, - }); - const results = artifacts.filter( - (artifact) => artifact.name === "fingerprint-result" && !artifact.expired, - ); - if (results.length !== 1) { - core.info(`Expected one fingerprint-result artifact; found ${results.length}. Skipping.`); - return; - } - - // workflow_run.pull_requests and the commit-to-PR lookup are both - // empty for fork PRs, so list open PRs by head owner and branch. - // Requiring the PR's current head to be the checked commit keeps a - // slow run for an older push from overwriting a newer result. - const open = await github.paginate(github.rest.pulls.list, { - owner, - repo, - state: "open", - head: `${run.head_repository?.owner?.login}:${run.head_branch}`, - per_page: 100, - }); - const matching = open.filter( - (candidate) => - candidate.state === "open" && - candidate.head.sha === run.head_sha && - candidate.head.ref === run.head_branch && - candidate.head.repo?.full_name === run.head_repository?.full_name, - ); - if (matching.length !== 1) { - core.info(`Expected one open PR at ${run.head_sha}; found ${matching.length}. Skipping.`); - return; - } - - core.setOutput("artifact_id", String(results[0].id)); - core.setOutput("pr_number", String(matching[0].number)); - - # Read the archive as bytes; never extract PR-produced files into a - # workspace this job runs code from. - - id: result - name: Read changed platforms - if: steps.pr.outputs.pr_number != '' - shell: bash - env: - GH_TOKEN: ${{ github.token }} - ARTIFACT_ID: ${{ steps.pr.outputs.artifact_id }} - run: | - set -euo pipefail - gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/fingerprint-result.zip" - changed="$(unzip -p "$RUNNER_TEMP/fingerprint-result.zip" changed-platforms)" - if [[ ! "$changed" =~ ^((ios|android)( (ios|android))?)?$ ]]; then - echo "Unexpected changed-platforms value '$changed'." >&2 - exit 1 - fi - echo "changed_platforms=$changed" >> "$GITHUB_OUTPUT" - - - name: Sync native change label - if: steps.pr.outputs.pr_number != '' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - PR_NUMBER: ${{ steps.pr.outputs.pr_number }} - CHANGED_PLATFORMS: ${{ steps.result.outputs.changed_platforms }} - with: - script: | - const managedLabel = { - name: "📱 Native Change", - color: "d93f0b", - description: - "Changes the native fingerprint; merging blocks production OTAs until a new store build ships.", - }; - const nativeChanged = (process.env.CHANGED_PLATFORMS ?? "").trim() !== ""; - const issueNumber = Number(process.env.PR_NUMBER); - - try { - const { data: existing } = await github.rest.issues.getLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: managedLabel.name, - }); - - if ( - existing.color !== managedLabel.color || - (existing.description ?? "") !== managedLabel.description - ) { - await github.rest.issues.updateLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: managedLabel.name, - color: managedLabel.color, - description: managedLabel.description, - }); - } - } catch (error) { - if (error.status !== 404) { - throw error; - } - - try { - await github.rest.issues.createLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - name: managedLabel.name, - color: managedLabel.color, - description: managedLabel.description, - }); - } catch (createError) { - if (createError.status !== 422) { - throw createError; - } - } - } - - const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - per_page: 100, - }); - const hasLabel = currentLabels.some((label) => label.name === managedLabel.name); - - if (nativeChanged && !hasLabel) { - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: [managedLabel.name], - }); - } else if (!nativeChanged && hasLabel) { - try { - await github.rest.issues.removeLabel({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - name: managedLabel.name, - }); - } catch (removeError) { - if (removeError.status !== 404) { - throw removeError; - } - } - } - - core.info( - `PR #${issueNumber}: native fingerprint ${nativeChanged ? `changed (${process.env.CHANGED_PLATFORMS})` : "unchanged"}`, - ); diff --git a/.github/workflows/thread-transfer-report.yml b/.github/workflows/thread-transfer-report.yml deleted file mode 100644 index 23eec72923bd8..0000000000000 --- a/.github/workflows/thread-transfer-report.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: Thread Transfer Report - -on: - workflow_run: - workflows: [CI] - types: [completed] - -permissions: - actions: read - contents: read - pull-requests: write - -jobs: - publish: - name: Publish PR comment - if: github.event.workflow_run.event == 'pull_request' - runs-on: ubuntu-24.04 - concurrency: - group: thread-transfer-report-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.id }} - cancel-in-progress: true - steps: - # workflow_run has a write-capable token even for fork PRs. Only load the - # publisher from the trusted default branch and never execute PR code. - - name: Checkout trusted publisher - uses: actions/checkout@v6 - with: - ref: ${{ github.event.repository.default_branch }} - sparse-checkout: .github/scripts - - - name: Test trusted publisher - run: node --test .github/scripts/thread-transfer-report.test.cjs - - - id: resolve - name: Resolve PR and baseline artifacts - uses: actions/github-script@v8 - with: - script: | - const reporter = require("./.github/scripts/thread-transfer-report.cjs"); - await reporter.resolve({ github, context, core }); - - - name: Download PR result - if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.pr_artifact == 'true' - uses: actions/download-artifact@v8 - with: - name: thread-transfer-results - path: ${{ runner.temp }}/thread-transfer/pr - github-token: ${{ secrets.GITHUB_TOKEN }} - run-id: ${{ steps.resolve.outputs.pr_run_id }} - - - name: Download main baseline - if: steps.resolve.outputs.publish == 'true' && steps.resolve.outputs.baseline_artifact == 'true' - uses: actions/download-artifact@v8 - with: - name: thread-transfer-results - path: ${{ runner.temp }}/thread-transfer/main - github-token: ${{ secrets.GITHUB_TOKEN }} - run-id: ${{ steps.resolve.outputs.baseline_run_id }} - - - name: Update thread transfer comment - if: steps.resolve.outputs.publish == 'true' - uses: actions/github-script@v8 - env: - PR_NUMBER: ${{ steps.resolve.outputs.pull_number }} - PR_SHA: ${{ steps.resolve.outputs.pr_sha }} - PR_CONCLUSION: ${{ steps.resolve.outputs.pr_conclusion }} - PR_RUN_ID: ${{ steps.resolve.outputs.pr_run_id }} - PR_RESULT_DIR: ${{ runner.temp }}/thread-transfer/pr - BASELINE_SHA: ${{ steps.resolve.outputs.baseline_sha }} - BASELINE_MATCHES_BASE: ${{ steps.resolve.outputs.baseline_matches_base }} - BASELINE_RUN_ID: ${{ steps.resolve.outputs.baseline_run_id }} - BASELINE_RESULT_DIR: ${{ runner.temp }}/thread-transfer/main - with: - script: | - const reporter = require("./.github/scripts/thread-transfer-report.cjs"); - await reporter.publish({ github, context, core });