From cf7f3b878e50c5f1a4ab7162ac2c880ef6a1769c Mon Sep 17 00:00:00 2001 From: scuttletoby <123843277+scuttletoby@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:09:06 +0000 Subject: [PATCH 1/2] fork: delete four outside-started upstream workflows and keep them deleted The fork's sweep disables upstream workflows only once GitHub lists them, so it cannot stop the first run of one an outside event starts. Owner decision 2026-11-12: delete mobile-eas-preview-fork.yml (pull_request_target), desktop-macos-preview.yml, thread-transfer-report.yml and mobile-fingerprint-label.yml (workflow_run on "CI", a name an outside PR can take). .github/fork-removed-workflows.txt lists them. fork-follow.sh reads it from the series head (missing or malformed: the plan stops), resolves a rebase conflict on listed files only by deleting them, deletes again any listed file upstream brings back, refuses a same-base series that still has one, and refuses a new upstream workflow that outside events can start (or whose triggers fork-workflow-triggers.cjs cannot read) unless the list names it. Co-Authored-By: Claude Opus 5.5 --- .github/fork-removed-workflows.txt | 14 + .github/scripts/fork-contracts.test.py | 43 +++ .github/scripts/fork-follow.sh | 105 +++++- .github/scripts/fork-follow.test.sh | 128 ++++++- .github/scripts/fork-workflow-triggers.cjs | 88 +++++ .github/workflows/desktop-macos-preview.yml | 108 ------ .github/workflows/fork-follow.yml | 4 + .github/workflows/mobile-eas-preview-fork.yml | 355 ------------------ .../workflows/mobile-fingerprint-label.yml | 189 ---------- .github/workflows/thread-transfer-report.yml | 75 ---- 10 files changed, 378 insertions(+), 731 deletions(-) create mode 100644 .github/fork-removed-workflows.txt create mode 100644 .github/scripts/fork-workflow-triggers.cjs delete mode 100644 .github/workflows/desktop-macos-preview.yml delete mode 100644 .github/workflows/mobile-eas-preview-fork.yml delete mode 100644 .github/workflows/mobile-fingerprint-label.yml delete mode 100644 .github/workflows/thread-transfer-report.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..b20245f9fc3c4 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,61 @@ 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, from main's copy of the file: an +# outside pull request (pull_request_target), a workflow of a given name that an outside pull +# request can add (workflow_run), an issue, a comment, a discussion, a star or a fork. GitHub runs +# such a workflow before the sweep can see and disable it, so a new one must not reach the series. +outside_events='^(pull_request_target|workflow_run|issue_comment|issues|discussion|discussion_comment|watch|fork)$' +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 +307,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 +338,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 +373,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..e5d235fb4fb36 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,130 @@ 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' + +# 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'" \ + "could not be read|printf 'on: {workflow_run: {workflows: [CI]}}\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-workflow-triggers.cjs b/.github/scripts/fork-workflow-triggers.cjs new file mode 100644 index 0000000000000..62c0fce22ae64 --- /dev/null +++ b/.github/scripts/fork-workflow-triggers.cjs @@ -0,0 +1,88 @@ +// Fork only: prints the events a GitHub Actions workflow file starts on (the keys or names under +// its top-level `on:`), one per line, sorted. fork-follow.sh uses it to refuse an upstream release +// that adds a workflow someone outside the fork can start. No dependencies: the plan job installs +// nothing. Tests: fork-follow.test.sh. +// node fork-workflow-triggers.cjs FILE +// Exit 2, with the reason on stderr, when it cannot tell for sure (no `on:`, two of them, tabs, a +// flow mapping, an anchor or alias, a block scalar, a list split over lines, a name that is not an +// event name). The caller then treats the file as one it cannot clear. +const fs = require("node:fs"); + +const unsure = (why) => { + process.stderr.write(`cannot read the triggers: ${why}\n`); + process.exit(2); +}; + +let text; +try { + text = fs.readFileSync(process.argv[2], "utf8"); +} catch (error) { + unsure(`cannot read ${process.argv[2]}: ${error.message}`); +} + +const uncomment = (s) => s.replace(/(^|\s)#.*$/, "").trimEnd(); +const unquote = (s) => s.trim().replace(/^(["'])(.*)\1$/, "$2"); +const indentOf = (s) => s.length - s.trimStart().length; +const EVENT = /^[a-z_]+$/; +const ON_KEY = /^(?:on|"on"|'on'):(?=\s|$)(.*)$/; + +const lines = text.split(/\r?\n/); +if (lines.some((l) => /^\s*\t/.test(l))) unsure("a line is indented with a tab"); + +const events = []; +const name = (raw) => { + const event = unquote(raw); + if (!EVENT.test(event)) unsure(`\`${raw.trim()}\` is not an event name`); + events.push(event); +}; + +let found = -1; +let seenContent = false; +lines.forEach((line, i) => { + const code = uncomment(line); + if (code.trim() === "") return; + if (code === "---" || code === "...") { + if (seenContent) unsure("more than one YAML document"); + return; + } + seenContent = true; + if (indentOf(code) === 0 && ON_KEY.test(code)) { + if (found !== -1) unsure("`on:` appears twice"); + found = i; + } +}); +if (found === -1) unsure("no top-level `on:`"); + +const rest = uncomment(lines[found]).match(ON_KEY)[1].trim(); +if (rest.startsWith("[")) { + if (!rest.endsWith("]")) unsure("the event list goes on over several lines"); + 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 line of lines.slice(found + 1)) { + const code = uncomment(line); + if (code.trim() === "") 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*:(?=\s|$)/); + 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"); +} + +process.stdout.write([...new Set(events)].sort().map((e) => `${e}\n`).join("")); 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 }); From 95a22647d6633e73e5a34550f8f836ed473f68b8 Mon Sep 17 00:00:00 2001 From: scuttletoby <123843277+scuttletoby@users.noreply.github.com> Date: Thu, 12 Nov 2026 17:42:14 +0000 Subject: [PATCH 2/2] fork: the trigger reader fails closed on more shapes and has its own tests - fork-workflow-triggers.cjs exports readTriggers and gets fork-workflow-triggers.test.cjs, like the other fork-*.cjs scripts. The test requires it, so knip no longer reports it as an unused file. - The reader refuses: a line break other than \n or \r\n (a lone \r, U+0085, U+2028, U+2029); an unindented line that is not a plain `key:` (`on :`, escaped or quoted keys, `? on`, directives, a root list, a BOM); a quote left open at the end of a line; a bracket list whose later lines are not indented more than its first, or that never closes. Block scalar text is skipped. Indent counts ASCII spaces only. - Outside events add pull_request_review, pull_request_review_comment, gollum, check_run, check_suite, status and deployment_status. - fork-follow.test.sh: plan cases for a CRLF list, the plain-text pull_request_target backstop, the new events and a decoy on: in a multi-line string. - fork-schema-check.test.cjs: drop an unused parameter that vp check reports as an error. --- .github/fork-ci-tests.txt | 1 + .github/scripts/fork-follow.sh | 17 +- .github/scripts/fork-follow.test.sh | 12 + .github/scripts/fork-schema-check.test.cjs | 2 +- .github/scripts/fork-workflow-triggers.cjs | 248 ++++++++++++------ .../scripts/fork-workflow-triggers.test.cjs | 197 ++++++++++++++ 6 files changed, 395 insertions(+), 82 deletions(-) create mode 100644 .github/scripts/fork-workflow-triggers.test.cjs 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/scripts/fork-follow.sh b/.github/scripts/fork-follow.sh index b20245f9fc3c4..637780154199b 100755 --- a/.github/scripts/fork-follow.sh +++ b/.github/scripts/fork-follow.sh @@ -182,11 +182,18 @@ present_removed() { # present_removed COMMIT: the listed files COMMIT has, one p return 0 } -# Events someone outside the fork can start a workflow with, from main's copy of the file: an -# outside pull request (pull_request_target), a workflow of a given name that an outside pull -# request can add (workflow_run), an issue, a comment, a discussion, a star or a fork. GitHub runs -# such a workflow before the sweep can see and disable it, so a new one must not reach the series. -outside_events='^(pull_request_target|workflow_run|issue_comment|issues|discussion|discussion_comment|watch|fork)$' +# 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" diff --git a/.github/scripts/fork-follow.test.sh b/.github/scripts/fork-follow.test.sh index e5d235fb4fb36..2e6c9a1c3f221 100755 --- a/.github/scripts/fork-follow.test.sh +++ b/.github/scripts/fork-follow.test.sh @@ -476,6 +476,13 @@ fork_commit "fork: comments only" 'printf "# nothing\n\n # still nothing\n" > . 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. @@ -492,7 +499,12 @@ for case in "pull_request_target|printf 'on:\n pull_request_target:\n types: "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 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 index 62c0fce22ae64..3a6f6790603a7 100644 --- a/.github/scripts/fork-workflow-triggers.cjs +++ b/.github/scripts/fork-workflow-triggers.cjs @@ -1,88 +1,184 @@ -// Fork only: prints the events a GitHub Actions workflow file starts on (the keys or names under -// its top-level `on:`), one per line, sorted. fork-follow.sh uses it to refuse an upstream release -// that adds a workflow someone outside the fork can start. No dependencies: the plan job installs -// nothing. Tests: fork-follow.test.sh. -// node fork-workflow-triggers.cjs FILE -// Exit 2, with the reason on stderr, when it cannot tell for sure (no `on:`, two of them, tabs, a -// flow mapping, an anchor or alias, a block scalar, a list split over lines, a name that is not an -// event name). The caller then treats the file as one it cannot clear. +// 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 unsure = (why) => { - process.stderr.write(`cannot read the triggers: ${why}\n`); - process.exit(2); +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 }; }; -let text; -try { - text = fs.readFileSync(process.argv[2], "utf8"); -} catch (error) { - unsure(`cannot read ${process.argv[2]}: ${error.message}`); -} +// 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"); -const uncomment = (s) => s.replace(/(^|\s)#.*$/, "").trimEnd(); -const unquote = (s) => s.trim().replace(/^(["'])(.*)\1$/, "$2"); -const indentOf = (s) => s.length - s.trimStart().length; -const EVENT = /^[a-z_]+$/; -const ON_KEY = /^(?:on|"on"|'on'):(?=\s|$)(.*)$/; + // 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`); -const lines = text.split(/\r?\n/); -if (lines.some((l) => /^\s*\t/.test(l))) unsure("a line is indented with a tab"); + 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 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(); }; -let found = -1; -let seenContent = false; -lines.forEach((line, i) => { - const code = uncomment(line); - if (code.trim() === "") return; - if (code === "---" || code === "...") { - if (seenContent) unsure("more than one YAML document"); - return; - } - seenContent = true; - if (indentOf(code) === 0 && ON_KEY.test(code)) { - if (found !== -1) unsure("`on:` appears twice"); - found = i; +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); } -}); -if (found === -1) unsure("no top-level `on:`"); +}; -const rest = uncomment(lines[found]).match(ON_KEY)[1].trim(); -if (rest.startsWith("[")) { - if (!rest.endsWith("]")) unsure("the event list goes on over several lines"); - 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 line of lines.slice(found + 1)) { - const code = uncomment(line); - if (code.trim() === "") 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*:(?=\s|$)/); - 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"); -} +module.exports = { readTriggers }; -process.stdout.write([...new Set(events)].sort().map((e) => `${e}\n`).join("")); +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 }); + } +});