Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 `<no value>`.

### 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

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
7 changes: 6 additions & 1 deletion internal/hail/hail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
65 changes: 53 additions & 12 deletions internal/python/wheel/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
27 changes: 26 additions & 1 deletion internal/python/wheel/metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"os"
"path/filepath"
"slices"
"testing"
)

Expand Down Expand Up @@ -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)
}
})
}

Expand Down
18 changes: 15 additions & 3 deletions internal/runners/export/ingredientmeta.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
}
38 changes: 38 additions & 0 deletions internal/runners/export/ingredientmeta_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
32 changes: 31 additions & 1 deletion internal/runners/publish/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package publish
import (
"cmp"
"context"
"errors"
"os"
"path/filepath"
"slices"
"strings"

"github.com/ActiveState/cli/internal/archiver"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions internal/runners/publish/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading
Loading