devrites 4.0.1 → 4.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@ package install
2
2
 
3
3
  import (
4
4
  "bytes"
5
+ "context"
5
6
  "crypto/sha256"
6
7
  "fmt"
7
8
  "os"
@@ -14,6 +15,7 @@ import (
14
15
  "time"
15
16
 
16
17
  "github.com/devrites/devrites/internal/hostpack"
18
+ "github.com/devrites/devrites/internal/release"
17
19
  "github.com/devrites/devrites/internal/testutil"
18
20
  )
19
21
 
@@ -484,6 +486,36 @@ func buildVersionBinary(t *testing.T, version string) string {
484
486
  return binary
485
487
  }
486
488
 
489
+ func buildUpdateHandoffBinary(t *testing.T, version string) string {
490
+ t.Helper()
491
+ dir := t.TempDir()
492
+ source := filepath.Join(dir, "main.go")
493
+ testutil.WriteFile(t, source, fmt.Sprintf(`package main
494
+ import (
495
+ "fmt"
496
+ "os"
497
+ "strings"
498
+ )
499
+ func main() {
500
+ if len(os.Args) == 2 && os.Args[1] == "version" {
501
+ fmt.Println(%q)
502
+ return
503
+ }
504
+ output := strings.Join(os.Args[1:], "\n") + "\n" +
505
+ "DEVRITES_UPDATE_HANDOFF=" + os.Getenv("DEVRITES_UPDATE_HANDOFF") + "\n" +
506
+ "DEVRITES_ENGINE_CLI=" + os.Getenv("DEVRITES_ENGINE_CLI") + "\n"
507
+ if err := os.WriteFile(os.Getenv("DEVRITES_TEST_UPDATE_HANDOFF"), []byte(output), 0600); err != nil {
508
+ panic(err)
509
+ }
510
+ }
511
+ `, version))
512
+ binary := filepath.Join(dir, engineBinaryName())
513
+ if out, err := exec.Command("go", "build", "-o", binary, source).CombinedOutput(); err != nil {
514
+ t.Fatalf("build update handoff engine: %v\n%s", err, out)
515
+ }
516
+ return binary
517
+ }
518
+
487
519
  func TestBinaryCandidatesSkipsUnsetBinDir(t *testing.T) {
488
520
  t.Setenv("DEVRITES_BIN_DIR", "")
489
521
 
@@ -531,6 +563,7 @@ func TestUpdateInstallsLocalPayload(t *testing.T) {
531
563
  opts.Stderr = &bytes.Buffer{}
532
564
  check := opts
533
565
  check.UpdateCheck = true
566
+ check.PayloadDir = ""
534
567
  if err := Apply(check); err == nil || !strings.Contains(err.Error(), "update available: 1.0.0 -> 2.0.0") {
535
568
  t.Fatalf("update check error = %v, want local candidate report", err)
536
569
  }
@@ -552,6 +585,84 @@ func TestUpdateInstallsLocalPayload(t *testing.T) {
552
585
  }
553
586
  }
554
587
 
588
+ func TestUpdateAcquiresLatestReleaseAndHandsOff(t *testing.T) {
589
+ t.Setenv("DEVRITES_NO_BINARY", "1")
590
+ target := t.TempDir()
591
+ runInstall(t, target, testPayload(t), func(o *Options) {
592
+ o.SourceDir = testSource(t, "1.0.0")
593
+ })
594
+ source := testSource(t, "2.0.0")
595
+ payload := testPayload(t)
596
+ engine := buildUpdateHandoffBinary(t, "2.0.0")
597
+ marker := filepath.Join(t.TempDir(), "handoff.txt")
598
+ t.Setenv("DEVRITES_TEST_UPDATE_HANDOFF", marker)
599
+
600
+ oldResolve, oldAcquire := resolveLatestRelease, acquireRelease
601
+ resolveLatestRelease = func(context.Context, string) (string, error) { return "v2.0.0", nil }
602
+ cleaned := false
603
+ acquireRelease = func(context.Context, string, string) (release.Candidate, func(), error) {
604
+ return release.Candidate{
605
+ SourceDir: source,
606
+ PayloadDir: payload,
607
+ EnginePath: engine,
608
+ BundleURL: "https://example.invalid/devrites-v2.0.0.tar.gz",
609
+ }, func() { cleaned = true }, nil
610
+ }
611
+ t.Cleanup(func() {
612
+ resolveLatestRelease, acquireRelease = oldResolve, oldAcquire
613
+ })
614
+
615
+ var stdout, stderr bytes.Buffer
616
+ opts := DefaultOptions(ModeUpdate)
617
+ opts.Target = target
618
+ opts.Stdout = &stdout
619
+ opts.Stderr = &stderr
620
+ if err := Apply(opts); err != nil {
621
+ t.Fatalf("remote update: %v\n%s", err, stderr.String())
622
+ }
623
+ if !cleaned {
624
+ t.Fatal("remote update did not clean acquired release")
625
+ }
626
+ handoff := testutil.ReadFile(t, marker)
627
+ for _, want := range []string{
628
+ "update\n",
629
+ "--target\n" + target + "\n",
630
+ "--source-dir\n" + source + "\n",
631
+ "--payload-dir\n" + payload + "\n",
632
+ "DEVRITES_UPDATE_HANDOFF=1\n",
633
+ "DEVRITES_ENGINE_CLI=" + engine + "\n",
634
+ } {
635
+ if !strings.Contains(handoff, want) {
636
+ t.Fatalf("handoff missing %q:\n%s", want, handoff)
637
+ }
638
+ }
639
+ if !strings.Contains(stdout.String(), "latest: 2.0.0") || !strings.Contains(stdout.String(), "bundle: https://example.invalid/") {
640
+ t.Fatalf("remote update output:\n%s", stdout.String())
641
+ }
642
+ }
643
+
644
+ func TestRemoteUpdateCheckDoesNotDownload(t *testing.T) {
645
+ target := t.TempDir()
646
+ testutil.WriteFile(t, filepath.Join(target, ManifestName), "# devrites-version: 1.0.0\n")
647
+ oldResolve, oldAcquire := resolveLatestRelease, acquireRelease
648
+ resolveLatestRelease = func(context.Context, string) (string, error) { return "v2.0.0", nil }
649
+ acquireRelease = func(context.Context, string, string) (release.Candidate, func(), error) {
650
+ t.Fatal("--check downloaded release assets")
651
+ return release.Candidate{}, func() {}, nil
652
+ }
653
+ t.Cleanup(func() {
654
+ resolveLatestRelease, acquireRelease = oldResolve, oldAcquire
655
+ })
656
+ opts := DefaultOptions(ModeUpdate)
657
+ opts.Target = target
658
+ opts.UpdateCheck = true
659
+ opts.Stdout = &bytes.Buffer{}
660
+ opts.Stderr = &bytes.Buffer{}
661
+ if err := Apply(opts); err == nil || !strings.Contains(err.Error(), "update available: 1.0.0 -> 2.0.0") {
662
+ t.Fatalf("remote update check error = %v", err)
663
+ }
664
+ }
665
+
555
666
  func TestUpdateCleansLegacyCodexHooks(t *testing.T) {
556
667
  t.Setenv("DEVRITES_NO_BINARY", "1")
557
668
  payload := testPayload(t)
@@ -0,0 +1,411 @@
1
+ // Package release acquires verified DevRites release artifacts for self-update.
2
+ package release
3
+
4
+ import (
5
+ "archive/tar"
6
+ "compress/gzip"
7
+ "context"
8
+ "crypto/sha256"
9
+ "encoding/hex"
10
+ "encoding/json"
11
+ "errors"
12
+ "fmt"
13
+ "io"
14
+ "io/fs"
15
+ "net/http"
16
+ "net/netip"
17
+ "net/url"
18
+ "os"
19
+ "path"
20
+ "path/filepath"
21
+ "regexp"
22
+ "runtime"
23
+ "strings"
24
+ "time"
25
+ )
26
+
27
+ const (
28
+ DefaultRepository = "ViktorsBaikers/DevRites"
29
+ maxAssetBytes = int64(64) << 20
30
+ maxJSONBytes = int64(4) << 20
31
+ maxChecksumBytes = int64(4) << 10
32
+ maxArchiveEntries = 4096
33
+ maxEntryBytes = int64(64) << 20
34
+ maxExtractedBytes = int64(512) << 20
35
+ )
36
+
37
+ var (
38
+ apiBaseURL = "https://api.github.com"
39
+ webBaseURL = "https://github.com"
40
+ httpClient = &http.Client{
41
+ Timeout: 2 * time.Minute,
42
+ CheckRedirect: checkRedirect,
43
+ }
44
+ repositoryPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$`)
45
+ tagPattern = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$`)
46
+ )
47
+
48
+ // Candidate is one verified, extracted release and its platform engine.
49
+ type Candidate struct {
50
+ SourceDir string
51
+ PayloadDir string
52
+ EnginePath string
53
+ BundleURL string
54
+ }
55
+
56
+ // Latest returns the exact tag of the latest published release.
57
+ func Latest(ctx context.Context, repository string) (string, error) {
58
+ if !repositoryPattern.MatchString(repository) {
59
+ return "", fmt.Errorf("invalid DevRites repository %q", repository)
60
+ }
61
+ var metadata struct {
62
+ TagName string `json:"tag_name"`
63
+ }
64
+ endpoint := strings.TrimRight(apiBaseURL, "/") + "/repos/" + repository + "/releases/latest"
65
+ if err := fetchJSON(ctx, endpoint, &metadata); err != nil {
66
+ return "", fmt.Errorf("resolve latest release: %w", err)
67
+ }
68
+ if !validTag(metadata.TagName) {
69
+ return "", fmt.Errorf("resolve latest release: invalid tag %q", metadata.TagName)
70
+ }
71
+ return metadata.TagName, nil
72
+ }
73
+
74
+ // Acquire downloads, verifies, and extracts the bundle and platform engine.
75
+ func Acquire(ctx context.Context, repository, tag string) (Candidate, func(), error) {
76
+ if !repositoryPattern.MatchString(repository) {
77
+ return Candidate{}, func() {}, fmt.Errorf("invalid DevRites repository %q", repository)
78
+ }
79
+ if !validTag(tag) {
80
+ return Candidate{}, func() {}, fmt.Errorf("invalid DevRites release tag %q", tag)
81
+ }
82
+ binaryName, err := platformBinaryName()
83
+ if err != nil {
84
+ return Candidate{}, func() {}, err
85
+ }
86
+ tmp, err := os.MkdirTemp("", "devrites-update-*")
87
+ if err != nil {
88
+ return Candidate{}, func() {}, fmt.Errorf("create update directory: %w", err)
89
+ }
90
+ cleanup := func() { _ = os.RemoveAll(tmp) }
91
+ fail := func(err error) (Candidate, func(), error) {
92
+ cleanup()
93
+ return Candidate{}, func() {}, err
94
+ }
95
+
96
+ base := strings.TrimRight(webBaseURL, "/") + "/" + repository + "/releases/download/" + tag
97
+ bundleName := "devrites-" + tag + ".tar.gz"
98
+ bundleURL := base + "/" + bundleName
99
+ bundlePath := filepath.Join(tmp, bundleName)
100
+ if err := downloadVerified(ctx, bundleURL, bundlePath, bundleName, 0o644); err != nil {
101
+ return fail(fmt.Errorf("download release bundle: %w", err))
102
+ }
103
+ source, err := extractBundle(bundlePath, tmp, tag)
104
+ if err != nil {
105
+ return fail(err)
106
+ }
107
+
108
+ engineURL := base + "/" + binaryName
109
+ enginePath := filepath.Join(tmp, binaryName)
110
+ if err := downloadVerified(ctx, engineURL, enginePath, binaryName, 0o755); err != nil {
111
+ return fail(fmt.Errorf("download engine binary: %w", err))
112
+ }
113
+ return Candidate{
114
+ SourceDir: source,
115
+ PayloadDir: filepath.Join(source, "pack", "generated"),
116
+ EnginePath: enginePath,
117
+ BundleURL: bundleURL,
118
+ }, cleanup, nil
119
+ }
120
+
121
+ func validTag(tag string) bool {
122
+ if !tagPattern.MatchString(tag) {
123
+ return false
124
+ }
125
+ version := strings.SplitN(strings.TrimPrefix(tag, "v"), "+", 2)[0]
126
+ _, prerelease, found := strings.Cut(version, "-")
127
+ if !found {
128
+ return true
129
+ }
130
+ for _, identifier := range strings.Split(prerelease, ".") {
131
+ if len(identifier) > 1 && identifier[0] == '0' {
132
+ numeric := true
133
+ for _, char := range identifier {
134
+ if char < '0' || char > '9' {
135
+ numeric = false
136
+ break
137
+ }
138
+ }
139
+ if numeric {
140
+ return false
141
+ }
142
+ }
143
+ }
144
+ return true
145
+ }
146
+
147
+ func platformBinaryName() (string, error) {
148
+ supported := (runtime.GOOS == "darwin" || runtime.GOOS == "linux") && (runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64")
149
+ if runtime.GOOS == "windows" && runtime.GOARCH == "amd64" {
150
+ supported = true
151
+ }
152
+ if !supported {
153
+ return "", fmt.Errorf("no DevRites engine release for %s/%s", runtime.GOOS, runtime.GOARCH)
154
+ }
155
+ name := "devrites-" + runtime.GOOS + "-" + runtime.GOARCH
156
+ if runtime.GOOS == "windows" {
157
+ name += ".exe"
158
+ }
159
+ return name, nil
160
+ }
161
+
162
+ func fetchJSON(ctx context.Context, rawURL string, out any) error {
163
+ resp, err := get(ctx, rawURL)
164
+ if err != nil {
165
+ return err
166
+ }
167
+ defer resp.Body.Close()
168
+ if resp.StatusCode < 200 || resp.StatusCode > 299 {
169
+ return fmt.Errorf("%s returned %s", resp.Request.URL.Redacted(), resp.Status)
170
+ }
171
+ if resp.ContentLength > maxJSONBytes {
172
+ return fmt.Errorf("JSON response exceeds %d bytes", maxJSONBytes)
173
+ }
174
+ body, err := io.ReadAll(io.LimitReader(resp.Body, maxJSONBytes+1))
175
+ if err != nil {
176
+ return fmt.Errorf("read JSON response: %w", err)
177
+ }
178
+ if int64(len(body)) > maxJSONBytes {
179
+ return fmt.Errorf("JSON response exceeds %d bytes", maxJSONBytes)
180
+ }
181
+ if err := json.Unmarshal(body, out); err != nil {
182
+ return fmt.Errorf("decode JSON response: %w", err)
183
+ }
184
+ return nil
185
+ }
186
+
187
+ func downloadVerified(ctx context.Context, rawURL, destination, filename string, mode fs.FileMode) error {
188
+ if err := download(ctx, rawURL, destination, maxAssetBytes, mode); err != nil {
189
+ return err
190
+ }
191
+ sumPath := destination + ".sha256"
192
+ if err := download(ctx, rawURL+".sha256", sumPath, maxChecksumBytes, 0o600); err != nil {
193
+ return fmt.Errorf("missing checksum for %s: %w", filename, err)
194
+ }
195
+ sum, err := os.ReadFile(sumPath)
196
+ if err != nil {
197
+ return fmt.Errorf("read checksum for %s: %w", filename, err)
198
+ }
199
+ fields := strings.Fields(string(sum))
200
+ if len(fields) != 2 || strings.TrimPrefix(fields[1], "*") != filename {
201
+ return fmt.Errorf("invalid checksum record for %s", filename)
202
+ }
203
+ want, err := hex.DecodeString(fields[0])
204
+ if err != nil || len(want) != sha256.Size {
205
+ return fmt.Errorf("invalid SHA-256 for %s", filename)
206
+ }
207
+ file, err := os.Open(destination)
208
+ if err != nil {
209
+ return fmt.Errorf("open %s for checksum: %w", filename, err)
210
+ }
211
+ hash := sha256.New()
212
+ _, copyErr := io.Copy(hash, file)
213
+ closeErr := file.Close()
214
+ if copyErr != nil {
215
+ return fmt.Errorf("hash %s: %w", filename, copyErr)
216
+ }
217
+ if closeErr != nil {
218
+ return fmt.Errorf("close %s: %w", filename, closeErr)
219
+ }
220
+ if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), hex.EncodeToString(want)) {
221
+ return fmt.Errorf("checksum mismatch for %s", filename)
222
+ }
223
+ return nil
224
+ }
225
+
226
+ func download(ctx context.Context, rawURL, destination string, maxBytes int64, mode fs.FileMode) error {
227
+ resp, err := get(ctx, rawURL)
228
+ if err != nil {
229
+ return err
230
+ }
231
+ defer resp.Body.Close()
232
+ if resp.StatusCode < 200 || resp.StatusCode > 299 {
233
+ return fmt.Errorf("%s returned %s", resp.Request.URL.Redacted(), resp.Status)
234
+ }
235
+ if resp.ContentLength > maxBytes {
236
+ return fmt.Errorf("download exceeds %d bytes", maxBytes)
237
+ }
238
+ out, err := os.CreateTemp(filepath.Dir(destination), ".download-*")
239
+ if err != nil {
240
+ return fmt.Errorf("create download: %w", err)
241
+ }
242
+ tmp := out.Name()
243
+ keep := false
244
+ defer func() {
245
+ _ = out.Close()
246
+ if !keep {
247
+ _ = os.Remove(tmp)
248
+ }
249
+ }()
250
+ written, err := io.Copy(out, io.LimitReader(resp.Body, maxBytes+1))
251
+ if err != nil {
252
+ return fmt.Errorf("write download: %w", err)
253
+ }
254
+ if written > maxBytes {
255
+ return fmt.Errorf("download exceeds %d bytes", maxBytes)
256
+ }
257
+ if err := out.Chmod(mode); err != nil {
258
+ return fmt.Errorf("set download mode: %w", err)
259
+ }
260
+ if err := out.Sync(); err != nil {
261
+ return fmt.Errorf("sync download: %w", err)
262
+ }
263
+ if err := out.Close(); err != nil {
264
+ return fmt.Errorf("close download: %w", err)
265
+ }
266
+ if err := os.Rename(tmp, destination); err != nil {
267
+ return fmt.Errorf("install download: %w", err)
268
+ }
269
+ keep = true
270
+ return nil
271
+ }
272
+
273
+ func get(ctx context.Context, rawURL string) (*http.Response, error) {
274
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
275
+ if err != nil {
276
+ return nil, fmt.Errorf("build release request: %w", err)
277
+ }
278
+ if err := validateURL(req.URL); err != nil {
279
+ return nil, err
280
+ }
281
+ req.Header.Set("Accept", "application/vnd.github+json")
282
+ req.Header.Set("User-Agent", "devrites-engine")
283
+ return httpClient.Do(req)
284
+ }
285
+
286
+ func validateURL(u *url.URL) error {
287
+ if u == nil || u.Hostname() == "" {
288
+ return errors.New("release URL has no host")
289
+ }
290
+ if strings.EqualFold(u.Scheme, "https") {
291
+ return nil
292
+ }
293
+ if strings.EqualFold(u.Scheme, "http") {
294
+ if ip, err := netip.ParseAddr(u.Hostname()); err == nil && ip.Unmap().IsLoopback() {
295
+ return nil
296
+ }
297
+ }
298
+ return fmt.Errorf("release URL must use HTTPS: %s", u.Redacted())
299
+ }
300
+
301
+ func checkRedirect(req *http.Request, via []*http.Request) error {
302
+ if len(via) >= 10 {
303
+ return errors.New("stopped after 10 redirects")
304
+ }
305
+ if err := validateURL(req.URL); err != nil {
306
+ return err
307
+ }
308
+ if len(via) > 0 && !isLoopback(via[0].URL) && isLoopback(req.URL) {
309
+ return fmt.Errorf("remote release URL cannot redirect to loopback: %s", req.URL.Redacted())
310
+ }
311
+ return nil
312
+ }
313
+
314
+ func isLoopback(u *url.URL) bool {
315
+ if u == nil {
316
+ return false
317
+ }
318
+ host := strings.TrimSuffix(strings.ToLower(u.Hostname()), ".")
319
+ if host == "localhost" || strings.HasSuffix(host, ".localhost") {
320
+ return true
321
+ }
322
+ ip, err := netip.ParseAddr(host)
323
+ return err == nil && ip.Unmap().IsLoopback()
324
+ }
325
+
326
+ func extractBundle(archive, destination, tag string) (string, error) {
327
+ if err := extractTarGz(archive, destination); err != nil {
328
+ return "", fmt.Errorf("extract release bundle: %w", err)
329
+ }
330
+ source := filepath.Join(destination, "devrites-"+tag)
331
+ if _, err := os.Stat(filepath.Join(source, "package.json")); err != nil {
332
+ return "", fmt.Errorf("release bundle is missing package.json: %w", err)
333
+ }
334
+ if info, err := os.Stat(filepath.Join(source, "pack", "generated")); err != nil || !info.IsDir() {
335
+ return "", fmt.Errorf("release bundle is missing pack/generated")
336
+ }
337
+ return source, nil
338
+ }
339
+
340
+ func extractTarGz(archive, destination string) error {
341
+ file, err := os.Open(archive)
342
+ if err != nil {
343
+ return fmt.Errorf("open archive: %w", err)
344
+ }
345
+ defer file.Close()
346
+ gz, err := gzip.NewReader(file)
347
+ if err != nil {
348
+ return fmt.Errorf("open gzip stream: %w", err)
349
+ }
350
+ defer gz.Close()
351
+ reader := tar.NewReader(gz)
352
+ root, err := filepath.Abs(destination)
353
+ if err != nil {
354
+ return fmt.Errorf("resolve extraction directory: %w", err)
355
+ }
356
+ entries := 0
357
+ var total int64
358
+ for {
359
+ header, err := reader.Next()
360
+ if errors.Is(err, io.EOF) {
361
+ return nil
362
+ }
363
+ if err != nil {
364
+ return fmt.Errorf("read archive: %w", err)
365
+ }
366
+ entries++
367
+ if entries > maxArchiveEntries {
368
+ return fmt.Errorf("release bundle has more than %d entries", maxArchiveEntries)
369
+ }
370
+ if header.Size < 0 || header.Size > maxEntryBytes || header.Size > maxExtractedBytes-total {
371
+ return fmt.Errorf("release bundle entry %q exceeds extraction limits", header.Name)
372
+ }
373
+ total += header.Size
374
+ name := path.Clean(header.Name)
375
+ if name != strings.TrimSuffix(header.Name, "/") || !fs.ValidPath(name) || name == "." {
376
+ return fmt.Errorf("unsafe path in release bundle: %s", header.Name)
377
+ }
378
+ target := filepath.Join(root, filepath.FromSlash(name))
379
+ if target != root && !strings.HasPrefix(target, root+string(os.PathSeparator)) {
380
+ return fmt.Errorf("unsafe path in release bundle: %s", header.Name)
381
+ }
382
+ mode := fs.FileMode(header.Mode & 0o777)
383
+ switch header.Typeflag {
384
+ case tar.TypeDir:
385
+ if err := os.MkdirAll(target, mode); err != nil {
386
+ return fmt.Errorf("create directory %s: %w", name, err)
387
+ }
388
+ case tar.TypeReg:
389
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
390
+ return fmt.Errorf("create directory for %s: %w", name, err)
391
+ }
392
+ out, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode)
393
+ if err != nil {
394
+ return fmt.Errorf("create %s: %w", name, err)
395
+ }
396
+ written, copyErr := io.Copy(out, io.LimitReader(reader, maxEntryBytes+1))
397
+ closeErr := out.Close()
398
+ if copyErr != nil {
399
+ return fmt.Errorf("extract %s: %w", name, copyErr)
400
+ }
401
+ if written != header.Size || written > maxEntryBytes {
402
+ return fmt.Errorf("release bundle entry %q has inconsistent size", header.Name)
403
+ }
404
+ if closeErr != nil {
405
+ return fmt.Errorf("close %s: %w", name, closeErr)
406
+ }
407
+ default:
408
+ return fmt.Errorf("release bundle entry %q has unsupported type", header.Name)
409
+ }
410
+ }
411
+ }