feat: publish releases with this repository's own SDK code

There is no CI: the instance has no act_runner and none is planned, so releases
are cut by hand. That makes `make check` the only thing standing between a
mistake and the tracker, and it is one command: gofmt, vet, the suite with the
cache defeated, `go mod verify`, a vendored build, and `kettle gen skills
--check`. The last one is the invariant worth having — the plugin's SKILL.md
command reference is generated from the binary's registry, so a flag that
changed cannot ship with documentation that recommends the old one.

`cli/cmd/release` publishes to Gitea using the same SDK the binary already
vendors, which is a pleasing thing to be able to say: nothing third-party
handles the artifacts. It is a second binary rather than a `kettle` subcommand
on purpose — `kettle`'s command tree is what generates the plugin's skills, so a
verb there ships to every operator, and publishing a release is build
infrastructure. It is idempotent end to end: an existing release for the tag is
reused, an asset of the same name is replaced rather than doubled, and a retried
run converges instead of duplicating.

`make release` refuses three things, each with its own message: a dirty working
tree, a TAG that is not what `git describe` reports, and a tag the remote does
not have. A release built from uncommitted code is unreproducible and nobody
finds out until they need to reproduce it.

`kettle version` reports the stamp, the toolchain and the VCS revision. The
default is `dev`, and a hand build says so and means it — a binary out of
somebody's working tree is not a release and must not claim to be one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
naudachu
2026-08-12 01:01:27 +05:00
parent ec0a1893b7
commit 01fb5a2703
27 changed files with 3385 additions and 266 deletions
+44 -2
View File
@@ -243,7 +243,34 @@ func Resolve(start string) (*Resolved, error) {
} else if !errors.Is(err, ErrNoConfig) {
return nil, err
}
return merge(p)
}
// ResolveOutsideAProject is Resolve for a caller that legitimately has no
// project to stand in.
//
// `cmd/release` is the one, and it is not an exception being carved out: the
// marker is gitignored, so a fresh clone has none, and a tool that publishes a
// tag must not create one on its way past. With no marker there is nothing to
// merge and the ENVIRONMENT IS the configuration — KETTLE_URL, KETTLE_TOKEN and
// KETTLE_REPO, which is exactly what somebody exports before cutting a release.
//
// A marker that IS there is read as always, overrides and all, so the same
// command run from a maintainer's own checkout picks up the login pinned in it
// and needs no token in the shell.
//
// Every other caller wants Resolve: for `kettle`, "no project" is the answer,
// not a state to work around. A push that quietly ran against whatever was in
// the environment would be a push into somebody else's repository.
func ResolveOutsideAProject(start string) (*Resolved, error) {
if ProjectPath(start) == "" {
return merge(Project{})
}
return Resolve(start)
}
// merge applies the login file and the environment to a project's settings.
func merge(p Project) (*Resolved, error) {
out := &Resolved{Login: p.Login}
if v := os.Getenv(EnvLogin); v != "" {
out.Login = v
@@ -295,6 +322,21 @@ func Require(start string) (*Resolved, error) {
if err != nil {
return nil, err
}
if err := r.Complete(); err != nil {
return nil, err
}
return r, nil
}
// Complete reports what a resolved configuration is still missing, naming the
// one command or the one variable that supplies each.
//
// A half-filled struct allowed through is a 401 three calls later, and "401
// Unauthorized" names nothing an operator can act on. It is a method rather
// than part of Resolve because the two questions are different: `kettle config`
// wants to SHOW a half-filled configuration, and everything that dials wants to
// refuse one.
func (r *Resolved) Complete() error {
var missing []string
if r.URL == "" {
missing = append(missing, "a URL (pin a login with `kettle init --login`, or set "+EnvURL+")")
@@ -306,9 +348,9 @@ func Require(start string) (*Resolved, error) {
missing = append(missing, "a repository (`kettle init --repo owner/name`, or set "+EnvRepo+")")
}
if len(missing) > 0 {
return nil, fmt.Errorf("this project has no %s", strings.Join(missing, ", and no "))
return fmt.Errorf("this project has no %s", strings.Join(missing, ", and no "))
}
return r, nil
return nil
}
// strictUnmarshal refuses keys the struct does not know.