From 3a4a7221027a89a147d4d4584451d3200d8210ef Mon Sep 17 00:00:00 2001 From: joeykchen <466719968@qq.com> Date: Fri, 21 Aug 2026 19:17:22 +0800 Subject: [PATCH] fix(launchpack): fall back to local dev runtime --- docs/en/dev/engine/buildlauncher.md | 11 +- docs/zh/dev/engine/buildlauncher.md | 9 +- internal/launchpack/runtime_assets.go | 100 ++++-- internal/launchpack/runtime_fetch.go | 9 +- internal/launchpack/runtime_local_test.go | 14 +- internal/launchpack/runtime_materialize.go | 4 +- internal/launchpack/runtime_source.go | 108 +++++++ internal/launchpack/runtime_source_test.go | 216 +++++++++++++ .../runtime_source_validation_test.go | 299 ++++++++++++++++++ internal/launchpack/service.go | 4 +- internal/release/local_runtime_manifest.go | 23 +- .../release/local_runtime_manifest_publish.go | 2 +- .../release/local_runtime_manifest_test.go | 33 ++ internal/release/runtime_manifest_pin.go | 6 +- internal/release/runtime_manifest_pin_test.go | 3 +- 15 files changed, 782 insertions(+), 59 deletions(-) create mode 100644 internal/launchpack/runtime_source.go create mode 100644 internal/launchpack/runtime_source_test.go create mode 100644 internal/launchpack/runtime_source_validation_test.go diff --git a/docs/en/dev/engine/buildlauncher.md b/docs/en/dev/engine/buildlauncher.md index dc2559a81..310994d00 100644 --- a/docs/en/dev/engine/buildlauncher.md +++ b/docs/en/dev/engine/buildlauncher.md @@ -25,6 +25,11 @@ The command freezes `GOWORK` and `GOFLAGS` for the invocation and verifies the module selection and graph metadata before and after both Go builds. The built bridge and payload are content-digested. -Runtime assets are resolved by the same local-manifest, source-checkout, and -pinned-release/cache policy used by the packaging service. Set -`SPX_RUNTIME_OFFLINE=1` to prevent network access. +An explicit `SPX_RUNTIME_LOCAL_MANIFEST` or `SPX_RUNTIME_ASSET_DIR` takes +priority and fails closed. Otherwise, the command uses the verified release +cache and download first. When the selected SPX module is a source checkout, +an unpublished runtime or failed acquisition falls back to the exact-version +Engine and PCK in the first `GOPATH/bin`. Both files must be regular, +non-symlink files and are hashed again before packaging. If they are missing, +run `make dev` in the SPX checkout; `buildlauncher` never starts a Godot build. +Set `SPX_RUNTIME_OFFLINE=1` to skip network access and use cache/local fallback. diff --git a/docs/zh/dev/engine/buildlauncher.md b/docs/zh/dev/engine/buildlauncher.md index d4b4c5cb2..3c69b52d9 100644 --- a/docs/zh/dev/engine/buildlauncher.md +++ b/docs/zh/dev/engine/buildlauncher.md @@ -20,5 +20,10 @@ module 和 local replacement,不依赖本地 XGo driver。 命令固定本次调用的 `GOWORK` 和 `GOFLAGS`,并在 bridge 与 launcher 构建前后 校验 module 选择和 graph 元数据;bridge 与 payload 通过内容摘要绑定。 -runtime asset 遵循 packaging service 的 local manifest、source checkout、 -pinned release/cache 顺序。设置 `SPX_RUNTIME_OFFLINE=1` 可禁止联网。 +显式设置的 `SPX_RUNTIME_LOCAL_MANIFEST` 或 `SPX_RUNTIME_ASSET_DIR` 优先,且 +校验失败不会静默回退。其他情况先使用经过校验的 release cache/下载;仅当 +当前 Go graph 选中 SPX 源码 checkout 时,runtime 尚未发布或下载失败才回退到 +首个 `GOPATH/bin` 中版本名完全一致的 Engine 与 PCK。两者必须是普通非符号 +链接文件,打包前会重新计算摘要。文件不存在时应在 SPX checkout 执行 +`make dev`;`buildlauncher` 不会隐式启动 Godot 编译。设置 +`SPX_RUNTIME_OFFLINE=1` 后不访问网络,只尝试 cache 和本地回退。 diff --git a/internal/launchpack/runtime_assets.go b/internal/launchpack/runtime_assets.go index ddab689d3..139e1080b 100644 --- a/internal/launchpack/runtime_assets.go +++ b/internal/launchpack/runtime_assets.go @@ -41,6 +41,7 @@ type runtimeAssetDependencies struct { fetch runtimebundle.FetchFunc cacheRoot func() string manifestPin func(release.RuntimeLock) (release.RuntimeManifestPin, error) + goBin func(context.Context, Config, []string) (string, error) } func defaultRuntimeAssetDependencies() runtimeAssetDependencies { @@ -48,6 +49,7 @@ func defaultRuntimeAssetDependencies() runtimeAssetDependencies { fetch: fetchRuntimeURL, cacheRoot: runtimebundle.DefaultCacheRoot, manifestPin: release.RuntimeManifestPinForLock, + goBin: resolveGoBin, } } @@ -59,9 +61,10 @@ type runtimeAssetSource struct { } type localRuntimeSource struct { - manifest release.LocalRuntimeManifest - directory string - bytes []byte + manifest release.LocalRuntimeManifest + bytes []byte + enginePath string + packPath string } // acquireRuntimeAssets obtains one verified Engine/PCK pair. @@ -73,6 +76,9 @@ func acquireRuntimeAssetsWith(ctx context.Context, cfg Config, streams IO, lock if ctx == nil { return Assets{}, errors.New("launchpack: nil context") } + if err := ctx.Err(); err != nil { + return Assets{}, err + } if dependencies.fetch == nil || dependencies.cacheRoot == nil || dependencies.manifestPin == nil { return Assets{}, errors.New("launchpack: incomplete runtime acquisition dependencies") } @@ -95,17 +101,40 @@ func acquireRuntimeAssetsWith(ctx context.Context, cfg Config, streams IO, lock } offline = offline || cfg.RuntimeOffline - if local, found, err := findLocalRuntimeManifest(cfg, env, lock, spec); err != nil { + if local, found, err := findExplicitLocalRuntimeManifest(env, lock, spec); err != nil { return Assets{}, err } else if found { return materializeLocalRuntime(ctx, cacheRoot, lock, spec, local) } - source, err := resolvePublishedRuntime(ctx, cacheRoot, lock, spec, env, offline, dependencies) + _, assetDirSet, duplicate := environmentValue(env, runtimeAssetDirEnv) + if duplicate { + return Assets{}, fmt.Errorf("launchpack: duplicate %s", runtimeAssetDirEnv) + } + pin, err := dependencies.manifestPin(lock) if err != nil { + publishedErr := fmt.Errorf("launchpack: resolve runtime manifest pin: %w", err) + if !assetDirSet && cfg.Source.SourceMode && errors.Is(err, release.ErrRuntimeManifestPinNotFound) { + return acquireSourceRuntime(ctx, cfg, env, cacheRoot, lock, spec, dependencies, publishedErr) + } + return Assets{}, publishedErr + } + if err := pin.ValidateForLock(lock); err != nil { + return Assets{}, err + } + + source, err := resolvePublishedRuntime(ctx, cacheRoot, lock, spec, pin, env, offline, dependencies) + if err == nil { + var assets Assets + assets, err = materializePublishedRuntime(ctx, cacheRoot, lock, spec, source, offline) + if err == nil { + return assets, nil + } + } + if assetDirSet || !cfg.Source.SourceMode { return Assets{}, err } - return materializePublishedRuntime(ctx, cacheRoot, lock, spec, source, offline) + return acquireSourceRuntime(ctx, cfg, env, cacheRoot, lock, spec, dependencies, err) } func resolveRuntimeCacheRoot(env []string, defaultRoot func() string) (string, error) { @@ -179,37 +208,34 @@ func runtimeEnvironment(cfg Config, base []string) []string { return env } -func findLocalRuntimeManifest(cfg Config, env []string, lock release.RuntimeLock, spec release.HostRuntimeSpec) (localRuntimeSource, bool, error) { - path, explicit, duplicate := environmentValue(env, runtimeLocalManifestEnv) +func findExplicitLocalRuntimeManifest(env []string, lock release.RuntimeLock, spec release.HostRuntimeSpec) (localRuntimeSource, bool, error) { + path, found, duplicate := environmentValue(env, runtimeLocalManifestEnv) if duplicate { return localRuntimeSource{}, false, fmt.Errorf("launchpack: duplicate %s", runtimeLocalManifestEnv) } - if !explicit { - if _, assetDirSet, duplicate := environmentValue(env, runtimeAssetDirEnv); duplicate { - return localRuntimeSource{}, false, fmt.Errorf("launchpack: duplicate %s", runtimeAssetDirEnv) - } else if assetDirSet { - return localRuntimeSource{}, false, nil - } - if cfg.RuntimeSourceRoot == "" { + if !found { + return localRuntimeSource{}, false, nil + } + return readLocalRuntimeManifest(path, lock, spec, true) +} + +func findSourceLocalRuntimeManifest(root string, lock release.RuntimeLock, spec release.HostRuntimeSpec) (localRuntimeSource, bool, error) { + path, err := release.LocalRuntimeManifestPath(root, lock, spec.GOOS, spec.GOARCH) + if err != nil { + return localRuntimeSource{}, false, err + } + if info, err := os.Lstat(path); err != nil { + if os.IsNotExist(err) { return localRuntimeSource{}, false, nil } - candidate, pathErr := release.LocalRuntimeManifestPath(cfg.RuntimeSourceRoot, lock, spec.GOOS, spec.GOARCH) - if pathErr != nil { - return localRuntimeSource{}, false, pathErr - } - if info, err := os.Lstat(candidate); err == nil { - if !isRegularNonSymlink(info) { - return localRuntimeSource{}, false, fmt.Errorf("launchpack: discovered local runtime manifest is not a regular non-symlink file: %s", candidate) - } - path = candidate - explicit = true - } else if !os.IsNotExist(err) { - return localRuntimeSource{}, false, fmt.Errorf("launchpack: inspect local runtime manifest: %w", err) - } - } - if !explicit { - return localRuntimeSource{}, false, nil + return localRuntimeSource{}, false, fmt.Errorf("launchpack: inspect local runtime manifest: %w", err) + } else if !isRegularNonSymlink(info) { + return localRuntimeSource{}, false, fmt.Errorf("launchpack: discovered local runtime manifest is not a regular non-symlink file: %s", path) } + return readLocalRuntimeManifest(path, lock, spec, false) +} + +func readLocalRuntimeManifest(path string, lock release.RuntimeLock, spec release.HostRuntimeSpec, strict bool) (localRuntimeSource, bool, error) { if !filepath.IsAbs(path) || filepath.Clean(path) != path { return localRuntimeSource{}, false, fmt.Errorf("launchpack: %s must be an absolute clean path", runtimeLocalManifestEnv) } @@ -228,12 +254,20 @@ func findLocalRuntimeManifest(cfg Config, env []string, lock release.RuntimeLock if err != nil { return localRuntimeSource{}, false, err } - if err := manifest.ValidateForLock(lock, spec.GOOS, spec.GOARCH); err != nil { + validate := manifest.ValidateForVersion + if strict { + validate = manifest.ValidateForLock + } + if err := validate(lock, spec.GOOS, spec.GOARCH); err != nil { return localRuntimeSource{}, false, err } directory := filepath.Dir(path) if err := manifest.VerifyFiles(directory); err != nil { return localRuntimeSource{}, false, err } - return localRuntimeSource{manifest: manifest, directory: directory, bytes: data}, true, nil + return localRuntimeSource{ + manifest: manifest, bytes: data, + enginePath: filepath.Join(directory, manifest.Engine.Name), + packPath: filepath.Join(directory, manifest.Pack.Name), + }, true, nil } diff --git a/internal/launchpack/runtime_fetch.go b/internal/launchpack/runtime_fetch.go index 09a69fad8..558261b5f 100644 --- a/internal/launchpack/runtime_fetch.go +++ b/internal/launchpack/runtime_fetch.go @@ -32,14 +32,7 @@ import ( var runtimeHTTPClient = &http.Client{Timeout: 30 * time.Minute} -func resolvePublishedRuntime(ctx context.Context, cacheRoot string, lock release.RuntimeLock, spec release.HostRuntimeSpec, env []string, offline bool, dependencies runtimeAssetDependencies) (runtimeAssetSource, error) { - pin, err := dependencies.manifestPin(lock) - if err != nil { - return runtimeAssetSource{}, fmt.Errorf("launchpack: resolve runtime manifest pin: %w", err) - } - if err := pin.ValidateForLock(lock); err != nil { - return runtimeAssetSource{}, err - } +func resolvePublishedRuntime(ctx context.Context, cacheRoot string, lock release.RuntimeLock, spec release.HostRuntimeSpec, pin release.RuntimeManifestPin, env []string, offline bool, dependencies runtimeAssetDependencies) (runtimeAssetSource, error) { assetDir, assetDirSet, duplicate := environmentValue(env, runtimeAssetDirEnv) if duplicate { return runtimeAssetSource{}, fmt.Errorf("launchpack: duplicate %s", runtimeAssetDirEnv) diff --git a/internal/launchpack/runtime_local_test.go b/internal/launchpack/runtime_local_test.go index f4a1ca757..bfde2f369 100644 --- a/internal/launchpack/runtime_local_test.go +++ b/internal/launchpack/runtime_local_test.go @@ -24,7 +24,6 @@ import ( "os" "path/filepath" "runtime" - "strings" "testing" "github.com/goplus/spx/v3/internal/release" @@ -73,6 +72,7 @@ func TestAcquireRuntimeAssetsPrefersExplicitLocalManifest(t *testing.T) { } cfg.RuntimeManifestPath = "" + cfg.Source.SourceMode = true assets, err = acquireRuntimeAssetsWith(context.Background(), cfg, IO{Env: []string{"SPX_RUNTIME_OFFLINE=1"}}, lock, runtimeAssetDependencies{ fetch: func(context.Context, string, io.Writer) error { return errors.New("network must not be used") }, cacheRoot: func() string { return filepath.Join(root, "cache-source") }, manifestPin: release.RuntimeManifestPinForLock, @@ -110,7 +110,13 @@ func TestAcquireRuntimeAssetsRejectsInvalidExplicitManifestWithoutFetch(t *testi return errors.New("invalid local manifest must not fetch") } cacheRoot := t.TempDir() - _, err := acquireRuntimeAssetsWith(context.Background(), Config{RuntimeCacheRoot: cacheRoot, RuntimeManifestPath: manifestPath}, IO{Env: []string{runtimeCacheEnv + "=" + cacheRoot}}, release.DefaultRuntimeLock(), localRuntimeTestDependencies(cacheRoot, fetch)) + dependencies := localRuntimeTestDependencies(cacheRoot, fetch) + dependencies.goBin = func(context.Context, Config, []string) (string, error) { + calls++ + return t.TempDir(), nil + } + cfg := Config{RuntimeCacheRoot: cacheRoot, RuntimeManifestPath: manifestPath, Source: SourceIdentity{SourceMode: true}} + _, err := acquireRuntimeAssetsWith(context.Background(), cfg, IO{Env: []string{runtimeCacheEnv + "=" + cacheRoot}}, release.DefaultRuntimeLock(), dependencies) if err == nil { t.Fatal("invalid explicit local manifest was accepted") } @@ -207,7 +213,7 @@ func TestAcquireRuntimeAssetsAutoDiscoveredManifestIdentityMismatchFailsClosed(t calls++ return errors.New("auto-discovered local manifest must not fetch") } - cfg := Config{RuntimeSourceRoot: sourceRoot, RuntimeCacheRoot: cacheRoot} + cfg := Config{RuntimeSourceRoot: sourceRoot, RuntimeCacheRoot: cacheRoot, Source: SourceIdentity{SourceMode: true}} env := IO{Env: []string{}} dependencies := localRuntimeTestDependencies(cacheRoot, fetch) assets, err := acquireRuntimeAssetsWith(context.Background(), cfg, env, lock, dependencies) @@ -216,7 +222,7 @@ func TestAcquireRuntimeAssetsAutoDiscoveredManifestIdentityMismatchFailsClosed(t } assets.Cleanup() - manifest.LockSHA256 = strings.Repeat("0", 64) + manifest.RuntimeVersion = "9.9.9" data, err := json.Marshal(manifest) if err != nil { t.Fatal(err) diff --git a/internal/launchpack/runtime_materialize.go b/internal/launchpack/runtime_materialize.go index a9d8bbd35..220e4726f 100644 --- a/internal/launchpack/runtime_materialize.go +++ b/internal/launchpack/runtime_materialize.go @@ -97,8 +97,8 @@ func materializePublishedRuntime(ctx context.Context, cacheRoot string, lock rel func materializeLocalRuntime(ctx context.Context, cacheRoot string, lock release.RuntimeLock, spec release.HostRuntimeSpec, source localRuntimeSource) (Assets, error) { return materializeRuntimeBundle(ctx, cacheRoot, lock, spec, runtimeMaterializationInput{ manifestSHA256: digestBytes(source.bytes), - enginePath: filepath.Join(source.directory, source.manifest.Engine.Name), - packPath: filepath.Join(source.directory, source.manifest.Pack.Name), + enginePath: source.enginePath, + packPath: source.packPath, engineSize: source.manifest.Engine.Size, engineSHA256: source.manifest.Engine.SHA256, packSize: source.manifest.Pack.Size, diff --git a/internal/launchpack/runtime_source.go b/internal/launchpack/runtime_source.go new file mode 100644 index 000000000..d6c6e06b5 --- /dev/null +++ b/internal/launchpack/runtime_source.go @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package launchpack + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "path/filepath" + "strings" + + "github.com/goplus/spx/v3/internal/release" +) + +func acquireSourceRuntime(ctx context.Context, cfg Config, env []string, cacheRoot string, lock release.RuntimeLock, spec release.HostRuntimeSpec, dependencies runtimeAssetDependencies, publishedErr error) (Assets, error) { + if err := ctx.Err(); err != nil { + return Assets{}, err + } + root := cfg.RuntimeSourceRoot + if root == "" { + return Assets{}, sourceRuntimeError(root, publishedErr, errors.New("SPX source root is unavailable")) + } + local, found, err := findSourceLocalRuntimeManifest(root, lock, spec) + if err != nil { + return Assets{}, sourceRuntimeError(root, publishedErr, err) + } + if found { + assets, err := materializeLocalRuntime(ctx, cacheRoot, lock, spec, local) + if err != nil { + return Assets{}, sourceRuntimeError(root, publishedErr, err) + } + return assets, nil + } + if dependencies.goBin == nil { + return Assets{}, sourceRuntimeError(root, publishedErr, errors.New("Go-bin resolver is unavailable")) + } + bin, err := dependencies.goBin(ctx, cfg, env) + if err != nil { + return Assets{}, sourceRuntimeError(root, publishedErr, err) + } + enginePath := filepath.Join(bin, spec.RuntimeName) + packPath := filepath.Join(bin, spec.PackName) + manifest, err := release.NewLocalRuntimeManifest(lock, spec.GOOS, spec.GOARCH, enginePath, packPath) + if err != nil { + return Assets{}, sourceRuntimeError(root, publishedErr, fmt.Errorf("inspect %s: %w", bin, err)) + } + data, err := manifest.JSON() + if err != nil { + return Assets{}, sourceRuntimeError(root, publishedErr, err) + } + local = localRuntimeSource{manifest: manifest, bytes: data, enginePath: enginePath, packPath: packPath} + assets, err := materializeLocalRuntime(ctx, cacheRoot, lock, spec, local) + if err != nil { + return Assets{}, sourceRuntimeError(root, publishedErr, err) + } + return assets, nil +} + +func sourceRuntimeError(root string, publishedErr, localErr error) error { + hint := "run make dev in the SPX source checkout" + if root != "" { + hint = "run make dev in " + root + } + return fmt.Errorf("launchpack: published runtime unavailable: %w; local runtime unavailable: %w; %s", publishedErr, localErr, hint) +} + +func resolveGoBin(ctx context.Context, cfg Config, env []string) (string, error) { + if cfg.GoCommand == "" { + return "", errors.New("Go command is unavailable") + } + command := exec.CommandContext(ctx, cfg.GoCommand, "env", "GOPATH") + command.Dir = cfg.WorkDir + command.Env = hostGoEnv(cfg, env) + var stderr bytes.Buffer + command.Stderr = &stderr + output, err := command.Output() + if err != nil { + if message := strings.TrimSpace(stderr.String()); message != "" { + return "", fmt.Errorf("resolve Go bin: %w: %s", err, message) + } + return "", fmt.Errorf("resolve Go bin: %w", err) + } + paths := filepath.SplitList(strings.TrimSpace(string(output))) + if len(paths) == 0 || paths[0] == "" { + return "", errors.New("Go environment has no GOPATH") + } + bin := filepath.Join(paths[0], "bin") + if !filepath.IsAbs(bin) || filepath.Clean(bin) != bin { + return "", fmt.Errorf("Go bin must be an absolute clean path: %q", bin) + } + return bin, nil +} diff --git a/internal/launchpack/runtime_source_test.go b/internal/launchpack/runtime_source_test.go new file mode 100644 index 000000000..a958bbd31 --- /dev/null +++ b/internal/launchpack/runtime_source_test.go @@ -0,0 +1,216 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package launchpack + +import ( + "context" + "errors" + "io" + "os" + "runtime" + "testing" + + "github.com/goplus/spx/v3/internal/release" +) + +func TestSourceRuntimePrefersPublishedAssets(t *testing.T) { + fixture := newPublishedRuntimeFixture(t) + cacheRoot := t.TempDir() + bin := writeInstalledRuntimeTest(t, fixture.spec, "local-engine", "local-pack") + fetchCalls, binCalls := 0, 0 + dependencies := fixture.dependencies(cacheRoot, fixture.fetcher(nil, &fetchCalls)) + dependencies.goBin = func(context.Context, Config, []string) (string, error) { + binCalls++ + return bin, nil + } + assets, err := acquireRuntimeAssetsWith(context.Background(), sourceRuntimeConfig(t.TempDir(), cacheRoot), IO{Env: []string{}}, fixture.lock, dependencies) + if err != nil { + t.Fatal(err) + } + defer assets.Cleanup() + assertRuntimeFile(t, assets.EnginePath, "fixture-engine-"+runtime.GOOS+"-"+runtime.GOARCH) + if fetchCalls != 3 || binCalls != 0 { + t.Fatalf("fetch calls = %d, Go-bin calls = %d; want 3, 0", fetchCalls, binCalls) + } +} + +func TestSourceRuntimeFallsBackAfterPublishedFetchFailure(t *testing.T) { + fixture := newPublishedRuntimeFixture(t) + cacheRoot, sourceRoot := t.TempDir(), t.TempDir() + bin := writeInstalledRuntimeTest(t, fixture.spec, "local-engine", "local-pack") + fetchCalls, binCalls := 0, 0 + dependencies := fixture.dependencies(cacheRoot, func(context.Context, string, io.Writer) error { + fetchCalls++ + return errors.New("network unavailable") + }) + dependencies.goBin = func(context.Context, Config, []string) (string, error) { + binCalls++ + return bin, nil + } + assets, err := acquireRuntimeAssetsWith(context.Background(), sourceRuntimeConfig(sourceRoot, cacheRoot), IO{Env: []string{}}, fixture.lock, dependencies) + if err != nil { + t.Fatal(err) + } + defer assets.Cleanup() + assertRuntimeFile(t, assets.EnginePath, "local-engine") + if fetchCalls != 1 || binCalls != 1 { + t.Fatalf("fetch calls = %d, Go-bin calls = %d; want 1, 1", fetchCalls, binCalls) + } + manifestPath, err := release.LocalRuntimeManifestPath(sourceRoot, fixture.lock, runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(manifestPath); !os.IsNotExist(err) { + t.Fatalf("fallback wrote source manifest: %v", err) + } +} + +func TestPublishedFetchFailureDoesNotFallbackOutsideSourceMode(t *testing.T) { + fixture := newPublishedRuntimeFixture(t) + cacheRoot := t.TempDir() + binCalls := 0 + dependencies := fixture.dependencies(cacheRoot, func(context.Context, string, io.Writer) error { + return errors.New("network unavailable") + }) + dependencies.goBin = func(context.Context, Config, []string) (string, error) { + binCalls++ + return t.TempDir(), nil + } + cfg := Config{RuntimeSourceRoot: t.TempDir(), RuntimeCacheRoot: cacheRoot} + if _, err := acquireRuntimeAssetsWith(context.Background(), cfg, IO{Env: []string{}}, fixture.lock, dependencies); err == nil { + t.Fatal("published fetch failure was accepted outside source mode") + } + if binCalls != 0 { + t.Fatalf("Go-bin calls = %d, want 0", binCalls) + } +} + +func TestUnpublishedSourceRuntimeUsesGoBinWithoutFetch(t *testing.T) { + lock := release.DefaultRuntimeLock() + spec, err := release.HostRuntimeSpecFor(lock, runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Fatal(err) + } + cacheRoot := t.TempDir() + bin := writeInstalledRuntimeTest(t, spec, "dev-engine", "dev-pack") + fetchCalls, binCalls := 0, 0 + dependencies := localRuntimeTestDependencies(cacheRoot, func(context.Context, string, io.Writer) error { + fetchCalls++ + return errors.New("unpublished runtime must not fetch") + }) + dependencies.goBin = func(context.Context, Config, []string) (string, error) { + binCalls++ + return bin, nil + } + assets, err := acquireRuntimeAssetsWith(context.Background(), sourceRuntimeConfig(t.TempDir(), cacheRoot), IO{Env: []string{}}, lock, dependencies) + if err != nil { + t.Fatal(err) + } + defer assets.Cleanup() + assertRuntimeFile(t, assets.PackPath, "dev-pack") + if fetchCalls != 0 || binCalls != 1 { + t.Fatalf("fetch calls = %d, Go-bin calls = %d; want 0, 1", fetchCalls, binCalls) + } +} + +func TestOfflineSourceRuntimeFallsBackWithoutFetch(t *testing.T) { + fixture := newPublishedRuntimeFixture(t) + cacheRoot := t.TempDir() + bin := writeInstalledRuntimeTest(t, fixture.spec, "offline-engine", "offline-pack") + fetchCalls := 0 + dependencies := fixture.dependencies(cacheRoot, func(context.Context, string, io.Writer) error { + fetchCalls++ + return errors.New("offline acquisition fetched") + }) + dependencies.goBin = func(context.Context, Config, []string) (string, error) { return bin, nil } + cfg := sourceRuntimeConfig(t.TempDir(), cacheRoot) + cfg.RuntimeOffline = true + assets, err := acquireRuntimeAssetsWith(context.Background(), cfg, IO{Env: []string{}}, fixture.lock, dependencies) + if err != nil { + t.Fatal(err) + } + defer assets.Cleanup() + assertRuntimeFile(t, assets.EnginePath, "offline-engine") + if fetchCalls != 0 { + t.Fatalf("fetch calls = %d, want 0", fetchCalls) + } +} + +func TestExplicitReleaseDirectoryFailureDoesNotUseGoBin(t *testing.T) { + fixture := newPublishedRuntimeFixture(t) + cacheRoot := t.TempDir() + binCalls := 0 + dependencies := fixture.dependencies(cacheRoot, fixture.fetcher(nil, new(int))) + dependencies.goBin = func(context.Context, Config, []string) (string, error) { + binCalls++ + return t.TempDir(), nil + } + cfg := sourceRuntimeConfig(t.TempDir(), cacheRoot) + cfg.RuntimeAssetDir = t.TempDir() + if _, err := acquireRuntimeAssetsWith(context.Background(), cfg, IO{Env: []string{}}, fixture.lock, dependencies); err == nil { + t.Fatal("missing explicit release directory assets were accepted") + } + if binCalls != 0 { + t.Fatalf("Go-bin calls = %d, want 0", binCalls) + } +} + +func TestManifestPinErrorsDoNotUseGoBin(t *testing.T) { + fixture := newPublishedRuntimeFixture(t) + for _, test := range []struct { + name string + mutate func(runtimeAssetDependencies) runtimeAssetDependencies + }{ + { + name: "resolution", + mutate: func(dependencies runtimeAssetDependencies) runtimeAssetDependencies { + dependencies.manifestPin = func(release.RuntimeLock) (release.RuntimeManifestPin, error) { + return release.RuntimeManifestPin{}, errors.New("corrupt embedded pin") + } + return dependencies + }, + }, + { + name: "validation", + mutate: func(dependencies runtimeAssetDependencies) runtimeAssetDependencies { + valid := dependencies.manifestPin + dependencies.manifestPin = func(lock release.RuntimeLock) (release.RuntimeManifestPin, error) { + pin, err := valid(lock) + pin.RuntimeVersion = "9.9.9" + return pin, err + } + return dependencies + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + cacheRoot := t.TempDir() + dependencies := test.mutate(fixture.dependencies(cacheRoot, fixture.fetcher(nil, new(int)))) + binCalls := 0 + dependencies.goBin = func(context.Context, Config, []string) (string, error) { + binCalls++ + return t.TempDir(), nil + } + if _, err := acquireRuntimeAssetsWith(context.Background(), sourceRuntimeConfig(t.TempDir(), cacheRoot), IO{Env: []string{}}, fixture.lock, dependencies); err == nil { + t.Fatal("manifest pin error was accepted") + } + if binCalls != 0 { + t.Fatalf("Go-bin calls = %d, want 0", binCalls) + } + }) + } +} diff --git a/internal/launchpack/runtime_source_validation_test.go b/internal/launchpack/runtime_source_validation_test.go new file mode 100644 index 000000000..00d7e4ee1 --- /dev/null +++ b/internal/launchpack/runtime_source_validation_test.go @@ -0,0 +1,299 @@ +/* + * Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package launchpack + +import ( + "context" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/goplus/spx/v3/internal/release" +) + +func TestSourceRuntimeRejectsInvalidInstalledPair(t *testing.T) { + lock := release.DefaultRuntimeLock() + spec, err := release.HostRuntimeSpecFor(lock, runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name string + setup func(*testing.T, string) + }{ + {name: "missing Engine", setup: func(t *testing.T, bin string) { + if err := os.WriteFile(filepath.Join(bin, spec.PackName), []byte("pack"), 0o644); err != nil { + t.Fatal(err) + } + }}, + {name: "missing PCK", setup: func(t *testing.T, bin string) { + if err := os.WriteFile(filepath.Join(bin, spec.RuntimeName), []byte("engine"), 0o755); err != nil { + t.Fatal(err) + } + }}, + {name: "Engine symlink", setup: func(t *testing.T, bin string) { + target := filepath.Join(bin, "engine-target") + if err := os.WriteFile(target, []byte("engine"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(bin, spec.RuntimeName)); err != nil { + t.Skip(err) + } + if err := os.WriteFile(filepath.Join(bin, spec.PackName), []byte("pack"), 0o644); err != nil { + t.Fatal(err) + } + }}, + {name: "PCK directory", setup: func(t *testing.T, bin string) { + if err := os.WriteFile(filepath.Join(bin, spec.RuntimeName), []byte("engine"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(bin, spec.PackName), 0o755); err != nil { + t.Fatal(err) + } + }}, + } { + t.Run(test.name, func(t *testing.T) { + bin := t.TempDir() + test.setup(t, bin) + cacheRoot := t.TempDir() + dependencies := localRuntimeTestDependencies(cacheRoot, func(context.Context, string, io.Writer) error { + return errors.New("fetch must not run") + }) + dependencies.goBin = func(context.Context, Config, []string) (string, error) { return bin, nil } + _, err := acquireRuntimeAssetsWith(context.Background(), sourceRuntimeConfig(t.TempDir(), cacheRoot), IO{Env: []string{}}, lock, dependencies) + if err == nil || !strings.Contains(err.Error(), "make dev") { + t.Fatalf("invalid installed pair error = %v", err) + } + }) + } +} + +func TestLocalRuntimeMaterializationDetectsRewrite(t *testing.T) { + lock := release.DefaultRuntimeLock() + spec, err := release.HostRuntimeSpecFor(lock, runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Fatal(err) + } + bin := writeInstalledRuntimeTest(t, spec, "engine-v1", "pack-v1") + enginePath := filepath.Join(bin, spec.RuntimeName) + packPath := filepath.Join(bin, spec.PackName) + manifest, err := release.NewLocalRuntimeManifest(lock, runtime.GOOS, runtime.GOARCH, enginePath, packPath) + if err != nil { + t.Fatal(err) + } + data, err := manifest.JSON() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(packPath, []byte("pack-v2"), 0o644); err != nil { + t.Fatal(err) + } + source := localRuntimeSource{manifest: manifest, bytes: data, enginePath: enginePath, packPath: packPath} + if _, err := materializeLocalRuntime(context.Background(), t.TempDir(), lock, spec, source); err == nil || !strings.Contains(err.Error(), "changed after manifest verification") { + t.Fatalf("rewritten local runtime error = %v", err) + } +} + +func TestSourceManifestUsesVersionValidationButExplicitStaysStrict(t *testing.T) { + lock := release.DefaultRuntimeLock() + spec, err := release.HostRuntimeSpecFor(lock, runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Fatal(err) + } + root := t.TempDir() + manifestPath, err := release.LocalRuntimeManifestPath(root, lock, runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Fatal(err) + } + publishLocalRuntimeTest(t, root, manifestPath, spec, "source-engine", "source-pack") + manifest, err := release.ParseLocalRuntimeManifest(mustReadLaunchpackTestFile(t, manifestPath)) + if err != nil { + t.Fatal(err) + } + manifest.RuntimeABI++ + manifest.LockSHA256 = strings.Repeat("0", 64) + data, err := manifest.JSON() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(manifestPath, data, 0o644); err != nil { + t.Fatal(err) + } + cacheRoot := t.TempDir() + dependencies := localRuntimeTestDependencies(cacheRoot, func(context.Context, string, io.Writer) error { + return errors.New("unpublished runtime must not fetch") + }) + binCalls := 0 + dependencies.goBin = func(context.Context, Config, []string) (string, error) { + binCalls++ + return t.TempDir(), nil + } + assets, err := acquireRuntimeAssetsWith(context.Background(), sourceRuntimeConfig(root, cacheRoot), IO{Env: []string{}}, lock, dependencies) + if err != nil { + t.Fatalf("source manifest: %v", err) + } + assets.Cleanup() + if binCalls != 0 { + t.Fatalf("source manifest Go-bin calls = %d, want 0", binCalls) + } + explicit := sourceRuntimeConfig(root, t.TempDir()) + explicit.RuntimeManifestPath = manifestPath + if _, err := acquireRuntimeAssetsWith(context.Background(), explicit, IO{Env: []string{}}, lock, dependencies); err == nil { + t.Fatal("explicit manifest accepted stale lock metadata") + } + if binCalls != 0 { + t.Fatalf("explicit manifest Go-bin calls = %d, want 0", binCalls) + } +} + +func TestSourceRuntimePassesAcquisitionEnvironmentToGoBin(t *testing.T) { + lock := release.DefaultRuntimeLock() + spec, err := release.HostRuntimeSpecFor(lock, runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Fatal(err) + } + bin := writeInstalledRuntimeTest(t, spec, "engine", "pack") + cacheRoot := t.TempDir() + dependencies := localRuntimeTestDependencies(cacheRoot, func(context.Context, string, io.Writer) error { + return errors.New("fetch must not run") + }) + dependencies.goBin = func(_ context.Context, _ Config, env []string) (string, error) { + value, found, duplicate := environmentValue(env, "GOPATH") + if !found || duplicate || value != "/frozen-gopath" { + return "", errors.New("acquisition environment was not forwarded") + } + return bin, nil + } + cfg := sourceRuntimeConfig(t.TempDir(), cacheRoot) + cfg.IO.Env = []string{"GOPATH=/stale-gopath"} + assets, err := acquireRuntimeAssetsWith(context.Background(), cfg, IO{Env: []string{"GOPATH=/frozen-gopath"}}, lock, dependencies) + if err != nil { + t.Fatal(err) + } + assets.Cleanup() +} + +func TestSourceRuntimeHonorsCanceledContextBeforeResolution(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + calls := 0 + dependencies := localRuntimeTestDependencies(t.TempDir(), func(context.Context, string, io.Writer) error { + calls++ + return nil + }) + dependencies.goBin = func(context.Context, Config, []string) (string, error) { + calls++ + return t.TempDir(), nil + } + _, err := acquireRuntimeAssetsWith(ctx, sourceRuntimeConfig(t.TempDir(), t.TempDir()), IO{Env: []string{}}, release.DefaultRuntimeLock(), dependencies) + if !errors.Is(err, context.Canceled) { + t.Fatalf("canceled acquisition error = %v", err) + } + if calls != 0 { + t.Fatalf("resolution calls = %d, want 0", calls) + } +} + +func TestSourceRuntimeDoesNotFallbackAfterFetchCancellation(t *testing.T) { + fixture := newPublishedRuntimeFixture(t) + ctx, cancel := context.WithCancel(context.Background()) + binCalls := 0 + dependencies := fixture.dependencies(t.TempDir(), func(context.Context, string, io.Writer) error { + cancel() + return context.Canceled + }) + dependencies.goBin = func(context.Context, Config, []string) (string, error) { + binCalls++ + return t.TempDir(), nil + } + _, err := acquireRuntimeAssetsWith(ctx, sourceRuntimeConfig(t.TempDir(), t.TempDir()), IO{Env: []string{}}, fixture.lock, dependencies) + if !errors.Is(err, context.Canceled) { + t.Fatalf("canceled fetch error = %v", err) + } + if binCalls != 0 { + t.Fatalf("Go-bin calls = %d, want 0", binCalls) + } +} + +func TestSourceRuntimeMissingFilesSuggestsMakeDev(t *testing.T) { + lock := release.DefaultRuntimeLock() + cacheRoot := t.TempDir() + dependencies := localRuntimeTestDependencies(cacheRoot, func(context.Context, string, io.Writer) error { + return errors.New("fetch must not run") + }) + dependencies.goBin = func(context.Context, Config, []string) (string, error) { return t.TempDir(), nil } + _, err := acquireRuntimeAssetsWith(context.Background(), sourceRuntimeConfig(t.TempDir(), cacheRoot), IO{Env: []string{}}, lock, dependencies) + if err == nil || !strings.Contains(err.Error(), "make dev") || !strings.Contains(err.Error(), "gdspxrt"+lock.RuntimeVersion) { + t.Fatalf("missing local runtime error = %v", err) + } + if !errors.Is(err, release.ErrRuntimeManifestPinNotFound) { + t.Fatalf("missing local runtime lost published cause: %v", err) + } +} + +func TestResolveGoBinUsesFirstGOPATHEntry(t *testing.T) { + goCommand, err := exec.LookPath("go") + if err != nil { + t.Skip(err) + } + first, second, other := t.TempDir(), t.TempDir(), t.TempDir() + t.Setenv("GOPATH", strings.Join([]string{first, second}, string(os.PathListSeparator))) + t.Setenv("GOBIN", other) + got, err := resolveGoBin(context.Background(), Config{GoCommand: goCommand, WorkDir: t.TempDir(), GoWork: "off"}, os.Environ()) + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(first, "bin"); got != want { + t.Fatalf("Go bin = %q, want %q", got, want) + } +} + +func sourceRuntimeConfig(sourceRoot, cacheRoot string) Config { + return Config{ + RuntimeSourceRoot: sourceRoot, + RuntimeCacheRoot: cacheRoot, + Source: SourceIdentity{SourceMode: true}, + } +} + +func writeInstalledRuntimeTest(t *testing.T, spec release.HostRuntimeSpec, engine, pack string) string { + t.Helper() + bin := t.TempDir() + if err := os.WriteFile(filepath.Join(bin, spec.RuntimeName), []byte(engine), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(bin, spec.PackName), []byte(pack), 0o644); err != nil { + t.Fatal(err) + } + return bin +} + +func assertRuntimeFile(t *testing.T, path, want string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(data) != want { + t.Fatalf("runtime file = %q, want %q", data, want) + } +} diff --git a/internal/launchpack/service.go b/internal/launchpack/service.go index aff81e6f7..70c7077a4 100644 --- a/internal/launchpack/service.go +++ b/internal/launchpack/service.go @@ -26,8 +26,8 @@ import ( ) // AcquireRuntimeAssets resolves and materializes one verified Engine/PCK pair. -// Explicit manifest/asset settings are honored before RuntimeSourceRoot and -// the pinned release cache. No GOPATH/bin lookup is performed. +// Explicit local settings take priority. Source checkouts may use an exact +// GOPATH/bin runtime when the pinned release is unavailable. func AcquireRuntimeAssets(ctx context.Context, cfg Config) (Assets, error) { lock, err := runtimeLock(cfg) if err != nil { diff --git a/internal/release/local_runtime_manifest.go b/internal/release/local_runtime_manifest.go index b3d268e7e..8c59f8e4c 100644 --- a/internal/release/local_runtime_manifest.go +++ b/internal/release/local_runtime_manifest.go @@ -102,7 +102,7 @@ func validateLocalRuntimeFile(label string, file LocalRuntimeFile) error { return nil } -// ValidateForLock verifies the local manifest's runtime and target identity. +// ValidateForLock verifies the local manifest against the complete lock. func (m LocalRuntimeManifest) ValidateForLock(lock RuntimeLock, goos, goarch string) error { if err := lock.Validate(); err != nil { return err @@ -120,6 +120,24 @@ func (m LocalRuntimeManifest) ValidateForLock(lock RuntimeLock, goos, goarch str if m.LockSHA256 != lockSHA { return fmt.Errorf("release: local runtime lock SHA-256 %q does not match %q", m.LockSHA256, lockSHA) } + return m.validateVersionAndTarget(lock, goos, goarch) +} + +// ValidateForVersion verifies a source-mode runtime by version and target. +func (m LocalRuntimeManifest) ValidateForVersion(lock RuntimeLock, goos, goarch string) error { + if err := lock.Validate(); err != nil { + return err + } + if err := m.Validate(); err != nil { + return err + } + if m.RuntimeVersion != lock.RuntimeVersion { + return fmt.Errorf("release: local runtime version %q does not match %q", m.RuntimeVersion, lock.RuntimeVersion) + } + return m.validateVersionAndTarget(lock, goos, goarch) +} + +func (m LocalRuntimeManifest) validateVersionAndTarget(lock RuntimeLock, goos, goarch string) error { if m.GOOS != goos || m.GOARCH != goarch { return fmt.Errorf("release: local runtime target %s/%s does not match host %s/%s", m.GOOS, m.GOARCH, goos, goarch) } @@ -133,7 +151,8 @@ func (m LocalRuntimeManifest) ValidateForLock(lock RuntimeLock, goos, goarch str return nil } -func (m LocalRuntimeManifest) json() ([]byte, error) { +// JSON returns the canonical local manifest representation. +func (m LocalRuntimeManifest) JSON() ([]byte, error) { if err := m.Validate(); err != nil { return nil, err } diff --git a/internal/release/local_runtime_manifest_publish.go b/internal/release/local_runtime_manifest_publish.go index 85d6174bf..c1cbe27d6 100644 --- a/internal/release/local_runtime_manifest_publish.go +++ b/internal/release/local_runtime_manifest_publish.go @@ -26,7 +26,7 @@ import ( ) func writeLocalRuntimeManifest(path string, manifest LocalRuntimeManifest) error { - data, err := manifest.json() + data, err := manifest.JSON() if err != nil { return err } diff --git a/internal/release/local_runtime_manifest_test.go b/internal/release/local_runtime_manifest_test.go index 550bfa15e..c6d11e8af 100644 --- a/internal/release/local_runtime_manifest_test.go +++ b/internal/release/local_runtime_manifest_test.go @@ -105,6 +105,39 @@ func TestLocalRuntimeManifestWriteAndVerify(t *testing.T) { } } +func TestLocalRuntimeManifestSourceValidationUsesVersion(t *testing.T) { + lock := DefaultRuntimeLock() + spec, err := HostRuntimeSpecFor(lock, runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Fatal(err) + } + root := t.TempDir() + enginePath := filepath.Join(root, spec.RuntimeName) + packPath := filepath.Join(root, spec.PackName) + if err := os.WriteFile(enginePath, []byte("engine"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(packPath, []byte("pack"), 0o644); err != nil { + t.Fatal(err) + } + manifest, err := NewLocalRuntimeManifest(lock, runtime.GOOS, runtime.GOARCH, enginePath, packPath) + if err != nil { + t.Fatal(err) + } + manifest.RuntimeABI++ + manifest.LockSHA256 = strings.Repeat("0", 64) + if err := manifest.ValidateForVersion(lock, runtime.GOOS, runtime.GOARCH); err != nil { + t.Fatalf("source-mode version validation: %v", err) + } + if err := manifest.ValidateForLock(lock, runtime.GOOS, runtime.GOARCH); err == nil { + t.Fatal("strict validation accepted stale local metadata") + } + manifest.RuntimeVersion = "9.9.9" + if err := manifest.ValidateForVersion(lock, runtime.GOOS, runtime.GOARCH); err == nil { + t.Fatal("source-mode validation accepted a different runtime version") + } +} + func TestNewLocalRuntimeManifestRejectsSymlink(t *testing.T) { lock := DefaultRuntimeLock() spec, err := HostRuntimeSpecFor(lock, runtime.GOOS, runtime.GOARCH) diff --git a/internal/release/runtime_manifest_pin.go b/internal/release/runtime_manifest_pin.go index 7833fb8e8..9d5b9ed25 100644 --- a/internal/release/runtime_manifest_pin.go +++ b/internal/release/runtime_manifest_pin.go @@ -19,6 +19,7 @@ package release import ( "crypto/sha256" "embed" + "errors" "fmt" "io/fs" "path" @@ -29,6 +30,9 @@ import ( const runtimeManifestPinSchema = 1 +// ErrRuntimeManifestPinNotFound marks an unpublished runtime version. +var ErrRuntimeManifestPinNotFound = errors.New("release: runtime manifest pin not found") + // RuntimeManifestPin pins a release manifest independently of RuntimeLock. type RuntimeManifestPin struct { Schema int `json:"schema"` @@ -96,7 +100,7 @@ func RuntimeManifestPinForLock(lock RuntimeLock) (RuntimeManifestPin, error) { } pin, ok := runtimeManifestPins[lock.RuntimeVersion] if !ok { - return RuntimeManifestPin{}, fmt.Errorf("release: no runtime manifest pin for version %q", lock.RuntimeVersion) + return RuntimeManifestPin{}, fmt.Errorf("%w: no runtime manifest pin for version %q", ErrRuntimeManifestPinNotFound, lock.RuntimeVersion) } if err := pin.ValidateForLock(lock); err != nil { return RuntimeManifestPin{}, err diff --git a/internal/release/runtime_manifest_pin_test.go b/internal/release/runtime_manifest_pin_test.go index 97183e700..27db87c1a 100644 --- a/internal/release/runtime_manifest_pin_test.go +++ b/internal/release/runtime_manifest_pin_test.go @@ -17,6 +17,7 @@ package release import ( + "errors" "strings" "testing" ) @@ -69,7 +70,7 @@ func TestRuntimeManifestPinValidation(t *testing.T) { func TestRuntimeManifestPinForLockRejectsUnpinnedRuntime(t *testing.T) { lock := DefaultRuntimeLock() lock.RuntimeVersion = "9.9.9" - if _, err := RuntimeManifestPinForLock(lock); err == nil || !strings.Contains(err.Error(), "no runtime manifest pin") { + if _, err := RuntimeManifestPinForLock(lock); err == nil || !errors.Is(err, ErrRuntimeManifestPinNotFound) || !strings.Contains(err.Error(), "no runtime manifest pin") { t.Fatalf("RuntimeManifestPinForLock error = %v", err) } }