Skip to content

Commit c77984e

Browse files
sorccuclaude
andcommitted
feat: include package.json files in code bundle cache hash
Extend the cache hash to cover every package.json outside node_modules in addition to the lockfile. Each package.json is canonicalized (parsed, top-level "version" stripped, re-marshaled with sorted keys) before hashing, so cosmetic or CI-driven version bumps don't invalidate the cache. Rename PlaywrightCodeBundleMetadata.LockfileChecksum to CacheHash and bump metadata version to 4 so existing resources recompute on upgrade. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7dabfa5 commit c77984e

3 files changed

Lines changed: 343 additions & 35 deletions

File tree

checkly/resource_playwright_check_suite.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -624,7 +624,7 @@ func PlaywrightCheckSuiteResourceFromResourceData(
624624

625625
check.CodeBundlePath = string(bundlePath)
626626

627-
check.CacheHash = bundleAttr.Metadata.LockfileChecksum
627+
check.CacheHash = bundleAttr.Metadata.CacheHash
628628
}
629629

630630
runtimeAttr, err := PlaywrightCheckSuiteRuntimeAttributeFromList(d.Get("runtime").([]any))

checkly/resource_playwright_code_bundle.go

Lines changed: 148 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -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

221232
type 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

230241
func 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.
361372
type LockfileInfo struct {
362-
PackageManager string
363-
PackageVersion string
364-
ChecksumSha256 string
373+
PackageManager string
374+
PackageVersion string
375+
ChecksumSha256 string
365376
}
366377

367378
type 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

465587
var playwrightConfigExtensions = map[string]bool{

0 commit comments

Comments
 (0)