Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/release-notes/v0.3.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
4 changes: 2 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion docs/action.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
5 changes: 4 additions & 1 deletion pr_body.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
22 changes: 22 additions & 0 deletions scripts/push_pr_branch.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Push HEAD to refs/heads/<branch> on <remote> 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/<branch>. 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 <branch> [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}"
137 changes: 137 additions & 0 deletions tests/test_action_push.py
Original file line number Diff line number Diff line change
@@ -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/<pr-branch>. 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
Loading