From 6caf0e916f15e4961694d9a54c83fa0f4591afaf Mon Sep 17 00:00:00 2001 From: "shingo.imota" Date: Tue, 20 Oct 2026 13:47:53 +0900 Subject: [PATCH 1/2] feat: build a pull_request payload from the repository when no event file is given Without -e, pull_request and pull_request_target runs had an empty payload, so github.base_ref and head_ref were empty. Build a PR from the current branch into the default branch (--defaultbranch overrides it), with base.sha from / unless it is HEAD. --- README.md | 2 +- src/context.rs | 27 +++++++++++++++++++ src/main.rs | 10 +++++-- tests/e2e.rs | 4 ++- .../repo/.github/workflows/failing.yml | 1 + 5 files changed, 40 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6aaf288..d6e1668 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Flags follow act's names (`--matrix`, `--reuse`, `--json`, `--input-file`, …); | Event payload | `-e event.json` | — | | Default arguments | — | `$XDG_CONFIG_HOME/act/actrc`, `~/.actrc`, `./.actrc` | -Flags win over files. Arguments from `.actrc` files are read before the command line's. Without `-e`, a `merge_group` run gets a payload built from the repository (`head_sha`/`head_ref` from HEAD, `base_sha` where HEAD forked from the remote's default branch). +Flags win over files. Arguments from `.actrc` files are read before the command line's. Without `-e`, a `merge_group` run gets a payload built from the repository (`head_sha`/`head_ref` from HEAD, `base_sha` where HEAD forked from the remote's default branch). A `pull_request` or `pull_request_target` run gets a PR from the current branch into the default branch (`github.base_ref`/`head_ref`; `--defaultbranch` picks another base). ### Dashboard diff --git a/src/context.rs b/src/context.rs index 27a64e2..7c218bf 100644 --- a/src/context.rs +++ b/src/context.rs @@ -174,6 +174,33 @@ impl RepoInfo { }) } + /// A pull_request payload for HEAD when no event file is given: a PR from + /// the current branch (`head`) into the default branch (`base`), so + /// `github.base_ref`/`head_ref` are set. `base.sha` is `/` + /// unless it is HEAD itself, where `on.pull_request.paths` would see no + /// changes. No `action`, so every `types` filter passes. + pub fn pull_request_event(&self, remote: &str, default_branch: &str) -> Value { + let mut event = json!({ + "pull_request": { + "head": {"ref": self.ref_name, "sha": self.sha}, + "base": {"ref": default_branch}, + }, + }); + let base_sha = git( + &self.root, + &[ + "rev-parse", + "--verify", + "-q", + &format!("refs/remotes/{remote}/{default_branch}^{{commit}}"), + ], + ); + if let Some(sha) = base_sha.filter(|s| *s != self.sha) { + event["pull_request"]["base"]["sha"] = json!(sha); + } + event + } + /// `/` when HEAD is ahead of it, as GitHub's `before` for a /// push of HEAD; `None` for a new, behind or up-to-date branch. fn push_before(&self, remote: &str) -> Option { diff --git a/src/main.rs b/src/main.rs index 5677690..694a8f4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -562,8 +562,14 @@ fn run(cli: &Cli) -> Result { .defaultbranch .clone() .unwrap_or_else(|| repo.default_branch.clone()); - if cli.eventpath.is_none() && event_name == "merge_group" { - event = repo.merge_group_event(&cli.remote_name, &branch); + if cli.eventpath.is_none() { + match event_name.as_str() { + "merge_group" => event = repo.merge_group_event(&cli.remote_name, &branch), + "pull_request" | "pull_request_target" => { + event = repo.pull_request_event(&cli.remote_name, &branch) + } + _ => {} + } } repo.fill_event_repository(&mut event, &branch); diff --git a/tests/e2e.rs b/tests/e2e.rs index ef9861f..13027c6 100644 --- a/tests/e2e.rs +++ b/tests/e2e.rs @@ -170,9 +170,11 @@ fn runthrough_dir_workflows_are_discovered() { #[test] fn failing_workflow_runs_failure_steps_and_skips_dependents() { - let (code, out) = runthrough(&["pull_request"]); + let (code, out) = runthrough(&["pull_request", "--defaultbranch", "trunk"]); assert_eq!(code, 1, "{out}"); assert!(out.contains("node got ab"), "{out}"); + // Without `-e`, the payload is a PR from HEAD into the default branch. + assert!(out.contains("pr base_ref=trunk head_ref=true"), "{out}"); assert!(out.contains("cleanup after failure"), "{out}"); assert!(out.contains("post sees state ab"), "{out}"); assert!(out.contains("failing / never: skipped"), "{out}"); diff --git a/tests/fixtures/repo/.github/workflows/failing.yml b/tests/fixtures/repo/.github/workflows/failing.yml index fd47001..f4a646b 100644 --- a/tests/fixtures/repo/.github/workflows/failing.yml +++ b/tests/fixtures/repo/.github/workflows/failing.yml @@ -10,6 +10,7 @@ jobs: with: value: ab - run: test "${{ steps.js.outputs.doubled }}" = abab + - run: echo "pr base_ref=${{ github.base_ref }} head_ref=${{ github.head_ref != '' }}" - run: exit 3 - if: failure() run: echo "cleanup after failure" From 5bfafc475bef42019477a78c5184ff54765caddb Mon Sep 17 00:00:00 2001 From: "shingo.imota" Date: Tue, 20 Oct 2026 13:55:46 +0900 Subject: [PATCH 2/2] fix: fall back to the local default branch for a built pull_request payload's base.sha Without /, pull_request_target pointed github.ref at the base branch but left github.sha at HEAD, and path filters passed unchecked. --- src/context.rs | 60 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/src/context.rs b/src/context.rs index 7c218bf..6646e16 100644 --- a/src/context.rs +++ b/src/context.rs @@ -176,9 +176,10 @@ impl RepoInfo { /// A pull_request payload for HEAD when no event file is given: a PR from /// the current branch (`head`) into the default branch (`base`), so - /// `github.base_ref`/`head_ref` are set. `base.sha` is `/` - /// unless it is HEAD itself, where `on.pull_request.paths` would see no - /// changes. No `action`, so every `types` filter passes. + /// `github.base_ref`/`head_ref` are set. `base.sha` is `/`, + /// or the local `` without it, unless it is HEAD itself, where + /// `on.pull_request.paths` would see no changes. No `action`, so every + /// `types` filter passes. pub fn pull_request_event(&self, remote: &str, default_branch: &str) -> Value { let mut event = json!({ "pull_request": { @@ -186,15 +187,14 @@ impl RepoInfo { "base": {"ref": default_branch}, }, }); - let base_sha = git( - &self.root, - &[ - "rev-parse", - "--verify", - "-q", - &format!("refs/remotes/{remote}/{default_branch}^{{commit}}"), - ], - ); + let commit = |r: String| { + git( + &self.root, + &["rev-parse", "--verify", "-q", &format!("{r}^{{commit}}")], + ) + }; + let base_sha = commit(format!("refs/remotes/{remote}/{default_branch}")) + .or_else(|| commit(format!("refs/heads/{default_branch}"))); if let Some(sha) = base_sha.filter(|s| *s != self.sha) { event["pull_request"]["base"]["sha"] = json!(sha); } @@ -512,6 +512,42 @@ mod tests { assert_eq!(repo.push_before("origin"), None); } + #[test] + fn pull_request_base_sha_is_the_remote_or_local_default_branch() { + let dir = tempfile::tempdir().unwrap(); + let run = |args: &[&str]| { + let ok = Command::new("git") + .arg("-C") + .arg(dir.path()) + .args(["-c", "user.name=t", "-c", "user.email=t@x"]) + .args(args) + .status() + .unwrap() + .success(); + assert!(ok, "git {args:?}"); + }; + let base_sha = |remote: &str| { + let repo = RepoInfo::detect(dir.path(), remote); + repo.pull_request_event(remote, "main")["pull_request"]["base"]["sha"].clone() + }; + run(&["init", "-q", "-b", "main"]); + run(&["commit", "-q", "--allow-empty", "-m", "a"]); + let a = git(dir.path(), &["rev-parse", "HEAD"]).unwrap(); + // On the default branch itself: no base.sha. + assert_eq!(base_sha("origin"), Value::Null); + run(&["checkout", "-q", "-b", "topic"]); + run(&["commit", "-q", "--allow-empty", "-m", "b"]); + // No remote-tracking ref: the local default branch. + assert_eq!(base_sha("origin"), a.as_str()); + run(&["update-ref", "refs/remotes/origin/main", "topic"]); + run(&["commit", "-q", "--allow-empty", "-m", "c"]); + let b = git(dir.path(), &["rev-parse", "HEAD~1"]).unwrap(); + // The remote-tracking ref wins over the local branch. + assert_eq!(base_sha("origin"), b.as_str()); + let event = RepoInfo::detect(dir.path(), "origin").pull_request_event("origin", "main"); + assert_eq!(event["pull_request"]["head"]["ref"], "topic"); + } + #[test] fn slugs() { assert_eq!(