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
29 changes: 29 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,35 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 0.48.2

### Added

- `state publish --build` now reads `[project].dependencies` from `pyproject.toml` and publishes them as runtime
dependencies of the encrypted private ingredient. Version ranges carry over, and OS and Python version markers become
dependency conditions (unsupported markers are skipped with a notice).
- Added `state export ingredient-meta <namespace>/<name>[@version]`, which writes an existing ingredient's meta file for
use with `state publish --edit --meta`.

### Changed

- Installing runtimes on Windows is significantly faster: link paths are resolved once per tree instead of once per
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`).

### Fixed

- `state publish --edit` no longer overwrites the meta file's dependencies and authors with the previous revision's,
keeps inherited dependency types and conditions, and rejects author or description changes on an existing version
up front.
- `state publish --edit --meta` with an exported meta file now edits the version named in the file rather than the
latest version.

### Security

- Updated `github.com/klauspost/compress` to v1.18.7 to remediate CVE-2026-63209 (GHSA-259r-337f-4rfw).

## 0.48.1

### Added
Expand Down
1 change: 1 addition & 0 deletions cmd/state/internal/cmdtree/cmdtree.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ func New(prime *primer.Values, args ...string) *CmdTree {
newExportLogCommand(prime),
newExportRuntimeCommand(prime),
newExportBuildPlanCommand(prime),
newExportIngredientMetaCommand(prime),
deptree,
)

Expand Down
24 changes: 24 additions & 0 deletions cmd/state/internal/cmdtree/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -370,3 +370,27 @@ func newExportDepTreeIngredientsCommand(prime *primer.Values) *captain.Command {
cmd.SetUnstable(true)
return cmd
}

func newExportIngredientMetaCommand(prime *primer.Values) *captain.Command {
runner := export.NewIngredientMeta(prime)
params := &export.IngredientMetaParams{}

return captain.NewCommand(
"ingredient-meta",
locale.Tl("export_ingredient_meta_title", "Exporting Ingredient Meta File"),
locale.Tl("export_ingredient_meta_description", "Export the meta file of a published ingredient, to modify and pass to '[ACTIONABLE]state publish --edit --meta[/RESET]'"),
prime,
[]*captain.Flag{},
[]*captain.Argument{
{
Name: "namespace/name[@version]",
Description: locale.Tl("export_ingredient_meta_arg_description", "The ingredient to export, e.g. private/<org>/python/mypkg@1.0. Defaults to the latest version."),
Value: &params.Ingredient,
Required: true,
},
},
func(_ *captain.Command, _ []string) error {
return runner.Run(params)
},
)
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ require (
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/compress v1.18.7 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/kr/pty v1.1.8 // indirect
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -478,8 +478,8 @@ github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQL
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.9.5/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
Expand Down
3 changes: 3 additions & 0 deletions internal/python/wheel/distinfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ func buildMetadata(meta Metadata) []byte {
if meta.Summary != "" {
b.WriteString("Summary: " + meta.Summary + "\n")
}
for _, req := range meta.Requires {
b.WriteString("Requires-Dist: " + req + "\n")
}
return b.Bytes()
}

Expand Down
31 changes: 18 additions & 13 deletions internal/python/wheel/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,19 @@ import (

// 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.
// errors 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"))
if err != nil {
return nil, err
}

meta := Metadata{
Name: firstNonEmpty(override.Name, proj.Name),
Version: firstNonEmpty(override.Version, proj.Version),
Summary: firstNonEmpty(override.Summary, proj.Description),
Name: firstNonEmpty(override.Name, proj.Name),
Version: firstNonEmpty(override.Version, proj.Version),
Summary: firstNonEmpty(override.Summary, proj.Description),
Requires: proj.Dependencies,
}
if meta.Name == "" || meta.Version == "" {
return nil, ErrMissingMetadata
Expand All @@ -31,9 +33,10 @@ func ResolveMetadata(srcDir string, override Metadata) (*Metadata, error) {
}

type pyProject struct {
Name string
Version string
Description string
Name string
Version string
Description string
Dependencies []string
}

// readPyProject reads the [project] table from a pyproject.toml. A missing file
Expand All @@ -49,18 +52,20 @@ func readPyProject(path string) (pyProject, error) {

var parsed struct {
Project struct {
Name string `toml:"name"`
Version string `toml:"version"`
Description string `toml:"description"`
Name string `toml:"name"`
Version string `toml:"version"`
Description string `toml:"description"`
Dependencies []string `toml:"dependencies"`
} `toml:"project"`
}
if err := toml.Unmarshal(data, &parsed); err != nil {
return pyProject{}, errs.Wrap(err, "could not parse pyproject.toml")
}
return pyProject{
Name: parsed.Project.Name,
Version: parsed.Project.Version,
Description: parsed.Project.Description,
Name: parsed.Project.Name,
Version: parsed.Project.Version,
Description: parsed.Project.Description,
Dependencies: parsed.Project.Dependencies,
}, nil
}

Expand Down
12 changes: 12 additions & 0 deletions internal/python/wheel/metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ func TestResolveMetadata(t *testing.T) {
}
})

t.Run("pyproject dependencies become Requires", func(t *testing.T) {
dir := t.TempDir()
writePyproject(t, dir, "[project]\nname = \"proj\"\nversion = \"3.1\"\ndependencies = [\"requests>=2\", \"click\"]\n")
res, err := ResolveMetadata(dir, Metadata{Name: "override"})
if err != nil {
t.Fatal(err)
}
if len(res.Requires) != 2 || res.Requires[0] != "requests>=2" || res.Requires[1] != "click" {
t.Errorf("Requires = %q", res.Requires)
}
})

t.Run("caller overrides pyproject", func(t *testing.T) {
dir := t.TempDir()
writePyproject(t, dir, "[project]\nname = \"proj\"\nversion = \"3.1\"\n")
Expand Down
2 changes: 2 additions & 0 deletions internal/python/wheel/wheel.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ type Metadata struct {
Name string
Version string
Summary string
// Requires holds PEP 508 requirement strings, recorded as Requires-Dist.
Requires []string
}

// sourceFile is one file destined for the wheel, identified by its slash path
Expand Down
4 changes: 2 additions & 2 deletions internal/python/wheel/wheel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func TestPackProducesValidWheel(t *testing.T) {
})

out := t.TempDir()
wheelPath, err := Pack(src, Metadata{Name: "My.Pkg-Name", Version: "1.0", Summary: "a pkg"}, out)
wheelPath, err := Pack(src, Metadata{Name: "My.Pkg-Name", Version: "1.0", Summary: "a pkg", Requires: []string{"requests>=2"}}, out)
if err != nil {
t.Fatalf("Pack: %v", err)
}
Expand All @@ -94,7 +94,7 @@ func TestPackProducesValidWheel(t *testing.T) {

// METADATA / WHEEL contents.
metaFile := string(entries[di+"/METADATA"])
for _, want := range []string{"Metadata-Version: 2.1", "Name: My.Pkg-Name", "Version: 1.0", "Summary: a pkg"} {
for _, want := range []string{"Metadata-Version: 2.1", "Name: My.Pkg-Name", "Version: 1.0", "Summary: a pkg", "Requires-Dist: requests>=2"} {
if !bytes.Contains([]byte(metaFile), []byte(want)) {
t.Errorf("METADATA missing %q; got:\n%s", want, metaFile)
}
Expand Down
40 changes: 40 additions & 0 deletions internal/runners/export/ingredientmeta.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package export

import (
"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"
)

type IngredientMetaParams struct {
Ingredient captain.PackageValueNSRequired
}

type IngredientMeta struct {
prime primeable
}

func NewIngredientMeta(p primeable) *IngredientMeta {
return &IngredientMeta{p}
}

func (i *IngredientMeta) Run(params *IngredientMetaParams) error {
auth := i.prime.Auth()
if !auth.Authenticated() {
return locale.NewInputError("err_auth_required")
}

meta, err := publish.FetchMeta(params.Ingredient.Namespace, params.Ingredient.Name, params.Ingredient.Version, auth)
if err != nil {
return errs.Wrap(err, "Could not fetch ingredient meta data")
}

b, err := meta.MarshalYaml(false)
if err != nil {
return errs.Wrap(err, "Could not marshal ingredient meta data")
}

i.prime.Output().Print(string(b))
return nil
}
53 changes: 42 additions & 11 deletions internal/runners/publish/build.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package publish

import (
"cmp"
"context"
"os"
"path/filepath"
Expand All @@ -14,44 +15,51 @@ import (
"github.com/ActiveState/cli/internal/logging"
"github.com/ActiveState/cli/internal/python/wheel"
"github.com/ActiveState/cli/internal/runbits/orgkey"
"github.com/ActiveState/cli/pkg/platform/api/graphql/request"
"github.com/ActiveState/cli/pkg/platform/model"
)

// generateEncryptedArtifact validates the --build inputs, fetches and validates
// the org key, builds the wrapped, encrypted artifact, and points the publish
// flow at it. It fails closed before producing any artifact if the key is
// unavailable, and returns a cleanup function the caller must defer.
func (r *Runner) generateEncryptedArtifact(params *Params) (cleanup func(), rerr error) {
// unavailable, and returns the resolved wheel metadata and a cleanup function
// the caller must defer. Flags take precedence over the meta file, which takes
// precedence over pyproject.toml.
func (r *Runner) generateEncryptedArtifact(params *Params, metaFile request.PublishVariables) (_ *wheel.Metadata, cleanup func(), rerr error) {
if params.Filepath != "" {
return nil, locale.NewInputError("err_publish_build_and_file", "The '[ACTIONABLE]--build[/RESET]' flag cannot be combined with a source archive filepath.")
return nil, nil, locale.NewInputError("err_publish_build_and_file", "The '[ACTIONABLE]--build[/RESET]' flag cannot be combined with a source archive filepath.")
}
if r.project == nil {
return nil, locale.NewInputError("err_publish_build_no_project", "The '[ACTIONABLE]--build[/RESET]' flag requires a project so the organization can be determined.")
return nil, nil, locale.NewInputError("err_publish_build_no_project", "The '[ACTIONABLE]--build[/RESET]' flag requires a project so the organization can be determined.")
}
if !fileutils.DirExists(params.Build) {
return nil, locale.NewInputError("err_publish_build_dir_not_found", "The '[ACTIONABLE]--build[/RESET]' source directory does not exist: [ACTIONABLE]{{.V0}}[/RESET]", params.Build)
return nil, nil, locale.NewInputError("err_publish_build_dir_not_found", "The '[ACTIONABLE]--build[/RESET]' source directory does not exist: [ACTIONABLE]{{.V0}}[/RESET]", params.Build)
}

meta, err := wheel.ResolveMetadata(params.Build, wheel.Metadata{Name: params.Name, Version: params.Version})
meta, err := wheel.ResolveMetadata(params.Build, wheel.Metadata{
Name: cmp.Or(params.Name, metaFile.Name),
Version: cmp.Or(params.Version, metaFile.Version),
Summary: cmp.Or(params.Description, metaFile.Description),
})
if err != nil {
return nil, locale.WrapInputError(err, "err_publish_build_metadata", "Could not determine the ingredient name and version: {{.V0}}", errs.JoinMessage(err))
return nil, nil, locale.WrapInputError(err, "err_publish_build_metadata", "Could not determine the ingredient name and version: {{.V0}}", errs.JoinMessage(err))
}

// Fetch and validate the org key before building anything: a private publish
// is encrypted-required, so fail closed before any byte could be uploaded.
provider := orgkey.New(r.cfg, r.project.Owner())
if !provider.Configured() {
return nil, locale.NewInputError("err_publish_orgkey_unconfigured", "No organization key service is configured, so this private ingredient cannot be encrypted.")
return nil, nil, locale.NewInputError("err_publish_orgkey_unconfigured", "No organization key service is configured, so this private ingredient cannot be encrypted.")
}
defer provider.Close()
key, keyID, err := provider.Key(context.Background())
if err != nil {
return nil, locale.WrapInputError(err, "err_publish_orgkey_unavailable", "Could not obtain the organization key, so nothing was uploaded: {{.V0}}", errs.JoinMessage(err))
return nil, nil, locale.WrapInputError(err, "err_publish_orgkey_unavailable", "Could not obtain the organization key, so nothing was uploaded: {{.V0}}", errs.JoinMessage(err))
}

archivePath, cleanup, err := buildWrappedArtifact(params.Build, *meta, key, keyID)
if err != nil {
return nil, errs.Wrap(err, "Could not build encrypted artifact")
return nil, nil, errs.Wrap(err, "Could not build encrypted artifact")
}

// TODO(ENG-1641): once the platform supports the genesis/timeless publish
Expand All @@ -65,7 +73,11 @@ func (r *Runner) generateEncryptedArtifact(params *Params) (cleanup func(), rerr
if params.Version == "" {
params.Version = meta.Version
}
return cleanup, nil
// The platform rejects description changes on existing ingredients.
if params.Description == "" && !params.Edit {
params.Description = meta.Summary
}
return meta, cleanup, nil
}

// requireOrgNamespace ensures ns belongs to the project owner's private org, so
Expand All @@ -85,6 +97,25 @@ const (
privateBuilderName = "private-builder"
)

// addPrivateBuilderDependency makes a --build publish depend on the
// private-builder. Without a builder dependency the platform falls back to the
// noop-builder, which produces an empty artifact. An --edit revision already
// inherits it from the previous revision, so it is only added when missing.
func addPrivateBuilderDependency(reqVars *request.PublishVariables) {
for _, dep := range reqVars.Dependencies {
if dep.Namespace == privateBuilderNamespace && dep.Name == privateBuilderName {
return
}
}
reqVars.Dependencies = append(reqVars.Dependencies, request.PublishVariableDep{
Dependency: request.Dependency{
Name: privateBuilderName,
Namespace: privateBuilderNamespace,
VersionRequirements: ">=0",
},
})
}

// buildWrappedArtifact packs srcDir into a wheel under the given metadata,
// encrypts it under the org key, and wraps the ciphertext in a tar.gz ready for
// upload. It returns the wrapped archive path and a cleanup function the caller
Expand Down
20 changes: 20 additions & 0 deletions internal/runners/publish/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

"github.com/ActiveState/cli/internal/artifactcrypto"
"github.com/ActiveState/cli/internal/python/wheel"
"github.com/ActiveState/cli/pkg/platform/api/graphql/request"
)

func testKey() []byte {
Expand Down Expand Up @@ -173,3 +174,22 @@ func equalStrings(a, b []string) bool {
}
return true
}

func TestAddPrivateBuilderDependency(t *testing.T) {
userDep := request.PublishVariableDep{Dependency: request.Dependency{Name: "requests", Namespace: "language/python"}}
reqVars := request.PublishVariables{Dependencies: []request.PublishVariableDep{userDep}}

addPrivateBuilderDependency(&reqVars)
if len(reqVars.Dependencies) != 2 {
t.Fatalf("expected user dependency plus private-builder, got %+v", reqVars.Dependencies)
}
if reqVars.Dependencies[0].Dependency != userDep.Dependency {
t.Errorf("user dependency was not preserved: %+v", reqVars.Dependencies[0])
}

// An --edit revision inherits the private-builder; it must not be added twice.
addPrivateBuilderDependency(&reqVars)
if len(reqVars.Dependencies) != 2 {
t.Errorf("private-builder was added twice: %+v", reqVars.Dependencies)
}
}
Loading
Loading