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:
@@ -0,0 +1,101 @@
|
||||
# AGENTS.md — internal/config
|
||||
|
||||
**Two files: what this project is, and who this machine is.** The only package in
|
||||
the tree that imports yaml.
|
||||
|
||||
| file | what is in it |
|
||||
|---|---|
|
||||
| `config.go` | `Project` and `Logins` (the two files), `Resolve`/`ResolveOutsideAProject`/`Require`, `Resolved` with `Complete` and `Redacted`, the `KETTLE_*` overrides, and the 0600 write |
|
||||
|
||||
## The split is the whole design
|
||||
|
||||
```
|
||||
<project>/.kettle/config.yaml login: noodles a NAME, never a token
|
||||
repo: owner/name
|
||||
|
||||
~/.config/kettle/logins.yaml logins: [{name, url, user, token}] mode 0600
|
||||
```
|
||||
|
||||
**A token in a file inside a working tree ends up in a commit.** Not always, not
|
||||
immediately, and not by anyone careless — but a project config is exactly the file
|
||||
somebody eventually decides to share, and a secret that has ever been pushed has to
|
||||
be rotated. So the project pins a login by **name**, and the name is worth nothing
|
||||
on its own, which is what makes it safe to keep in a repository.
|
||||
|
||||
Which tokens this computer holds is a fact about the computer, the way which issues
|
||||
a tree holds is a fact about the tree. `SaveLogins` writes 0600 into a 0700
|
||||
directory; nothing else on the machine has any business reading it. `$KETTLE_CONFIG_HOME`
|
||||
relocates it — the test suite sets it, so a run can neither read nor overwrite the
|
||||
developer's own tokens — and `$XDG_CONFIG_HOME` is honoured too.
|
||||
|
||||
**Nothing prints a token.** `Redacted` is what a receipt gets; `kettle config` shows
|
||||
`(set)`.
|
||||
|
||||
## Resolution, and why it fails early
|
||||
|
||||
`Resolve` merges three sources — the project config, the machine's login file, and
|
||||
the environment — into `Resolved`, which is everything the transport needs.
|
||||
|
||||
**Every failure names the file it read and the command that fixes it.**
|
||||
"401 Unauthorized" is what happens when this function is allowed to return a
|
||||
half-filled struct, and a 401 names nothing an operator can act on.
|
||||
|
||||
The same discipline splits the two "missing" answers: a missing `config.yaml` is
|
||||
`ErrNoConfig`, not an empty config, because "this project has not been told which
|
||||
tracker it belongs to" and "it belongs to no tracker" are different answers and only
|
||||
one is fixed by running `init`. A missing login file, by contrast, **is** an empty
|
||||
list — a machine with no logins yet is an ordinary machine.
|
||||
|
||||
`Complete` is that assertion on its own, as a method, because the two questions are
|
||||
different: `kettle config` wants to **show** a half-filled configuration and
|
||||
everything that dials wants to **refuse** one. `Require` is `Resolve` plus
|
||||
`Complete`; [`gitea.New`](../gitea/AGENTS.md) and `cmd/release` call `Complete`
|
||||
themselves, so a client can never be built from a struct that is missing a field.
|
||||
|
||||
`ResolveOutsideAProject` is for the one caller that legitimately stands nowhere near
|
||||
a project: [`cmd/release`](../../cmd/release/AGENTS.md), run from a fresh clone. The
|
||||
marker is gitignored, so a clone has none and a build tool must not create one — and
|
||||
with no marker there is nothing to merge, so the **environment is** the
|
||||
configuration. A marker that is there is read as always, so the same command run
|
||||
from a maintainer's own checkout picks up the login pinned in it. Every other caller
|
||||
wants `Resolve`, where "no project" is the answer rather than 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.
|
||||
|
||||
`ReadProjectFile` exists for exactly one caller: `kettle init`, which is creating
|
||||
the marker `LoadProject` walks for, and on a dry run may not have created it at all.
|
||||
|
||||
## Overrides
|
||||
|
||||
| variable | shadows |
|
||||
|---|---|
|
||||
| `KETTLE_LOGIN` | `login:` in the project config |
|
||||
| `KETTLE_REPO` | `repo:` in the project config |
|
||||
| `KETTLE_URL` | the login's `url` |
|
||||
| `KETTLE_TOKEN` | the login's `token` |
|
||||
| `KETTLE_CONFIG_HOME` | the directory holding `logins.yaml` |
|
||||
|
||||
Each wins over the file it shadows. They exist for CI, for a one-off run against
|
||||
another instance, and for anyone who would rather not have a token on disk at all.
|
||||
|
||||
## Unknown keys are an error
|
||||
|
||||
Not a silent drop. An older binary reading a newer config would otherwise delete the
|
||||
setting it did not recognize the next time it wrote the file — which is a data-loss
|
||||
bug that only shows up on the machine running the older build.
|
||||
|
||||
## What does not belong here
|
||||
|
||||
A request, a store path, an issue. This package reads and writes two files and
|
||||
answers "who am I and where am I pointed"; [`gitea`](../gitea/AGENTS.md) takes the
|
||||
answer and dials, and the paths themselves come from
|
||||
[`project`](../project/AGENTS.md).
|
||||
|
||||
## Keeping this file true
|
||||
|
||||
- **Scope:** `config.go` — the two files, their fields, the overrides, the file modes.
|
||||
- **Update it when** a field is added to either file (both tables above are the
|
||||
contract), an override is added or renamed, the location or mode of the login file
|
||||
changes, or the unknown-key policy changes.
|
||||
- **Do not** move a credential into the project file, and if that ever changes, the
|
||||
argument above is what has to be answered first.
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user