From f4af6f8a7e195d1c69b50bce3eae76d41a3d0a84 Mon Sep 17 00:00:00 2001 From: Dongsheng <41723543+Blackman99@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:41:09 +0800 Subject: [PATCH] fix(action): create-pr can update its PR branch on later runs actions/checkout only fetches the triggering ref, so there is no refs/remotes/origin/ on the runner and a bare --force-with-lease is rejected (stale info) once the branch exists. scripts/push_pr_branch.sh reads the branch tip with ls-remote and leases on that exact value (empty when absent), which works for both the checkout-style wildcard refspec and single-branch clones. Tests simulate two scheduled runs against a local bare remote. Also: document fail: false alongside create-pr; note pr_body.py is Action-only. --- .github/release-notes/v0.3.0.md | 2 +- CHANGELOG.md | 2 +- action.yml | 4 +- docs/action.md | 2 +- pr_body.py | 5 +- scripts/push_pr_branch.sh | 22 +++++ tests/test_action_push.py | 137 ++++++++++++++++++++++++++++++++ 7 files changed, 168 insertions(+), 6 deletions(-) create mode 100755 scripts/push_pr_branch.sh create mode 100644 tests/test_action_push.py diff --git a/.github/release-notes/v0.3.0.md b/.github/release-notes/v0.3.0.md index a58cc17..fb1eb5c 100644 --- a/.github/release-notes/v0.3.0.md +++ b/.github/release-notes/v0.3.0.md @@ -2,6 +2,6 @@ - **`--latest`**: suggest the newest release that already runs on the target Node (default stays the smallest major jump). Also available as Action input `latest: true`. - **Release-notes links**: every finding with a suggested bump includes a `release_notes` URL (`https://github.com/{owner}/{repo}/releases/tag/{tag}`). Shown in text (`notes: …`), Markdown (extra column), and JSON. -- **Action PR mode**: set `create-pr: true` (plus optional `pr-branch` / `pr-title`) to apply `--fix` and open or update a pull request when there is a diff. No PR if the diff is empty. Caller needs `contents: write` and `pull-requests: write`. +- **Action PR mode**: set `create-pr: true` (plus optional `pr-branch` / `pr-title`) to apply `--fix` and open a pull request when there is a diff; later scheduled runs update the same PR branch. No PR if the diff is empty. Caller needs `contents: write` and `pull-requests: write`; set `fail: false` so the job doesn't fail after proposing the fix. Docs: https://blackman99.github.io/actnode/docs/ diff --git a/CHANGELOG.md b/CHANGELOG.md index edf32e3..72048b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## v0.3.0 — 2026-10-19 - `--latest` (Action input `latest`): suggest the newest node24-capable release instead of the smallest major jump; default behavior unchanged - Release-notes links on every suggested bump (`release_notes` in JSON; text `notes:` URL; Markdown column) -- Action optional PR mode: `create-pr` (+ `pr-branch` / `pr-title`) applies `--fix` and opens or updates a PR when there is a diff (needs `contents:write` + `pull-requests:write`) +- Action optional PR mode: `create-pr` (+ `pr-branch` / `pr-title`) applies `--fix` and opens or updates a PR when there is a diff on every run, including later runs that update the existing PR branch (needs `contents:write` + `pull-requests:write`); pair it with `fail: false`, otherwise the job still fails after opening the PR ## v0.2.0 — 2026-10-12 - Reusable workflows (`uses: org/repo/.github/workflows/x.yml@ref`) scanned recursively (status `workflow` / `wfl`); nested findings show `(inside …)` and are not rewritten by `--fix` diff --git a/action.yml b/action.yml index 0a47acb..dfb310d 100644 --- a/action.yml +++ b/action.yml @@ -18,7 +18,7 @@ inputs: description: "Suggest the newest release already on the target Node instead of the smallest major jump (true/false)" default: "false" create-pr: - description: "Apply --fix and open a pull request when there is a diff (true/false). Caller needs contents:write and pull-requests:write." + description: "Apply --fix and open (or update) a pull request when there is a diff (true/false). Caller needs contents:write and pull-requests:write. Set fail: false too, or the job still fails after opening the PR." default: "false" pr-branch: description: "Branch name for create-pr" @@ -104,7 +104,7 @@ runs: echo "pr-url=" >> "$GITHUB_OUTPUT" else git commit -m "chore: bump GitHub Actions to Node ${IN_TARGET} runtime" - git push -u origin "HEAD:refs/heads/${branch}" --force-with-lease + bash "${{ github.action_path }}/scripts/push_pr_branch.sh" "$branch" body_file="$RUNNER_TEMP/actnode-pr-body.md" python3 "${{ github.action_path }}/pr_body.py" \ "$RUNNER_TEMP/actnode.json" "$IN_TARGET" > "$body_file" diff --git a/docs/action.md b/docs/action.md index cdc5f4d..99c5619 100644 --- a/docs/action.md +++ b/docs/action.md @@ -25,7 +25,7 @@ The step writes a Markdown table to the job summary and fails if any step uses a | `target` | `24` | minimum Node major | | `fail` | `true` | fail the step when something old is found | | `latest` | `false` | suggest the newest release already on the target Node instead of the smallest major jump | -| `create-pr` | `false` | apply `--fix` and open a PR when there is a diff | +| `create-pr` | `false` | apply `--fix` and open (or update, on later runs) a PR when there is a diff; pair with `fail: false`, otherwise the job still fails after opening the PR | | `pr-branch` | `actnode/node-runtime` | branch name for `create-pr` | | `pr-title` | `chore: bump actions to Node 24 runtime` | PR title for `create-pr` | | `token` | `github.token` | used to list tags; also to push/open a PR when `create-pr` is true | diff --git a/pr_body.py b/pr_body.py index 8dedf9b..90b4711 100644 --- a/pr_body.py +++ b/pr_body.py @@ -1,4 +1,7 @@ -"""Build a markdown PR body from an actnode JSON report. Usage: pr_body.py REPORT.json TARGET > body.md""" +"""Build a markdown PR body from an actnode JSON report. Usage: pr_body.py REPORT.json TARGET > body.md + +Action-only helper: action.yml runs it via ${{ github.action_path }}; it is not part of the wheel. +""" from __future__ import annotations import json diff --git a/scripts/push_pr_branch.sh b/scripts/push_pr_branch.sh new file mode 100755 index 0000000..5591163 --- /dev/null +++ b/scripts/push_pr_branch.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Push HEAD to refs/heads/ on for the Action's create-pr mode. +# +# actions/checkout only fetches the triggering ref, so on runs after the first +# the runner has no refs/remotes/origin/. A bare --force-with-lease then +# expects the remote branch to be absent and is rejected ("stale info"), so the +# existing PR could never be updated. Fetching the branch is not always enough +# either: with a single-branch fetch refspec git still can't map the branch to a +# tracking ref. So read the branch's current tip from the remote and lease on +# that exact value (empty = must not exist yet). The push still refuses to +# clobber a branch that moved between this lookup and the push. +set -euo pipefail +branch="${1:?usage: push_pr_branch.sh [remote]}" +remote="${2:-origin}" +expect=$(git ls-remote --heads "$remote" "refs/heads/${branch}" | cut -f1) +if [ -n "$expect" ]; then + # Best effort: keep a tracking ref around for later git commands / -u. + git fetch --no-tags --depth=1 "$remote" \ + "+refs/heads/${branch}:refs/remotes/${remote}/${branch}" >/dev/null 2>&1 || true +fi +git push -u "$remote" "HEAD:refs/heads/${branch}" \ + --force-with-lease="refs/heads/${branch}:${expect}" diff --git a/tests/test_action_push.py b/tests/test_action_push.py new file mode 100644 index 0000000..5bf8ff9 --- /dev/null +++ b/tests/test_action_push.py @@ -0,0 +1,137 @@ +"""create-pr must be able to update its PR branch on runs after the first. + +actions/checkout only fetches the triggering ref, so the runner has no +refs/remotes/origin/. scripts/push_pr_branch.sh fetches it before +``git push --force-with-lease``; this test simulates two scheduled runs. +""" +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +SCRIPT = ROOT / "scripts" / "push_pr_branch.sh" +BRANCH = "actnode/node-runtime" + +pytestmark = pytest.mark.skipif( + sys.platform.startswith("win") or not shutil.which("bash") or not shutil.which("git"), + reason="needs bash + git (the Action runs the script with bash on Linux/macOS runners)", +) + +ENV = { + **os.environ, + "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@example.com", + "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@example.com", + "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": os.devnull, +} + + +def git(cwd, *args, check=True): + return subprocess.run(["git", *args], cwd=cwd, env=ENV, check=check, + capture_output=True, text=True) + + +@pytest.fixture +def origin(tmp_path): + seed = tmp_path / "seed" + seed.mkdir() + git(seed, "init", "-q", "-b", "main") + (seed / "ci.yml").write_text("uses: actions/checkout@v4\n") + git(seed, "add", ".") + git(seed, "commit", "-qm", "init") + bare = tmp_path / "origin.git" + git(tmp_path, "clone", "-q", "--bare", str(seed), str(bare)) + return bare + + +def checkout_like_actions(origin, dest, style="checkout"): + """Leave the repo the way actions/checkout does: only main, shallow. + + style="checkout": init + remote add (wildcard refspec) + fetch main only, + which is what actions/checkout runs. style="single-branch": a + --single-branch clone, whose refspec only maps main. + """ + if style == "checkout": + dest.mkdir() + git(dest, "init", "-q") + git(dest, "remote", "add", "origin", origin.as_uri()) + git(dest, "fetch", "-q", "--no-tags", "--depth=1", "origin", + "+refs/heads/main:refs/remotes/origin/main") + git(dest, "checkout", "-q", "-B", "main", "refs/remotes/origin/main") + else: + git(dest.parent, "clone", "-q", "--depth=1", "--single-branch", "--branch", "main", + origin.as_uri(), str(dest)) + refs = git(dest, "for-each-ref", "--format=%(refname)", "refs/remotes/").stdout.split() + assert f"refs/remotes/origin/{BRANCH}" not in refs + + +def run_fix(work, content, msg): + (work / "ci.yml").write_text(content) + git(work, "checkout", "-q", "-B", BRANCH) + git(work, "commit", "-qam", msg) + + +def push(work): + return subprocess.run(["bash", str(SCRIPT), BRANCH], cwd=work, env=ENV, + capture_output=True, text=True) + + +def remote_head(origin): + return git(origin, "rev-parse", f"refs/heads/{BRANCH}").stdout.strip() + + +@pytest.mark.parametrize("style", ["checkout", "single-branch"]) +def test_first_and_second_run_both_push(origin, tmp_path, style): + run1 = tmp_path / "run1" + checkout_like_actions(origin, run1, style) + run_fix(run1, "uses: actions/checkout@v5\n", "run 1") + r = push(run1) + assert r.returncode == 0, r.stderr + assert remote_head(origin) == git(run1, "rev-parse", "HEAD").stdout.strip() + + # Next scheduled run: fresh runner, new diff, PR branch already on origin. + run2 = tmp_path / "run2" + checkout_like_actions(origin, run2, style) + run_fix(run2, "uses: actions/checkout@v6\n", "run 2") + r = push(run2) + assert r.returncode == 0, r.stderr + assert remote_head(origin) == git(run2, "rev-parse", "HEAD").stdout.strip() + + +def test_lease_still_protects_a_branch_that_moved(origin, tmp_path): + """If someone pushes to the PR branch after our lookup, we must not clobber it.""" + run1 = tmp_path / "run1" + checkout_like_actions(origin, run1) + run_fix(run1, "uses: actions/checkout@v5\n", "run 1") + assert push(run1).returncode == 0 + stale = remote_head(origin) + + other = tmp_path / "other" + checkout_like_actions(origin, other) + run_fix(other, "uses: actions/checkout@v6\n", "human edit") + git(other, "push", "-q", "-f", "origin", f"HEAD:refs/heads/{BRANCH}") + moved = remote_head(origin) + + r = git(run1, "push", "origin", f"HEAD:refs/heads/{BRANCH}", + f"--force-with-lease=refs/heads/{BRANCH}:{stale}", check=False) + assert r.returncode != 0 and "stale info" in r.stderr + assert remote_head(origin) == moved + + +def test_bare_force_with_lease_is_rejected_without_fetch(origin, tmp_path): + """Regression guard: documents the bug the fetch in the script works around.""" + run1 = tmp_path / "run1" + checkout_like_actions(origin, run1) + run_fix(run1, "uses: actions/checkout@v5\n", "run 1") + assert push(run1).returncode == 0 + + run2 = tmp_path / "run2" + checkout_like_actions(origin, run2) + run_fix(run2, "uses: actions/checkout@v6\n", "run 2") + r = git(run2, "push", "-u", "origin", f"HEAD:refs/heads/{BRANCH}", "--force-with-lease", + check=False) + assert r.returncode != 0 + assert "stale info" in r.stderr