77 "errors"
88 "fmt"
99 "net/http"
10- "os"
11- "os/exec"
12- "path/filepath"
1310 "regexp"
1411 "strconv"
1512 "strings"
@@ -56,15 +53,13 @@ type ReachabilityResult struct {
5653// Resolver resolves action refs to commit SHAs.
5754type Resolver struct {
5855 client * api.GraphQLClient
56+ restClient * api.RESTClient
5957 hostname string
6058 MaxRecursionDepth int
6159 cache map [string ]resolvedEntry
6260 latestRefCache map [string ]string
6361 reachCache map [string ]ReachabilityStatus
64- // CacheDir is the directory for bare git clones used in reachability checks.
65- // Defaults to ~/.actions-lockfile/cache.
66- CacheDir string
67- // checkReachFn overrides the default git-based reachability check (for tests).
62+ // checkReachFn overrides the default REST-based reachability check (for tests).
6863 checkReachFn func (owner , repo , sha , ref string ) (ReachabilityStatus , string )
6964}
7065
@@ -86,20 +81,19 @@ func NewWithOptions(opts api.ClientOptions) (*Resolver, error) {
8681 return nil , err
8782 }
8883
89- homeDir , err := os . UserHomeDir ( )
84+ restClient , err := api . NewRESTClient ( opts )
9085 if err != nil {
91- homeDir = os . TempDir ()
86+ return nil , err
9287 }
93- cacheDir := filepath .Join (homeDir , ".actions-lockfile" , "cache" )
9488
9589 return & Resolver {
9690 client : client ,
91+ restClient : restClient ,
9792 hostname : hostname ,
9893 MaxRecursionDepth : DefaultMaxRecursionDepth ,
9994 cache : make (map [string ]resolvedEntry ),
10095 latestRefCache : make (map [string ]string ),
10196 reachCache : make (map [string ]ReachabilityStatus ),
102- CacheDir : cacheDir ,
10397 }, nil
10498}
10599
@@ -119,21 +113,29 @@ func (r *Resolver) Hostname() string {
119113 return r .hostname
120114}
121115
122- // SetCheckReachabilityFunc overrides the default git -based reachability check.
116+ // SetCheckReachabilityFunc overrides the default REST -based reachability check.
123117// Intended for tests.
124118func (r * Resolver ) SetCheckReachabilityFunc (fn func (owner , repo , sha , ref string ) (ReachabilityStatus , string )) {
125119 r .checkReachFn = fn
126120}
127121
122+ // isSHARef returns true if the ref looks like a full commit SHA (40 hex chars).
123+ var shaRefRE = regexp .MustCompile (`^[0-9a-fA-F]{40}$` )
124+
128125// CheckReachability verifies that a resolved SHA is on the lineage of the
129126// given ref within the repository. This catches fork-network injection where
130127// a SHA exists in GitHub's shared object store but is not actually part of
131128// the canonical repository's history.
132129//
133- // Uses a bare blobless clone of the upstream repo and git merge-base:
134- // - exit 0 from merge-base --is-ancestor → Reachable
135- // - exit 1 → Unreachable (SHA not an ancestor of ref)
136- // - clone/fetch failure → Unknown
130+ // Uses the GitHub Compare API and checks merge_base identity:
131+ // - merge_base == pinnedSHA → Reachable (SHA is a true ancestor of ref)
132+ // - merge_base != pinnedSHA → Unreachable (fork/imposter commit)
133+ // - 404 (no common ancestor or not found) → Unreachable
134+ // - 403/429 (rate limit) or other error → Unknown
135+ //
136+ // When ref is itself a raw SHA (the "uses: owner/repo@SHA" anti-pattern),
137+ // the compare becomes {sha}...{sha} which trivially returns "identical" and
138+ // cannot detect fork commits. In this case, a warning is returned instead.
137139func (r * Resolver ) CheckReachability (owner , repo , sha , ref string ) ReachabilityResult {
138140 result := ReachabilityResult {
139141 Owner : owner ,
@@ -158,7 +160,15 @@ func (r *Resolver) CheckReachability(owner, repo, sha, ref string) ReachabilityR
158160 return result
159161 }
160162
161- status , detail := r .gitReachabilityCheck (owner , repo , sha , ref )
163+ // SHA-as-ref anti-pattern: compare/{sha}...{sha} is trivially identical
164+ // and cannot detect fork commits. Warn the user.
165+ if shaRefRE .MatchString (ref ) {
166+ result .Status = ReachabilityUnknown
167+ result .Detail = "ref is a raw SHA — reachability cannot be verified; pin to a tag instead"
168+ return result
169+ }
170+
171+ status , detail := r .apiReachabilityCheck (owner , repo , sha , ref )
162172 result .Status = status
163173 result .Detail = detail
164174 if result .Status != ReachabilityUnknown {
@@ -167,55 +177,45 @@ func (r *Resolver) CheckReachability(owner, repo, sha, ref string) ReachabilityR
167177 return result
168178}
169179
170- // ensureBareClone clones or fetches a bare blobless repo into the cache dir.
171- func (r * Resolver ) ensureBareClone (owner , repo string ) (string , error ) {
172- repoDir := filepath .Join (r .CacheDir , owner , repo + ".git" )
173- cloneURL := fmt .Sprintf ("https://%s/%s/%s.git" , r .hostname , owner , repo )
174-
175- if _ , err := os .Stat (filepath .Join (repoDir , "HEAD" )); err == nil {
176- // Already cloned — fetch latest refs
177- cmd := exec .Command ("git" , "-C" , repoDir , "fetch" , "--quiet" , "--tags" , "--force" )
178- if out , err := cmd .CombinedOutput (); err != nil {
179- return "" , fmt .Errorf ("git fetch failed: %s: %w" , strings .TrimSpace (string (out )), err )
180- }
181- return repoDir , nil
182- }
183-
184- if err := os .MkdirAll (filepath .Dir (repoDir ), 0o755 ); err != nil {
185- return "" , err
186- }
187- cmd := exec .Command ("git" , "clone" , "--filter=blob:none" , "--bare" , "--quiet" , cloneURL , repoDir )
188- if out , err := cmd .CombinedOutput (); err != nil {
189- return "" , fmt .Errorf ("git clone failed: %s: %w" , strings .TrimSpace (string (out )), err )
190- }
191- return repoDir , nil
180+ // compareResponse is the subset of the GitHub Compare API response we need.
181+ type compareResponse struct {
182+ MergeBaseCommit struct {
183+ SHA string `json:"sha"`
184+ } `json:"merge_base_commit"`
185+ Status string `json:"status"`
192186}
193187
194- // gitReachabilityCheck uses a bare clone and merge-base --is-ancestor to verify
195- // that sha is an ancestor of ref.
196- func (r * Resolver ) gitReachabilityCheck (owner , repo , sha , ref string ) (ReachabilityStatus , string ) {
197- repoDir , err := r .ensureBareClone (owner , repo )
188+ // apiReachabilityCheck uses the GitHub Compare API to verify that sha is an
189+ // ancestor of ref. The key insight: merge_base(ancestor, descendant) == ancestor.
190+ // If the merge_base is NOT the pinned SHA, the commit lives on the fork network.
191+ func (r * Resolver ) apiReachabilityCheck (owner , repo , sha , ref string ) (ReachabilityStatus , string ) {
192+ path := fmt .Sprintf ("repos/%s/%s/compare/%s...%s" , owner , repo , sha , ref )
193+
194+ var resp compareResponse
195+ err := r .restClient .Get (path , & resp )
198196 if err != nil {
197+ var httpErr * api.HTTPError
198+ if errors .As (err , & httpErr ) {
199+ switch {
200+ case httpErr .StatusCode == http .StatusNotFound :
201+ return Unreachable , "no common ancestor or commit not found"
202+ case httpErr .StatusCode == http .StatusForbidden || httpErr .StatusCode == http .StatusTooManyRequests :
203+ detail := fmt .Sprintf ("rate limited (HTTP %d)" , httpErr .StatusCode )
204+ if reset := httpErr .Headers .Get ("X-RateLimit-Reset" ); reset != "" {
205+ detail += "; resets at " + reset
206+ }
207+ return ReachabilityUnknown , detail
208+ default :
209+ return ReachabilityUnknown , fmt .Sprintf ("API error (HTTP %d): %s" , httpErr .StatusCode , httpErr .Message )
210+ }
211+ }
199212 return ReachabilityUnknown , err .Error ()
200213 }
201214
202- cmd := exec .Command ("git" , "-C" , repoDir , "merge-base" , "--is-ancestor" , sha , ref )
203- out , err := cmd .CombinedOutput ()
204- if err == nil {
205- return Reachable , "ancestor of " + ref
206- }
207-
208- // exit 1 = not ancestor, exit 128 = SHA unknown
209- var exitErr * exec.ExitError
210- if errors .As (err , & exitErr ) {
211- switch exitErr .ExitCode () {
212- case 1 :
213- return Unreachable , "commit is not an ancestor of " + ref
214- case 128 :
215- return Unreachable , "commit not found in repository: " + strings .TrimSpace (string (out ))
216- }
215+ if resp .MergeBaseCommit .SHA == sha {
216+ return Reachable , "ancestor of " + ref + " (compare: " + resp .Status + ")"
217217 }
218- return ReachabilityUnknown , err . Error ( )
218+ return Unreachable , fmt . Sprintf ( "merge base is %s, not the pinned SHA — likely a fork-network commit" , resp . MergeBaseCommit . SHA [: 12 ] )
219219}
220220
221221// CheckReachabilityAll runs reachability checks on a batch of dependencies,
0 commit comments