@@ -4,16 +4,18 @@ import (
44 "archive/tar"
55 "bytes"
66 "compress/gzip"
7- "crypto/sha256"
8- "encoding/hex"
97 "context"
8+ "crypto/sha256"
109 "encoding/base64"
10+ "encoding/binary"
11+ "encoding/hex"
1112 "encoding/json"
1213 "errors"
1314 "fmt"
1415 "io"
1516 "os"
1617 "path"
18+ "sort"
1719 "strings"
1820
1921 checkly "github.com/checkly/checkly-go-sdk"
@@ -76,7 +78,7 @@ func resourcePlaywrightCodeBundle() *schema.Resource {
7678 }
7779
7880 switch {
79- case bundle .Data .Version < 3 :
81+ case bundle .Data .Version < 4 :
8082 // Data should be updated.
8183 case checksum != bundle .Data .ChecksumSha256 :
8284 // Data should be updated.
@@ -85,7 +87,16 @@ func resourcePlaywrightCodeBundle() *schema.Resource {
8587 return nil
8688 }
8789
88- lockfileInfo , err := bundle .PrebuiltArchive .InspectLockfile ("@playwright/test" )
90+ lockfileInfo , err := bundle .PrebuiltArchive .InspectLockfile ("@playwright/test" , InspectLockfileOptions {
91+ PackageJSONExcludedFields : []string {
92+ // Exclude "version" because CI workflows often
93+ // stamp it with a commit hash or build number.
94+ // Including it would invalidate the dependency
95+ // cache on every build even when no dependencies
96+ // actually changed.
97+ "version" ,
98+ },
99+ })
89100 if err != nil {
90101 return fmt .Errorf ("failed to inspect lockfile in archive: %w" , err )
91102 }
@@ -109,11 +120,11 @@ func resourcePlaywrightCodeBundle() *schema.Resource {
109120 return fmt .Errorf ("failed to detect working directory in archive: %v" , err )
110121 }
111122
112- bundle .Data .Version = 3
123+ bundle .Data .Version = 4
113124 bundle .Data .ChecksumSha256 = checksum
114125 bundle .Data .PlaywrightVersion = lockfileInfo .PackageVersion
115126 bundle .Data .PackageManager = lockfileInfo .PackageManager
116- bundle .Data .LockfileChecksum = lockfileInfo .ChecksumSha256
127+ bundle .Data .CacheHash = lockfileInfo .ChecksumSha256
117128 bundle .Data .WorkingDir = workingDir
118129
119130 err = diff .SetNew (metadataAttributeName , bundle .Data .EncodeToString ())
@@ -219,12 +230,12 @@ func resourcePlaywrightCodeBundleDelete(
219230}
220231
221232type PlaywrightCodeBundleMetadata struct {
222- Version int `json:"v"`
223- ChecksumSha256 string `json:"s256"`
224- PlaywrightVersion string `json:"pwv,omitempty"`
225- PackageManager string `json:"pm,omitempty"`
226- LockfileChecksum string `json:"lcs ,omitempty"`
227- WorkingDir string `json:"wd,omitempty"`
233+ Version int `json:"v"`
234+ ChecksumSha256 string `json:"s256"`
235+ PlaywrightVersion string `json:"pwv,omitempty"`
236+ PackageManager string `json:"pm,omitempty"`
237+ CacheHash string `json:"ch ,omitempty"`
238+ WorkingDir string `json:"wd,omitempty"`
228239}
229240
230241func PlaywrightCodeBundleMetadataFromString (s string ) (* PlaywrightCodeBundleMetadata , error ) {
@@ -359,9 +370,9 @@ func (a *PlaywrightCodeBundlePrebuiltArchiveAttribute) ChecksumSha256() (string,
359370
360371// LockfileInfo contains information extracted from a lockfile found in an archive.
361372type LockfileInfo struct {
362- PackageManager string
363- PackageVersion string
364- ChecksumSha256 string
373+ PackageManager string
374+ PackageVersion string
375+ ChecksumSha256 string
365376}
366377
367378type lockfileParser struct {
@@ -384,11 +395,32 @@ var ErrUnsupportedBunLockb = errors.New(
384395 "`saveTextLockfile = true` under `[install.lockfile]` in bunfig.toml, then rebuild the archive" ,
385396)
386397
398+ // InspectLockfileOptions controls optional behavior of InspectLockfile.
399+ type InspectLockfileOptions struct {
400+ // PackageJSONExcludedFields lists top-level keys to remove from every
401+ // package.json before it contributes to ChecksumSha256. Useful for
402+ // fields that don't affect runtime behavior, like "version".
403+ PackageJSONExcludedFields []string
404+ }
405+
406+ type packageJSONEntry struct {
407+ path string
408+ raw []byte
409+ }
410+
387411// InspectLockfile opens the tar.gz archive and searches for a lockfile
388412// (package-lock.json, pnpm-lock.yaml, yarn.lock, or bun.lock) at the root
389413// of the archive. If found, it returns the detected package manager and
390414// the resolved version of the given package.
391- func (a * PlaywrightCodeBundlePrebuiltArchiveAttribute ) InspectLockfile (packageName string ) (* LockfileInfo , error ) {
415+ //
416+ // ChecksumSha256 covers both the lockfile contents and every package.json
417+ // outside node_modules (at any depth). Each package.json is canonicalized
418+ // as JSON with opts.PackageJSONExcludedFields removed from the top level,
419+ // so cosmetic changes and excluded fields don't influence the checksum.
420+ func (a * PlaywrightCodeBundlePrebuiltArchiveAttribute ) InspectLockfile (
421+ packageName string ,
422+ opts InspectLockfileOptions ,
423+ ) (* LockfileInfo , error ) {
392424 file , err := os .Open (a .File )
393425 if err != nil {
394426 return nil , fmt .Errorf ("failed to open archive file %q: %w" , a .File , err )
@@ -403,7 +435,15 @@ func (a *PlaywrightCodeBundlePrebuiltArchiveAttribute) InspectLockfile(packageNa
403435
404436 tr := tar .NewReader (gzr )
405437
406- var sawBunLockb bool
438+ var (
439+ sawBunLockb bool
440+ lockfileName string
441+ lockfileHash []byte
442+ packageManager string
443+ packageVersion string
444+ packageJSONs []packageJSONEntry
445+ )
446+
407447 for {
408448 header , err := tr .Next ()
409449 if err == io .EOF {
@@ -417,8 +457,18 @@ func (a *PlaywrightCodeBundlePrebuiltArchiveAttribute) InspectLockfile(packageNa
417457 continue
418458 }
419459
420- // Only consider files at the root of the archive.
421460 name := strings .TrimPrefix (header .Name , "./" )
461+
462+ if path .Base (name ) == "package.json" && ! hasNodeModulesSegment (name ) {
463+ raw , err := io .ReadAll (tr )
464+ if err != nil {
465+ return nil , fmt .Errorf ("failed to read %q from archive %q: %w" , header .Name , a .File , err )
466+ }
467+ packageJSONs = append (packageJSONs , packageJSONEntry {path : name , raw : raw })
468+ continue
469+ }
470+
471+ // Only consider lockfiles at the root of the archive.
422472 if strings .Contains (name , "/" ) {
423473 continue
424474 }
@@ -433,6 +483,11 @@ func (a *PlaywrightCodeBundlePrebuiltArchiveAttribute) InspectLockfile(packageNa
433483 continue
434484 }
435485
486+ if lockfileName != "" {
487+ // Already parsed a lockfile; ignore any duplicates.
488+ continue
489+ }
490+
436491 // Hash the lockfile content as it flows through the parser.
437492 hash := sha256 .New ()
438493 tee := io .TeeReader (tr , hash )
@@ -448,18 +503,85 @@ func (a *PlaywrightCodeBundlePrebuiltArchiveAttribute) InspectLockfile(packageNa
448503 return nil , fmt .Errorf ("failed to read lockfile %q from archive: %w" , header .Name , err )
449504 }
450505
451- return & LockfileInfo {
452- PackageManager : parser .packageManager ,
453- PackageVersion : version ,
454- ChecksumSha256 : hex .EncodeToString (hash .Sum (nil )),
455- }, nil
506+ lockfileName = name
507+ lockfileHash = hash .Sum (nil )
508+ packageManager = parser .packageManager
509+ packageVersion = version
510+ }
511+
512+ if lockfileName == "" {
513+ if sawBunLockb {
514+ return nil , ErrUnsupportedBunLockb
515+ }
516+ return nil , nil
517+ }
518+
519+ checksum , err := composeBundleChecksum (lockfileName , lockfileHash , packageJSONs , opts .PackageJSONExcludedFields )
520+ if err != nil {
521+ return nil , fmt .Errorf ("failed to compute archive checksum: %w" , err )
522+ }
523+
524+ return & LockfileInfo {
525+ PackageManager : packageManager ,
526+ PackageVersion : packageVersion ,
527+ ChecksumSha256 : checksum ,
528+ }, nil
529+ }
530+
531+ func hasNodeModulesSegment (p string ) bool {
532+ return strings .Contains ("/" + p + "/" , "/node_modules/" )
533+ }
534+
535+ // canonicalizePackageJSON parses raw as JSON, deletes the named top-level
536+ // fields, and re-encodes. Re-encoding via json.Marshal produces output with
537+ // map keys sorted alphabetically, so whitespace and key order in the source
538+ // don't affect the result.
539+ func canonicalizePackageJSON (raw []byte , excludedFields []string ) ([]byte , error ) {
540+ var obj map [string ]any
541+ if err := json .Unmarshal (raw , & obj ); err != nil {
542+ return nil , fmt .Errorf ("failed to parse package.json: %w" , err )
543+ }
544+ for _ , f := range excludedFields {
545+ delete (obj , f )
546+ }
547+ return json .Marshal (obj )
548+ }
549+
550+ // composeBundleChecksum combines the lockfile hash and every canonicalized
551+ // package.json (sorted by path) into a single SHA-256. Records are
552+ // length-prefixed to prevent collisions from ambiguous concatenation.
553+ func composeBundleChecksum (
554+ lockfileName string ,
555+ lockfileHash []byte ,
556+ packageJSONs []packageJSONEntry ,
557+ excludedFields []string ,
558+ ) (string , error ) {
559+ sort .Slice (packageJSONs , func (i , j int ) bool {
560+ return packageJSONs [i ].path < packageJSONs [j ].path
561+ })
562+
563+ h := sha256 .New ()
564+ writeRecord := func (label string , content []byte ) {
565+ var lenBuf [8 ]byte
566+ binary .BigEndian .PutUint64 (lenBuf [:], uint64 (len (label )))
567+ h .Write (lenBuf [:])
568+ h .Write ([]byte (label ))
569+ binary .BigEndian .PutUint64 (lenBuf [:], uint64 (len (content )))
570+ h .Write (lenBuf [:])
571+ h .Write (content )
456572 }
457573
458- if sawBunLockb {
459- return nil , ErrUnsupportedBunLockb
574+ writeRecord ("lockfile:" + lockfileName , lockfileHash )
575+
576+ for _ , entry := range packageJSONs {
577+ canonical , err := canonicalizePackageJSON (entry .raw , excludedFields )
578+ if err != nil {
579+ return "" , fmt .Errorf ("failed to canonicalize %q: %w" , entry .path , err )
580+ }
581+ writeRecord ("package.json:" + entry .path , canonical )
460582 }
461583
462- return nil , nil
584+ return hex . EncodeToString ( h . Sum ( nil )) , nil
463585}
464586
465587var playwrightConfigExtensions = map [string ]bool {
0 commit comments