Skip to content

Commit f82d2ed

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 8c76808 commit f82d2ed

File tree

1 file changed

+222
-0
lines changed

1 file changed

+222
-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 {
@@ -1491,6 +1569,9 @@ func (p *Package) buildYarn(buildctx *buildContext, wd, result string) (bld *pac
14911569
}
14921570

14931571
pkgYarnLock := "pkg-yarn.lock"
1572+
// Collect yarn dependencies to patch link: dependencies in package.json
1573+
// Maps npm package name -> built tarball path
1574+
yarnDepsForLinkPatching := make(map[string]string)
14941575
for _, deppkg := range p.GetTransitiveDependencies() {
14951576
if deppkg.Ephemeral {
14961577
continue
@@ -1533,6 +1614,18 @@ func (p *Package) buildYarn(buildctx *buildContext, wd, result string) (bld *pac
15331614
untarCmd,
15341615
}...)
15351616
}
1617+
1618+
// For any yarn package dependency, extract npm package names for link: patching
1619+
if deppkg.Type == YarnPackage {
1620+
npmNames, err := extractNpmPackageNames(builtpkg)
1621+
if err != nil {
1622+
log.WithField("package", deppkg.FullName()).WithError(err).Debug("cannot extract npm package names from yarn dependency")
1623+
} else {
1624+
for npmName := range npmNames {
1625+
yarnDepsForLinkPatching[npmName] = builtpkg
1626+
}
1627+
}
1628+
}
15361629
}
15371630

