mirror of
https://github.com/imjasonh/terraform-playground
synced 2026-07-07 23:35:16 +00:00
cli: build multi-arch image index from multiple --platform values
- --platform is now repeatable/comma-separated; >1 platform builds one image per platform and assembles them into an OCI image index (remote.WriteIndex / OCI layout). SBOM aggregates wheels across platforms. - adds TestBuildMultiArchIndex (2-platform index, reproducible) against an in-process registry; documents multi-arch in the README Co-authored-by: Jason Hall <imjasonh@users.noreply.github.com>
This commit is contained in:
parent
7290806232
commit
c15f3e350c
3 changed files with 261 additions and 39 deletions
|
|
@ -40,6 +40,25 @@ The wheels referenced by the lock must be present in a `--find-links` directory
|
||||||
(e.g. produced by `pip download -r requirements.txt -d ./wheelhouse`). Resolution
|
(e.g. produced by `pip download -r requirements.txt -d ./wheelhouse`). Resolution
|
||||||
is intentionally local so builds are hermetic and reproducible.
|
is intentionally local so builds are hermetic and reproducible.
|
||||||
|
|
||||||
|
### Multi-arch
|
||||||
|
|
||||||
|
Pass multiple platforms to build a multi-arch image **index** (one image per
|
||||||
|
platform, assembled into an OCI index). Because no Docker daemon is involved,
|
||||||
|
this works from any host OS — Linux, macOS, or Windows:
|
||||||
|
|
||||||
|
```
|
||||||
|
pymage build \
|
||||||
|
--base python:3.12-slim \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
--lock requirements.txt --find-links ./wheelhouse --source ./ \
|
||||||
|
--entrypoint python --entrypoint -m --entrypoint myapp \
|
||||||
|
-t registry.example.com/me/myapp:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Each platform selects its own compatible wheels (pure-python wheels are shared),
|
||||||
|
so the wheelhouse must contain a compatible wheel per platform for any compiled
|
||||||
|
dependency.
|
||||||
|
|
||||||
### Useful flags
|
### Useful flags
|
||||||
|
|
||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
|
|
@ -49,7 +68,7 @@ is intentionally local so builds are hermetic and reproducible.
|
||||||
| `--print-digest` | Print only the resulting image digest (no push). |
|
| `--print-digest` | Print only the resulting image digest (no push). |
|
||||||
| `--sbom PATH` | Write a CycloneDX SBOM of the resolved wheels. |
|
| `--sbom PATH` | Write a CycloneDX SBOM of the resolved wheels. |
|
||||||
| `--layer-strategy` | `per-wheel` (default) or `single-deps-layer`. |
|
| `--layer-strategy` | `per-wheel` (default) or `single-deps-layer`. |
|
||||||
| `--platform` | Target platform; selects compatible wheels and the base, e.g. `linux/amd64`. |
|
| `--platform` | Target platform(s); selects compatible wheels and base. Repeatable / comma-separated (e.g. `linux/amd64,linux/arm64`) builds a multi-arch image index. |
|
||||||
| `--python` | Interpreter version / site-packages dir (default `python3.12`); also selects compatible wheels. |
|
| `--python` | Interpreter version / site-packages dir (default `python3.12`); also selects compatible wheels. |
|
||||||
| `--cache-dir` | Content-addressed layer cache; reuses compressed layers across rebuilds. |
|
| `--cache-dir` | Content-addressed layer cache; reuses compressed layers across rebuilds. |
|
||||||
| `--prefix` | install prefix / venv root (default `/app/.venv`). |
|
| `--prefix` | install prefix / venv root (default `/app/.venv`). |
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
v1 "github.com/google/go-containerregistry/pkg/v1"
|
v1 "github.com/google/go-containerregistry/pkg/v1"
|
||||||
"github.com/google/go-containerregistry/pkg/v1/empty"
|
"github.com/google/go-containerregistry/pkg/v1/empty"
|
||||||
"github.com/google/go-containerregistry/pkg/v1/layout"
|
"github.com/google/go-containerregistry/pkg/v1/layout"
|
||||||
|
"github.com/google/go-containerregistry/pkg/v1/mutate"
|
||||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
|
@ -42,7 +43,7 @@ type buildFlags struct {
|
||||||
findLinks []string
|
findLinks []string
|
||||||
source string
|
source string
|
||||||
tag string
|
tag string
|
||||||
platform string
|
platforms []string
|
||||||
pythonTag string
|
pythonTag string
|
||||||
prefix string
|
prefix string
|
||||||
workingDir string
|
workingDir string
|
||||||
|
|
@ -78,7 +79,7 @@ func buildCmd() *cobra.Command {
|
||||||
fs.StringArrayVar(&f.findLinks, "find-links", nil, "directory of wheels to resolve against (repeatable, required)")
|
fs.StringArrayVar(&f.findLinks, "find-links", nil, "directory of wheels to resolve against (repeatable, required)")
|
||||||
fs.StringVar(&f.source, "source", "", "application source directory (optional)")
|
fs.StringVar(&f.source, "source", "", "application source directory (optional)")
|
||||||
fs.StringVarP(&f.tag, "tag", "t", "", "image tag to push, e.g. registry/repo:tag")
|
fs.StringVarP(&f.tag, "tag", "t", "", "image tag to push, e.g. registry/repo:tag")
|
||||||
fs.StringVar(&f.platform, "platform", "", "platform for base resolution, e.g. linux/amd64")
|
fs.StringSliceVar(&f.platforms, "platform", nil, "target platform(s); repeatable or comma-separated, e.g. linux/amd64,linux/arm64 (multiple builds a multi-arch index)")
|
||||||
fs.StringVar(&f.pythonTag, "python", "python3.12", "python lib directory name for site-packages")
|
fs.StringVar(&f.pythonTag, "python", "python3.12", "python lib directory name for site-packages")
|
||||||
fs.StringVar(&f.prefix, "prefix", "/app/.venv", "install prefix (venv-like root)")
|
fs.StringVar(&f.prefix, "prefix", "/app/.venv", "install prefix (venv-like root)")
|
||||||
fs.StringVar(&f.workingDir, "workdir", "/app", "image working directory and source destination")
|
fs.StringVar(&f.workingDir, "workdir", "/app", "image working directory and source destination")
|
||||||
|
|
@ -126,19 +127,7 @@ func runBuild(ctx context.Context, f *buildFlags) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var platform *v1.Platform
|
platforms, err := parsePlatforms(f.platforms)
|
||||||
if f.platform != "" {
|
|
||||||
platform, err = v1.ParsePlatform(f.platform)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
target, err := resolveTarget(platform, f.pythonTag)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
wheels, err := wheelhouse.Resolve(reqs, f.findLinks, target)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -148,11 +137,6 @@ func runBuild(ctx context.Context, f *buildFlags) error {
|
||||||
nameOpts = append(nameOpts, name.Insecure)
|
nameOpts = append(nameOpts, name.Insecure)
|
||||||
}
|
}
|
||||||
|
|
||||||
base, err := resolveBase(ctx, f.base, platform, nameOpts...)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var layerCache *cache.Cache
|
var layerCache *cache.Cache
|
||||||
if f.cacheDir != "" {
|
if f.cacheDir != "" {
|
||||||
layerCache, err = cache.New(f.cacheDir)
|
layerCache, err = cache.New(f.cacheDir)
|
||||||
|
|
@ -161,6 +145,69 @@ func runBuild(ctx context.Context, f *buildFlags) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build one image per target platform. With more than one platform we
|
||||||
|
// assemble them into a multi-arch OCI image index; with zero or one we push
|
||||||
|
// a plain image manifest.
|
||||||
|
var (
|
||||||
|
images []v1.Image
|
||||||
|
adds []mutate.IndexAddendum
|
||||||
|
allDeps []wheelhouse.ResolvedWheel
|
||||||
|
)
|
||||||
|
targets := platforms
|
||||||
|
if len(targets) == 0 {
|
||||||
|
targets = []v1.Platform{{}} // single build using registry defaults
|
||||||
|
}
|
||||||
|
for i := range targets {
|
||||||
|
p := targets[i]
|
||||||
|
var pp *v1.Platform
|
||||||
|
if p.OS != "" || p.Architecture != "" {
|
||||||
|
pp = &p
|
||||||
|
}
|
||||||
|
img, deps, err := buildOne(ctx, f, reqs, pp, labels, layerCache, nameOpts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
images = append(images, img)
|
||||||
|
allDeps = append(allDeps, deps...)
|
||||||
|
desc := v1.Descriptor{}
|
||||||
|
if pp != nil {
|
||||||
|
desc.Platform = pp
|
||||||
|
}
|
||||||
|
adds = append(adds, mutate.IndexAddendum{Add: img, Descriptor: desc})
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.sbomOut != "" {
|
||||||
|
data, err := sbom.Generate(dedupeWheels(allDeps))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(f.sbomOut, data, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(platforms) > 1 {
|
||||||
|
idx := mutate.AppendManifests(empty.Index, adds...)
|
||||||
|
return output(ctx, f, nameOpts, nil, idx)
|
||||||
|
}
|
||||||
|
return output(ctx, f, nameOpts, images[0], nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildOne resolves the base and wheels for a single platform and builds the
|
||||||
|
// image, returning the resolved wheels for SBOM aggregation.
|
||||||
|
func buildOne(ctx context.Context, f *buildFlags, reqs []lock.Requirement, platform *v1.Platform, labels map[string]string, layerCache *cache.Cache, nameOpts []name.Option) (v1.Image, []wheelhouse.ResolvedWheel, error) {
|
||||||
|
target, err := resolveTarget(platform, f.pythonTag)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
wheels, err := wheelhouse.Resolve(reqs, f.findLinks, target)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
base, err := resolveBase(ctx, f.base, platform, nameOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
img, err := build.Build(build.Options{
|
img, err := build.Build(build.Options{
|
||||||
Base: base,
|
Base: base,
|
||||||
Wheels: wheels,
|
Wheels: wheels,
|
||||||
|
|
@ -177,33 +224,38 @@ func runBuild(ctx context.Context, f *buildFlags) error {
|
||||||
Cache: layerCache,
|
Cache: layerCache,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
return img, wheels, nil
|
||||||
|
}
|
||||||
|
|
||||||
if f.sbomOut != "" {
|
// output writes/pushes either a single image or a multi-arch index (exactly one
|
||||||
data, err := sbom.Generate(wheels)
|
// of img/idx is non-nil).
|
||||||
if err != nil {
|
func output(ctx context.Context, f *buildFlags, nameOpts []name.Option, img v1.Image, idx v1.ImageIndex) error {
|
||||||
return err
|
var (
|
||||||
}
|
digest v1.Hash
|
||||||
if err := os.WriteFile(f.sbomOut, data, 0o644); err != nil {
|
err error
|
||||||
return err
|
)
|
||||||
}
|
if idx != nil {
|
||||||
|
digest, err = idx.Digest()
|
||||||
|
} else {
|
||||||
|
digest, err = img.Digest()
|
||||||
}
|
}
|
||||||
|
|
||||||
digest, err := img.Digest()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if f.ociLayout != "" {
|
if f.ociLayout != "" {
|
||||||
if _, err := layout.Write(f.ociLayout, empty.Index); err != nil {
|
p, err := layout.Write(f.ociLayout, empty.Index)
|
||||||
return err
|
|
||||||
}
|
|
||||||
p, err := layout.FromPath(f.ociLayout)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := p.AppendImage(img); err != nil {
|
if idx != nil {
|
||||||
|
err = p.AppendIndex(idx)
|
||||||
|
} else {
|
||||||
|
err = p.AppendImage(img)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -221,10 +273,16 @@ func runBuild(ctx context.Context, f *buildFlags) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := remote.Write(ref, img,
|
opts := []remote.Option{
|
||||||
remote.WithContext(ctx),
|
remote.WithContext(ctx),
|
||||||
remote.WithAuthFromKeychain(authn.DefaultKeychain),
|
remote.WithAuthFromKeychain(authn.DefaultKeychain),
|
||||||
); err != nil {
|
}
|
||||||
|
if idx != nil {
|
||||||
|
err = remote.WriteIndex(ref, idx, opts...)
|
||||||
|
} else {
|
||||||
|
err = remote.Write(ref, img, opts...)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("push: %w", err)
|
return fmt.Errorf("push: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Printf("pushed %s@%s\n", ref.Context().Name(), digest)
|
fmt.Printf("pushed %s@%s\n", ref.Context().Name(), digest)
|
||||||
|
|
@ -235,6 +293,39 @@ func runBuild(ctx context.Context, f *buildFlags) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parsePlatforms parses the (comma-split, repeatable) --platform values.
|
||||||
|
func parsePlatforms(specs []string) ([]v1.Platform, error) {
|
||||||
|
var out []v1.Platform
|
||||||
|
for _, s := range specs {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p, err := v1.ParsePlatform(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, *p)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dedupeWheels removes duplicate wheels (same name+version+sha) so a multi-arch
|
||||||
|
// SBOM doesn't list shared pure-python wheels multiple times.
|
||||||
|
func dedupeWheels(in []wheelhouse.ResolvedWheel) []wheelhouse.ResolvedWheel {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []wheelhouse.ResolvedWheel
|
||||||
|
for _, w := range in {
|
||||||
|
k := w.Name + "\x00" + w.Version + "\x00" + w.SHA256
|
||||||
|
if seen[k] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[k] = true
|
||||||
|
out = append(out, w)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// resolveTarget derives the wheel-selection target (platform + interpreter)
|
// resolveTarget derives the wheel-selection target (platform + interpreter)
|
||||||
// from the --platform and --python flags, defaulting to linux/amd64.
|
// from the --platform and --python flags, defaulting to linux/amd64.
|
||||||
func resolveTarget(platform *v1.Platform, pythonTag string) (wheel.Target, error) {
|
func resolveTarget(platform *v1.Platform, pythonTag string) (wheel.Target, error) {
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,124 @@ import (
|
||||||
|
|
||||||
"github.com/google/go-containerregistry/pkg/name"
|
"github.com/google/go-containerregistry/pkg/name"
|
||||||
"github.com/google/go-containerregistry/pkg/registry"
|
"github.com/google/go-containerregistry/pkg/registry"
|
||||||
|
v1 "github.com/google/go-containerregistry/pkg/v1"
|
||||||
"github.com/google/go-containerregistry/pkg/v1/empty"
|
"github.com/google/go-containerregistry/pkg/v1/empty"
|
||||||
|
"github.com/google/go-containerregistry/pkg/v1/mutate"
|
||||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||||
|
|
||||||
"github.com/imjasonh/terraform-playground/pymage/internal/testwheel"
|
"github.com/imjasonh/terraform-playground/pymage/internal/testwheel"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestBuildMultiArchIndex drives the CLI to build a linux/amd64 + linux/arm64
|
||||||
|
// image index from a single host, and verifies the pushed artifact is an index
|
||||||
|
// covering both platforms and is reproducible.
|
||||||
|
func TestBuildMultiArchIndex(t *testing.T) {
|
||||||
|
s := httptest.NewServer(registry.New())
|
||||||
|
t.Cleanup(s.Close)
|
||||||
|
host := strings.TrimPrefix(s.URL, "http://")
|
||||||
|
|
||||||
|
// A multi-arch base (linux/amd64 + linux/arm64), as a real registry would
|
||||||
|
// serve python:3.12-slim.
|
||||||
|
baseRef := host + "/base:latest"
|
||||||
|
writeMultiArchBase(t, baseRef, []string{"amd64", "arm64"})
|
||||||
|
|
||||||
|
// Pure-python wheels are compatible with every platform.
|
||||||
|
wh := t.TempDir()
|
||||||
|
_, shaA := testwheel.Write(t, wh, testwheel.Spec{Name: "alpha", Version: "1.0", Modules: map[string]string{"alpha/__init__.py": "V='1.0'\n"}})
|
||||||
|
reqDir := t.TempDir()
|
||||||
|
reqFile := filepath.Join(reqDir, "requirements.txt")
|
||||||
|
if err := os.WriteFile(reqFile, []byte(fmt.Sprintf("alpha==1.0 --hash=sha256:%s\n", shaA)), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
src := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(src, "app.py"), []byte("print('hi')\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
build := func(tag string) error {
|
||||||
|
cmd := Root()
|
||||||
|
cmd.SetArgs([]string{
|
||||||
|
"build",
|
||||||
|
"--base", baseRef,
|
||||||
|
"--lock", reqFile,
|
||||||
|
"--find-links", wh,
|
||||||
|
"--source", src,
|
||||||
|
"--platform", "linux/amd64,linux/arm64",
|
||||||
|
"--entrypoint", "python", "--entrypoint", "/app/app.py",
|
||||||
|
"-t", tag,
|
||||||
|
})
|
||||||
|
cmd.SetOut(os.Stderr)
|
||||||
|
return cmd.Execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := build(host + "/multi:v1"); err != nil {
|
||||||
|
t.Fatalf("multi-arch build failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ref, _ := name.ParseReference(host + "/multi:v1")
|
||||||
|
idx, err := remote.Index(ref, remote.WithContext(context.Background()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pulled artifact is not an index: %v", err)
|
||||||
|
}
|
||||||
|
im, err := idx.IndexManifest()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := map[string]bool{}
|
||||||
|
for _, m := range im.Manifests {
|
||||||
|
if m.Platform != nil {
|
||||||
|
got[m.Platform.OS+"/"+m.Platform.Architecture] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []string{"linux/amd64", "linux/arm64"} {
|
||||||
|
if !got[want] {
|
||||||
|
t.Errorf("index missing platform %s; got %v", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(im.Manifests) != 2 {
|
||||||
|
t.Fatalf("expected 2 manifests, got %d", len(im.Manifests))
|
||||||
|
}
|
||||||
|
d1, _ := idx.Digest()
|
||||||
|
|
||||||
|
// Reproducible: a second build yields the same index digest.
|
||||||
|
if err := build(host + "/multi:v2"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ref2, _ := name.ParseReference(host + "/multi:v2")
|
||||||
|
idx2, err := remote.Index(ref2, remote.WithContext(context.Background()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
d2, _ := idx2.Digest()
|
||||||
|
if d1 != d2 {
|
||||||
|
t.Fatalf("multi-arch index not reproducible: %s != %s", d1, d2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMultiArchBase(t *testing.T, ref string, arches []string) {
|
||||||
|
t.Helper()
|
||||||
|
var adds []mutate.IndexAddendum
|
||||||
|
for _, arch := range arches {
|
||||||
|
img, err := mutate.ConfigFile(empty.Image, &v1.ConfigFile{
|
||||||
|
OS: "linux",
|
||||||
|
Architecture: arch,
|
||||||
|
Config: v1.Config{Env: []string{"PATH=/usr/local/bin:/usr/bin:/bin"}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
adds = append(adds, mutate.IndexAddendum{
|
||||||
|
Add: img,
|
||||||
|
Descriptor: v1.Descriptor{Platform: &v1.Platform{OS: "linux", Architecture: arch}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
idx := mutate.AppendManifests(empty.Index, adds...)
|
||||||
|
r, _ := name.ParseReference(ref)
|
||||||
|
if err := remote.WriteIndex(r, idx, remote.WithContext(context.Background())); err != nil {
|
||||||
|
t.Fatalf("write base index: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParsePythonTag(t *testing.T) {
|
func TestParsePythonTag(t *testing.T) {
|
||||||
maj, min, err := parsePythonTag("python3.12")
|
maj, min, err := parsePythonTag("python3.12")
|
||||||
if err != nil || maj != 3 || min != 12 {
|
if err != nil || maj != 3 || min != 12 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue