Skip to content

Commit 8cb1ccf

Browse files
leodidoona-agent
andcommitted
fix(yarn): patch yarn.lock for link: dependencies and fix extraction path
Problem 1: Leeway patches package.json for link: dependencies but wasn't consistently patching yarn.lock. This caused yarn install to fail with --frozen-lockfile because the lockfile didn't match package.json. Problem 2: When extracting YarnApp dependencies to _link_deps/<pkg>/, the --strip-components=2 was incorrect. It stripped './node_modules/' but left '<pkg>/' resulting in _link_deps/<pkg>/<pkg>/. Changed to --strip-components=3 to strip './node_modules/<pkg>/' completely. Also handle path normalization: package.json may have 'link:./../shared' while yarn.lock normalizes it to 'link:../shared'. Co-authored-by: Ona <no-reply@ona.com>
1 parent 1ab62ee commit 8cb1ccf

File tree

3 files changed

+646
-0
lines changed

3 files changed

+646
-0
lines changed

pkg/leeway/build.go

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,84 @@ func computeSHA256(filePath string) (string, error) {
347347
return hex.EncodeToString(hash.Sum(nil)), nil
348348
}
349349

350+
// extractNpmPackageNames extracts npm package names from a yarn tarball.
351+
// It handles both YarnLibrary tarballs (package/package.json) and YarnApp tarballs (node_modules/*/package.json).
352+
// Returns a map of npm package name -> true for all packages found.
353+
func extractNpmPackageNames(tarballPath string) (map[string]bool, error) {
354+
file, err := os.Open(tarballPath)
355+
if err != nil {
356+
return nil, xerrors.Errorf("cannot open tarball: %w", err)
357+
}
358+
defer file.Close()
359+
360+
gzr, err := gzip.NewReader(file)
361+
if err != nil {
362+
return nil, xerrors.Errorf("cannot create gzip reader: %w", err)
363+
}
364+
defer gzr.Close()
365+
366+
result := make(map[string]bool)
367+
tr := tar.NewReader(gzr)
368+
for {
369+
header, err := tr.Next()
370+
if err == io.EOF {
371+
break
372+
}
373+
if err != nil {
374+
return nil, xerrors.Errorf("cannot read tarball: %w", err)
375+
}
376+
377+
// YarnLibrary: package/package.json
378+
// YarnApp: node_modules/<pkg>/package.json (but not nested node_modules)
379+
isLibraryPkgJSON := header.Name == "package/package.json"
380+
isAppPkgJSON := strings.HasPrefix(header.Name, "node_modules/") ||
381+
strings.HasPrefix(header.Name, "./node_modules/")
382+
383+
if isAppPkgJSON {
384+
// Check it's a direct child of node_modules, not nested
385+
// e.g., node_modules/foo/package.json but not node_modules/foo/node_modules/bar/package.json
386+
name := strings.TrimPrefix(header.Name, "./")
387+
parts := strings.Split(name, "/")
388+
// Should be: node_modules, <pkg-name>, package.json (3 parts)
389+
// Or for scoped: node_modules, @scope, pkg-name, package.json (4 parts)
390+
if len(parts) < 3 {
391+
continue
392+
}
393+
if parts[len(parts)-1] != "package.json" {
394+
continue
395+
}
396+
// Check no nested node_modules
397+
nodeModulesCount := 0
398+
for _, p := range parts {
399+
if p == "node_modules" {
400+
nodeModulesCount++
401+
}
402+
}
403+
if nodeModulesCount != 1 {
404+
continue
405+
}
406+
isAppPkgJSON = true
407+
} else {
408+
isAppPkgJSON = false
409+
}
410+
411+
if isLibraryPkgJSON || isAppPkgJSON {
412+
var pkgJSON struct {
413+
Name string `json:"name"`
414+
}
415+
if err := json.NewDecoder(tr).Decode(&pkgJSON); err != nil {
416+
log.WithField("file", header.Name).WithError(err).Debug("cannot parse package.json in tarball")
417+
continue
418+
}
419+
if pkgJSON.Name != "" && pkgJSON.Name != "local" {
420+
result[pkgJSON.Name] = true
421+
}
422+
}
423+
}
424+
425+
return result, nil
426+
}
427+
350428
// verifyAllArtifactChecksums verifies all tracked cache artifacts before signing handoff
351429
func verifyAllArtifactChecksums(buildctx *buildContext) error {
352430
if buildctx.artifactChecksums == nil {
@@ -1414,6 +1492,9 @@ func (p *Package) buildYarn(buildctx *buildContext, wd, result string) (bld *pac
14141492
}
14151493

14161494
pkgYarnLock := "pkg-yarn.lock"
1495+
// Collect yarn dependencies to patch link: dependencies in package.json
1496+
// Maps npm package name -> built tarball path
1497+
yarnDepsForLinkPatching := make(map[string]string)
14171498
for _, deppkg := range p.GetTransitiveDependencies() {
14181499
if deppkg.Ephemeral {
14191500
continue
@@ -1456,6 +1537,18 @@ func (p *Package) buildYarn(buildctx *buildContext, wd, result string) (bld *pac
14561537
untarCmd,
14571538
}...)
14581539
}
1540+
1541+
// For any yarn package dependency, extract npm package names for link: patching
1542+
if deppkg.Type == YarnPackage {
1543+
npmNames, err := extractNpmPackageNames(builtpkg)
1544+
if err != nil {
1545+
log.WithField("package", deppkg.FullName()).WithError(err).Debug("cannot extract npm package names from yarn dependency")
1546+
} else {
1547+
for npmName := range npmNames {
1548+
yarnDepsForLinkPatching[npmName] = builtpkg
1549+
}
1550+
}
1551+
}
14591552
}
14601553

14611554
pkgJSONFilename := filepath.Join(wd, "package.json")
@@ -1469,6 +1562,135 @@ func (p *Package) buildYarn(buildctx *buildContext, wd, result string) (bld *pac
14691562
return nil, xerrors.Errorf("cannot patch package.json of yarn package: %w", err)
14701563
}
14711564
var modifiedPackageJSON bool
1565+
1566+
// Patch link: dependencies to point to built yarn packages
1567+
// This is necessary because link: paths are relative to the original source location,
1568+
// but yarn install runs in an isolated build directory where those paths don't exist.
1569+
// For YarnApp packages, we extract node_modules/<pkg>/ to _link_deps/<pkg>/
1570+
// For YarnLibrary packages, we use the tarball directly (yarn pack format)
1571+
// We also need to patch yarn.lock to match the new package.json references.
1572+
type linkPatch struct {
1573+
npmName string
1574+
oldRef string // e.g., "link:../shared"
1575+
newRef string // e.g., "file:./_link_deps/gitpod-shared"
1576+
builtPath string
1577+
isYarnPack bool
1578+
extractCmd string // command to extract YarnApp package (empty for YarnLibrary)
1579+
}
1580+
var linkPatches []linkPatch
1581+
1582+
if len(yarnDepsForLinkPatching) > 0 {
1583+
for _, depField := range []string{"dependencies", "devDependencies"} {
1584+
deps, ok := packageJSON[depField].(map[string]interface{})
1585+
if !ok {
1586+
continue
1587+
}
1588+
for npmName, builtPath := range yarnDepsForLinkPatching {
1589+
if depValue, exists := deps[npmName]; exists {
1590+
if depStr, ok := depValue.(string); ok && strings.HasPrefix(depStr, "link:") {
1591+
// Check if this is a YarnLibrary (yarn pack) or YarnApp (node_modules) tarball
1592+
isYarnPack := false
1593+
if f, err := os.Open(builtPath); err == nil {
1594+
if gzr, err := gzip.NewReader(f); err == nil {
1595+
tr := tar.NewReader(gzr)
1596+
for {
1597+
header, err := tr.Next()
1598+
if err != nil {
1599+
break
1600+
}
1601+
if header.Name == "package/package.json" {
1602+
isYarnPack = true
1603+
break
1604+
}
1605+
}
1606+
gzr.Close()
1607+
}
1608+
f.Close()
1609+
}
1610+
1611+
var newRef string
1612+
var extractCmd string
1613+
// Extract dependency to _link_deps/<pkg>/ directory
1614+
// We need to strip the tarball's internal directory structure:
1615+
// - YarnLibrary tarballs (from yarn pack) have: package/<files>
1616+
// - YarnApp tarballs have: ./node_modules/<pkg-name>/<files>
1617+
linkDepDir := filepath.Join("_link_deps", npmName)
1618+
if isYarnPack {
1619+
// YarnLibrary: extract package/* to _link_deps/<pkg>/
1620+
// --strip-components=1 removes "package/" prefix
1621+
extractCmd = fmt.Sprintf("mkdir -p %s && tar -xzf %s -C %s --strip-components=1 package/", linkDepDir, builtPath, linkDepDir)
1622+
} else {
1623+
// YarnApp: extract ./node_modules/<pkg>/* to _link_deps/<pkg>/
1624+
// --strip-components=3 removes "./node_modules/<pkg>/" prefix
1625+
extractCmd = fmt.Sprintf("mkdir -p %s && tar -xzf %s -C %s --strip-components=3 ./node_modules/%s/", linkDepDir, builtPath, linkDepDir, npmName)
1626+
}
1627+
newRef = fmt.Sprintf("file:./%s", linkDepDir)
1628+
1629+
linkPatches = append(linkPatches, linkPatch{
1630+
npmName: npmName,
1631+
oldRef: depStr,
1632+
newRef: newRef,
1633+
builtPath: builtPath,
1634+
isYarnPack: isYarnPack,
1635+
extractCmd: extractCmd,
1636+
})
1637+
1638+
deps[npmName] = newRef
1639+
modifiedPackageJSON = true
1640+
log.WithField("package", p.FullName()).WithField("dependency", npmName).WithField("isYarnPack", isYarnPack).Debug("patched link: dependency in package.json")
1641+
}
1642+
}
1643+
}
1644+
}
1645+
}
1646+
1647+
// Add extraction commands for YarnApp link dependencies
1648+
for _, patch := range linkPatches {
1649+
if patch.extractCmd != "" {
1650+
commands[PackageBuildPhasePrep] = append(commands[PackageBuildPhasePrep], []string{"sh", "-c", patch.extractCmd})
1651+
}
1652+
}
1653+
1654+
// Patch yarn.lock to replace link: references with file: references
1655+
// This is necessary because --frozen-lockfile requires package.json and yarn.lock to match
1656+
if len(linkPatches) > 0 {
1657+
yarnLockPath := filepath.Join(wd, "yarn.lock")
1658+
yarnLockContent, err := os.ReadFile(yarnLockPath)
1659+
if err == nil {
1660+
yarnLockStr := string(yarnLockContent)
1661+
modified := false
1662+
for _, patch := range linkPatches {
1663+
// yarn.lock format: "package-name@link:../path":
1664+
// Note: yarn.lock may normalize paths differently than package.json
1665+
// e.g., package.json has "link:./../shared" but yarn.lock has "link:../shared"
1666+
oldPattern := fmt.Sprintf(`"%s@%s"`, patch.npmName, patch.oldRef)
1667+
newPattern := fmt.Sprintf(`"%s@%s"`, patch.npmName, patch.newRef)
1668+
1669+
// Try exact match first
1670+
if strings.Contains(yarnLockStr, oldPattern) {
1671+
yarnLockStr = strings.ReplaceAll(yarnLockStr, oldPattern, newPattern)
1672+
modified = true
1673+
log.WithField("package", p.FullName()).WithField("dependency", patch.npmName).Debug("patched link: dependency in yarn.lock")
1674+
} else if strings.HasPrefix(patch.oldRef, "link:") {
1675+
// Try normalized path: remove leading "./" from the path
1676+
// e.g., "link:./../shared" -> "link:../shared"
1677+
normalizedOldRef := strings.Replace(patch.oldRef, "link:./", "link:", 1)
1678+
normalizedOldPattern := fmt.Sprintf(`"%s@%s"`, patch.npmName, normalizedOldRef)
1679+
if strings.Contains(yarnLockStr, normalizedOldPattern) {
1680+
yarnLockStr = strings.ReplaceAll(yarnLockStr, normalizedOldPattern, newPattern)
1681+
modified = true
1682+
log.WithField("package", p.FullName()).WithField("dependency", patch.npmName).Debug("patched link: dependency in yarn.lock")
1683+
}
1684+
}
1685+
}
1686+
if modified {
1687+
if err := os.WriteFile(yarnLockPath, []byte(yarnLockStr), 0644); err != nil {
1688+
return nil, xerrors.Errorf("cannot write patched yarn.lock: %w", err)
1689+
}
1690+
}
1691+
}
1692+
}
1693+
14721694
if cfg.Packaging == YarnLibrary {
14731695
// We can't modify the `yarn pack` generated tar file without runnign the risk of yarn blocking when attempting to unpack it again. Thus, we must include the pkgYarnLock in the npm
14741696
// package we're building. To this end, we modify the package.json of the source package.

0 commit comments

Comments
 (0)