@@ -1557,10 +1557,14 @@ func TestSHARecordedRef_FixReRecords(t *testing.T) {
15571557 tags []string
15581558 compare string
15591559 want string
1560+ // unconfirmed leaves out the repo ID the identity proof needs.
1561+ unconfirmed bool
1562+ refused string
15601563 }{
15611564 {name : "tag at the commit" , tags : []string {"v6.1.0" , parityTestSHA }, compare : "behind" , want : "v6.1.0" },
15621565 {name : "branch containing the commit" , compare : "behind" , want : "main" },
1563- {name : "on no tag or branch" , compare : "diverged" },
1566+ {name : "on no tag or branch" , compare : "diverged" , refused : "is not on any tag or branch" },
1567+ {name : "in an unconfirmed repository" , tags : []string {"v6.1.0" , parityTestSHA }, compare : "behind" , unconfirmed : true , refused : "could not confirm actions/checkout is the repository locked" },
15641568 }
15651569 for _ , tt := range tests {
15661570 for mode , args := range map [string ][]string {"default" : nil , "relock" : {"--relock" }, "no-onboard" : {"--no-onboard" }} {
@@ -1570,9 +1574,13 @@ func TestSHARecordedRef_FixReRecords(t *testing.T) {
15701574 base = "cccccccccccccccccccccccccccccccccccccccc"
15711575 }
15721576 reg := & httpmock.Registry {}
1577+ repo := testRepoResponse ("actions/checkout" , parityTestSHA , nodeActionYAML )
1578+ if ! tt .unconfirmed {
1579+ repo = withRepoID (repo , 1 )
1580+ }
15731581 for range 4 {
15741582 reg .Register (httpmock .GraphQLForRepo ("actions" , "checkout" ), httpmock .JSONResponse (map [string ]any {
1575- "data" : map [string ]any {"a0" : testRepoResponse ( "actions/checkout" , parityTestSHA , nodeActionYAML ) },
1583+ "data" : map [string ]any {"a0" : repo },
15761584 }))
15771585 reg .Register (httpmock .REST ("GET" , `repos/actions/checkout$` ),
15781586 httpmock .JSONResponse (map [string ]any {"full_name" : "actions/checkout" , "default_branch" : "main" , "id" : 1 , "owner" : map [string ]any {"id" : 1 }}))
@@ -1596,9 +1604,13 @@ func TestSHARecordedRef_FixReRecords(t *testing.T) {
15961604 require .NoError (t , rerr )
15971605 assert .Equal (t , string (wfBefore ), string (wfAfter ), "`uses:` keeps the full SHA" )
15981606 lock := readTempLockfilePins (t )
1599- if tt .want = = "" {
1607+ if tt .refused ! = "" {
16001608 require .ErrorIs (t , err , errSilent , stderr )
1601- assert .Contains (t , stderr , "is not on any tag or branch" )
1609+ assert .Contains (t , stderr , tt .refused )
1610+ if tt .unconfirmed {
1611+ assert .Contains (t , stderr , "Retry if the check was inconclusive, or change to a verified repository and ref" )
1612+ assert .NotContains (t , stderr , "--relock` to re-resolve" , "--relock re-records the same entry" )
1613+ }
16021614 assert .Equal (t , lockBefore , lock )
16031615 return
16041616 }
@@ -2111,7 +2123,8 @@ func TestSHARecordedRef_RepairFollowsTheWrite(t *testing.T) {
21112123 if tt .identity != "" {
21122124 require .ErrorIs (t , err , errSilent , stderr )
21132125 assert .Contains (t , stderr , "actions/cache@" + parityTestSHA )
2114- assert .Contains (t , stderr , "--relock" )
2126+ assert .Contains (t , stderr , "Retry if the check was inconclusive, or change to a verified repository and ref; for a pin a composite uses, upgrade or replace actions/checkout@v6.1.0" )
2127+ assert .NotContains (t , stderr , "--relock" , "--relock re-records the same entry" )
21152128 assert .Equal (t , lock , after )
21162129 return
21172130 }
@@ -2136,14 +2149,16 @@ func TestSHARecordedRef_RepairFollowsTheWrite(t *testing.T) {
21362149func TestSHARecordedRef_SameCommitTagAndSHA (t * testing.T ) {
21372150 const head = "dddddddddddddddddddddddddddddddddddddddd"
21382151 reg := & httpmock.Registry {}
2139- // The parity stub goes first: a repo stub matches any query for the repo.
2152+ // The parity and manifest stubs go first: a repo stub matches any query
2153+ // for the repo.
2154+ registerLockedManifests (reg , nil )
21402155 for range 3 {
2141- registerParity ( reg )
2156+ reg . Register ( httpmock . GraphQL ( `commit: object\(oid` ), parityOKWithIDs ( map [ string ] int { "checkout" : 1 , "cache" : 1 }) )
21422157 }
21432158 for _ , repo := range []string {"checkout" , "cache" } {
21442159 for range 4 {
21452160 reg .Register (httpmock .GraphQLForRepo ("actions" , repo ), httpmock .JSONResponse (map [string ]any {
2146- "data" : map [string ]any {"a0" : testRepoResponse ("actions/" + repo , parityTestSHA , nodeActionYAML )},
2161+ "data" : map [string ]any {"a0" : withRepoID ( testRepoResponse ("actions/" + repo , parityTestSHA , nodeActionYAML ), 1 )},
21472162 }))
21482163 reg .Register (httpmock .REST ("GET" , `repos/actions/` + repo + `$` ),
21492164 httpmock .JSONResponse (map [string ]any {"full_name" : "actions/" + repo , "default_branch" : "main" , "id" : 1 , "owner" : map [string ]any {"id" : 1 }}))
@@ -3180,11 +3195,14 @@ func TestParity_UnreachableChildNamesComposite(t *testing.T) {
31803195 "relock" : {"--relock" },
31813196 "accept-moved" : {"--accept-moved" },
31823197 } {
3183- t .Run (name , func (t * testing.T ) { testUnreachableChild (t , args ) })
3198+ t .Run (name , func (t * testing.T ) { testUnreachableChild (t , args , false ) })
31843199 }
3200+ // Onboarding another workflow onto the locked composite reuses its
3201+ // recorded children, so they are checked too.
3202+ t .Run ("scoped onboarding" , func (t * testing.T ) { testUnreachableChild (t , nil , true ) })
31853203}
31863204
3187- func testUnreachableChild (t * testing.T , args []string ) {
3205+ func testUnreachableChild (t * testing.T , args []string , onboard bool ) {
31883206 const rootSHA = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
31893207 root := "name: r\n runs:\n using: composite\n steps:\n - uses: actions/checkout@v4\n "
31903208 reg := & httpmock.Registry {}
@@ -3250,6 +3268,12 @@ jobs:
32503268 "workflows:\n '.github/workflows/workflow.yml':\n - 'octo/root@v1'\n "
32513269 lockPath := filepath .Join (".github" , "workflows" , "actions.lock" )
32523270 require .NoError (t , os .WriteFile (lockPath , []byte (lock ), 0o600 ))
3271+ if onboard {
3272+ wf , err := os .ReadFile (path )
3273+ require .NoError (t , err )
3274+ path = filepath .Join (".github" , "workflows" , "other.yml" )
3275+ require .NoError (t , os .WriteFile (path , wf , 0o600 ))
3276+ }
32533277
32543278 p , err := runParity (t , reg , append (args , "--no-narrow" , "--no-interactive" , path )... )
32553279 if slices .Contains (args , "--accept-moved" ) {
@@ -3275,6 +3299,73 @@ jobs:
32753299 assert .Equal (t , lock , string (after ), "nothing is written" )
32763300}
32773301
3302+ // TestParity_OnboardingSiblingSubActionSkipsReplacedChildren: onboarding
3303+ // org/actions/b@v1 while an unselected workflow locks org/actions/a@v1 at
3304+ // the same commit reads b's manifest, so the onboarded workflow doesn't
3305+ // keep a's recorded children. a's unavailable child doesn't block it.
3306+ func TestParity_OnboardingSiblingSubActionSkipsUnusedChildren (t * testing.T ) {
3307+ const rootSHA = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
3308+ reg := & httpmock.Registry {}
3309+ reg .RegisterReusable (httpmock .GraphQL (`commit: object\(oid` ), func (req * http.Request ) (* http.Response , error ) {
3310+ var body struct {
3311+ Variables map [string ]any `json:"variables"`
3312+ }
3313+ require .NoError (t , json .NewDecoder (req .Body ).Decode (& body ))
3314+ data := map [string ]any {}
3315+ for k , v := range body .Variables {
3316+ i , ok := strings .CutPrefix (k , "oid" )
3317+ if ! ok {
3318+ continue
3319+ }
3320+ nwo := body .Variables ["owner" + i ].(string ) + "/" + body .Variables ["name" + i ].(string )
3321+ if nwo == "octo/gone" {
3322+ data ["a" + i ] = nil
3323+ continue
3324+ }
3325+ data ["a" + i ] = map [string ]any {
3326+ "nameWithOwner" : nwo ,
3327+ "databaseId" : 1 ,
3328+ "commit" : map [string ]any {"oid" : v },
3329+ "tag" : map [string ]any {"oid" : v },
3330+ "tagRef" : map [string ]any {"oid" : v },
3331+ }
3332+ }
3333+ return httpmock .JSONResponse (map [string ]any {"data" : data })(req )
3334+ })
3335+ reg .RegisterReusable (httpmock .GraphQLForRepo ("org" , "actions" ), httpmock .JSONResponse (map [string ]any {
3336+ "data" : map [string ]any {"a0" : testRepoResponse ("org/actions" , rootSHA , nodeActionYAML )},
3337+ }))
3338+ useDefaultTransport (t , reg )
3339+ writeTempWorkflow (t , `
3340+ name: ci
3341+ on: push
3342+ jobs:
3343+ test:
3344+ runs-on: ubuntu-latest
3345+ steps:
3346+ - uses: org/actions/a@v1
3347+ ` )
3348+ lock := "version: '" + parserlock .Version + "'\n dependencies:\n " +
3349+ " 'org/actions@v1':\n ref: 'v1'\n commit: 'sha1-" + rootSHA + "'\n owner_id: 1\n repo_id: 1\n " +
3350+ " uses:\n - 'octo/gone@v1'\n " +
3351+ " 'octo/gone@v1':\n ref: 'v1'\n commit: 'sha1-" + parityTestSHA + "'\n owner_id: 1\n repo_id: 1\n " +
3352+ "workflows:\n '.github/workflows/workflow.yml':\n - 'org/actions@v1'\n "
3353+ lockPath := filepath .Join (".github" , "workflows" , "actions.lock" )
3354+ require .NoError (t , os .WriteFile (lockPath , []byte (lock ), 0o600 ))
3355+ path := filepath .Join (".github" , "workflows" , "other.yml" )
3356+ require .NoError (t , os .WriteFile (path , []byte ("name: other\n on: push\n jobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: org/actions/b@v1\n " ), 0o600 ))
3357+
3358+ p , err := runParity (t , reg , "--no-narrow" , "--no-interactive" , path )
3359+ require .NoError (t , err , "%+v" , p .Findings )
3360+ for _ , f := range p .Findings {
3361+ assert .NotContains (t , f .Detail , "octo/gone" , "a's replaced child is not checked" )
3362+ }
3363+ after , rerr := os .ReadFile (lockPath )
3364+ require .NoError (t , rerr )
3365+ assert .Contains (t , string (after ), "'.github/workflows/other.yml'" )
3366+ assert .Contains (t , string (after ), "'.github/workflows/workflow.yml':\n - 'org/actions@v1'" , "the unselected workflow keeps its pins" )
3367+ }
3368+
32783369// TestParity_SharedMutablePinAcceptsMoved: actions/checkout@v4 is used
32793370// directly and through an unchanged composite, and its recorded commit left
32803371// v4's history. Both uses share the one v4 entry, which --accept-moved
@@ -4899,6 +4990,13 @@ func TestParity_AcceptMovedReadOnlyStillBlocks(t *testing.T) {
48994990
49004991// writeFullSHAWorkflow writes a workflow using actions/checkout at
49014992// parityTestSHA, locked to that commit with the given recorded ref.
4993+ // withRepoID answers a repository fixture with the repo ID the identity
4994+ // proof compares against the lockfile's.
4995+ func withRepoID (repo map [string ]any , id int ) map [string ]any {
4996+ repo ["databaseId" ] = id
4997+ return repo
4998+ }
4999+
49025000func writeFullSHAWorkflow (t * testing.T , ref string ) string {
49035001 t .Helper ()
49045002 path := writeTempWorkflow (t , `
@@ -4950,3 +5048,44 @@ func assertFullSHARun(t *testing.T, reg *httpmock.Registry, args []string, path,
49505048 assert .Contains (t , string (wfAfter ), "actions/checkout@" + parityTestSHA )
49515049 assert .Equal (t , lockBefore , readTempLockfilePins (t ), "a blocked pin must not reach the lockfile" )
49525050}
5051+
5052+ // TestSHARecordedRef_RenameKeepsFullSHAUse: a workflow's own full-SHA pin
5053+ // whose entry records the SHA as its ref, in a repository since renamed
5054+ // with the same repo ID, moves to the new name and keeps its full SHA; only
5055+ // the entry's ref is re-recorded.
5056+ func TestSHARecordedRef_RenameKeepsFullSHAUse (t * testing.T ) {
5057+ const (
5058+ oldName = "krzema12/github-actions-typing"
5059+ newName = "typesafegithub/github-actions-typing"
5060+ )
5061+ reg := & httpmock.Registry {}
5062+ for range 3 {
5063+ reg .Register (httpmock .GraphQL (`commit: object\(oid` ), renamedParity )
5064+ reg .Register (httpmock .GraphQLForRepo ("krzema12" , "github-actions-typing" ), httpmock .JSONResponse (map [string ]any {
5065+ "data" : map [string ]any {"a0" : withRepoID (testRepoResponse (newName , parityTestSHA , nodeActionYAML ), 1 )},
5066+ }))
5067+ for _ , nwo := range []string {oldName , newName } {
5068+ reg .Register (httpmock .REST ("GET" , `^/repos/` + nwo + `$` ), httpmock .JSONResponse (map [string ]any {
5069+ "full_name" : newName , "id" : 1 , "default_branch" : "main" , "owner" : map [string ]any {"id" : 42 },
5070+ }))
5071+ reg .Register (httpmock .REST ("GET" , `repos/` + nwo + `/tags` ), httpmock .JSONResponse (httpmock .TagListResponse ("v2.2.2" , parityTestSHA )))
5072+ reg .Register (httpmock .REST ("GET" , `repos/` + nwo + `/releases` ), httpmock .JSONResponse ([]map [string ]any {}))
5073+ reg .Register (httpmock .REST ("GET" , `repos/` + nwo + `/git/ref/heads/main` ),
5074+ httpmock .JSONResponse (map [string ]any {"ref" : "refs/heads/main" , "object" : map [string ]any {"sha" : parityTestSHA , "type" : "commit" }}))
5075+ reg .Register (httpmock .REST ("GET" , `repos/` + nwo + `/branches` ), httpmock .JSONResponse (httpmock .BranchListResponse ("main" , parityTestSHA )))
5076+ }
5077+ }
5078+ httpmock .NoTagsNamed (reg , "typesafegithub" , "github-actions-typing" )
5079+ isolateRunLogs (t )
5080+ path := writeTempWorkflow (t , strings .Replace (transferWorkflow , "@v2.2.2" , "@" + parityTestSHA , 1 ),
5081+ oldName + "@" + parityTestSHA + "=sha1-" + parityTestSHA )
5082+
5083+ _ , stderr , err := runCommandWithHTTP (t , parityFallback {reg }, "--no-interactive" , path )
5084+ require .NoError (t , err , stderr )
5085+ wf , err := os .ReadFile (path )
5086+ require .NoError (t , err )
5087+ assert .Contains (t , string (wf ), "uses: " + newName + "@" + parityTestSHA )
5088+ lock := readTempLockfilePins (t )
5089+ assert .Contains (t , lock , "'" + newName + "@" + parityTestSHA + "':\n ref: 'v2.2.2'" )
5090+ assert .NotContains (t , lock , "krzema12" )
5091+ }
0 commit comments