Compare commits
2
Commits
v0.10.3
...
v0.10.4-rc.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dac1854c2 | ||
|
|
ad6d2d348d |
No files matched your search
@@ -20,6 +20,28 @@ is only released once its **exit criterion** is met, not when its code lands.
|
||||
Until v1.0.0 the minor version is the breaking one: configuration formats and Go
|
||||
APIs change between minors without a deprecation period.
|
||||
|
||||
## Unreleased
|
||||
|
||||
Changes since v0.10.3, published as release candidates (v0.10.4-rc.N) until
|
||||
there are enough for v0.10.4.
|
||||
|
||||
### Restarting replicas
|
||||
|
||||
`llmgw ctl restart REPLICA... | --model M | --all` replaces replicas with new
|
||||
ones. Each replacement is started beside the replica it replaces, which keeps
|
||||
serving until the new one is ready; replicas of one model go one at a time.
|
||||
Where no second copy fits anywhere, the old replica is stopped after the drain
|
||||
grace period and the new one starts in its place. This is how running engines
|
||||
pick up the keys introduced in v0.10.3, an edited profile, or a new engine
|
||||
version.
|
||||
|
||||
### Releases
|
||||
|
||||
Small fixes are published as release candidates of the next version —
|
||||
v0.10.4-rc.1, rc.2 — which sort after v0.10.3 and before v0.10.4. Their release
|
||||
notes come from this section, and they are marked as pre-releases. Onboarding
|
||||
treats a release candidate as a release and downloads it.
|
||||
|
||||
## v0.10.3 — 2026-09-30
|
||||
|
||||
Security fixes from a review of v0.10.2. Three of them were confirmed on the
|
||||
|
||||
@@ -143,7 +143,7 @@ $SSH "set -e
|
||||
# that is not a release (a developer's, with commits past the tag) has no such
|
||||
# file, and is copied as before.
|
||||
TAG="$("$BINARY" --version 2>/dev/null | awk '{print $NF}')"
|
||||
if printf '%s' "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
if printf '%s' "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$'; then
|
||||
say "installing llmgw $TAG from the release"
|
||||
$SSH "set -e
|
||||
arch=\$(dpkg --print-architecture)
|
||||
|
||||
@@ -135,6 +135,7 @@ func newCtlCmd() *cobra.Command {
|
||||
newStatusCmd(client, &asJSON),
|
||||
newModelCmd(client),
|
||||
newScaleCmd(client),
|
||||
newRestartCmd(client),
|
||||
newEngineCheckCmd(),
|
||||
newToolCheckCmd(),
|
||||
newWeightsCmd(client, &asJSON),
|
||||
@@ -370,6 +371,9 @@ func newReplicasCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
if r.Stale {
|
||||
state += " (stale profile)"
|
||||
}
|
||||
if r.Restart != "" {
|
||||
state += " (restart " + r.Restart + ")"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
r.ID, r.Model, state, r.Pool, join(nodes),
|
||||
(time.Duration(r.AgeSec) * time.Second).String(),
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
)
|
||||
|
||||
func newRestartCmd(client func() *ctlClient) *cobra.Command {
|
||||
var (
|
||||
models []string
|
||||
all bool
|
||||
)
|
||||
c := &cobra.Command{
|
||||
Use: "restart [REPLICA...]",
|
||||
Short: "Replace replicas with new ones, starting each replacement before stopping the old",
|
||||
Long: "How a running engine picks up what only applies at start: an engine key,\n" +
|
||||
"an edited profile, a new engine version.\n\n" +
|
||||
"Each replacement is placed beside the replica it replaces, and the old one\n" +
|
||||
"keeps serving until the new one is ready. Replicas of one model are\n" +
|
||||
"replaced one at a time. Where nothing else has room for a second copy —\n" +
|
||||
"the usual case on a small CPU fleet — the old one is stopped after the\n" +
|
||||
"drain grace period (5 minutes by default) and the new one starts in its\n" +
|
||||
"place; a model with only that replica is unserved while it loads.\n\n" +
|
||||
" llmgw ctl restart qwen2.5-7b-3f2a91c0\n" +
|
||||
" llmgw ctl restart --model qwen2.5-7b\n" +
|
||||
" llmgw ctl restart --all",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 && len(models) == 0 && !all {
|
||||
return errors.New("name replicas, --model, or --all")
|
||||
}
|
||||
body, _ := json.Marshal(adminapi.RestartRequest{Replicas: args, Models: models, All: all})
|
||||
var res adminapi.RestartResult
|
||||
if err := client().post(cmd.Context(), "/api/v1/restart", body, &res); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%d replica(s) queued for restart; watch with: llmgw ctl replicas\n", len(res.Queued))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
c.Flags().StringArrayVar(&models, "model", nil, "every replica of this model; repeatable")
|
||||
c.Flags().BoolVar(&all, "all", false, "every ready replica in the cluster")
|
||||
return c
|
||||
}
|
||||
@@ -77,6 +77,7 @@ func (a *API) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("DELETE /api/v1/nodes/{id}", a.removeNode)
|
||||
mux.HandleFunc("POST /api/v1/fabric-test", a.fabricTest)
|
||||
mux.HandleFunc("GET /api/v1/nodes/{id}/logs", a.nodeLogs)
|
||||
mux.HandleFunc("POST /api/v1/restart", a.restart)
|
||||
}
|
||||
|
||||
// --- responses ---
|
||||
@@ -243,8 +244,11 @@ type ReplicaView struct {
|
||||
// and anyone else holding the admin listener who needs to reach an engine
|
||||
// directly. The client-facing pages never render it.
|
||||
EngineKey string `json:"engine_key,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Profile string `json:"profile"`
|
||||
// Restart is "queued" or "replacing" while an operator's restart of this
|
||||
// replica is under way.
|
||||
Restart string `json:"restart,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Profile string `json:"profile"`
|
||||
// Stale is true when the profile has been edited since this replica was
|
||||
// placed, so it is running a definition the catalog no longer holds.
|
||||
//
|
||||
@@ -476,6 +480,7 @@ func (a *API) Replicas(model string) []ReplicaView {
|
||||
State: rep.State.String(), Pool: rep.Pool, Fabric: rep.Fabric,
|
||||
Endpoint: rep.Endpoint, Err: rep.Err,
|
||||
EngineKey: a.ctl.EngineKey(rep.ID),
|
||||
Restart: a.ctl.Reconciler().RestartState(rep.ID),
|
||||
Stale: a.profileChanged(rep.Model, rep.Profile, rep.ProfileFP),
|
||||
AgeSec: int64(time.Since(rep.CreatedAt).Seconds()),
|
||||
IdleSec: -1,
|
||||
@@ -1044,3 +1049,39 @@ func (a *API) Topology(id string) (TopologyView, bool) {
|
||||
func (a *API) NVLink() nvlink.Report {
|
||||
return nvlink.Analyze(a.ctl.Inventory().Nodes())
|
||||
}
|
||||
|
||||
// RestartRequest names what to restart: replicas by id, every replica of some
|
||||
// models, or — All — every ready replica in the cluster.
|
||||
type RestartRequest struct {
|
||||
Replicas []string `json:"replicas,omitempty"`
|
||||
Models []string `json:"models,omitempty"`
|
||||
All bool `json:"all,omitempty"`
|
||||
}
|
||||
|
||||
// RestartResult lists the replicas queued.
|
||||
type RestartResult struct {
|
||||
Queued []string `json:"queued"`
|
||||
}
|
||||
|
||||
func (a *API) restart(w http.ResponseWriter, r *http.Request) {
|
||||
var req RestartRequest
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "the restart request is not valid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
ids := slices.Clone(req.Replicas)
|
||||
for _, rep := range a.ctl.Reconciler().ReadyReplicas() {
|
||||
if req.All || slices.Contains(req.Models, rep.Model) {
|
||||
ids = append(ids, rep.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "nothing ready matches; name replicas, models, or all")
|
||||
return
|
||||
}
|
||||
if err := a.ctl.Reconciler().Restart(ids...); err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, RestartResult{Queued: ids})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package reconciler
|
||||
import (
|
||||
"context"
|
||||
crypterand "crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
@@ -204,6 +205,10 @@ type Reconciler struct {
|
||||
// because it is state about this moment and not about what the model is.
|
||||
// A model nobody has wanted has no entry and is simply not running.
|
||||
wanted map[string]time.Time
|
||||
// restartQueue holds, per model, the replicas an operator asked to be
|
||||
// restarted and not yet begun. One is replaced at a time: replacing them
|
||||
// all at once would need the memory of every replica twice over.
|
||||
restartQueue map[string][]string
|
||||
|
||||
wake chan struct{}
|
||||
stop chan struct{}
|
||||
@@ -232,6 +237,7 @@ func New(cfg Config, cat *catalog.Live, inv *inventory.Store, disp Dispatcher, l
|
||||
attempts: map[string]int{},
|
||||
reservedPorts: map[int]struct{}{},
|
||||
wanted: map[string]time.Time{},
|
||||
restartQueue: map[string][]string{},
|
||||
lastExplain: map[string]placement.Result{},
|
||||
wake: make(chan struct{}, 1),
|
||||
stop: make(chan struct{}),
|
||||
@@ -708,19 +714,37 @@ func (r *Reconciler) reconcileModel(ctx context.Context, m catalog.Model) {
|
||||
// pointer stops being safe the moment it is in the map: an agent's status
|
||||
// report can mutate it from another goroutine at any time.
|
||||
r.mu.Lock()
|
||||
var liveIDs, drainingIDs, avoid []string
|
||||
var liveIDs, drainingIDs, retiringIDs, avoid []string
|
||||
readyLive := 0
|
||||
for _, rep := range r.replicas {
|
||||
if rep.Model != m.Name {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case rep.State.Counts() && !rep.RetiringAt.IsZero():
|
||||
retiringIDs = append(retiringIDs, rep.ID)
|
||||
avoid = append(avoid, rep.NodeIDs()...)
|
||||
case rep.State.Counts():
|
||||
liveIDs = append(liveIDs, rep.ID)
|
||||
avoid = append(avoid, rep.NodeIDs()...)
|
||||
if rep.State == Ready {
|
||||
readyLive++
|
||||
}
|
||||
case rep.State == Draining:
|
||||
drainingIDs = append(drainingIDs, rep.ID)
|
||||
}
|
||||
}
|
||||
// The next restart an operator queued, once the last one is done.
|
||||
if len(retiringIDs) == 0 {
|
||||
if id, ok := r.nextRestartLocked(m.Name); ok {
|
||||
r.replicas[id].RetiringAt = r.now()
|
||||
retiringIDs = append(retiringIDs, id)
|
||||
liveIDs = slices.DeleteFunc(liveIDs, func(x string) bool { return x == id })
|
||||
readyLive--
|
||||
r.log.Info("restarting a replica: placing its replacement first",
|
||||
"replica", id, "model", m.Name)
|
||||
}
|
||||
}
|
||||
blockedUntil := r.nextAttempt[m.Name]
|
||||
r.mu.Unlock()
|
||||
|
||||
@@ -763,6 +787,112 @@ func (r *Reconciler) reconcileModel(ctx context.Context, m catalog.Model) {
|
||||
r.stopReplica(ctx, id)
|
||||
}
|
||||
}
|
||||
|
||||
r.retire(ctx, retiringIDs, readyLive, len(liveIDs), desired)
|
||||
}
|
||||
|
||||
// retire finishes restarting replicas.
|
||||
//
|
||||
// A retiring replica keeps serving until its replacement is ready, and is then
|
||||
// drained like any other. When no replacement could be placed at all —
|
||||
// nowhere else has the memory, which on a small CPU fleet is the usual case —
|
||||
// it is stopped after DrainGrace so its replacement can take its place: a
|
||||
// restart in place, with the model unserved while it loads if it had no other
|
||||
// replica. Said in the log, since that is the one case with an outage.
|
||||
func (r *Reconciler) retire(ctx context.Context, ids []string, readyLive, live, desired int) {
|
||||
for _, id := range ids {
|
||||
r.mu.Lock()
|
||||
rep, ok := r.replicas[id]
|
||||
var since time.Time
|
||||
if ok {
|
||||
since = rep.RetiringAt
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case readyLive >= desired:
|
||||
r.drainForRestart(ctx, id)
|
||||
case live < desired && r.now().Sub(since) > r.cfg.DrainGrace:
|
||||
r.log.Warn("restarting a replica in place: no room for its replacement beside it",
|
||||
"replica", id, "after", r.cfg.DrainGrace)
|
||||
r.drainForRestart(ctx, id)
|
||||
r.stopReplica(ctx, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Reconciler) drainForRestart(ctx context.Context, id string) {
|
||||
r.mu.Lock()
|
||||
rep, ok := r.replicas[id]
|
||||
var shards []Shard
|
||||
if ok {
|
||||
rep.State, rep.DrainingAt, rep.RetiringAt = Draining, r.now(), time.Time{}
|
||||
shards = slices.Clone(rep.Shards)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
for _, s := range shards {
|
||||
if err := r.disp.DrainShard(ctx, s.NodeID, s.ID); err != nil {
|
||||
r.log.Warn("draining a shard failed", "shard", s.ID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nextRestartLocked pops the next queued replica of a model that is still
|
||||
// ready to be restarted. Called with mu held.
|
||||
func (r *Reconciler) nextRestartLocked(model string) (string, bool) {
|
||||
q := r.restartQueue[model]
|
||||
for len(q) > 0 {
|
||||
id := q[0]
|
||||
q = q[1:]
|
||||
if rep, ok := r.replicas[id]; ok && rep.State == Ready && rep.RetiringAt.IsZero() {
|
||||
r.restartQueue[model] = q
|
||||
return id, true
|
||||
}
|
||||
}
|
||||
delete(r.restartQueue, model)
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ErrNoSuchReplica means no ready replica has that id.
|
||||
var ErrNoSuchReplica = errors.New("no ready replica with that id")
|
||||
|
||||
// Restart queues replicas to be replaced, each by a new one started beside it
|
||||
// before it is stopped — to pick up an edited profile, an engine key, a new
|
||||
// engine version. Replicas of one model are replaced one at a time.
|
||||
func (r *Reconciler) Restart(ids ...string) error {
|
||||
r.mu.Lock()
|
||||
for _, id := range ids {
|
||||
rep, ok := r.replicas[id]
|
||||
if !ok || rep.State != Ready {
|
||||
r.mu.Unlock()
|
||||
return fmt.Errorf("%w: %s", ErrNoSuchReplica, id)
|
||||
}
|
||||
if !slices.Contains(r.restartQueue[rep.Model], id) && rep.RetiringAt.IsZero() {
|
||||
r.restartQueue[rep.Model] = append(r.restartQueue[rep.Model], id)
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
r.Notify()
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestartState says where a replica's restart stands: "queued", "replacing",
|
||||
// or "" when none was asked for.
|
||||
func (r *Reconciler) RestartState(id string) string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
rep, ok := r.replicas[id]
|
||||
switch {
|
||||
case !ok:
|
||||
return ""
|
||||
case !rep.RetiringAt.IsZero():
|
||||
return "replacing"
|
||||
case slices.Contains(r.restartQueue[rep.Model], id):
|
||||
return "queued"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// scaleUp places and starts one replica per pass.
|
||||
|
||||
@@ -1741,3 +1741,98 @@ func TestAnEngineIsStartedWithItsReplicasKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func replicaByID(h *harness, id string) (Replica, bool) {
|
||||
for _, rep := range h.r.Replicas() {
|
||||
if rep.ID == id {
|
||||
return rep, true
|
||||
}
|
||||
}
|
||||
return Replica{}, false
|
||||
}
|
||||
|
||||
func TestARestartedReplicaServesUntilItsReplacementIsReady(t *testing.T) {
|
||||
// Restarting is how a running engine picks up a key, an edited profile or
|
||||
// a new engine version. Stopping first would leave a one-replica model
|
||||
// unserved for as long as its replacement takes to load.
|
||||
h := newHarness(t, testModel(1), gpuNode("1", 4), gpuNode("2", 4))
|
||||
h.r.Reconcile(t.Context())
|
||||
h.markReady()
|
||||
old := h.r.ReadyReplicas()[0].ID
|
||||
|
||||
if err := h.r.Restart(old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.r.Reconcile(t.Context())
|
||||
if rep, _ := replicaByID(h, old); rep.State != Ready || h.r.RestartState(old) != "replacing" {
|
||||
t.Fatalf("the old replica stopped serving before its replacement was up: %+v", rep)
|
||||
}
|
||||
if n := len(h.r.Replicas()); n != 2 {
|
||||
t.Fatalf("no replacement was placed beside it: %d replicas", n)
|
||||
}
|
||||
|
||||
h.markReady()
|
||||
h.r.Reconcile(t.Context())
|
||||
h.r.Reconcile(t.Context())
|
||||
if _, still := replicaByID(h, old); still {
|
||||
if rep, _ := replicaByID(h, old); rep.State != Draining && rep.State != Stopped {
|
||||
t.Errorf("the old replica is %s after its replacement became ready", rep.State)
|
||||
}
|
||||
}
|
||||
if got := len(h.r.ReadyReplicas()); got != 1 {
|
||||
t.Errorf("%d ready replicas at the end; the model asks for 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARestartWithNoRoomBesideItHappensInPlace(t *testing.T) {
|
||||
// One node, room for one replica: the replacement can only start once the
|
||||
// old one has gone. Waiting forever would mean never restarting at all.
|
||||
h := newHarness(t, testModel(1), gpuNode("1", 4))
|
||||
h.r.Reconcile(t.Context())
|
||||
h.markReady()
|
||||
old := h.r.ReadyReplicas()[0].ID
|
||||
if err := h.r.Restart(old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.r.Reconcile(t.Context())
|
||||
if rep, _ := replicaByID(h, old); rep.State != Ready {
|
||||
t.Fatalf("stopped before the grace period: %s", rep.State)
|
||||
}
|
||||
|
||||
h.advance(h.r.cfg.DrainGrace + time.Second)
|
||||
h.r.Reconcile(t.Context())
|
||||
if rep, ok := replicaByID(h, old); ok && rep.State == Ready {
|
||||
t.Fatal("still serving after the grace period with no replacement possible")
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
h.r.Reconcile(t.Context())
|
||||
h.markReady()
|
||||
}
|
||||
ready := h.r.ReadyReplicas()
|
||||
if len(ready) != 1 || ready[0].ID == old {
|
||||
t.Errorf("expected one new replica in its place, got %+v", ready)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplicasOfAModelAreRestartedOneAtATime(t *testing.T) {
|
||||
h := newHarness(t, testModel(2), gpuNode("1", 8), gpuNode("2", 8), gpuNode("3", 8))
|
||||
for i := 0; i < 3; i++ {
|
||||
h.r.Reconcile(t.Context())
|
||||
h.markReady()
|
||||
}
|
||||
var ids []string
|
||||
for _, rep := range h.r.ReadyReplicas() {
|
||||
ids = append(ids, rep.ID)
|
||||
}
|
||||
if err := h.r.Restart(ids...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.r.Reconcile(t.Context())
|
||||
states := []string{h.r.RestartState(ids[0]), h.r.RestartState(ids[1])}
|
||||
if !slices.Contains(states, "replacing") || !slices.Contains(states, "queued") {
|
||||
t.Errorf("expected one replacing and one queued, got %v", states)
|
||||
}
|
||||
if err := h.r.Restart("no-such"); err == nil {
|
||||
t.Error("an unknown replica was accepted")
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,10 @@ type Replica struct {
|
||||
// DrainingAt is when the replica stopped taking new work, so the wait for a
|
||||
// replacement can be bounded.
|
||||
DrainingAt time.Time
|
||||
// RetiringAt is set while the replica is being restarted: it keeps serving
|
||||
// but no longer counts toward the model's replicas, so a replacement is
|
||||
// placed beside it, and it is drained only once that replacement is ready.
|
||||
RetiringAt time.Time
|
||||
// Attempt counts how many times this model has failed to come up in a row,
|
||||
// carried across rebuilds so the backoff does not reset on every attempt.
|
||||
Attempt int
|
||||
|
||||
@@ -332,7 +332,10 @@ func certNames(path string) ([]string, error) {
|
||||
|
||||
// --- binary ---
|
||||
|
||||
var releaseTag = regexp.MustCompile(`^v\d+\.\d+\.\d+$`)
|
||||
// releaseTag is a version CI published: v0.10.4, or a release candidate of
|
||||
// it, v0.10.4-rc.2. Anything else — a build with commits past its tag — has no
|
||||
// release to download.
|
||||
var releaseTag = regexp.MustCompile(`^v\d+\.\d+\.\d+(-rc\.\d+)?$`)
|
||||
|
||||
func (r *Runner) binary(ctx context.Context, res *Result) error {
|
||||
v := r.Opts.Version
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package onboard
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAReleaseCandidateIsARelease(t *testing.T) {
|
||||
for v, want := range map[string]bool{
|
||||
"v0.10.4": true, "v0.10.4-rc.1": true, "v0.10.4-rc.12": true,
|
||||
"v0.10.3-5-gad6d2d3": false, "v0.10.4-rc": false, "v0.10.3-a": false, "dev": false,
|
||||
} {
|
||||
if got := releaseTag.MatchString(v); got != want {
|
||||
t.Errorf("%s: %v", v, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,11 @@ it, it does not replace it.
|
||||
Idempotent: a release whose body already matches is left alone, a version with
|
||||
no release is skipped, and nothing is created or deleted.
|
||||
|
||||
A release candidate — v0.10.4-rc.2 — takes the "## Unreleased" section, which
|
||||
is where changes gather until the version they belong to is released, and is
|
||||
marked as a pre-release. The final v0.10.4 takes its own section once
|
||||
"Unreleased" has been renamed to it.
|
||||
|
||||
Usage:
|
||||
GITEA_TOKEN=<token> scripts/gitea/release_notes.py [--tag v0.8.1] [--dry-run]
|
||||
|
||||
@@ -32,7 +37,8 @@ DEFAULT_REPO = "ilker/llmgw"
|
||||
|
||||
# "## v0.8.1 — 2026-09-28". The dash is whatever was typed; the version is what
|
||||
# matters.
|
||||
HEADING = re.compile(r"^## (v\d+\.\d+\.\d+)\b.*$", re.M)
|
||||
HEADING = re.compile(r"^## (v\d+\.\d+\.\d+|Unreleased)\b.*$", re.M)
|
||||
CANDIDATE = re.compile(r"^v\d+\.\d+\.\d+-rc\.\d+$")
|
||||
|
||||
|
||||
def sections(changelog):
|
||||
@@ -77,11 +83,16 @@ def main():
|
||||
with open(args.changelog, encoding="utf-8") as f:
|
||||
notes = sections(f.read())
|
||||
if args.tag:
|
||||
if args.tag not in notes:
|
||||
section = "Unreleased" if CANDIDATE.match(args.tag) else args.tag
|
||||
if section not in notes:
|
||||
# A tag with no section is a release somebody forgot to describe.
|
||||
# Failing says so where it is seen; an empty page says nothing.
|
||||
sys.exit(f"{args.changelog} has no section for {args.tag}")
|
||||
notes = {args.tag: notes[args.tag]}
|
||||
sys.exit(f"{args.changelog} has no section for {args.tag} (looked for {section!r})")
|
||||
notes = {args.tag: notes[section]}
|
||||
else:
|
||||
# Syncing everything: "Unreleased" belongs to whichever candidate is
|
||||
# newest, which only a tag says.
|
||||
notes.pop("Unreleased", None)
|
||||
|
||||
base = f"{args.instance.rstrip('/')}/api/v1/repos/{args.repo}/releases"
|
||||
for tag, body in notes.items():
|
||||
@@ -92,11 +103,12 @@ def main():
|
||||
if rel is None:
|
||||
print(f"skip {tag} (no release)")
|
||||
continue
|
||||
if (rel.get("body") or "").strip() == body.strip():
|
||||
pre = bool(CANDIDATE.match(tag))
|
||||
if (rel.get("body") or "").strip() == body.strip() and bool(rel.get("prerelease")) == pre:
|
||||
print(f"same {tag}")
|
||||
continue
|
||||
request("PATCH", f"{base}/{rel['id']}", token, {"body": body})
|
||||
print(f"set {tag}")
|
||||
request("PATCH", f"{base}/{rel['id']}", token, {"body": body, "prerelease": pre})
|
||||
print(f"set {tag}" + (" (pre-release)" if pre else ""))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in new issue
Block a user