Skip to content

Commit 01f2d55

Browse files
committed
Add pnpm recipe
Builds pnpm from its self-contained linux-x64 release archive and injects a bin/pnpm wrapper so the artifact is consumed exactly like yarn's. Covers pnpm 11.0.0 and newer: v11.0.0 is the first release to publish pnpm-linux-x64.tar.gz. Earlier releases attach a bare pnpm-linux-x64 binary rather than an archive and are out of range. Verified end to end against 11.24.0 and 12.0.0. The npm registry package is not used, because it stopped being self-contained. Through pnpm 11 it bundled a complete dist/ tree, but as of pnpm 12 it is a ~1 MB stub: its preinstall script downloads a platform-native binary, and the bin/pnpm.mjs Corepack shim fetches that same binary on first run. Neither is available to a buildpack staging offline, so sourcing from npm would keep working right up until the first pnpm 12 build and then produce an artifact that only runs with network access. The release archive ships the native binary outright. The archive is flat, so nothing is stripped and a bin/pnpm wrapper is injected to provide the bin/<dep> layout. It resolves $0 through readlink -f because buildpacks symlink bin/ entries into their own bin directory, where the binary is not a sibling. InjectFile gains a mode-aware variant for this; the existing entry point keeps its 0644 default.
1 parent 65709d4 commit 01f2d55

9 files changed

Lines changed: 300 additions & 14 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ A Go tool for building binaries used by Cloud Foundry buildpacks.
1616
| Apache HTTPD | cflinuxfs4, cflinuxfs5 |
1717
| Bundler | cflinuxfs4, cflinuxfs5 |
1818
| RubyGems | cflinuxfs4, cflinuxfs5 |
19-
| Yarn / Bower / Composer | cflinuxfs4, cflinuxfs5 |
19+
| Yarn / pnpm / Bower / Composer | cflinuxfs4, cflinuxfs5 |
2020
| Pip / Pipenv / Setuptools | cflinuxfs4, cflinuxfs5 |
2121
| OpenJDK / Zulu / SAPMachine | cflinuxfs4, cflinuxfs5 |
2222
| .NET SDK / Runtime / ASP.NET Core | cflinuxfs4, cflinuxfs5 |

