From aa2c9b1399ecfffca873889baeb3154755a9a861 Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Tue, 27 Oct 2026 14:01:21 -0500 Subject: [PATCH 1/4] Update version.txt --- version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.txt b/version.txt index b1641ea4ae..d7b1bbe4e9 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.48.2-RC1 +0.48.2-RC2 From b95c821784fee651abe04ae4d244d1412187211d Mon Sep 17 00:00:00 2001 From: Marc Gutman <43051639+icanhasmath@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:27:56 -0500 Subject: [PATCH 2/4] Fix state publish meta data issues found testing 0.48.2-RC1 (#3867) * Fix state publish meta data issues found testing 0.48.2-RC1 - state export ingredient-meta writes plain yaml, unwrapped and without field help comments, so the file can be passed back with --meta. - Dependencies and conditions without a version range (a bare name in pyproject.toml, --depend without a version, or a condition inherited without requirements) get >=0. The platform rejected them with "Expecting an OBJECT with a field named 'versionRequirements'". - When --build can't determine the name or version, say which is missing, why pyproject.toml didn't provide it (no file, not in [project], or listed in [project].dynamic), and how to set it. - Answering 'n' at the publish confirmation offers to edit the meta data in the editor or save it to -.meta.yaml with the command to publish it, instead of only cancelling. With --build or --edit, a name, namespace or version change in the editor is saved to a file instead, as the package was already built and looked up with them. - Fields removed in the editor are removed from the request, and the publish API error is shown instead of . The platform requires the source with every publish, so --edit still needs --build or an archive; the readme now says so. Co-Authored-By: Claude Opus 5.5 * Update echo to v4.16.1 and drop an unused publish error argument echo v4.15.4 has GHSA-cxf3-9h37-7rr8 (critical), GHSA-9969-6676-49h5, GHSA-f48p-c9v4-h7xv and GHSA-h7wr-284f-5qvc, which failed the Scan job. err_uploadingredient_publish is defined in en-us.yaml without a {{.V0}}, so the joined message passed to it was never shown. The wrapped error is already printed beneath it, so drop the argument and the changelog claim. * Keep the API error in the publish failure message err_uploadingredient_publish in en-us.yaml ends in "Error received: {{.V0}}" and overrides the fallback text, so without the argument the message reads "". The wrapped API error isn't user-facing, so it isn't printed beneath it either; it only reaches the log. Co-Authored-By: Claude Opus 5.5 * Fix TestOpen failing on Windows; correct the publish error comment TestOpen removed its temp file while the hail watcher could still be reading it for a later write event: the remove was deferred after the cancel, so it ran first. On Windows an open file can't be removed. Stop the watcher and drain its channel before removing the file. The publish error comment said the wrapped API error is never printed; a transport failure becomes a user-facing BuildPlannerError and is. Only an error reported in the publish result (an errs.New) relies on {{.V0}}. Co-Authored-By: Claude Opus 5.5 * Validate each confirm pass and test the publish fixes through their call paths Address review on #3867: - Run the namespace and checkEditKeepsIdentity checks at the top of each pass of the confirm loop, now confirmRequest, so a namespace cleared or a description added in the editor is rejected with a clear message instead of failing in MarshalJSON or the API. - Add tests that fail if a fix is reverted: confirmRequest with a fake prompter (>=0 filling, editor removing fields, cleared namespace, description on a revision, private-builder restored, save, cancel), using the test binary as $EDITOR so it also runs on Windows; publishFailedError keeping the API error; metaFromIngredient filling conditions; and writeIngredientMeta writing plain yaml that round-trips. FetchMeta's pure part moves into metaFromIngredient and the export's writing into writeIngredientMeta so they can be tested without the API. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- changelog.md | 12 + go.mod | 2 +- go.sum | 4 +- internal/hail/hail_test.go | 7 +- internal/python/wheel/metadata.go | 65 ++++- internal/python/wheel/metadata_test.go | 27 +- internal/runners/export/ingredientmeta.go | 18 +- .../runners/export/ingredientmeta_test.go | 38 +++ internal/runners/publish/build.go | 32 ++- internal/runners/publish/build_test.go | 25 ++ internal/runners/publish/confirm_test.go | 231 ++++++++++++++++++ internal/runners/publish/meta.go | 10 +- internal/runners/publish/publish.go | 222 ++++++++++++++--- internal/runners/publish/publish_test.go | 43 ++++ internal/runners/publish/readme.md | 20 +- test/integration/publish_int_test.go | 2 + .../github.com/labstack/echo/v4/CHANGELOG.md | 45 ++++ vendor/github.com/labstack/echo/v4/README.md | 18 +- vendor/github.com/labstack/echo/v4/bind.go | 39 ++- vendor/github.com/labstack/echo/v4/context.go | 97 +++++--- vendor/github.com/labstack/echo/v4/echo.go | 16 +- vendor/github.com/labstack/echo/v4/echo_fs.go | 75 ++++++ .../labstack/echo/v4/middleware/body_dump.go | 49 +++- .../labstack/echo/v4/middleware/body_limit.go | 56 ++--- .../labstack/echo/v4/middleware/decompress.go | 43 +++- .../echo/v4/middleware/method_override.go | 20 +- .../labstack/echo/v4/middleware/proxy.go | 142 +++++------ .../labstack/echo/v4/middleware/secure.go | 3 +- .../labstack/echo/v4/middleware/slash.go | 30 +++ .../labstack/echo/v4/middleware/static.go | 93 ++++++- .../github.com/labstack/echo/v4/route_path.go | 104 ++++++++ vendor/github.com/labstack/echo/v4/router.go | 209 +++++++++++----- vendor/github.com/labstack/echo/v4/scheme.go | 134 ++++++++++ vendor/modules.txt | 4 +- 34 files changed, 1651 insertions(+), 284 deletions(-) create mode 100644 internal/runners/export/ingredientmeta_test.go create mode 100644 internal/runners/publish/confirm_test.go create mode 100644 vendor/github.com/labstack/echo/v4/route_path.go create mode 100644 vendor/github.com/labstack/echo/v4/scheme.go diff --git a/changelog.md b/changelog.md index 0908eecfae..088671b97e 100644 --- a/changelog.md +++ b/changelog.md @@ -22,6 +22,10 @@ and this project adheres to file, and artifacts are linked concurrently. - A `--meta` file's name, version, and description now feed the built wheel (flags take precedence over the meta file, which takes precedence over `pyproject.toml`). +- Answering `n` when `state publish` asks to confirm now offers to edit the ingredient meta data in your editor or save + it to a meta file to edit and publish later with `--meta`, instead of only cancelling. +- When `state publish --build` cannot determine the ingredient name or version, the error now says which is missing, + why `pyproject.toml` did not provide it (for example, a version listed in `[project].dynamic`), and how to set it. ### Fixed @@ -30,11 +34,19 @@ and this project adheres to up front. - `state publish --edit --meta` with an exported meta file now edits the version named in the file rather than the latest version. +- `state export ingredient-meta` now writes plain YAML without help comments or line wrapping, so its output can be + passed back with `--meta` as is. +- Dependencies and conditions without a version range (such as a bare `requests` in `pyproject.toml`) are now published + as accepting any version instead of failing with "Expecting an OBJECT with a field named 'versionRequirements'". +- Fields removed in the `state publish --editor` file are now removed from the publish instead of keeping their + previous values, and publish API errors are shown instead of ``. ### Security - Updated `github.com/klauspost/compress` to v1.18.7 to remediate CVE-2026-63209 (GHSA-259r-337f-4rfw). - Updated to Go 1.26.10 and `golang.org/x/net` v0.60.0 to remediate 14 CVEs in the Go standard library and HTTP/2 handling. +- Updated `github.com/labstack/echo/v4` to v4.16.1 to remediate GHSA-cxf3-9h37-7rr8, GHSA-9969-6676-49h5, + GHSA-f48p-c9v4-h7xv and GHSA-h7wr-284f-5qvc. ## 0.48.1 diff --git a/go.mod b/go.mod index d2b63f4abb..91660e8763 100644 --- a/go.mod +++ b/go.mod @@ -34,7 +34,7 @@ require ( github.com/jarcoal/httpmock v1.0.3 github.com/jessevdk/go-flags v1.4.0 github.com/kami-zh/go-capturer v0.0.0-20171211120116-e492ea43421d - github.com/labstack/echo/v4 v4.15.4 + github.com/labstack/echo/v4 v4.16.1 github.com/mash/go-tempfile-suffix v0.0.0-20150731093933-48f0f8a3a5ab github.com/mattn/go-runewidth v0.0.15 github.com/nicksnyder/go-i18n v1.10.0 diff --git a/go.sum b/go.sum index 7dab0fdf09..0b1cf68135 100644 --- a/go.sum +++ b/go.sum @@ -499,8 +499,8 @@ github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/labstack/echo/v4 v4.15.4 h1:DL45vVYa+BWE+XuW+zZNd9H0YEdZ80UAWJGcTVW4EVs= -github.com/labstack/echo/v4 v4.15.4/go.mod h1:CuMetKIRwsuO/qlAgMq+KTAalwGoB/h4tC+yPdrTj1g= +github.com/labstack/echo/v4 v4.16.1 h1:h05NuF1GvNu6jSAZxOv++AdjD/NJK+mZdGy0thiiuns= +github.com/labstack/echo/v4 v4.16.1/go.mod h1:V/EQaTVHYJH92QhbQe7kFQ4mjgcjxLQQUQtAN87zZdw= github.com/labstack/gommon v0.5.0 h1:6VSQ2NOzsnEJ5W6+84E0RbcaDDmgB6NIAzWCczTEe6c= github.com/labstack/gommon v0.5.0/go.mod h1:Rzlg7HHy1maLfzBYGg9NZcVuz1sA68HHhLjhcEllYE0= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= diff --git a/internal/hail/hail_test.go b/internal/hail/hail_test.go index 286c798d15..6cf31cf100 100644 --- a/internal/hail/hail_test.go +++ b/internal/hail/hail_test.go @@ -52,11 +52,16 @@ func TestOpen(t *testing.T) { file = tempFile.Name() rcvs, err := Open(ctx, file) + require.NoError(t, err) defer func() { + // Stop the watcher and wait for it to finish before removing the file: it reads the file on every event, and + // on Windows a file that is still open can't be removed. + cancel() + for range rcvs { + } _ = tempFile.Close() assert.NoError(t, os.Remove(file)) }() - require.NoError(t, err) postOpen := time.Now() data := []byte("some data") diff --git a/internal/python/wheel/metadata.go b/internal/python/wheel/metadata.go index 83c8de47da..a1a87bbe14 100644 --- a/internal/python/wheel/metadata.go +++ b/internal/python/wheel/metadata.go @@ -4,18 +4,40 @@ import ( "os" "path/filepath" "regexp" + "slices" "strings" "github.com/ActiveState/cli/internal/errs" "github.com/BurntSushi/toml" ) +// MissingMetadataError reports which of name and version neither the caller nor +// pyproject.toml supplied, and what pyproject.toml said about them, so the user +// can be told how to provide them. +type MissingMetadataError struct { + Fields []string + // Pyproject is the pyproject.toml that was read, or empty when there is none. + Pyproject string + // Dynamic holds the missing fields pyproject.toml lists in [project].dynamic, + // which a build backend computes and so cannot be read from the file. + Dynamic []string +} + +func (e *MissingMetadataError) Error() string { + return "package " + strings.Join(e.Fields, " and ") + " could not be determined" +} + +func (e *MissingMetadataError) Is(target error) bool { + return target == ErrMissingMetadata +} + // ResolveMetadata reads srcDir's pyproject.toml [project] table and applies the // non-empty fields of override on top, producing the metadata to pack with. It -// errors when neither source supplies a name or version. Requires always comes -// from pyproject.toml's dependencies. +// returns a *MissingMetadataError when neither source supplies a name or +// version. Requires always comes from pyproject.toml's dependencies. func ResolveMetadata(srcDir string, override Metadata) (*Metadata, error) { - proj, err := readPyProject(filepath.Join(srcDir, "pyproject.toml")) + path := filepath.Join(srcDir, "pyproject.toml") + proj, found, err := readPyProject(path) if err != nil { return nil, err } @@ -26,8 +48,23 @@ func ResolveMetadata(srcDir string, override Metadata) (*Metadata, error) { Summary: firstNonEmpty(override.Summary, proj.Description), Requires: proj.Dependencies, } - if meta.Name == "" || meta.Version == "" { - return nil, ErrMissingMetadata + missing := &MissingMetadataError{} + if found { + missing.Pyproject = path + } + for field, value := range map[string]string{"name": meta.Name, "version": meta.Version} { + if value != "" { + continue + } + missing.Fields = append(missing.Fields, field) + if slices.Contains(proj.Dynamic, field) { + missing.Dynamic = append(missing.Dynamic, field) + } + } + if len(missing.Fields) > 0 { + slices.Sort(missing.Fields) // "name" before "version", regardless of map order + slices.Sort(missing.Dynamic) + return nil, missing } return &meta, nil } @@ -37,17 +74,19 @@ type pyProject struct { Version string Description string Dependencies []string + Dynamic []string } -// readPyProject reads the [project] table from a pyproject.toml. A missing file -// is not an error; caller-supplied metadata may stand in for it. -func readPyProject(path string) (pyProject, error) { +// readPyProject reads the [project] table from a pyproject.toml and reports +// whether the file exists. A missing file is not an error; caller-supplied +// metadata may stand in for it. +func readPyProject(path string) (pyProject, bool, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { - return pyProject{}, nil + return pyProject{}, false, nil } - return pyProject{}, errs.Wrap(err, "could not read pyproject.toml") + return pyProject{}, false, errs.Wrap(err, "could not read %s", path) } var parsed struct { @@ -56,17 +95,19 @@ func readPyProject(path string) (pyProject, error) { Version string `toml:"version"` Description string `toml:"description"` Dependencies []string `toml:"dependencies"` + Dynamic []string `toml:"dynamic"` } `toml:"project"` } if err := toml.Unmarshal(data, &parsed); err != nil { - return pyProject{}, errs.Wrap(err, "could not parse pyproject.toml") + return pyProject{}, true, errs.Wrap(err, "could not parse %s", path) } return pyProject{ Name: parsed.Project.Name, Version: parsed.Project.Version, Description: parsed.Project.Description, Dependencies: parsed.Project.Dependencies, - }, nil + Dynamic: parsed.Project.Dynamic, + }, true, nil } func firstNonEmpty(values ...string) string { diff --git a/internal/python/wheel/metadata_test.go b/internal/python/wheel/metadata_test.go index 2147e88ddc..73af082023 100644 --- a/internal/python/wheel/metadata_test.go +++ b/internal/python/wheel/metadata_test.go @@ -4,6 +4,7 @@ import ( "errors" "os" "path/filepath" + "slices" "testing" ) @@ -53,9 +54,33 @@ func TestResolveMetadata(t *testing.T) { t.Run("missing name and version errors", func(t *testing.T) { dir := t.TempDir() // no pyproject.toml - if _, err := ResolveMetadata(dir, Metadata{Name: "only-name"}); !errors.Is(err, ErrMissingMetadata) { + _, err := ResolveMetadata(dir, Metadata{Name: "only-name"}) + if !errors.Is(err, ErrMissingMetadata) { t.Errorf("error = %v, want ErrMissingMetadata", err) } + var missing *MissingMetadataError + if !errors.As(err, &missing) { + t.Fatalf("error = %T, want *MissingMetadataError", err) + } + if !slices.Equal(missing.Fields, []string{"version"}) || missing.Pyproject != "" { + t.Errorf("got %+v, want only version missing and no pyproject.toml", missing) + } + }) + + t.Run("dynamic fields are reported", func(t *testing.T) { + dir := t.TempDir() + writePyproject(t, dir, "[project]\ndynamic = [\"version\"]\n") + _, err := ResolveMetadata(dir, Metadata{}) + var missing *MissingMetadataError + if !errors.As(err, &missing) { + t.Fatalf("error = %v, want *MissingMetadataError", err) + } + if !slices.Equal(missing.Fields, []string{"name", "version"}) || !slices.Equal(missing.Dynamic, []string{"version"}) { + t.Errorf("got %+v", missing) + } + if missing.Pyproject != filepath.Join(dir, "pyproject.toml") { + t.Errorf("Pyproject = %q", missing.Pyproject) + } }) } diff --git a/internal/runners/export/ingredientmeta.go b/internal/runners/export/ingredientmeta.go index 2dc33b3dfe..7213ab0bb1 100644 --- a/internal/runners/export/ingredientmeta.go +++ b/internal/runners/export/ingredientmeta.go @@ -1,10 +1,14 @@ package export import ( + "io" + "github.com/ActiveState/cli/internal/captain" "github.com/ActiveState/cli/internal/errs" "github.com/ActiveState/cli/internal/locale" "github.com/ActiveState/cli/internal/runners/publish" + "github.com/ActiveState/cli/pkg/platform/api/graphql/request" + "gopkg.in/yaml.v3" ) type IngredientMetaParams struct { @@ -30,11 +34,19 @@ func (i *IngredientMeta) Run(params *IngredientMetaParams) error { return errs.Wrap(err, "Could not fetch ingredient meta data") } - b, err := meta.MarshalYaml(false) + return writeIngredientMeta(i.prime.Output().Config().OutWriter, meta) +} + +// writeIngredientMeta writes meta in the format --meta reads. It is plain yaml rather than meta.MarshalYaml, whose field +// help comments would end up in the file, and written as is: the outputer's Print would word-wrap long lines and +// interpret [TAGS]. +func writeIngredientMeta(w io.Writer, meta *request.PublishVariables) error { + b, err := yaml.Marshal(meta) if err != nil { return errs.Wrap(err, "Could not marshal ingredient meta data") } - - i.prime.Output().Print(string(b)) + if _, err := w.Write(b); err != nil { + return errs.Wrap(err, "Could not write ingredient meta data") + } return nil } diff --git a/internal/runners/export/ingredientmeta_test.go b/internal/runners/export/ingredientmeta_test.go new file mode 100644 index 0000000000..290d919806 --- /dev/null +++ b/internal/runners/export/ingredientmeta_test.go @@ -0,0 +1,38 @@ +package export + +import ( + "bytes" + "strings" + "testing" + + "github.com/ActiveState/cli/pkg/platform/api/graphql/request" + "gopkg.in/yaml.v3" +) + +func TestWriteIngredientMeta(t *testing.T) { + meta := &request.PublishVariables{ + Name: "mypkg", + Namespace: "private/org/python", + Version: "1.0.0", + Description: strings.Repeat("a long description [RED]with tags[/RESET] ", 10), + Dependencies: []request.PublishVariableDep{{ + Dependency: request.Dependency{Name: "requests", Namespace: "language/python", VersionRequirements: ">=2"}, + }}, + } + + var out bytes.Buffer + if err := writeIngredientMeta(&out, meta); err != nil { + t.Fatal(err) + } + if strings.Contains(out.String(), "#") { + t.Errorf("meta file contains comments:\n%s", out.String()) + } + + var got request.PublishVariables + if err := yaml.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("meta file is not valid yaml: %v\n%s", err, out.String()) + } + if got.Description != meta.Description || len(got.Dependencies) != 1 || got.Dependencies[0].Dependency != meta.Dependencies[0].Dependency { + t.Errorf("meta file does not round-trip:\n%s", out.String()) + } +} diff --git a/internal/runners/publish/build.go b/internal/runners/publish/build.go index 3912b05ca7..1152ae5389 100644 --- a/internal/runners/publish/build.go +++ b/internal/runners/publish/build.go @@ -3,8 +3,10 @@ package publish import ( "cmp" "context" + "errors" "os" "path/filepath" + "slices" "strings" "github.com/ActiveState/cli/internal/archiver" @@ -42,7 +44,11 @@ func (r *Runner) generateEncryptedArtifact(params *Params, metaFile request.Publ Summary: cmp.Or(params.Description, metaFile.Description), }) if err != nil { - return nil, nil, locale.WrapInputError(err, "err_publish_build_metadata", "Could not determine the ingredient name and version: {{.V0}}", errs.JoinMessage(err)) + var missing *wheel.MissingMetadataError + if errors.As(err, &missing) { + return nil, nil, missingMetadataError(missing, params.Build) + } + return nil, nil, locale.WrapInputError(err, "err_publish_build_metadata", "Could not read the ingredient metadata: {{.V0}}", errs.JoinMessage(err)) } // Fetch and validate the org key before building anything: a private publish @@ -80,6 +86,30 @@ func (r *Runner) generateEncryptedArtifact(params *Params, metaFile request.Publ return meta, cleanup, nil } +// missingMetadataError tells the user which of name and version are missing, why pyproject.toml did not supply them, +// and where to set them instead. +func missingMetadataError(missing *wheel.MissingMetadataError, srcDir string) error { + var lines []string + for _, field := range missing.Fields { + var reason string + switch { + case missing.Pyproject == "": + reason = locale.Tl("publish_metadata_no_pyproject", "there is no pyproject.toml in {{.V0}}", srcDir) + case slices.Contains(missing.Dynamic, field): + reason = locale.Tl("publish_metadata_dynamic", + "{{.V0}} lists it in [project].dynamic, so it is computed by a build backend and cannot be read from the file", + missing.Pyproject) + default: + reason = locale.Tl("publish_metadata_absent", "{{.V0}} has no '{{.V1}}' in its [project] table", missing.Pyproject, field) + } + lines = append(lines, locale.Tl("publish_metadata_missing_field", + " - [ACTIONABLE]{{.V0}}[/RESET]: {{.V1}}. Set it in pyproject.toml, with '[ACTIONABLE]--{{.V0}}[/RESET]', or as '[ACTIONABLE]{{.V0}}:[/RESET]' in a '[ACTIONABLE]--meta[/RESET]' file.", + field, reason)) + } + return locale.NewInputError("err_publish_build_metadata_missing", + "Could not determine the ingredient {{.V0}}:\n{{.V1}}", strings.Join(missing.Fields, " and "), strings.Join(lines, "\n")) +} + // requireOrgNamespace ensures ns belongs to the project owner's private org, so // an artifact encrypted under that org's key is published under that same org // and stays decryptable by its consumers. diff --git a/internal/runners/publish/build_test.go b/internal/runners/publish/build_test.go index 3bb54f1dc3..9bada6f744 100644 --- a/internal/runners/publish/build_test.go +++ b/internal/runners/publish/build_test.go @@ -193,3 +193,28 @@ func TestAddPrivateBuilderDependency(t *testing.T) { t.Errorf("private-builder was added twice: %+v", reqVars.Dependencies) } } + +func TestMissingMetadataError(t *testing.T) { + err := missingMetadataError(&wheel.MissingMetadataError{ + Fields: []string{"name", "version"}, + Pyproject: "src/pyproject.toml", + Dynamic: []string{"version"}, + }, "src") + msg := err.Error() + for _, want := range []string{ + "Could not determine the ingredient name and version", + "src/pyproject.toml has no 'name' in its [project] table", + "--name", + "src/pyproject.toml lists it in [project].dynamic", + "--version", + } { + if !strings.Contains(msg, want) { + t.Errorf("message does not contain %q:\n%s", want, msg) + } + } + + msg = missingMetadataError(&wheel.MissingMetadataError{Fields: []string{"version"}}, "src").Error() + if !strings.Contains(msg, "there is no pyproject.toml in src") { + t.Errorf("message does not explain the missing pyproject.toml:\n%s", msg) + } +} diff --git a/internal/runners/publish/confirm_test.go b/internal/runners/publish/confirm_test.go new file mode 100644 index 0000000000..78f7b22e79 --- /dev/null +++ b/internal/runners/publish/confirm_test.go @@ -0,0 +1,231 @@ +package publish + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ActiveState/cli/internal/errs" + "github.com/ActiveState/cli/internal/prompt" + "github.com/ActiveState/cli/internal/rtutils/ptr" + "github.com/ActiveState/cli/internal/testhelpers/outputhelper" + "github.com/ActiveState/cli/pkg/platform/api/graphql/request" + "github.com/ActiveState/cli/pkg/platform/api/inventory/inventory_models" + "gopkg.in/yaml.v3" +) + +// editorContentEnv makes the test binary act as $EDITOR: it writes the variable's value to the file it is given. +const editorContentEnv = "PUBLISH_TEST_EDITOR_CONTENT" + +func TestMain(m *testing.M) { + if content, ok := os.LookupEnv(editorContentEnv); ok && len(os.Args) == 2 { + if err := os.WriteFile(os.Args[1], []byte(content), 0644); err != nil { + os.Exit(1) + } + os.Exit(0) + } + os.Exit(m.Run()) +} + +// useTestEditor points $EDITOR at the test binary, which replaces the edited file with content. +func useTestEditor(t *testing.T, content string) { + t.Helper() + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + t.Setenv("EDITOR", exe) + t.Setenv(editorContentEnv, content) +} + +// fakePrompter answers Confirm and Select from queues; any other prompt panics through the nil embedded Prompter. +type fakePrompter struct { + prompt.Prompter + confirms []bool + selects []int // index into the offered choices + messages []string +} + +func (f *fakePrompter) Confirm(_, message string, _, _ *bool) (bool, error) { + f.messages = append(f.messages, message) + if len(f.confirms) == 0 { + return false, errs.New("unexpected confirmation: %s", message) + } + answer := f.confirms[0] + f.confirms = f.confirms[1:] + return answer, nil +} + +func (f *fakePrompter) Select(_, message string, choices []string, _, _ *string) (string, error) { + if len(f.selects) == 0 { + return "", errs.New("unexpected selection: %s", message) + } + choice := choices[f.selects[0]] + f.selects = f.selects[1:] + return choice, nil +} + +func (f *fakePrompter) Input(_, _ string, _, _ *string, _ ...prompt.ValidatorFlag) (string, error) { + return "", nil // "Press enter when done editing" +} + +const ( + choiceEdit = iota + choiceSave + choiceCancel +) + +func newRequest() *request.PublishVariables { + return &request.PublishVariables{ + Name: "mypkg", + Namespace: "private/org/python", + Version: "1.0.0", + Dependencies: []request.PublishVariableDep{{ + Dependency: request.Dependency{Name: "requests", Namespace: "language/python", Type: request.DependencyTypeRuntime}, + Conditions: []request.Dependency{{Name: "Windows", Namespace: "kernel"}}, + }}, + } +} + +func TestConfirmRequestFillsVersionRequirements(t *testing.T) { + p := &fakePrompter{confirms: []bool{true}} + r := &Runner{out: outputhelper.NewCatcher(), prompt: p} + reqVars := newRequest() + + publish, err := r.confirmRequest(reqVars, &Params{}, false) + if err != nil || !publish { + t.Fatalf("publish = %v, err = %v", publish, err) + } + dep := reqVars.Dependencies[0] + if dep.VersionRequirements != ">=0" || dep.Conditions[0].VersionRequirements != ">=0" { + t.Errorf("version requirements not filled in: %+v", dep) + } + if !strings.Contains(p.messages[0], "versionRequirements: '>=0'") { + t.Errorf("confirmation does not show the filled in requirements:\n%s", p.messages[0]) + } +} + +func TestConfirmRequestEditor(t *testing.T) { + t.Run("removed fields are removed", func(t *testing.T) { + useTestEditor(t, "name: mypkg\nnamespace: private/org/python\nversion: 1.0.0\n") + p := &fakePrompter{confirms: []bool{false, true}, selects: []int{choiceEdit}} + r := &Runner{out: outputhelper.NewCatcher(), prompt: p} + reqVars := newRequest() + + publish, err := r.confirmRequest(reqVars, &Params{}, false) + if err != nil || !publish { + t.Fatalf("publish = %v, err = %v", publish, err) + } + if len(reqVars.Dependencies) != 0 { + t.Errorf("dependencies removed in the editor were kept: %+v", reqVars.Dependencies) + } + }) + + t.Run("cleared namespace is rejected", func(t *testing.T) { + useTestEditor(t, "name: mypkg\nversion: 1.0.0\n") + p := &fakePrompter{confirms: []bool{false}, selects: []int{choiceEdit}} + r := &Runner{out: outputhelper.NewCatcher(), prompt: p} + + _, err := r.confirmRequest(newRequest(), &Params{}, false) + if err == nil || !strings.Contains(err.Error(), "namespace") { + t.Errorf("err = %v, want the namespace error", err) + } + }) + + t.Run("description added on a revision is rejected", func(t *testing.T) { + useTestEditor(t, "name: mypkg\nnamespace: private/org/python\nversion: 1.0.0\ndescription: changed\n") + p := &fakePrompter{confirms: []bool{false}, selects: []int{choiceEdit}} + r := &Runner{out: outputhelper.NewCatcher(), prompt: p} + + _, err := r.confirmRequest(newRequest(), &Params{Edit: true}, true) + if err == nil || !strings.Contains(err.Error(), "cannot be changed") { + t.Errorf("err = %v, want the identity error", err) + } + }) + + t.Run("private-builder is restored with --build", func(t *testing.T) { + useTestEditor(t, "name: mypkg\nnamespace: private/org/python\nversion: 1.0.0\n") + p := &fakePrompter{confirms: []bool{false, true}, selects: []int{choiceEdit}} + r := &Runner{out: outputhelper.NewCatcher(), prompt: p} + reqVars := newRequest() + + if _, err := r.confirmRequest(reqVars, &Params{Build: "src"}, false); err != nil { + t.Fatal(err) + } + if len(reqVars.Dependencies) != 1 || reqVars.Dependencies[0].Name != privateBuilderName { + t.Errorf("dependencies = %+v, want only the private-builder", reqVars.Dependencies) + } + }) +} + +func TestConfirmRequestSave(t *testing.T) { + t.Chdir(t.TempDir()) + p := &fakePrompter{confirms: []bool{false}, selects: []int{choiceSave}} + r := &Runner{out: outputhelper.NewCatcher(), prompt: p} + + publish, err := r.confirmRequest(newRequest(), &Params{Build: "src"}, false) + if err != nil || publish { + t.Fatalf("publish = %v, err = %v, want saved", publish, err) + } + + b, err := os.ReadFile(filepath.Join(".", "mypkg-1.0.0.meta.yaml")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(b), "#") { + t.Errorf("saved meta file contains comments:\n%s", b) + } + var saved request.PublishVariables + if err := yaml.Unmarshal(b, &saved); err != nil { + t.Fatal(err) + } + if saved.Name != "mypkg" || saved.Version != "1.0.0" || len(saved.Dependencies) != 2 || + saved.Dependencies[0].VersionRequirements != ">=0" { + t.Errorf("saved meta file is incomplete: %+v", saved) + } +} + +func TestConfirmRequestCancel(t *testing.T) { + p := &fakePrompter{confirms: []bool{false}, selects: []int{choiceCancel}} + r := &Runner{out: outputhelper.NewCatcher(), prompt: p} + + if _, err := r.confirmRequest(newRequest(), &Params{}, false); err == nil || !strings.Contains(err.Error(), "cancelled") { + t.Errorf("err = %v, want cancelled", err) + } +} + +func TestPublishFailedError(t *testing.T) { + msg := publishFailedError(errs.New("API responded with error: File to upload was not provided")).Error() + if !strings.Contains(msg, "File to upload was not provided") || strings.Contains(msg, "") { + t.Errorf("message does not include the API error:\n%s", msg) + } +} + +func TestMetaFromIngredient(t *testing.T) { + ingredient := &ParentIngredient{Version: "1.0.0", Dependencies: windowsOnlyDependency()} + + meta, err := metaFromIngredient("private/org/python", "mypkg", ingredient) + if err != nil { + t.Fatal(err) + } + if len(meta.Dependencies) != 1 || len(meta.Dependencies[0].Conditions) != 1 { + t.Fatalf("dependencies = %+v", meta.Dependencies) + } + if got := meta.Dependencies[0].Conditions[0].VersionRequirements; got != ">=0" { + t.Errorf("condition without requirements exported as %q, want >=0", got) + } +} + +// windowsOnlyDependency is a revision's dependency on requests, conditioned on Windows without a version requirement. +func windowsOnlyDependency() []inventory_models.Dependency { + return []inventory_models.Dependency{{ + Feature: ptr.To("requests"), + Namespace: ptr.To("language/python"), + Type: inventory_models.DependencyTypeRuntime, + Conditions: []*inventory_models.DependencyCondition{{Condition: inventory_models.Condition{ + Feature: ptr.To("Windows"), + Namespace: ptr.To("kernel"), + }}}, + }} +} diff --git a/internal/runners/publish/meta.go b/internal/runners/publish/meta.go index 5f157da642..da43b54d5d 100644 --- a/internal/runners/publish/meta.go +++ b/internal/runners/publish/meta.go @@ -48,9 +48,17 @@ func FetchMeta(namespace, name, version string, auth *authentication.Auth) (*req ingredient = &ParentIngredient{*i.IngredientID, *i.IngredientVersionID, *i.Version, i.Dependencies} } + return metaFromIngredient(namespace, name, ingredient) +} + +// metaFromIngredient builds the meta file contents of an existing ingredient version, complete enough to be published +// back as is: dependencies and conditions without a version requirement get the any-version one. +func metaFromIngredient(namespace, name string, ingredient *ParentIngredient) (*request.PublishVariables, error) { meta := &request.PublishVariables{Name: name, Namespace: namespace} - if err := prepareEditRequest(ingredient, meta, true, auth); err != nil { + // As a revision, prepareEditRequest doesn't fetch authors, so it needs no auth. + if err := prepareEditRequest(ingredient, meta, true, nil); err != nil { return nil, errs.Wrap(err, "Could not read ingredient meta data") } + fillVersionRequirements(meta) return meta, nil } diff --git a/internal/runners/publish/publish.go b/internal/runners/publish/publish.go index 4ca7e621fa..fe57a9b123 100644 --- a/internal/runners/publish/publish.go +++ b/internal/runners/publish/publish.go @@ -239,52 +239,28 @@ func (r *Runner) Run(params *Params) error { return errs.Wrap(err, "Could not prepare request from params") } - if err := checkEditKeepsIdentity(&reqVars, params.Edit, isRevision); err != nil { - return err - } - - if params.Build != "" { - addPrivateBuilderDependency(&reqVars) - } - if params.Editor { if !r.out.Config().Interactive { return locale.NewInputError("err_uploadingredient_editor_not_supported", "Opening in editor is not supported in non-interactive mode") } - if err := r.OpenInEditor(&reqVars); err != nil { + if err := r.editRequest(&reqVars, params); err != nil { return err } } - if reqVars.Namespace == "" { - return locale.NewInputError("err_uploadingredient_namespace_required", "You have to supply the namespace when working outside of a project context") - } - - b, err := reqVars.MarshalYaml(false) + publish, err := r.confirmRequest(&reqVars, params, isRevision) if err != nil { - return errs.Wrap(err, "Could not marshal publish variables") - } - - cont, err := r.prompt.Confirm( - "", - locale.Tl("uploadingredient_confirm", `Prepared the following ingredient: - -{{.V0}} - -Do you want to publish this ingredient? -`, string(b)), ptr.To(true), nil) - if err != nil { - return errs.Wrap(err, "Not confirmed") + return err } - if !cont { - return locale.NewInputError("uploadingredient_cancel", "Publish cancelled") + if !publish { + return nil // saved to a meta file instead } r.out.Notice(locale.Tl("uploadingredient_uploading", "Publishing ingredient...")) publishResult, err := r.bp.Publish(reqVars, params.Filepath) if err != nil { - return locale.WrapError(err, "err_uploadingredient_publish", "Could not create publish request") + return publishFailedError(err) } if publishResult.Error != "" { @@ -491,6 +467,183 @@ func prepareEditRequest(ingredient *ParentIngredient, r *request.PublishVariable return nil } +// confirmRequest shows the prepared request and asks the user to publish it. Until they confirm, they can edit it or +// save it to a meta file and start over with it. Each pass completes and validates the request again, since an edit +// can undo what was done to it before. It reports whether to publish: false means the request was saved instead. +func (r *Runner) confirmRequest(reqVars *request.PublishVariables, params *Params, isRevision bool) (bool, error) { + for { + if params.Build != "" { + addPrivateBuilderDependency(reqVars) + } + fillVersionRequirements(reqVars) + if reqVars.Namespace == "" { + return false, locale.NewInputError("err_uploadingredient_namespace_required", "You have to supply the namespace when working outside of a project context") + } + if err := checkEditKeepsIdentity(reqVars, params.Edit, isRevision); err != nil { + return false, err + } + + b, err := reqVars.MarshalYaml(false) + if err != nil { + return false, errs.Wrap(err, "Could not marshal publish variables") + } + + cont, err := r.prompt.Confirm( + "", + locale.Tl("uploadingredient_confirm", `Prepared the following ingredient: + +{{.V0}} + +Do you want to publish this ingredient? Answer 'n' to edit it or save it to a file instead. +`, string(b)), ptr.To(true), nil) + if err != nil { + return false, errs.Wrap(err, "Not confirmed") + } + if cont { + return true, nil + } + + saved, err := r.reviseRequest(reqVars, params) + if err != nil { + return false, err + } + if saved { + return false, nil + } + } +} + +// publishFailedError wraps a failed publish request. en-us.yaml's err_uploadingredient_publish ends in +// "Error received: {{.V0}}" and overrides the fallback text, so the message must be passed in, or the user sees +// "". It is also the only place some failures are shown: an error the API reports in its publish result +// (e.g. "File to upload was not provided") is a plain errs.New, which is not printed beneath this message the way a +// user-facing BuildPlannerError is. +func publishFailedError(err error) error { + return locale.WrapError(err, "err_uploadingredient_publish", "Could not create publish request", errs.JoinMessage(err)) +} + +// reviseRequest lets the user edit a request they did not confirm, or save it to a meta file to edit and publish later. +// It reports whether the request was saved, in which case nothing is published. +func (r *Runner) reviseRequest(reqVars *request.PublishVariables, params *Params) (bool, error) { + edit := locale.Tl("uploadingredient_choice_edit", "Edit it in my editor") + save := locale.Tl("uploadingredient_choice_save", "Save it to a meta file to edit and publish later") + cancel := locale.Tl("uploadingredient_choice_cancel", "Cancel") + choice, err := r.prompt.Select("", locale.Tl("uploadingredient_choice", "What would you like to do instead?"), + []string{edit, save, cancel}, &cancel, nil) + if err != nil { + return false, errs.Wrap(err, "Not selected") + } + + switch choice { + case edit: + return false, r.editRequest(reqVars, params) + case save: + path, err := r.saveMetaFile(reqVars) + if err != nil { + return false, err + } + r.out.Notice(locale.Tl("uploadingredient_saved", + "Saved the ingredient meta data to [ACTIONABLE]{{.V0}}[/RESET]. Edit it, then publish it with:\n\n [ACTIONABLE]{{.V1}}[/RESET]\n\n"+ + "The meta file takes precedence over pyproject.toml, so the dependencies it lists replace the ones there.", + path, rerunCommand(params, path))) + return true, nil + } + return false, locale.NewInputError("uploadingredient_cancel", "Publish cancelled") +} + +// editRequest opens the request in the user's editor. With --build or --edit, the name, namespace and version were +// already used to build the package or find the ingredient, so changing them here would publish something +// inconsistent: the edits are saved to a meta file instead, and the user is told to start over with it. +func (r *Runner) editRequest(reqVars *request.PublishVariables, params *Params) error { + before := *reqVars + if err := r.OpenInEditor(reqVars); err != nil { + return err + } + if params.Build == "" && !params.Edit { + return nil + } + if reqVars.Name == before.Name && reqVars.Namespace == before.Namespace && reqVars.Version == before.Version { + return nil + } + + path, err := r.saveMetaFile(reqVars) + if err != nil { + return err + } + return locale.NewInputError("err_uploadingredient_edit_identity_changed", + "The name, namespace or version can't be changed at this point, because they were already used to build the package "+ + "or find the ingredient. Your changes were saved to [ACTIONABLE]{{.V0}}[/RESET]. Publish them with:\n\n [ACTIONABLE]{{.V1}}[/RESET]", + path, rerunCommand(params, path)) +} + +// saveMetaFile writes the request to -.meta.yaml in the working directory, in the format --meta reads, +// and returns its path. An existing file is only replaced if the user agrees. +func (r *Runner) saveMetaFile(reqVars *request.PublishVariables) (string, error) { + path, err := filepath.Abs(reqVars.Name + "-" + reqVars.Version + ".meta.yaml") + if err != nil { + return "", errs.Wrap(err, "Could not determine meta file path") + } + if fileutils.TargetExists(path) { + overwrite, err := r.prompt.Confirm("", locale.Tl("uploadingredient_meta_overwrite", + "[ACTIONABLE]{{.V0}}[/RESET] already exists. Do you want to replace it?", path), ptr.To(false), nil) + if err != nil { + return "", errs.Wrap(err, "Not confirmed") + } + if !overwrite { + return "", locale.NewInputError("uploadingredient_cancel", "Publish cancelled") + } + } + + // Plain yaml rather than MarshalYaml, whose field help comments don't belong in a file the user keeps. + b, err := yaml.Marshal(reqVars) + if err != nil { + return "", errs.Wrap(err, "Could not marshal publish variables") + } + if err := fileutils.WriteFile(path, b); err != nil { + return "", locale.WrapExternalError(err, "err_uploadingredient_meta_write", "Could not write meta file: {{.V0}}", path) + } + return path, nil +} + +// rerunCommand is the publish command that picks up a saved meta file. Other flags are left out: their values are in +// the file already. +func rerunCommand(params *Params, metaPath string) string { + args := []string{"state", "publish"} + if params.Edit { + args = append(args, "--edit") + } + if params.Build != "" { + args = append(args, "--build", quoteArg(params.Build)) + } else if params.Filepath != "" { + args = append(args, quoteArg(params.Filepath)) + } + args = append(args, "--meta", quoteArg(metaPath)) + return strings.Join(args, " ") +} + +func quoteArg(arg string) string { + if strings.ContainsAny(arg, " \t") { + return `"` + arg + `"` + } + return arg +} + +// anyVersion is the version requirement the platform uses for a dependency or condition on any version. +const anyVersion = ">=0" + +// fillVersionRequirements gives every dependency and condition without a version requirement the any-version one. The +// platform rejects them otherwise, and they come about easily: a pyproject.toml dependency without a version range, a +// --depend flag without a version, or a condition inherited from a revision that has no requirements. +func fillVersionRequirements(r *request.PublishVariables) { + for i := range r.Dependencies { + dep := &r.Dependencies[i] + dep.VersionRequirements = cmp.Or(dep.VersionRequirements, anyVersion) + for j := range dep.Conditions { + dep.Conditions[j].VersionRequirements = cmp.Or(dep.Conditions[j].VersionRequirements, anyVersion) + } + } +} + func (r *Runner) OpenInEditor(pr *request.PublishVariables) error { // Prepare file for editing b, err := pr.MarshalYaml(true) @@ -520,10 +673,13 @@ func (r *Runner) OpenInEditor(pr *request.PublishVariables) error { return errs.Wrap(err, "Could not read file") } - // Write changes to request - if err := pr.UnmarshalYaml(eb); err != nil { - return locale.WrapError(err, "err_uploadingredient_publish", "Could not unmarshal publish request") + // Write changes to request. Unmarshal into a fresh value, so that fields removed in the editor are removed from the + // request too instead of keeping their previous values. + var edited request.PublishVariables + if err := edited.UnmarshalYaml(eb); err != nil { + return locale.WrapInputError(err, "err_uploadingredient_editor_yaml", "The edited file [ACTIONABLE]{{.V0}}[/RESET] is not valid: {{.V1}}", fn, err.Error()) } + *pr = edited return nil } diff --git a/internal/runners/publish/publish_test.go b/internal/runners/publish/publish_test.go index 14940dfdba..f188293fae 100644 --- a/internal/runners/publish/publish_test.go +++ b/internal/runners/publish/publish_test.go @@ -108,3 +108,46 @@ func TestCheckEditKeepsIdentity(t *testing.T) { }) } } + +func TestFillVersionRequirements(t *testing.T) { + r := &request.PublishVariables{Dependencies: []request.PublishVariableDep{ + { + Dependency: request.Dependency{Name: "requests", Namespace: "language/python"}, + Conditions: []request.Dependency{ + {Name: "Windows", Namespace: "kernel"}, + {Name: "python", Namespace: "language", VersionRequirements: "<3.11"}, + }, + }, + {Dependency: request.Dependency{Name: "six", Namespace: "language/python", VersionRequirements: ">=1.16"}}, + }} + fillVersionRequirements(r) + + if got := r.Dependencies[0].VersionRequirements; got != ">=0" { + t.Errorf("dependency without a version = %q, want >=0", got) + } + if got := r.Dependencies[0].Conditions[0].VersionRequirements; got != ">=0" { + t.Errorf("condition without a version = %q, want >=0", got) + } + if got := r.Dependencies[0].Conditions[1].VersionRequirements; got != "<3.11" { + t.Errorf("condition version changed to %q", got) + } + if got := r.Dependencies[1].VersionRequirements; got != ">=1.16" { + t.Errorf("dependency version changed to %q", got) + } +} + +func TestRerunCommand(t *testing.T) { + tests := []struct { + params *Params + want string + }{ + {&Params{Build: "./src", Filepath: "/tmp/built.tar.gz"}, "state publish --build ./src --meta m.yaml"}, + {&Params{Edit: true, Build: "my src"}, `state publish --edit --build "my src" --meta m.yaml`}, + {&Params{Filepath: "pkg.tar.gz", Name: "ignored"}, "state publish pkg.tar.gz --meta m.yaml"}, + } + for _, tt := range tests { + if got := rerunCommand(tt.params, "m.yaml"); got != tt.want { + t.Errorf("rerunCommand(%+v) = %q, want %q", tt.params, got, tt.want) + } + } +} diff --git a/internal/runners/publish/readme.md b/internal/runners/publish/readme.md index 1653291e1c..6b6e0e97b4 100644 --- a/internal/runners/publish/readme.md +++ b/internal/runners/publish/readme.md @@ -51,7 +51,8 @@ dependencies = [ ``` Each entry becomes a runtime dependency on the platform's Python package of that name. Version ranges carry over, with -`~=1.16` becoming `>=1.16,<2` and `==4.2.*` becoming `>=4.2,<4.3`. +`~=1.16` becoming `>=1.16,<2` and `==4.2.*` becoming `>=4.2,<4.3`. An entry without a range, such as `"click"`, accepts +any version (`>=0`). Platform and Python version markers become **conditions**, so the dependency is only installed where it applies: @@ -111,12 +112,23 @@ dependencies: namespace: builder versionRequirements: '>=0' -Do you want to publish this ingredient? (Y/n) +Do you want to publish this ingredient? Answer 'n' to edit it or save it to a file instead. (Y/n) ``` Check the dependency list here: it is exactly what will be published. The `private-builder` dependency is always added; it is what decrypts and installs the package, so leave it in place. +If something is wrong, answer `n` and choose one of: + +- **Edit it in my editor:** opens the meta data in `$EDITOR` (or the default program for `.yaml` files), then shows + the result for confirmation again. Name, namespace and version can't be changed this way, because the package was + already built with them: if you change them, your edits are saved to a meta file as below. +- **Save it to a meta file to edit and publish later:** writes `mypkg-1.0.0.meta.yaml` to the current directory and + prints the command to publish it, e.g. `state publish --build ./mypkg-src --meta mypkg-1.0.0.meta.yaml`. Edit the file, + then run that command. The meta file takes precedence over `pyproject.toml`, so its name, version and dependency list + are used instead. +- **Cancel:** nothing is published. + Flags override `pyproject.toml`: `--name`, `--version` and `--description` replace the values in it, and any `--depend`, `--depend-runtime`, `--depend-build` or `--depend-test` flag replaces its whole dependency list. @@ -194,6 +206,8 @@ is `name: python`, `namespace: language`, with a requirement such as `<3.11`. state publish --edit --build ./mypkg-src --meta meta.yaml ``` +The platform requires the source with every revision, so pass `--build` even when only the meta data changes. + Things to know about `--edit`: - **The meta file replaces the whole dependency list.** Anything you delete from it is removed from the ingredient. @@ -222,7 +236,7 @@ accident. | Message | What to do | |-------------------------------------------------------------------------|------------------------------------------------------------------------------------------------| -| `Could not determine the ingredient name and version` | Add `name` and `version` to `pyproject.toml`, or pass `--name` and `--version`. | +| `Could not determine the ingredient name and version` | The message lists each missing field and why. Add it to `pyproject.toml`'s `[project]` table, pass `--name`/`--version`, or set it in a `--meta` file. A version listed in `[project].dynamic` can't be read and must be given this way. | | `The '--build' flag requires a namespace under 'private/'` | Use a namespace under the organization that owns the project you are in. | | `org key does not belong to this project's organization` | The key service returned another organization's key. Check that you are in the right project. | | `No organization key service is configured` | Configure the `privateingredient.*` settings (see Before You Start). | diff --git a/test/integration/publish_int_test.go b/test/integration/publish_int_test.go index d125553263..1264eb56bd 100644 --- a/test/integration/publish_int_test.go +++ b/test/integration/publish_int_test.go @@ -450,6 +450,8 @@ authors: cp.SendLine("Y") } else { cp.SendLine("n") + cp.Expect("What would you like to do instead?") + cp.SendLine("") // the default choice: Cancel cp.Expect("Publish cancelled") return } diff --git a/vendor/github.com/labstack/echo/v4/CHANGELOG.md b/vendor/github.com/labstack/echo/v4/CHANGELOG.md index 8388531e91..c8017d19d5 100644 --- a/vendor/github.com/labstack/echo/v4/CHANGELOG.md +++ b/vendor/github.com/labstack/echo/v4/CHANGELOG.md @@ -1,5 +1,50 @@ # Changelog +## v4.16.0 - 2026-09-27 + +**Security** + +This release fixes several security issues. Upgrading is recommended. Some fixes change behavior; read "Behavior changes to check before upgrading" below. + +* **Request scheme:** `Context.Scheme()` now uses the `X-Forwarded-Proto`, `X-Forwarded-Protocol`, `X-Forwarded-Ssl` and `X-Url-Scheme` headers only when the request comes directly from a loopback, link-local or private network address or a unix socket. Before this, any client could send `X-Forwarded-Proto: https` over plain HTTP and skip `HTTPSRedirect`. When `X-Forwarded-Proto` is present, only it is used (its last value), and the scheme is returned in lowercase. the new `Echo#SchemeExtractor` field selects the strategy: `ExtractSchemeFromHeaders(...TrustOption)` (default), `ExtractSchemeDirect()` or `LegacySchemeExtractor()`. The Secure middleware now sets HSTS based on `Context.Scheme()`. The Proxy middleware always sets `X-Forwarded-Proto` from `Context.Scheme()` and removes `X-Forwarded-Ssl`, `X-Forwarded-Protocol` and `X-Url-Scheme` before forwarding. [GHSA-2ffq-g2xg-c22p](https://github.com/labstack/echo/security/advisories/GHSA-2ffq-g2xg-c22p) +* **JSONP:** `Context.JSONP` and `Context.JSONPBlob` accept only a callback that is empty, a JavaScript identifier or a dot-separated path of identifiers (ASCII letters, digits, `_` and `$`). Any other callback returns a 400 Bad Request error that wraps the new `ErrInvalidJSONPCallback`, and nothing is written. JSONP responses now carry `X-Content-Type-Options: nosniff`. JSONP lets any website read the response with the user's cookies, so do not use it for data that needs authentication. [GHSA-h9g5-28mm-hx3g](https://github.com/labstack/echo/security/advisories/GHSA-h9g5-28mm-hx3g) +* **MethodOverride:** a POST can no longer be overridden to `GET`, `HEAD`, `OPTIONS`, `TRACE` or `CONNECT`. Before this, with the `MethodFromForm` or `MethodFromQuery` getter and MethodOverride registered with `Use` before the CSRF middleware, `_method=GET` skipped the CSRF check. Register MethodOverride with `Echo#Pre`. [GHSA-r7w9-592q-9vg4](https://github.com/labstack/echo/security/advisories/GHSA-r7w9-592q-9vg4) +* **Redirects:** the trailing slash middlewares and the static directory redirect percent-encode control characters in the redirect path. Before this, `/%09/evil.example/` redirected browsers to `evil.example`. [GHSA-v753-g4cw-jm48](https://github.com/labstack/echo/security/advisories/GHSA-v753-g4cw-jm48) +* **Static files:** with the default settings, the Static middleware resolves files from the same form of the path that the router matched, so `/admin%2Fsecret.txt` or `/%61dmin/secret.txt` can no longer reach a file under a guarded `/admin/*` route. [GHSA-375p-5qhx-8wq4](https://github.com/labstack/echo/security/advisories/GHSA-375p-5qhx-8wq4) The Static middleware and `StaticDirectoryHandler` (used by `Echo.Static`, `Echo.StaticFS`, `Group.Static` and `Group.StaticFS`) no longer serve paths with a `.`, `..` or empty segment, such as `/assets/../admin/secret.txt`, also after path unescaping. [GHSA-3pmx-cf9f-34xr](https://github.com/labstack/echo/security/advisories/GHSA-3pmx-cf9f-34xr) +* **Dependencies:** update `golang.org/x/text` to v0.40.0 ([GO-2026-5970](https://pkg.go.dev/vuln/GO-2026-5970)). + +**Client IP address (no code change in v4)** + +Without `Echo#IPExtractor`, `Context.RealIP()` in v4 trusts the `X-Forwarded-For` and `X-Real-IP` headers from any client, so the rate limiter can be bypassed and the Proxy middleware forwards a spoofed `X-Real-IP` ([GHSA-246p-cpwv-v3jq](https://github.com/labstack/echo/security/advisories/GHSA-246p-cpwv-v3jq), [GHSA-99jh-6h7p-pp36](https://github.com/labstack/echo/security/advisories/GHSA-99jh-6h7p-pp36)). Changing this default in v4 would put all clients behind a proxy into one rate-limit bucket, so v4 keeps it. Set an extractor that matches your deployment: +```go +e.IPExtractor = echo.ExtractIPDirect() // no proxy in front of the app +e.IPExtractor = echo.ExtractIPFromXFFHeader() // behind proxies in private networks that set X-Forwarded-For +// behind a proxy with public addresses (e.g. a CDN), also trust its ranges: +// e.IPExtractor = echo.ExtractIPFromXFFHeader(echo.TrustIPRange(cdnRange)) +``` +v5 uses the direct peer address by default since v5.1.0. + +**Behavior changes to check before upgrading** + +* **Proxies or load balancers with public IP addresses.** If a proxy connects to your app from a public (or `100.64.0.0/10`) address, its `X-Forwarded-Proto` is now ignored: `HTTPSRedirect` redirects in a loop and the Secure middleware stops sending HSTS. This affects, for example, Cloudflare, CloudFront and Azure Front Door connecting to a public origin, the GCP external HTTP(S) load balancer including GKE Ingress (`35.191.0.0/16`, `130.211.0.0/22`), and networks that use `100.64.0.0/10` (such as Alibaba Cloud SLB or EKS custom networking). Trust the proxy's address ranges: + ```go + _, gclb1, _ := net.ParseCIDR("35.191.0.0/16") + _, gclb2, _ := net.ParseCIDR("130.211.0.0/22") + e.SchemeExtractor = echo.ExtractSchemeFromHeaders(echo.TrustIPRange(gclb1), echo.TrustIPRange(gclb2)) + ``` + Proxies on the same host, in a private network (AWS ALB, in-cluster ingress controllers such as ingress-nginx or Traefik, most PaaS routers) or on a unix socket keep working without changes. `echo.LegacySchemeExtractor()` restores the old behavior but is not safe unless every request passes through a proxy that sets these headers. Serverless adapters or middleware that set `RemoteAddr` to the client's address also make `X-Forwarded-Proto` ignored (or, if they take it from a header, spoofable). +* **Trusted proxies must set `X-Forwarded-Proto`.** A proxy on a trusted address that passes the client's `X-Forwarded-Proto` through (for example nginx without `proxy_set_header X-Forwarded-Proto $scheme;`) still lets the client choose the scheme. An invalid `X-Forwarded-Proto` value now results in `http` instead of falling back to the other scheme headers. +* **Your own tests.** `httptest.NewRequest` sets `RemoteAddr` to `192.0.2.1:1234`, which is not trusted, so tests that set `X-Forwarded-Proto` now see `http`. Set `req.RemoteAddr = "10.0.0.1:1234"` or use `e.SchemeExtractor = echo.LegacySchemeExtractor()` in such tests. +* **Proxy middleware headers.** `X-Forwarded-Ssl`, `X-Forwarded-Protocol` and `X-Url-Scheme` are no longer forwarded to the upstream; `X-Forwarded-Proto` carries the scheme. +* **MethodOverride.** Overriding a POST to `GET` (for example with `X-HTTP-Method-Override: GET` to send a long query in a POST body) is no longer done; such requests keep the POST method. +* **Static files.** Paths with a double slash or dot segment (for example `/assets//app.js`) now return 404; in HTML5 mode the index is still served. The Static middleware no longer finds file names that the client sends with non-default escaping (for example `%2C`, `%40` or lowercase hex like `%c3%a9`) unless `StaticConfig.EnablePathUnescaping` is set; `Echo.Static` has behaved this way since v4.15.4. With `StaticConfig.EnablePathUnescaping` or `Echo#EnablePathUnescapingStaticFiles`, encoded dots (`%2e%2e`) no longer traverse directories, but encoded slashes are still decoded, so do not combine these options with route-based access control. +* **JSONP.** `Context.JSONP` returns an error for callbacks that are not JavaScript identifiers. + +**Documentation** + +* Static middleware: when registered with `Echo#Use` it runs before route and group middleware, so route guards do not protect the files it serves. + + ## v4.15.4 - 2026-06-15 **Security** diff --git a/vendor/github.com/labstack/echo/v4/README.md b/vendor/github.com/labstack/echo/v4/README.md index 5e52d1d4e8..49040fd7b0 100644 --- a/vendor/github.com/labstack/echo/v4/README.md +++ b/vendor/github.com/labstack/echo/v4/README.md @@ -36,6 +36,13 @@ Help and questions: [Github Discussions](https://github.com/labstack/echo/discus ## Sponsors + +
encore icon @@ -54,7 +61,15 @@ Click [here](https://github.com/sponsors/labstack) for more information on spons // go get github.com/labstack/echo/{version} go get github.com/labstack/echo/v4 ``` -Latest version of Echo supports last four Go major [releases](https://go.dev/doc/devel/release) and might work with older versions. +Echo v4.16.1 requires Go 1.26 or later so it can use patched `golang.org/x/` dependencies. Use a supported, patched Go toolchain when building applications. + +### Security defaults in v4.16.1 + +`BodyDump` captures at most 5 MiB of the request and 5 MiB of the response by default. The application and client still receive the complete bodies. Configure `BodyDumpConfig.MaxRequestBytes` and `MaxResponseBytes` to change the capture limits; `-1` disables a limit. + +`Decompress` limits decompressed request bodies to 100 MiB by default. Reading beyond the limit returns HTTP 413, including through JSON and XML binding. Configure `DecompressConfig.MaxDecompressedSize` to change the limit; `-1` disables it. Apply any smaller application body limit after decompression. + +The proxy now validates WebSocket upgrade responses, closes both WebSocket connections when either side disconnects, and removes incoming `Forwarded` headers. Backends should use the proxy's validated forwarding headers and enforce their own authorization. ### Example @@ -99,6 +114,7 @@ Following list of middleware is maintained by Echo team. |------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | [github.com/labstack/echo-jwt](https://github.com/labstack/echo-jwt) | [JWT](https://github.com/golang-jwt/jwt) middleware | | [github.com/labstack/echo-contrib](https://github.com/labstack/echo-contrib) | [casbin](https://github.com/casbin/casbin), [gorilla/sessions](https://github.com/gorilla/sessions), [jaegertracing](https://github.com/uber/jaeger-client-go), [prometheus](https://github.com/prometheus/client_golang/), [pprof](https://pkg.go.dev/net/http/pprof), [zipkin](https://github.com/openzipkin/zipkin-go) middlewares | +| [github.com/labstack/echo-otel](https://github.com/labstack/echo-otel/tree/v4) | [OpenTelemetry](https://opentelemetry.io/) middleware for tracing and metrics (`github.com/labstack/echo-otel/v4`) | # Third-party middleware repositories diff --git a/vendor/github.com/labstack/echo/v4/bind.go b/vendor/github.com/labstack/echo/v4/bind.go index 1d4fe6f0a7..b9cffab09e 100644 --- a/vendor/github.com/labstack/echo/v4/bind.go +++ b/vendor/github.com/labstack/echo/v4/bind.go @@ -22,6 +22,13 @@ type Binder interface { } // DefaultBinder is the default implementation of the Binder interface. +// For path, query, header, and form binding, time.Time and *time.Time fields (not slices) +// may use a format tag: "date-time" uses the standard RFC3339 decoding, "date-time-local" +// accepts "2006-01-02T15:04:05" with optional fractional seconds, "date" accepts +// "2006-01-02", and other values specify Go time layouts. "date", "date-time-local", and +// layouts without a timezone give UTC times. JSON and XML decoding do not use this tag. +// HTML datetime-local inputs omit seconds by default, so bind them with a Go layout +// such as "2006-01-02T15:04" instead of "date-time-local". type DefaultBinder struct{} // BindUnmarshaler is the interface used to wrap the UnmarshalParam method. @@ -42,10 +49,11 @@ type bindMultipleUnmarshaler interface { // BindPathParams binds path params to bindable object // // Time format support: time.Time fields can use `format` tags to specify custom parsing layouts. -// Example: `param:"created" format:"2006-01-02T15:04"` for datetime-local format +// Example: `form:"created" format:"2006-01-02T15:04"` for HTML datetime-local input // Example: `param:"date" format:"2006-01-02"` for date format // Uses Go's standard time format reference time: Mon Jan 2 15:04:05 MST 2006 -// Works with form data, query parameters, and path parameters (not JSON body) +// OpenAPI format names "date-time", "date-time-local" and "date" are also supported (see DefaultBinder) +// Works with form data, query parameters, headers, and path parameters (not JSON body) // Falls back to default time.Time parsing if no format tag is specified func (b *DefaultBinder) BindPathParams(c Context, i interface{}) error { names := c.ParamNames() @@ -95,6 +103,10 @@ func (b *DefaultBinder) BindBody(c Context, i interface{}) (err error) { } case MIMEApplicationXML, MIMETextXML: if err = xml.NewDecoder(req.Body).Decode(i); err != nil { + var httpError *HTTPError + if errors.As(err, &httpError) { + return httpError + } if ute, ok := err.(*xml.UnsupportedTypeError); ok { return NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Unsupported type error: type=%v, error=%v", ute.Type, ute.Error())).SetInternal(err) } else if se, ok := err.(*xml.SyntaxError); ok { @@ -105,6 +117,10 @@ func (b *DefaultBinder) BindBody(c Context, i interface{}) (err error) { case MIMEApplicationForm: params, err := c.FormParams() if err != nil { + var httpError *HTTPError + if errors.As(err, &httpError) { + return httpError + } return NewHTTPError(http.StatusBadRequest, err.Error()).SetInternal(err) } if err = b.bindData(i, params, "form", nil); err != nil { @@ -113,6 +129,10 @@ func (b *DefaultBinder) BindBody(c Context, i interface{}) (err error) { case MIMEMultipartForm: params, err := c.MultipartForm() if err != nil { + var httpError *HTTPError + if errors.As(err, &httpError) { + return httpError + } return NewHTTPError(http.StatusBadRequest, err.Error()).SetInternal(err) } if err = b.bindData(i, params.Value, "form", params.File); err != nil { @@ -375,10 +395,19 @@ func unmarshalInputToField(valueKind reflect.Kind, val string, field reflect.Val fieldIValue := field.Addr().Interface() - // Handle time.Time with custom format tag - if formatTag != "" { + // date-time uses the same TextUnmarshaler as an untagged time.Time. + if formatTag != "" && formatTag != "date-time" { if _, isTime := fieldIValue.(*time.Time); isTime { - t, err := time.Parse(formatTag, val) + // OpenAPI dates and local date-times have no timezone. time.Parse assigns UTC, + // as it does for custom layouts without timezone information. + layout := formatTag + switch formatTag { + case "date": + layout = "2006-01-02" + case "date-time-local": + layout = "2006-01-02T15:04:05" + } + t, err := time.Parse(layout, val) if err != nil { return true, err } diff --git a/vendor/github.com/labstack/echo/v4/context.go b/vendor/github.com/labstack/echo/v4/context.go index 6500e5eef2..dd5f11c463 100644 --- a/vendor/github.com/labstack/echo/v4/context.go +++ b/vendor/github.com/labstack/echo/v4/context.go @@ -134,10 +134,17 @@ type Context interface { // JSONP sends a JSONP response with status code. It uses `callback` to construct // the JSONP payload. + // + // The callback must be empty, a JavaScript identifier or a dot-separated path of identifiers using only ASCII + // letters, digits, `_` and `$`. Otherwise nothing is written and a 400 Bad Request error wrapping + // ErrInvalidJSONPCallback is returned. The response has the `X-Content-Type-Options: nosniff` header. + // + // Any website can load a JSONP response with a