GoogleContainerTools/skaffold · error

URI scheme is , must be 'gs

Error message

URI scheme is %q, must be 'gs'

What it means

parseGCSURI rejects URIs whose scheme is not 'gs'. Both DownloadRecursive (src) and UploadFile (dst) require Google Cloud Storage URIs of the form gs://bucket/path. This error means a non-GCS location (local path, s3://, https://, or a bare path that yields an empty scheme) was passed.

Solutions

  1. Prefix the argument with gs:// and a bucket name, e.g. "gs://my-bucket/path/**"
  2. Check the config for an empty/missing remote path and set it correctly
  3. Use the GCS URI from `gcloud storage ls` output rather than a console web URL
  4. Add an early check that strings.HasPrefix(arg, "gs://") in your calling code for a clearer failure

Example fix

// before
n.UploadFile(ctx, "./app.tar.gz", "artifacts/app.tar.gz") // no scheme
// after
if !strings.HasPrefix("artifacts/app.tar.gz", "gs://") {
    return errors.New("destination must be a gs:// URI")
}
if err := n.UploadFile(ctx, "./app.tar.gz", "gs://artifacts/app.tar.gz"); err != nil { /* handle */ }
Defensive patterns

Strategy: validation

Validate before calling

func requireGCSURI(uri string) error {
	if !strings.HasPrefix(uri, "gs://") || len(strings.TrimPrefix(uri, "gs://")) == 0 {
		return fmt.Errorf("expected gs://bucket/path, got %q", uri)
	}
	return nil
}
// call before DownloadRecursive/UploadFile

Prevention

When it happens

Trigger: Calling DownloadRecursive with src like "/local/dir" or "s3://bucket/key", or UploadFile with a plain dst like "./remote-cache"; a bare hostname/path has empty scheme so u.Scheme != "gs" triggers this error.

Common situations: Config file mixes local and remote sources and the remote one is unset/empty; user pasted an https console URL instead of a gs:// URI; migrating from another tool that accepted s3-style URIs.

Related errors


AI-assisted analysis of GoogleContainerTools/skaffold@a1189de023 (2026-09-05). Data as JSON: /api/errors/93623ae5cdcf46bf. Report an issue: GitHub.

Appendix: source

Thrown at pkg/skaffold/gcs/client/native.go:139

	dstObj := urinfo.ObjPath
	if isDirectory {
		dstObj, err = url.JoinPath(dstObj, filepath.Base(src))
		if err != nil {
			return err
		}
	}

	return bucket.UploadObject(ctx, dstObj, f)
}

func (n *Native) parseGCSURI(uri string) (uriInfo, error) {
	var gcsobj uriInfo
	u, err := url.Parse(uri)
	if err != nil {
		return uriInfo{}, fmt.Errorf("cannot parse URI %q: %w", uri, err)
	}
	if u.Scheme != "gs" {
		return uriInfo{}, fmt.Errorf("URI scheme is %q, must be 'gs'", u.Scheme)
	}
	if u.Host == "" {
		return uriInfo{}, errors.New("bucket name is empty")
	}
	gcsobj.Bucket = u.Host
	// If we do this with the url package it will scape the `?` character, breaking the glob.
	gcsobj.ObjPath = strings.TrimLeft(strings.ReplaceAll(uri, "gs://"+u.Host, ""), "/")

	return gcsobj, nil
}

func (n *Native) filesToDownload(ctx context.Context, bucket bucketHandler, urinfo uriInfo) (map[string]string, error) {
	uriToLocalPath := map[string]string{}

	// The exact match is with the original glob expression. This could be:
	// 1. a/b/c -> It will return the file `c` under a/b/ if it exists
	// 2. a/b/c* -> It will return any file under a/b/ that starts with c, e.g, c1, c-other, etc
	// 3. a/b/c** -> It will return any file that starts with 'c', and files inside any folder starting with 'c'. It is doing the recursion already

View on GitHub (pinned to a1189de023)