cmd/binary-builder/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,7 @@ func buildRegistry() *recipe.Registry {
328328
reg.Register(&recipe.PipenvRecipe{Fetcher: f})
329329
reg.Register(&recipe.BowerRecipe{Fetcher: f})
330330
reg.Register(&recipe.YarnRecipe{Fetcher: f})
331+
reg.Register(&recipe.PnpmRecipe{Fetcher: f})
331332
reg.Register(&recipe.RubygemsRecipe{Fetcher: f})
332333
reg.Register(&recipe.MinicondaRecipe{Fetcher: f})
333334
reg.Register(&recipe.DotnetSDKRecipe{Fetcher: f})

internal/archive/archive.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -269,10 +269,17 @@ func StripIncorrectWordsYAML(path string) error {
269269
return StripFiles(path, "incorrect_words.yaml")
270270
}
271271

272-
// InjectFile adds a file with the given name and content into an existing
273-
// gzipped tarball. The file is appended at the archive root (no directory
274-
// prefix). Typically used to inject sources.yml into an artifact tarball.
272+
// InjectFile adds a non-executable file with the given name and content into an
273+
// existing gzipped tarball. Typically used to inject sources.yml into an
274+
// artifact tarball.
275275
func InjectFile(tarPath, filename string, content []byte) error {
276+
return InjectFileWithMode(tarPath, filename, content, 0644)
277+
}
278+
279+
// InjectFileWithMode is InjectFile with an explicit file mode. Use it for
280+
// entries that must be executable, such as wrapper scripts placed in bin/.
281+
// filename may contain a directory prefix (e.g. "bin/pnpm").
282+
func InjectFileWithMode(tarPath, filename string, content []byte, mode int64) error {
276283
data, err := os.ReadFile(tarPath)
277284
if err != nil {
278285
return fmt.Errorf("reading %s: %w", tarPath, err)
@@ -309,10 +316,10 @@ func InjectFile(tarPath, filename string, content []byte) error {
309316
}
310317
}
311318

312-
// Append the new file at the archive root.
319+
// Append the new file, relative to the archive root.
313320
hdr := &tar.Header{
314321
Name: "./" + filename,
315-
Mode: 0644,
322+
Mode: mode,
316323
Size: int64(len(content)),
317324
Typeflag: tar.TypeReg,
318325
}

internal/archive/archive_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,66 @@ func TestStripIncorrectWordsYAML(t *testing.T) {
183183
assert.NotContains(t, entries, "incorrect_words.yaml")
184184
}
185185

186+
// readTarEntry returns the header and content of the named entry, matching
187+
// either the bare name or its "./"-prefixed form.
188+
func readTarEntry(t *testing.T, path, name string) (*tar.Header, string) {
189+
t.Helper()
190+
data, err := os.ReadFile(path)
191+
require.NoError(t, err)
192+
193+
gr, err := gzip.NewReader(bytes.NewReader(data))
194+
require.NoError(t, err)
195+
defer gr.Close()
196+
197+
tr := tar.NewReader(gr)
198+
for {
199+
hdr, err := tr.Next()
200+
if err == io.EOF {
201+
break
202+
}
203+
require.NoError(t, err)
204+
if hdr.Name != name && hdr.Name != "./"+name {
205+
continue
206+
}
207+
content, err := io.ReadAll(tr)
208+
require.NoError(t, err)
209+
return hdr, string(content)
210+
}
211+
212+
t.Fatalf("readTarEntry: %q not found in %s", name, path)
213+
return nil, ""
214+
}
215+
216+
func TestInjectFileDefaultsToNonExecutable(t *testing.T) {
217+
tmpDir := t.TempDir()
218+
path := filepath.Join(tmpDir, "test.tgz")
219+
require.NoError(t, os.WriteFile(path, createTestTarball(t, map[string]string{"bin/ruby": "binary"}), 0644))
220+
221+
require.NoError(t, archive.InjectFile(path, "sources.yml", []byte("---\n")))
222+
223+
// Existing entries survive.
224+
assert.Contains(t, listTarEntries(t, path), "bin/ruby")
225+
226+
hdr, content := readTarEntry(t, path, "sources.yml")
227+
assert.Equal(t, "---\n", content)
228+
assert.Equal(t, int64(0644), hdr.Mode)
229+
}
230+
231+
func TestInjectFileWithModePreservesModeAndPath(t *testing.T) {
232+
tmpDir := t.TempDir()
233+
path := filepath.Join(tmpDir, "test.tgz")
234+
require.NoError(t, os.WriteFile(path, createTestTarball(t, map[string]string{"bin/pnpm.mjs": "entrypoint"}), 0644))
235+
236+
require.NoError(t, archive.InjectFileWithMode(path, "bin/pnpm", []byte("#!/bin/sh\n"), 0755))
237+
238+
hdr, content := readTarEntry(t, path, "bin/pnpm")
239+
assert.Equal(t, "#!/bin/sh\n", content)
240+
assert.Equal(t, int64(0755), hdr.Mode, "injected wrapper must be executable")
241+
242+
// The directory prefix is kept rather than flattened to the archive root.
243+
assert.NotContains(t, listTarEntries(t, path), "./pnpm")
244+
}
245+
186246
func TestStripTopLevelDirFromZip(t *testing.T) {
187247
tmpDir := t.TempDir()
188248
path := filepath.Join(tmpDir, "test.zip")

internal/recipe/recipe_helpers_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package recipe_test
33
import (
44
"archive/tar"
55
"compress/gzip"
6+
"io"
67
"os"
78
"path/filepath"
89
"strings"
@@ -118,6 +119,44 @@ func useTempWorkDir(t *testing.T) string {
118119
return tmp
119120
}
120121

122+
// tarEntry returns the header and content of the named entry in a gzipped
123+
// tarball, matching either the bare name or its "./"-prefixed form. It returns
124+
// a nil header when the entry is absent.
125+
func tarEntry(t *testing.T, path, name string) (*tar.Header, string) {
126+
t.Helper()
127+
128+
f, err := os.Open(path)
129+
if err != nil {
130+
t.Fatalf("tarEntry: open %s: %v", path, err)
131+
}
132+
defer f.Close()
133+
134+
gr, err := gzip.NewReader(f)
135+
if err != nil {
136+
t.Fatalf("tarEntry: gzip %s: %v", path, err)
137+
}
138+
defer gr.Close()
139+
140+
tr := tar.NewReader(gr)
141+
for {
142+
hdr, err := tr.Next()
143+
if err == io.EOF {
144+
return nil, ""
145+
}
146+
if err != nil {
147+
t.Fatalf("tarEntry: read %s: %v", path, err)
148+
}
149+
if hdr.Name != name && hdr.Name != "./"+name {
150+
continue
151+
}
152+
content, err := io.ReadAll(tr)
153+
if err != nil {
154+
t.Fatalf("tarEntry: read %q: %v", name, err)
155+
}
156+
return hdr, string(content)
157+
}
158+
}
159+
121160
// writeFakeArtifact creates a minimal valid .tgz at <name> in the current
122161
// working directory. The tarball contains a single dummy file so that
123162
// archive.StripTopLevelDir / StripIncorrectWordsYAML don't fail.

internal/recipe/recipe_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,110 @@ func TestYarnRecipeNameAndArtifact(t *testing.T) {
400400
assert.Equal(t, "noarch", r.Artifact().Arch)
401401
}
402402

403+
// ── PnpmRecipe ────────────────────────────────────────────────────────────────
404+
405+
func pnpmReleaseURLFor(version string) string {
406+
return "https://github.com/pnpm/pnpm/releases/download/v" + version + "/pnpm-linux-x64.tar.gz"
407+
}
408+
409+
const pnpmReleaseURL = "https://github.com/pnpm/pnpm/releases/download/v12.0.0/pnpm-linux-x64.tar.gz"
410+
411+
// pnpmSupportedVersions spans the supported range. v11.0.0 is the first release
412+
// to publish pnpm-linux-x64.tar.gz at all; 12.0.0 is the first major after pnpm
413+
// gutted its npm package, which is why that package is not the source here.
414+
// Both archives have the same flat layout, so one recipe must serve both.
415+
var pnpmSupportedVersions = []string{"11.0.0", "11.24.0", "12.0.0"}
416+
417+
func TestPnpmRecipeNameAndArtifact(t *testing.T) {
418+
r := &recipe.PnpmRecipe{}
419+
assert.Equal(t, "pnpm", r.Name())
420+
assert.Equal(t, "linux", r.Artifact().OS)
421+
// The release archive bundles a native executable, so it is not noarch.
422+
assert.Equal(t, "x64", r.Artifact().Arch)
423+
}
424+
425+
func TestPnpmRecipeStripsVPrefix(t *testing.T) {
426+
for _, version := range pnpmSupportedVersions {
427+
t.Run(version, func(t *testing.T) {
428+
f := newFakeFetcher()
429+
url := pnpmReleaseURLFor(version)
430+
431+
src := newInput("pnpm", "v"+version, url)
432+
r := &recipe.PnpmRecipe{Fetcher: f}
433+
outData := &output.OutData{}
434+
require.NoError(t, r.Build(context.Background(), newStack(t), src, runner.NewFakeRunner(), outData))
435+
436+
require.Len(t, f.DownloadedURLs, 1)
437+
assert.Equal(t, url, f.DownloadedURLs[0].URL)
438+
// File on disk uses the stripped version so findIntermediateArtifact matches.
439+
assert.Equal(t, filepath.Join(os.TempDir(), "pnpm-"+version+".tar.gz"), f.DownloadedURLs[0].Dest)
440+
assert.Equal(t, version, outData.Version)
441+
// src.Version must NOT be mutated — callers after Build rely on the original.
442+
assert.Equal(t, "v"+version, src.Version)
443+
})
444+
}
445+
}
446+
447+
func TestPnpmRecipeInjectsExecutableBinWrapper(t *testing.T) {
448+
for _, version := range pnpmSupportedVersions {
449+
t.Run(version, func(t *testing.T) {
450+
f := newFakeFetcher()
451+
452+
dest := filepath.Join(os.TempDir(), "pnpm-"+version+".tar.gz")
453+
t.Cleanup(func() { _ = os.Remove(dest) })
454+
455+
src := newInput("pnpm", "v"+version, pnpmReleaseURLFor(version))
456+
r := &recipe.PnpmRecipe{Fetcher: f}
457+
require.NoError(t, r.Build(context.Background(), newStack(t), src, runner.NewFakeRunner(), &output.OutData{}))
458+
459+
hdr, content := tarEntry(t, dest, "bin/pnpm")
460+
require.NotNil(t, hdr, "artifact must contain a bin/pnpm entry")
461+
462+
// The release archive has no bin/ dir at all — the plain name only
463+
// exists because we inject it, and it is useless unless executable.
464+
assert.Equal(t, int64(0755), hdr.Mode)
465+
// Buildpacks symlink bin/ entries elsewhere, so $0 must be resolved.
466+
assert.Contains(t, content, "readlink -f")
467+
assert.Contains(t, content, `exec "$basedir/../pnpm"`)
468+
// The binary is native: pulling in an interpreter would be a regression.
469+
assert.NotContains(t, content, "node")
470+
})
471+
}
472+
}
473+
474+
func TestPnpmRecipeKeepsArchiveFlat(t *testing.T) {
475+
for _, version := range pnpmSupportedVersions {
476+
t.Run(version, func(t *testing.T) {
477+
f := newFakeFetcher()
478+
479+
dest := filepath.Join(os.TempDir(), "pnpm-"+version+".tar.gz")
480+
t.Cleanup(func() { _ = os.Remove(dest) })
481+
482+
src := newInput("pnpm", "v"+version, pnpmReleaseURLFor(version))
483+
r := &recipe.PnpmRecipe{Fetcher: f}
484+
require.NoError(t, r.Build(context.Background(), newStack(t), src, runner.NewFakeRunner(), &output.OutData{}))
485+
486+
// The upstream archive is already flat. Stripping a top-level
487+
// directory would silently discard the native binary, so the fake's
488+
// "fake-top/" entry must still be present.
489+
hdr, _ := tarEntry(t, dest, "fake-top/")
490+
assert.NotNil(t, hdr, "recipe must not strip a top-level directory")
491+
})
492+
}
493+
}
494+
495+
func TestPnpmRecipePropagatesDownloadError(t *testing.T) {
496+
f := newFakeFetcher()
497+
f.ErrMap[pnpmReleaseURL] = errors.New("boom")
498+
499+
src := newInput("pnpm", "v12.0.0", pnpmReleaseURL)
500+
r := &recipe.PnpmRecipe{Fetcher: f}
501+
err := r.Build(context.Background(), newStack(t), src, runner.NewFakeRunner(), &output.OutData{})
502+
503+
require.Error(t, err)
504+
assert.Contains(t, err.Error(), "downloading pnpm")
505+
}
506+
403507
// ── PyPISourceRecipe ──────────────────────────────────────────────────────────
404508

405509
func TestPyPISourceRecipeFilenameFromURL(t *testing.T) {

internal/recipe/repack.go

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ type RepackRecipe struct {
3737
// If nil, the default is "<depname>-<version>.<ext inferred from URL>".
3838
// PyPI sdist recipes use this to infer the filename from the URL's last path segment.
3939
DestFilename func(version, url string) string
40+
// AfterRepack runs once the archive has been downloaded and stripped, with
41+
// the path to the local artifact. Use it for per-dep transformations that
42+
// would otherwise have to recompute the destination filename.
43+
AfterRepack func(dest string) error
4044
}
4145

4246
func (r *RepackRecipe) Name() string { return r.DepName }
@@ -60,17 +64,26 @@ func (r *RepackRecipe) Build(ctx context.Context, _ *stack.Stack, src *source.In
6064
return fmt.Errorf("downloading %s: %w", r.DepName, err)
6165
}
6266

63-
if !r.StripTopLevelDir {
64-
return nil
67+
if r.StripTopLevelDir {
68+
// Use dest (already fragment-free) rather than src.URL to detect zip archives.
69+
// PyPI download URLs may contain a #sha256=… fragment that would fool a
70+
// suffix check on the raw URL.
71+
var err error
72+
if strings.HasSuffix(dest, ".zip") {
73+
err = archive.StripTopLevelDirFromZip(dest)
74+
} else {
75+
err = archive.StripTopLevelDir(dest)
76+
}
77+
if err != nil {
78+
return err
79+
}
6580
}
6681

67-
// Use dest (already fragment-free) rather than src.URL to detect zip archives.
68-
// PyPI download URLs may contain a #sha256=… fragment that would fool a
69-
// suffix check on the raw URL.
70-
if strings.HasSuffix(dest, ".zip") {
71-
return archive.StripTopLevelDirFromZip(dest)
82+
if r.AfterRepack != nil {
83+
return r.AfterRepack(dest)
7284
}
73-
return archive.StripTopLevelDir(dest)
85+
86+
return nil
7487
}
7588

7689
// inferExt returns the file extension for a download URL, recognising .tar.gz

internal/recipe/simple.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66

7+
"github.com/cloudfoundry/binary-builder/internal/archive"
78
"github.com/cloudfoundry/binary-builder/internal/fetch"
89
"github.com/cloudfoundry/binary-builder/internal/output"
910
"github.com/cloudfoundry/binary-builder/internal/runner"
@@ -47,6 +48,58 @@ func (y *YarnRecipe) Build(ctx context.Context, s *stack.Stack, src *source.Inpu
4748
}).Build(ctx, s, src, r, out)
4849
}
4950

51+
// pnpmWrapper is injected as bin/pnpm. The release archive lays the native
52+
// binary out flat at the archive root, so this wrapper supplies the bin/<dep>
53+
// layout consumers already expect from yarn.
54+
//
55+
// $0 is resolved with readlink -f because buildpacks symlink the artifact's
56+
// bin/ entries into their own bin directory — without it $0's dirname points at
57+
// the symlink's directory, where the binary does not exist. This mirrors what
58+
// upstream yarn's own bin/yarn wrapper does. No interpreter is involved: the
59+
// target is a native executable.
60+
const pnpmWrapper = `#!/bin/sh
61+
basedir=$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")
62+
exec "$basedir/../pnpm" "$@"
63+
`
64+
65+
// PnpmRecipe downloads pnpm's self-contained linux-x64 release archive and
66+
// injects a bin/pnpm wrapper.
67+
//
68+
// The npm registry tarball is deliberately not used. As of pnpm 12 that package
69+
// is a ~1 MB stub: its preinstall script downloads a platform-native binary, and
70+
// the bin/pnpm.mjs Corepack shim fetches the same binary on first run. A
71+
// buildpack cannot depend on either in an offline or air-gapped deployment. The
72+
// release archive ships the native binary outright, so the artifact is
73+
// self-contained and needs no network access at staging time.
74+
//
75+
// The archive is linux-x64 and glibc-linked, which covers every current
76+
// cflinuxfs stack; musl and other architectures are published separately and are
77+
// not built here.
78+
type PnpmRecipe struct {
79+
Fetcher fetch.Fetcher
80+
}
81+
82+
func (p *PnpmRecipe) Name() string { return "pnpm" }
83+
func (p *PnpmRecipe) Artifact() ArtifactMeta {
84+
return ArtifactMeta{OS: "linux", Arch: "x64", Stack: ""}
85+
}
86+
func (p *PnpmRecipe) Build(ctx context.Context, s *stack.Stack, src *source.Input, r runner.Runner, out *output.OutData) error {
87+
return (&RepackRecipe{
88+
DepName: "pnpm",
89+
Meta: ArtifactMeta{OS: "linux", Arch: "x64"},
90+
Fetcher: p.Fetcher,
91+
// Release tags carry a "v" prefix; the archive itself is already flat,
92+
// so there is no top-level directory to strip.
93+
StripVersionPrefix: "v",
94+
AfterRepack: func(dest string) error {
95+
if err := archive.InjectFileWithMode(dest, "bin/pnpm", []byte(pnpmWrapper), 0755); err != nil {
96+
return fmt.Errorf("pnpm: injecting bin/pnpm wrapper: %w", err)
97+
}
98+
return nil
99+
},
100+
}).Build(ctx, s, src, r, out)
101+
}
102+
50103
// PyPISourceRecipe downloads a PyPI source tarball and strips its top-level
51104
// directory. It covers any dep published as a plain sdist on PyPI (e.g.
52105
// setuptools, flit-core) where no compilation step is required.

0 commit comments

Comments
 (0)