Skip to content
Closed
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
140 changes: 140 additions & 0 deletions .claude/skills/release-state-tool/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
---
name: release-state-tool
description: Cut a State Tool beta (RC) or stable release from this repo — refresh the version/X-Y-Z-RCn branch, write the changelog, open the PR into beta (or beta into release), and walk the post-merge staging → Windows signing → S3 promotion → verification steps. Use when asked to "cut a beta", "make a release", "ship an RC", "release the state tool", or "promote beta to release".
---

# Releasing the State Tool

Releases move along a branch ladder. Each rung is a PR; merging it is what deploys.

```
beta ─▶ version/X-Y-Z-RCn ─PR▶ beta ─PR▶ release
master ─cherry-pick─▶ (version.txt = X.Y.Z-RCn) (beta channel) (stable channel)
```

- **A release is cut from `beta`, not from `master`.** `master` and `beta` have diverged:
`master` carries work that was never released (much of it CI and test churn, some of it
behaviour changes), plus merge-backs of every RC branch. So **never merge `master` into a
version branch.** That pulls in all of that unreleased work, and the PR shows close to a
hundred commits. Pick the specific changes this release ships from `master`. Each feature PR
lands on `master` as a single squash commit, so `git cherry-pick <sha>` works.
- `version.txt` holds the version being built. `master` carries the *next* RC
(e.g. `0.48.2-RC1` once 0.48.1 has shipped).
- Branch names use dashes: version `0.48.2-RC1` → branch `version/0-48-2-RC1`.
- Each RC that ships to beta bumps `n`. A failed attempt that never reached beta keeps its number.

## Before starting: read the state

```bash
git fetch origin
for b in master beta release; do echo "== $b: $(git show origin/$b:version.txt)"; git log -1 --format='%h %ad %s' --date=short origin/$b; done
git branch -r | grep 'version/' | sort -V | tail -5
gh pr list --base beta --state open # stale RC PRs must be closed, not merged by accident
# candidates: master commits since the last release. Hashes differ between the branches,
# so match on subject; anything already in changelog.md's last section has shipped.
git log --oneline --no-merges --since="<date beta was last cut>" origin/master
```

Confirm two things with the user before you push anything: the version being cut
(`X.Y.Z-RCn`) and **the list of master commits it ships**.

## 1. Prepare the release branch

Work in a separate worktree, not someone's checkout. Start from `beta`. If a
`version/X-Y-Z-RCn` branch already exists, check that it is `beta` plus a `version.txt` bump
(`git log --oneline origin/beta..origin/version/X-Y-Z-RCn`):

```bash
git worktree add ~/work/cli-X-Y-Z -b <you>/release-X-Y-Z origin/beta
cd ~/work/cli-X-Y-Z
git cherry-pick <sha> <sha> ... # the agreed master commits, oldest first
git diff --stat origin/beta HEAD # must show only those changes, the changelog and version.txt
```

- `version.txt` must read exactly `X.Y.Z-RCn` (no trailing text). Fix it in its own commit
("Update version.txt") if not.
- **Changelog:** add a `## X.Y.Z` section above the previous release in `changelog.md`,
in Keep-a-Changelog form (`### Added / Changed / Fixed / Security`). Write it from
the commit bodies of `git log --no-merges origin/beta..HEAD`. Describe the user-visible
behaviour, not the internals, and leave out test and CI-only commits. One section per
X.Y.Z. Later RCs of the same version edit that section rather than adding a new one.
- `version/*` branches are protected, and a direct push is rejected with "protected branch hook
declined". Push the personal branch and open the PR from it.

## 2. PR into beta

```bash
gh pr create --base beta --head <you>/release-X-Y-Z --title "X.Y.Z-RCn" --body "<changelog section>"
```

Any PR targeting `beta` runs the full integration suite on all five OS runners
(`.github/workflows/build.yml`). The PR-time branch-channel deploy only happens when the head
branch is named `version/*`, so a personal head branch skips it. That deploy isn't needed for
the release, because the push to `beta` after merge is what stages it. Red integration tests
block the release. Look at the failure before re-running; some suites are known to be flaky on
macOS and Windows. Land fixes on `master` first and cherry-pick them, so the two branches don't
drift further apart.

Merging is the release decision. **Do not merge without the user's go-ahead.**

## 3. After merge: staging

The push to `beta` runs the Deploy job with `DEPLOY_TO_STAGING=true`, which uploads
updates and installers to `s3://state-tool/staging/update/state/…` and the remote installer to
`staging/remote-installer`. Users see nothing yet. Check that the run on `beta` is green:

```bash
gh run list --branch beta --limit 3
```

## 4. Sign Windows binaries

This is a manual step, and how to do it is not recorded in this repo. Ask the release owner
(historically mitchell-as) and add the procedure here once it is known.

## 5. Promote staging to production

Run this from a checkout of the merged commit, with AWS credentials for the `state-tool` bucket:

```bash
state run promote-staging-to-production # interactive y/N prompt
```

`scripts/ci/s3-promoter` **moves** staged objects to production, and how much it moves depends
on the branch you run it from. Read the `promote-staging-to-production` line in
`activestate.yaml` first:

- `... s3-promoter/main.go us-east-1 state-tool` (`beta`/`release` as of 0.48.x) moves
**everything** under `staging/`. That includes the remote installer, and any other channel that
was staged and not yet promoted.
- `... state-tool update/state` (master's fix 589d443b4, not yet released) moves only
`staging/update/state/`. `staging/remote-installer` then has to be promoted separately.

Either way, list what is staged before running it:

```bash
aws s3 ls --recursive s3://state-tool/staging/ | head -50
```

## 6. Verify

```bash
sh <(curl -q https://platform.activestate.com/dl/cli/install.sh) -b beta # or ./installers/install.sh -b beta
~/.local/ActiveState/StateTool/beta/bin/state --version # expect X.Y.Z-RCn
```

An existing install can switch with `state update --set-channel beta`.

## 7. Merge back to master

Merge `beta` into `master` once the release has merged, as was done for each earlier RC
(`Merge branch 'version/...'`). This keeps the changelog and RC-only fixes, and keeps `beta`'s
history an ancestor of `master`. The merge goes one way only: `beta` into `master`, never the
reverse. Then bump `master`'s `version.txt` to the next RC
if a further RC is expected.

## Stable release (beta → release)

Once a beta has soaked, open a PR with `--base release --head beta`, titled `X.Y.Z`. Merging it
stages under `staging/`, and steps 4–6 then run the same way, with `-b release` when verifying.
Close stale `version/*` → `beta` PRs.
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
}
Loading
Loading