Conversation
`hashFiles` dropped any entry of an explicit `roots` list that did not resolve under the workspace unless `allowFilesOutsideWorkspace` was also set. The documented `roots: [GITHUB_WORKSPACE, GITHUB_ACTION_PATH]` usage therefore silently hashed only the workspace half, and callers who added the opt-in to work around it widened the allowlist to every matched file. An explicit `roots` list is itself the allowlist, so only apply the workspace restriction when the caller did not supply one.
Verification — adversarial pass at head
|
Rework — evidence only, no code change, head unchanged at
|
sprayberry-redline
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the GPT gating lane (gating review).
Verdict: APPROVED — ready for the operator to submit; no blocking issues found.
I reviewed the two-file diff, traced the base root-admission and per-file containment paths, and checked the five added regression/control tests against the base behavior. The new !explicitRoots condition at packages/glob/src/internal-hash-files.ts:131 preserves the existing per-file isInResolvedRoots containment check while allowing a caller-supplied root outside the workspace. The three regression tests cover mixed roots, all-outside roots, and a resolved symlink target; each would fail on base and pass with the change.
For this evidence-only rework, I also re-read the facts sheet and ## Rework comment at the unchanged head. All required facts-sheet sections are present; the corrected body accurately distinguishes the README's opt-in example from the affected no-opt-in case, accurately describes the warning behavior, and scopes the weaker unresolved-root control honestly. The base source and README support those corrections. Prior-art searches found no open upstream duplicate. Fork CI has no reported checks; the facts sheet records this as an absence rather than a passing signal.
What's good: the change is minimal, retains the default-roots and opt-in behavior, and the added control demonstrates that explicit roots remain an allowlist rather than widening inclusion.
sprayberry-secondread
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the Claude second-opinion lane (second opinion, non-gating; the gating review is posted separately).
Verdict: the fix and its tests hold up; I can independently confirm the bug, I found no reachable boundary case the body's ledger misses, and the two new tests each pin a distinct, non-vacuous failure mode. No blocking issues.
Confirmed independently
- The bug is real, and it is not the README example.
packages/glob/src/internal-hash-files.ts:130-137on base applied!allowOutside && !isInResolvedRoots(resolvedRoot, [resolvedWorkspace])to every root, including ones the caller passed explicitly viaroots. I readpackages/glob/README.md:100-104myself: the documented example passesroots: [GITHUB_WORKSPACE, GITHUB_ACTION_PATH]together withallowFilesOutsideWorkspace: true, so!allowOutsideis false there and the buggy branch never fires for it. The reachable victim is a caller who wants containment (an explicitrootsallowlist) and an outside root, without opting into the widerallowFilesOutsideWorkspaceper-file check — that combination has no correct expression on base. The PR body states this correctly (it was corrected from an earlier draft per its own "Disclosure facts" section), and I verified it against the actual README text rather than trusting the claim. - The fix is minimal and semantically sound.
explicitRoots = options?.roots !== undefined(internal-hash-files.ts:121) plus the new!explicitRoots &&conjunct (internal-hash-files.ts:131) is the smallest change that separates "no roots given, workspace is default" from "roots given, that list is the allowlist." Everything downstream — the per-fileisInResolvedRootscheck,resolvedRootsSetdedup, therealpathSynccatch path — is untouched, so containment for files outside all declared roots is preserved. - The two added tests (rows 2 and 3 in the PR's boundary table) are real and non-vacuous. I confirmed both by tracing the code, not just reading the assertions:
honors explicit roots when every one of them is outside the workspace(test file, all-outside case) — on base, the loop drops every root,resolvedRoots.length === 0, andhashFilesreturns''perinternal-hash-files.ts(theCould not resolve any allowed root(s)branch). This is a genuinely distinct failure shape from the mixed inside/outside case (which returns a partial, non-empty hash on base) — so it is not redundant with the first test.honors an explicit root that is a symlink pointing outside the workspace— this one exercisesrealpathSyncresolving the symlink before the new guard runs, and additionally asserts that declaring the resolved target directly produces the same digest as declaring the symlink, which pins that containment is computed on resolved paths, not the caller-supplied string. Also non-vacuous: on base this hits the exact same dropped-root branch as the other new outside-root cases.
Boundaries — rebuilt from the diff
The diff adds exactly one new expression (options?.roots !== undefined) and one new conjunct (!explicitRoots &&) to one if. Walking the reachable states myself:
| Input state | Base | Fixed | Covered? |
|---|---|---|---|
no options / no roots |
workspace-only, unaffected | same | pre-existing tests |
roots: [] (empty, but not undefined) |
explicitRoots true either way (!== undefined and truthiness agree since [] is truthy in JS) → loop body never runs → '' |
same | no test, but the two guard forms are provably equivalent over roots?: string[] (only null differs, and ?? routes null to the default) — a test here would be unfalsifiable, so its absence is not a hole |
roots explicit, one root inside workspace |
passes old guard anyway | unchanged | pre-existing |
roots explicit, mixed inside+outside, no opt-in |
bug: outside root dropped, partial hash | both honored | new test 1 |
roots explicit, all outside, no opt-in |
bug: '' |
honored, non-empty | new test 2 (I confirmed this is a distinct code path — resolvedRoots.length === 0 — from test 1's partial-hash path) |
roots explicit, symlink outside workspace |
bug: dropped after realpathSync resolves it |
honored, resolved-path containment preserved | new test 3 |
| file under no declared root, opt-in unset | still skipped (fix doesn't touch per-file check) | same | control test, and I checked the per-file isInResolvedRoots check at internal-hash-files.ts (post-resolution loop) is untouched by this diff |
| Windows path casing on an explicit root | guard is skipped before isInResolvedRoots's win32 lowercasing branch is ever reached for explicit roots, so no new case-sensitivity surface is introduced |
not executed on this fork (no Windows runner available); the body says as much rather than claiming a run that didn't happen | reasonable to leave to upstream CI |
I don't see a reachable row the body's own ledger misses.
What I checked against upstream conventions
internal-hash-files.tshistory (git log/gh api .../commits?path=...): the file's only prior functional changes are actions#2357 (introducedroots/allowFilesOutsideWorkspace, the origin of this bug), actions#1052 (added verbose mode), and actions#837 ("action author can decide" — a similar minimal, single-purpose fix philosophy). This PR's one-guard-conjunct scope matches that pattern; it doesn't bundle in thepackages/cache^0.6.1range fix the body explicitly declines to include (that's the right call — one bug per PR, consistent with how actions#2357 and actions#1052 were each scoped to one behavior).- Comment style: the multi-line comment replacing the old one-liner at
internal-hash-files.ts:116-120is denser than the surrounding file's usual single-line comments, but it's explaining a non-obvious invariant (why the restriction applies conditionally now), which is a defensible use of a longer comment — this is a style observation, not a finding. - No
RELEASES.md/package.jsonversion bump is included. Looking at comparable bug-fix-only PRs on this file (actions#837, actions#1052), those also didn't bump the package version in the same PR — version bumps in this repo appear to be batched separately (e.g. actions#2263, actions#2436 are dedicated bump PRs). So the omission here is consistent with how this package's maintainers actually work, not a gap. - Test file conventions (
getTestTemp()for in-workspace paths,fs.mkdtemp(os.tmpdir())for outside-workspace paths,io.rmRFinfinally) are followed correctly and match the existing tests in the same file (e.g. the pre-existinghashes files outside GITHUB_WORKSPACE only when opted-intest uses the sameos.tmpdir()pattern).
Minor
internal-hash-files.ts:118-119: "honored as given - otherwise a root outside the workspace would be dropped here and the files under it silently skipped" — "silently" is a slight overstatement inside the comment itself (the PR body correctly walks this back: acore.warningdoes name skipped files). Not a blocking issue since the body's prose elsewhere is accurate, but the in-code comment could match that nuance.
No blocking issues found. Checked: the diff, the new tests' independent non-vacuity, the changed guard against every reachable boundary I could construct, the README's own example against the bug it's claimed to be immune to, and this file's fix history for scope/convention fit.
SECOND READ: READY
Verification — resumed adversarial pass at head
|
|
Submitted upstream for review. |
Summary
@actions/glob'shashFiles()discards any entry of an explicitrootslist that does not resolve underGITHUB_WORKSPACE, unless the unrelatedallowFilesOutsideWorkspaceflag is also set. The discard itself is logged atcore.debugonly.rootsexists to provide and a root outside the workspace — an action hashing its own files (GITHUB_ACTION_PATH) alongside the workspace is the motivating case — has no way to express it. The declared root is dropped and the hash covers only the workspace half. For a cache key that means a permanent miss, or a key shared by two genuinely different inputs.core.warningnames each skipped file — but the only remedy that warning suggests,allowFilesOutsideWorkspace: true, disables the root check for every matched file, so the caller gets a wider allowlist than they asked for. Correct containment plus an outside root is unreachable on base.rootslist is the allowlist, so the workspace restriction only applies when the caller did not supply one.allowFilesOutsideWorkspacekeeps its documented meaning for the default-roots case and for the per-file check.@actions/glob: extend hashFiles options, merged 2026-07-14), which addedrootsand this restriction together. 8 lines changed insrc; 5 regression tests (3 discriminating + 2 controls) added next to the existingrootstests.Repro on base vs. the fix (
repro.cjs, in this directory — a workspace dir, an "action" dir outside it, one file in each, both declared inroots):Regression tests, both arms, at this head:
Upstream
actions/toolkit, default branchmain193fa46c20fde8b0ed54194bc08b841c78c0776dpackages/glob/src/internal-hash-files.ts, functionhashFiles()(the root-resolution loop, lines 115-139 on base)packages/glob/__tests__/hash-files.test.ts@actions/glob0.7.0Bug
Trigger. Call
hashFiles(patterns, workspace, {roots: [...]})where at least one entry ofrootsis not underGITHUB_WORKSPACE, and leaveallowFilesOutsideWorkspaceunset (its default,false).Wrong outcome. On base, the root-resolution loop applies the workspace restriction to every root, including explicitly supplied ones:
Every file under that root then fails the later per-file
isInResolvedRootscheck and is skipped, andhashFilesreturns a hash of the surviving subset — or''if no root survived. Two distinct input sets therefore produce the same hash, which is precisely the property a cache key must not have.How visible is it? The root-drop line itself is
writeDelegate, i.e.core.debugunless the caller passedverbose— so why the files vanished is not in a normal Actions log. The consequence is not silent, though: the skipped files fall intooutsideRootFilesand the tail ofhashFilesemits a realcore.warningnaming each one. Measured on base, running the README-shaped probe without the opt-in:::warning::Some matched files are outside the allowed root(s) and were skipped:%0A- /tmp/probe-action-AkKGdP/b.json -> /tmp/probe-action-AkKGdP/b.json%0ATo include them, set 'allowFilesOutsideWorkspace: true' in your options.and when every declared root is outside the workspace, the other warning fires instead and the return value is
'':::warning::Could not resolve any allowed root(s); no files will be considered for hashing.So the user is told. What they are told is the problem: the only remedy the warning offers over-widens the allowlist. Following it (
allowFilesOutsideWorkspace: true) disables the per-file root check for every matched file, including files under neither declared root — destroying the containment therootsoption exists to provide. There is no third option on base.Blast radius. Anyone using the
rootsoption added in actions#2357 with a directory outside the workspace and wanting containment. Both workarounds a caller would discover are wrong in the same direction:allowFilesOutsideWorkspace: truewidens to all matched files, and passing a common ancestor as the root widens further. The option is new (0.7.0, 2026-07-14), which is consistent with there being no issue report yet.Not the README example. The example in
packages/glob/README.md:100-104passesallowFilesOutsideWorkspace: truealongsideroots: [GITHUB_WORKSPACE, GITHUB_ACTION_PATH], which makes!allowOutsidefalse, so the buggy guard never fires for it. Measured on base — the README shape is immune, the same roots without the opt-in are not:The README example is what a caller writes when they are willing to give up containment. The bug is what happens to a caller who is not.
Repro
repro.cjsandreadme-probe.cjs(both in this directory) build the same shape: a workspace dir, an "action" dir outside it, one file in each, and both dirs declared inroots.readme-probe.cjsadditionally runs the README's example verbatim.roots: [ws, action]returns the same hash as hashing the workspace file alone —action.jsonwas never hashed. The third line shows the only way to get both files in on base is the opt-in flag, which also removes the containment guarantee. The run also emits the::warning::Some matched files are outside the allowed root(s) and were skippedline quoted under Bug.Same script at this head:
The declared root is honored, and it now agrees with the opt-in result for this input — i.e. both files, and only those two. The README-shaped call is unaffected by the fix (
e995b26f1ff802f9on both arms; see thereadme-probe.cjstranscripts under Bug and Test evidence).Fix
Why this is the minimal correct change. The defect is one conjunct in one predicate.
explicitRootsdistinguishes the two cases the code was conflating:rootsgiven →rootsdefaults to[resolvedWorkspace], the workspace is the only allowed root, andallowFilesOutsideWorkspaceis the documented opt-in that widens it. Unchanged. (The restriction is in fact a no-op in this branch — the sole root is the workspace — but it is left intact so the flag's meaning is not quietly altered.)rootsgiven → the caller has stated the allowlist. Honor it.Everything downstream is untouched: the per-file
isInResolvedRoots(resolvedFile, resolvedRoots)check, theallowOutsideper-file branch, the outside-root warning, dedup viaresolvedRootsSet, and therealpathSyncfailure path all behave exactly as before. Containment is not weakened — a file under no declared root is still skipped without the opt-in (pinned by control 1).The documented contract is unchanged. After the fix, when roots are explicit,
allowFilesOutsideWorkspaceno longer affects root admission at all — only the per-file check. That is what the README's option text already says: "Only files that resolve under (or equal) one of these roots are hashed" and "Explicit opt-in to include files outside the specified root path(s)" — root path(s), not workspace. The prose needs no edit; only the guard did not match it.options?.roots !== undefinedrather than a truthiness test is kept because it states the intent (was the option supplied?) and is robust if the declared type ever admitsnull. It is not load-bearing for present behaviour, and no test can pin it: the two forms are equivalent for every value ofroots?: string[], since[]is truthy in JS.(Only
nulldistinguishes them, and?? [resolvedWorkspace]sendsnullto the default either way. Mutating the guard to!!options?.rootson base leaves the suite green — see Boundaries row 3.)Alternatives rejected.
core.warningwhen dropping a root, keep the behaviour. A warning already exists downstream; the problem is not that the drop is unreported, it is that the documentedGITHUB_ACTION_PATHusage remains impossible without over-widening.@actions/cache's"@actions/glob": "^0.6.1"range at the same time. Real (see Prior art) but a separate change — one bug per PR.Test evidence
Five tests added to
packages/glob/__tests__/hash-files.test.ts— three by the hunt (9e11758) and two by the adversarial verification pass (6b2f5c9) — placed with the existingroots/allowFilesOutsideWorkspacetests and following the file's conventions (getTestTemp()for in-workspace dirs,fs.mkdtempinos.tmpdir()for outside-workspace dirs — the suite pinsGITHUB_WORKSPACEto__dirname— andio.rmRFcleanup infinally). The file holds 17 tests at this head: 12 pre-existing, 5 added.honors an explicit root outside the workspace without the opt-inhonors explicit roots when every one of them is outside the workspace''outright. A distinct failure mode from test 1honors an explicit root that is a symlink pointing outside the workspacerealpathSyncresolves the root before the guard; also asserts that declaring the link target gives the same digest, pinning that containment is computed on resolved paths(control) still restricts to the allowed roots when roots are explicitif (false)— the over-broadening the fix must not cause — fails 4 tests including this one(control) returns empty when every explicit root fails to resolverealpathSyncfailure path: an unresolvable root yields''. Note the scope honestly — the test globs files under a different (in-workspace) directory than the unresolvable root, so it asserts the return value, not the internals of thecatch; mutating the catch to add the raw root to the set leaves it passingTests 4 and 5 pass on base by design and are named
(control)for that reason: they exist to prove the fix does not weaken the guard it touches. Tests 1-3 each fail on base with a distinct assertion.Fails-before — base worktree at
193fa46cwith this head's test file copied in and the basesrc(measured:grep -c explicitRoots→0in bothsrcand the per-arm-builtlib):with the three assertions:
Test 1's failure is the sharp one: the hash with both roots declared is byte-identical to the workspace-only hash — the declared root contributed nothing. Tests 2 and 3 fail differently (
'', i.e. nothing was hashable at all), which is why they are separate rows rather than variants of row 1.Passes-after — this head (
6b2f5c9, measured:grep -c explicitRoots→2insrcandlib):All 12 pre-existing tests in the file are green in both arms — the fix changes nothing they assert.
Probes (not tests; they exercise
lib/viarequire, built per arm):Project tooling (the three things
.github/CONTRIBUTING.mdrequires before a PR is accepted), at this head:The repo-wide
npm run format-check/npm run lint/npm testwere not run whole — they cover every package in the monorepo and the change is confined to one file in one package. The commands above are those same tools scoped to the touched files.Verification method
executed, on Linux (Alpine container), Node v24.19.0, npm 11.17.0 — which is one of the two Node versions in the upstream matrix (unit-tests.yml:node-version: [20.x, 24.x]).Every transcript in this body was produced at head
6b2f5c90b7d369b4d6b34f82e4945a5755e68799, in a rebuild of both arms from scratch (the hunt's and the verification's worktrees no longer exist). Nothing is inherited.A/B methodology, stated because it is the part most easily got wrong: the two arms are two separate git worktrees, not a file swapped in place —
/agent-workspace/oss/toolkit-basewtdetached at193fa46cwith this head's test file copied in, and/agent-workspace/oss/toolkit-wt-rw1789476094at the PR head.node_modulesand the dependency packages'lib/are shared between arms, butpackages/glob/libis built per arm — sharing it makes both arms read the same compiled output and prints a convincing false negative for anyrequire-based probe. Nothing was ever staged or restored in the candidate worktree to produce the base numbers, so the committed diff cannot have been polluted by the evidence gathering.Arm integrity was measured rather than assumed, before every transcript:
Head integrity:
Adversarial pass. An independent run rebuilt the boundaries ledger from the diff, added tests 2 and 3 (rows 11 and 13, both failing on base), and mutation-tested both controls and the
!== undefinedguard. Its findings are recorded in this body: control 4 is genuine, control 5's scope is narrower than first claimed (row 10), and the!== undefined/truthiness distinction is not pinnable (row 3). The full write-up is at #1 (comment).Not covered here, for the reviewer: Windows and macOS.
isInResolvedRootshas aprocess.platform === 'win32'lowercasing branch, and the fix's guard sits before that call, so it is platform-independent by construction — but the assertion that the fixed path behaves identically on Windows is not executed evidence from any run. The upstream matrix (ubuntu-latest,macos-latest-large,windows-latest× Node 20/24) covers it. Note the base already had no Windows-specific test for therootsoption.Fork CI: none. GitHub Actions has never been enabled on
askalf/toolkit(the fork was created during the hunt; enabling it is a manual click with no API).gh pr checks 1 --repo askalf/toolkitreports no checks — an absence of CI, not a failing CI. So there is no fork CI run to link, and the Windows/macOS legs are confirmed by neither this run nor the fork — only by upstream CI once submitted.Prior art
rootsoption and this restriction. It is the origin of the bug, not a competing fix.internal-hash-files.tsbut is about result ordering; no overlap with root resolution.internal-pattern.ts(Windows separators), improve glob performance by not ignoring negate actions/toolkit#2107 (open) touches negate handling, chore(deps): bump brace-expansion from 5.0.7 to 5.0.9 in /packages/glob actions/toolkit#2465 is dependabot. None toucheshashFilesroots.No open or closed PR addresses this. No issue reports it — consistent with a new option whose failure mode surfaces as "my cache never hits" rather than as an error.
On the issue this hunt started from (actions#2484). Worth recording because it changes what a maintainer should do with it: actions#2484's stated root cause —
minimatch@3'stry { return require('path') }being left intact by@rollup/plugin-commonjs(ignoreTryCatchdefaults totrue), sosepfalls back to/in an ESM bundle — is already fixed onmain.packages/globis at 0.7.0 withminimatch: ^10.2.5(bumped in6bd5e50), and minimatch 10 derivessepfromprocess.platformwith norequire()in the path. The issue's own "Suggested resolution" says as much.What is still live from actions#2484 is its point 1:
packages/cache/package.jsondeclares"@actions/glob": "^0.6.1", a range that will not select 0.7.0, so@actions/cache@6.2.0consumers still get the bundling-unsafe version. That is a dependency-range change in a different package, not a code bug inpackages/glob— it is recorded as a separate follow-up and is deliberately not in this PR.Policy
.github/CONTRIBUTING.md, "Development Life Cycle":All three satisfied for the touched files (transcripts under Test evidence). The same file's "Enhancements and Feature Requests" section asks for a feature request and an ADR before significant effort — that governs enhancements; this is a bug fix with a failing regression test, filed under "Issues … for both bugs and enhancement requests".
AI stance: silent. No AI/LLM/agent policy exists in this repo. The full recursive tree of
mainwas dumped (gh api repos/actions/toolkit/git/trees/main?recursive=1) and pattern-matched forAI|LLM|POLICY|CONDUCT|CONTRIB|GUIDELINE; the only matches are.github/CONTRIBUTING.md,CODE_OF_CONDUCT.md,.github/ISSUE_TEMPLATE/{bug_report,enhancement_request}.mdandpackages/artifact/CONTRIBUTIONS.md. There is noAI_POLICY.md, noAGENTS.md, no.github/PULL_REQUEST_TEMPLATE, and no CLA requirement.CODE_OF_CONDUCT.mdis the standard Contributor Covenant with no AI clause.Disclosure facts for the operator
Plain facts, for you to word your own disclosure:
main. The agent read the surface the issue pointed at, found @actions/glob 0.6.1 silently fails to match Windows paths when bundled as ESM with Rollup actions/toolkit#2484 stale, and found this defect in the same file's sibling code path (rootsresolution, added by @actions/glob: extend hashFiles options actions/toolkit#2357).packages/glob/libbuilt per arm, the repro and probes on base and on the fix, prettier, eslint,tsc --noEmit. All transcripts above are copy-pasted from those runs, not reconstructed.core.warningnames the skipped files, grepped from the base run). If you compare against an earlier copy, that is why the framing changed. The fix and the tests never changed.isInResolvedRoots(the fix's guard runs before any platform branch), not on a Windows run.Boundaries
Every predicate, comparison and guard the diff adds or changes. The diff adds one new expression (
options?.roots !== undefined) and one new conjunct (!explicitRoots &&) to an existingif.optionsisundefined(no options at all)options?.roots→undefined,explicitRoots = false. Old behaviour exactly: roots default to[resolvedWorkspace], restriction applies (no-op — the sole root is the workspace).basic hashfiles test,hashes files outside GITHUB_WORKSPACE only when opted-in(both pre-existing, both callhashFileswith no options)optionsgiven,rootsabsentexplicitRoots = false.allowFilesOutsideWorkspaceretains its documented meaning.hashes files outside GITHUB_WORKSPACE only when opted-in(pre-existing; passes{allowFilesOutsideWorkspace: true}with noroots)roots: [](empty array — the falsy-looking-but-truthy case)!== undefined→explicitRoots = true. Loop body never runs,resolvedRoots.length === 0→core.warning+ return''. Identical on both arms.empty-roots-probe.cjs, run on base and head: both print theCould not resolve any allowed root(s)warning androots: [] => "". No test, and deliberately none:!== undefinedand!!options?.rootsare equivalent over the declared typeroots?: string[]([]is truthy in JS), so no test can discriminate them — the mutation to truthiness leaves the suite green on base. The row is closed by the probe, not by the guard's form.rootsexplicit, one entry, inside the workspaceexplicitRoots = trueshort-circuits the guard; the root would have passed it anyway. No change.hashes files in allowed roots only,excludes files matching exclude patterns(pre-existing)rootsexplicit, one entry, outside the workspace, no opt-in''or a partial hash. Fixed: honored.honors an explicit root outside the workspace without the opt-in(fails on base at:299)rootsexplicit, mixed inside + outside, no opt-inexpectedcomparison pins "exactly these two files"); control 4rootsexplicit andallowFilesOutsideWorkspace: true!explicitRoots(it would also have been skipped by!allowOutside). Per-file check still widened by the flag, as documented. Digest unchanged by the fix.allows files outside roots if opted-in(pre-existing);readme-probe.cjson both arms —e995b26f1ff802f9either wayisInResolvedRoots, which is where thewin32lowercasing lives; an explicit root now bypasses that comparison entirely, so no case/separator question arises for it. The per-file check is unchanged.windows-latest× Node 20/24 legs confirm. Base had no Windows test forrootseither.root === resolvedWorkspace)realpathSyncon the line above, unchanged; then the guard is skipped when explicit, and would have passed anyway (a root contains itself).hashes files in allowed roots only(pre-existing,rootsunder the test temp dir)realpathSyncthrowscatchlogs andcontinues — untouched by the diff and reached before the new guard matters. If it was the only root:core.warning+''.(control) returns empty when every explicit root fails to resolve— with the honest caveat that it globs an in-workspace directory while the unresolvable root is the only declared one, so it pins the''return value, not thecatchinternals: mutating the catch to add the raw root toresolvedRootsSetleaves it passing on base. Widening the row's test is a fair ask; the row's stated behaviour is unchanged by this diff either way.realpathSyncresolves it first (unchanged line), then the guard is skipped because it is explicit — so the caller's stated intent wins. The same resolved value feeds the per-file check, so containment is still computed on real paths.honors an explicit root that is a symlink pointing outside the workspace(fails on base at:415; its second assertion pins that declaring the link target directly gives the same digest)resolvedRootsSetis aSetof resolved paths — dedup is unchanged and happens after the guard.rootsexplicit, every entry outside the workspace, no opt-in'', with::warning::Could not resolve any allowed root(s). Fixed: all honored, and the digest equals the opt-in digest for the same inputs.honors explicit roots when every one of them is outside the workspace(fails on base at:386withExpected: not ""— a distinct failure mode from row 5's partial hash)if (!isInResolvedRoots(resolvedFile, resolvedRoots))withif (false)fails 4 tests including control 4Rows 3 and 12 are closed by probe and by argument respectively rather than by a committed test, and both say why above. Row 8 is the one row with no executed evidence anywhere; upstream CI is what closes it.
Suggested upstream PR title
@actions/glob: honor explicit hashFiles roots outside the workspace