15381631
pkgJSONFilename := filepath.Join(wd, "package.json")
@@ -1546,6 +1639,135 @@ func (p *Package) buildYarn(buildctx *buildContext, wd, result string) (bld *pac
15461639
return nil, xerrors.Errorf("cannot patch package.json of yarn package: %w", err)
15471640
}
15481641
var modifiedPackageJSON bool
1642+
1643+
// Patch link: dependencies to point to built yarn packages
1644+
// This is necessary because link: paths are relative to the original source location,
1645+
// but yarn install runs in an isolated build directory where those paths don't exist.
1646+
// For YarnApp packages, we extract node_modules/<pkg>/ to _link_deps/<pkg>/
1647+
// For YarnLibrary packages, we use the tarball directly (yarn pack format)
1648+
// We also need to patch yarn.lock to match the new package.json references.
1649+
type linkPatch struct {
1650+
npmName string
1651+
oldRef string // e.g., "link:../shared"
1652+
newRef string // e.g., "file:./_link_deps/gitpod-shared"
1653+
builtPath string
1654+
isYarnPack bool
1655+
extractCmd string // command to extract YarnApp package (empty for YarnLibrary)
1656+
}
1657+
var linkPatches []linkPatch
1658+
1659+
if len(yarnDepsForLinkPatching) > 0 {
1660+
for _, depField := range []string{"dependencies", "devDependencies"} {
1661+
deps, ok := packageJSON[depField].(map[string]interface{})
1662+
if !ok {
1663+
continue
1664+
}
1665+
for npmName, builtPath := range yarnDepsForLinkPatching {
1666+
if depValue, exists := deps[npmName]; exists {
1667+
if depStr, ok := depValue.(string); ok && strings.HasPrefix(depStr, "link:") {
1668+
// Check if this is a YarnLibrary (yarn pack) or YarnApp (node_modules) tarball
1669+
isYarnPack := false
1670+
if f, err := os.Open(builtPath); err == nil {
1671+
if gzr, err := gzip.NewReader(f); err == nil {
1672+
tr := tar.NewReader(gzr)
1673+
for {
1674+
header, err := tr.Next()
1675+
if err != nil {
1676+
break
1677+
}
1678+
if header.Name == "package/package.json" {
1679+
isYarnPack = true
1680+
break
1681+
}
1682+
}
1683+
gzr.Close()
1684+
}
1685+
f.Close()
1686+
}
1687+
1688+
var newRef string
1689+
var extractCmd string
1690+
// Extract dependency to _link_deps/<pkg>/ directory
1691+
// We need to strip the tarball's internal directory structure:
1692+
// - YarnLibrary tarballs (from yarn pack) have: package/<files>
1693+
// - YarnApp tarballs have: ./node_modules/<pkg-name>/<files>
1694+
linkDepDir := filepath.Join("_link_deps", npmName)
1695+
if isYarnPack {
1696+
// YarnLibrary: extract package/* to _link_deps/<pkg>/
1697+
// --strip-components=1 removes "package/" prefix
1698+
extractCmd = fmt.Sprintf("mkdir -p %s && tar -xzf %s -C %s --strip-components=1 package/", linkDepDir, builtPath, linkDepDir)
1699+
} else {
1700+
// YarnApp: extract ./node_modules/<pkg>/* to _link_deps/<pkg>/
1701+
// --strip-components=3 removes "./node_modules/<pkg>/" prefix
1702+
extractCmd = fmt.Sprintf("mkdir -p %s && tar -xzf %s -C %s --strip-components=3 ./node_modules/%s/", linkDepDir, builtPath, linkDepDir, npmName)
1703+
}
1704+
newRef = fmt.Sprintf("file:./%s", linkDepDir)
1705+
1706+
linkPatches = append(linkPatches, linkPatch{
1707+
npmName: npmName,
1708+
oldRef: depStr,
1709+
newRef: newRef,
1710+
builtPath: builtPath,
1711+
isYarnPack: isYarnPack,
1712+
extractCmd: extractCmd,
1713+
})
1714+
1715+
deps[npmName] = newRef
1716+
modifiedPackageJSON = true
1717+
log.WithField("package", p.FullName()).WithField("dependency", npmName).WithField("isYarnPack", isYarnPack).Debug("patched link: dependency in package.json")
1718+
}
1719+
}
1720+
}
1721+
}
1722+
}
1723+
1724+
// Add extraction commands for YarnApp link dependencies
1725+
for _, patch := range linkPatches {
1726+
if patch.extractCmd != "" {
1727+
commands[PackageBuildPhasePrep] = append(commands[PackageBuildPhasePrep], []string{"sh", "-c", patch.extractCmd})
1728+
}
1729+
}
1730+
1731+
// Patch yarn.lock to replace link: references with file: references
1732+
// This is necessary because --frozen-lockfile requires package.json and yarn.lock to match
1733+
if len(linkPatches) > 0 {
1734+
yarnLockPath := filepath.Join(wd, "yarn.lock")
1735+
yarnLockContent, err := os.ReadFile(yarnLockPath)
1736+
if err == nil {
1737+
yarnLockStr := string(yarnLockContent)
1738+
modified := false
1739+
for _, patch := range linkPatches {
1740+
// yarn.lock format: "package-name@link:../path":
1741+
// Note: yarn.lock may normalize paths differently than package.json
1742+
// e.g., package.json has "link:./../shared" but yarn.lock has "link:../shared"
1743+
oldPattern := fmt.Sprintf(`"%s@%s"`, patch.npmName, patch.oldRef)
1744+
newPattern := fmt.Sprintf(`"%s@%s"`, patch.npmName, patch.newRef)
1745+
1746+
// Try exact match first
1747+
if strings.Contains(yarnLockStr, oldPattern) {
1748+
yarnLockStr = strings.ReplaceAll(yarnLockStr, oldPattern, newPattern)
1749+
modified = true
1750+
log.WithField("package", p.FullName()).WithField("dependency", patch.npmName).Debug("patched link: dependency in yarn.lock")
1751+
} else if strings.HasPrefix(patch.oldRef, "link:") {
1752+
// Try normalized path: remove leading "./" from the path
1753+
// e.g., "link:./../shared" -> "link:../shared"
1754+
normalizedOldRef := strings.Replace(patch.oldRef, "link:./", "link:", 1)
1755+
normalizedOldPattern := fmt.Sprintf(`"%s@%s"`, patch.npmName, normalizedOldRef)
1756+
if strings.Contains(yarnLockStr, normalizedOldPattern) {
1757+
yarnLockStr = strings.ReplaceAll(yarnLockStr, normalizedOldPattern, newPattern)
1758+
modified = true
1759+
log.WithField("package", p.FullName()).WithField("dependency", patch.npmName).Debug("patched link: dependency in yarn.lock")
1760+
}
1761+
}
1762+
}
1763+
if modified {
1764+
if err := os.WriteFile(yarnLockPath, []byte(yarnLockStr), 0644); err != nil {
1765+
return nil, xerrors.Errorf("cannot write patched yarn.lock: %w", err)
1766+
}
1767+
}
1768+
}
1769+
}
1770+
15491771
if cfg.Packaging == YarnLibrary {
15501772
// 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
15511773
// package we're building. To this end, we modify the package.json of the source package.

0 commit comments

Comments
 (0)