feat: carry the release version across an update, so the header matches the code

The repository's config.example.yaml ships the release version this code was cut
from (upstream bumps it in the release commit: v1.7.21's config.example says
v1.7.21), while the live config.yaml is operator data that no merge ever touches.
So after an update the header badge - and the ?v= on every static asset - kept
saying the version the installation was born with while the tree already ran
newer code.

An update, an adoption, or a rollback now keeps the two together: after the merge
the update reads the version the new code carries (config.example.yaml) and writes
it into the live config's version field - that one line, temp file plus atomic
rename, everything else byte-for-byte preserved, and only when the value actually
differs. The job log records the change (the page shows the step), and the state
file records both numbers so a rollback puts the old version back with the old
code; a number hand-edited after the update is the operator's and is left alone.
The side effect worth naming: the version bump also changes the ?v= query on
static assets, so a browser stops serving the pre-update JavaScript.

Trees without a live config or without a shipped version are left untouched -
a source checkout has nothing to sync, and nothing is ever guessed.

internal/config gains the two focused helpers (FileVersion / WriteVersion, line
level like the tarball upgrade path always did); tests pin the replace, the
insert-when-absent, byte preservation, the mode, the malformed-value refusal, and
the update/adopt/rollback/leave-alone behaviours end to end.
This commit is contained in:
Sycun committed 2026-10-08 13:57:11 +08:00
1 parent 94a18263b9
commit 0d931569be
11 files changed
+414 -4

No files matched your search

+2 -1
View File
@@ -273,7 +273,8 @@ If server logs show `client sent an HTTP request to an HTTPS server`, a client i
`./cyberstrike-ai -update` / `-check-update` / `-update-rollback`): it pulls the newest commit of the
**update source repository**'s default branch (the official repository by default; one setting points
it at your own fork or a mirror), fast-forwards, rebuilds and swaps the binary atomically, keeps your
`roles/skills/tools/agents/data/config.yaml` untouched (the result names every file it kept), and
`roles/skills/tools/agents/data/config.yaml` untouched (the result names every file it kept; only
`config.yaml`'s `version` field is synced to the new code, so the header badge matches it), and
refuses - rather than overwrites - when source is modified locally or the local history has diverged.
See the [deployment guide](docs/en-US/deployment.md).
+2 -1
View File
@@ -271,7 +271,8 @@ go build -o cyberstrike-ai ./cmd/server
**优先用平台自带的「一键更新」**(控制台「系统设置 → 一键更新」,或 `./cyberstrike-ai -update` /
`-check-update` / `-update-rollback`):它从**更新源仓库**(默认官方仓库,可一处配置改成自己的二开或镜像)
拉取默认分支的最新代码,快进后重编译并原子换二进制,你的 `roles/skills/tools/agents/data/config.yaml`
会被原样保留(结果里逐个点名保留了什么),本地源码有改动或本地历史与更新源分叉时它会拒绝而不是覆盖。
会被原样保留(结果里逐个点名保留了什么,只有 `config.yaml` 的 `version` 字段会随新代码同步,让页头版本号与代码对上),
本地源码有改动或本地历史与更新源分叉时它会拒绝而不是覆盖。
详见 [部署指南](docs/zh-CN/deployment.md)。
`upgrade.sh` 仍然可用:本目录是 git 工作树时它就是上面那条命令的薄壳;不是 git 工作树(tarball 安装)时
+2 -1
View File
@@ -9,7 +9,8 @@
# 系统设置
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
# 前端显示的版本号(可选,不填则显示默认版本)。一键更新完成后,会用新代码里这个文件的值
# 同步更新你的 config.yaml 的 version 字段(页头版本号与静态资源缓存随代码走)。
version: "v1.7.21"
# 服务器配置
server:
+11
View File
@@ -92,6 +92,17 @@ installation with one-click update and rollback - but note there is no git histo
connection, so the connection itself has no earlier commit to roll back to; the first real update
writes the first rollback point.
### The version number follows the code
The repository's `config.example.yaml` carries the release version this code was cut from
(upstream bumps it in the release commit), while your `config.yaml` is operator data that no
update ever touches. So after a successful merge the update writes the new code's version back
into `config.yaml`'s `version` field (that one line only, temp file plus atomic rename; the job
log records the old and the new number). After a restart the header badge and the static
assets' `?v=` both match the code again - which is also why the frontend cache invalidates
itself on upgrade. A rollback puts the number back too (unless somebody hand-edited it in the
meantime), and connecting a tarball install syncs it as well.
### The four refusals
| situation | reason | meaning and what to do |
+8
View File
@@ -90,6 +90,14 @@ update:
接入后它就是正常安装,一键更新与回滚都可用了;注意接入前没有 git 历史,因此「接入」本身没有可回滚的
上一提交,第一个回滚点由接入后的第一次更新写下。
### 版本号随代码走
仓库里的 `config.example.yaml` 载着这份代码对应的发布版本(上游随 release 提交一起改),而你的
`config.yaml` 是运维数据、任何一次更新都不会碰它。所以更新在合并完成后会把新代码的版本号写回
`config.yaml` 的 `version` 字段(只动这一行、临时文件 + 原子替换;进度里会有一行「版本号已随新代码
更新:v1.7.20 → v1.7.21」)。重启后页头的版本徽标与静态资源的 `?v=` 都跟代码对上——升级后前端缓存
也因此自动失效。回滚会把版本号一起还原(前提是这期间没人手改过它);解压安装「接入」时同样同步一次。
### 四种拒绝场景
| 场景 | reason | 含义与处理 |
+72
View File
@@ -9,6 +9,7 @@ import (
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
@@ -1749,6 +1750,77 @@ func LoadUpdateRepo(path string) (string, error) {
return strings.TrimSpace(thin.Update.Repo), nil
}
// FileVersion 读一个配置文件形态 YAML 的顶层 version 字段。仓库里的 config.example.yaml
// 载着这份代码对应的发布版本(上游随 release 提交一起改),一键更新用它把"当前跑的是什么
// 版本"同步进运维者的 config.yaml。文件或字段不存在 = 未知,不是错误。
func FileVersion(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", fmt.Errorf("读取配置文件失败: %w", err)
}
var thin struct {
Version string `yaml:"version"`
}
if err := yaml.Unmarshal(data, &thin); err != nil {
return "", fmt.Errorf("解析配置文件失败: %w", err)
}
return strings.TrimSpace(thin.Version), nil
}
// versionValuePattern 约束能写进配置的版本号:它来自仓库里的文件,但落到的是运维者的
// 配置上,带引号或换行的值会把 YAML 写坏。
var versionValuePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]*$`)
// WriteVersion 就地替换配置文件的顶层 version 字段,保留其它每一行的原样(tarball 升级
// 路径一直这么做);没有这个字段就插到文件开头。返回是否真的改了。写盘走临时文件 + 原子
// rename——这是运维者的活配置,不能留半截。
func WriteVersion(path, version string) (bool, error) {
version = strings.TrimSpace(version)
if version == "" {
return false, nil
}
if len(version) > 64 || !versionValuePattern.MatchString(version) {
return false, fmt.Errorf("版本号不合法:%q", version)
}
data, err := os.ReadFile(path)
if err != nil {
return false, err
}
info, err := os.Stat(path)
if err != nil {
return false, err
}
lines := strings.Split(string(data), "\n")
replaced := false
for i, line := range lines {
// 只认顶层(顶格的)version 键;缩进里同名键属于别的段落,不动。
rest := strings.TrimSpace(strings.TrimPrefix(line, "version"))
if strings.HasPrefix(line, "version") && strings.HasPrefix(rest, ":") {
lines[i] = `version: "` + version + `"`
replaced = true
break
}
}
if !replaced {
lines = append([]string{`version: "` + version + `"`}, lines...)
}
out := strings.Join(lines, "\n")
if out == string(data) {
return false, nil
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, []byte(out), info.Mode().Perm()); err != nil {
return false, err
}
if err := os.Rename(tmp, path); err != nil {
return false, err
}
return true, nil
}
func EnsureLocalConfig(path string) (EnsureLocalConfigResult, error) {
path = strings.TrimSpace(path)
if path == "" {
+62
View File
@@ -371,3 +371,65 @@ func TestLoadUpdateRepoReadsOnlyTheSourceField(t *testing.T) {
t.Fatal("a malformed config must be an error, not a silent default")
}
}
func TestFileVersionAndWriteVersion(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
// Missing file reads as "unknown", not an error.
if v, err := FileVersion(filepath.Join(dir, "missing.yaml")); err != nil || v != "" {
t.Fatalf("missing file: %q %v", v, err)
}
original := "version: \"v1.0.0\"\nserver:\n port: 8088\n # a nested word: version: should stay put\nnotes:\n version: nested-untouched\n"
if err := os.WriteFile(path, []byte(original), 0o640); err != nil {
t.Fatal(err)
}
if v, err := FileVersion(path); err != nil || v != "v1.0.0" {
t.Fatalf("version = %q err = %v", v, err)
}
changed, err := WriteVersion(path, "v1.1.0")
if err != nil || !changed {
t.Fatalf("write: changed=%v err=%v", changed, err)
}
got := readTestFile(t, path)
want := "version: \"v1.1.0\"\nserver:\n port: 8088\n # a nested word: version: should stay put\nnotes:\n version: nested-untouched\n"
if got != want {
t.Fatalf("file after write:\n%q\nwant:\n%q", got, want)
}
if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0o640 {
t.Fatalf("mode = %v err = %v, want 0640 preserved", info.Mode(), err)
}
// Idempotent: the same value is not rewritten.
if changed, err := WriteVersion(path, "v1.1.0"); err != nil || changed {
t.Fatalf("rewriting the same value: changed=%v err=%v", changed, err)
}
// No top-level field: insert at the top, nested keys stay untouched.
if err := os.WriteFile(path, []byte("server:\n port: 8088\nnotes:\n version: nested\n"), 0o644); err != nil {
t.Fatal(err)
}
if changed, err := WriteVersion(path, "v1.2.0"); err != nil || !changed {
t.Fatalf("insert: changed=%v err=%v", changed, err)
}
got = readTestFile(t, path)
if !strings.HasPrefix(got, "version: \"v1.2.0\"\n") || !strings.Contains(got, " version: nested") {
t.Fatalf("file after insert:\n%q", got)
}
// A value that would write malformed YAML is refused rather than written.
if _, err := WriteVersion(path, "v1.0.0\"\nserver: hijacked"); err == nil {
t.Fatal("a version containing a quote/newline must be refused")
}
}
func readTestFile(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(data)
}
+5
View File
@@ -156,6 +156,11 @@ func Adopt(ctx context.Context, opts Options, onStep func(Step)) (*Result, error
// cleanup above must not remove the repository any more.
adopted = true
// The connected code carries its own release version; the live config gets it now, so
// a tarball install stops showing the version it was unpacked from.
versionBefore, versionAfter, versionErr := syncVersionToConfig(root)
versionStep(step, versionBefore, versionAfter, versionErr)
res := &Result{
ToCommit: commit,
FilesTouched: len(scan.paths),
+22 -1
View File
@@ -22,6 +22,11 @@ type State struct {
UpdatedCommit string `json:"updated_commit"`
BackupDir string `json:"backup_dir"`
UpdatedAt string `json:"updated_at"`
// VersionBefore/VersionAfter record a version-line change this update made, so a
// rollback can put the old number back next to the old code. Absent for updates that
// touched no version (old state files simply have no such fields).
VersionBefore string `json:"version_before,omitempty"`
VersionAfter string `json:"version_after,omitempty"`
}
func statePath(root string) string { return filepath.Join(root, stateFile) }
@@ -140,6 +145,11 @@ func Apply(ctx context.Context, opts Options, onStep func(Step)) (*Result, error
}
}
if !snap.UpdateAvailable {
// Nothing to move - but the installation's own bookkeeping still gets aligned: a
// version left over from a tree that was updated before this behaviour existed (or
// from a hand edit) is corrected here instead of waiting for the next commit.
versionBefore, versionAfter, versionErr := syncVersionToConfig(snap.Root)
versionStep(step, versionBefore, versionAfter, versionErr)
return &Result{
FromCommit: snap.Commit, ToCommit: snap.Commit,
BinaryPath: filepath.Join(snap.Root, opts.binaryName()),
@@ -181,7 +191,15 @@ func Apply(ctx context.Context, opts Options, onStep func(Step)) (*Result, error
FilesTouched: len(nulList(files)),
}
if err := writeState(root, State{PreviousCommit: snap.Commit, UpdatedCommit: snap.RemoteCommit, BackupDir: backupDir}); err != nil {
// The new code carries its own release version; the live config (which no merge ever
// touches) gets it now, so the page header and the static cache buster follow the code.
versionBefore, versionAfter, versionErr := syncVersionToConfig(root)
versionStep(step, versionBefore, versionAfter, versionErr)
if err := writeState(root, State{
PreviousCommit: snap.Commit, UpdatedCommit: snap.RemoteCommit, BackupDir: backupDir,
VersionBefore: versionBefore, VersionAfter: versionAfter,
}); err != nil {
return nil, &Error{Reason: "state_unwritable", Message: "无法写入更新状态文件,回滚将不可用:" + err.Error()}
}
@@ -366,6 +384,9 @@ func Rollback(ctx context.Context, opts Options) (*Result, error) {
if err := restorePrevBinary(bin); err != nil {
return nil, &Error{Reason: "swap_failed", Message: err.Error()}
}
// The version number goes back with the code it belongs to (only when it is still the
// one that update wrote - see restoreVersionAfterRollback).
restoreVersionAfterRollback(root, st)
if err := os.Remove(statePath(root)); err != nil && !os.IsNotExist(err) {
return nil, err
}
+124
View File
@@ -417,3 +417,127 @@ func mustGit(t *testing.T, dir string, args ...string) string {
}
return strings.TrimSpace(string(out))
}
// The release version rides with the code: after an update the live config has to say the
// version of the code now on disk (the header badge reads it), and a rollback has to put
// the old number back next to the old code.
func TestApplySyncsTheReleaseVersionIntoTheLiveConfig(t *testing.T) {
requireGit(t)
tr := newTree(t)
writeFile(t, filepath.Join(tr.install, "config.yaml"), "version: \"v1.0.0\"\nserver:\n port: 8088\n")
tr.upstreamCommit(t, "release: bump version", map[string]string{
"config.example.yaml": "version: \"v1.1.0\"\nserver:\n port: 8088\n",
"internal_service.go": "package service\n\nconst Version = \"2\"\n",
})
res, err := Apply(context.Background(), opts(tr), nil)
if err != nil {
t.Fatalf("apply failed: %v\nresult: %+v", err, res)
}
live := readFile(t, filepath.Join(tr.install, "config.yaml"))
if !strings.Contains(live, `version: "v1.1.0"`) || !strings.Contains(live, "port: 8088") {
t.Fatalf("live config after update = %q, want the new version with everything else untouched", live)
}
st, ok := readState(tr.install)
if !ok || st.VersionBefore != "v1.0.0" || st.VersionAfter != "v1.1.0" {
t.Fatalf("state = %+v, want the version change recorded for the rollback", st)
}
// Rollback puts the number back with the code.
bin := filepath.Join(tr.install, Binary)
writeFile(t, bin, "new binary bytes")
writeFile(t, bin+".prev", "old binary bytes")
if _, err := Rollback(context.Background(), Options{Root: tr.install}); err != nil {
t.Fatalf("rollback failed: %v", err)
}
live = readFile(t, filepath.Join(tr.install, "config.yaml"))
if !strings.Contains(live, `version: "v1.0.0"`) {
t.Fatalf("live config after rollback = %q, want the old version back", live)
}
}
func TestRollbackDoesNotClobberAManuallyChangedVersion(t *testing.T) {
requireGit(t)
tr := newTree(t)
writeFile(t, filepath.Join(tr.install, "config.yaml"), "version: \"v1.0.0\"\nserver:\n port: 8088\n")
tr.upstreamCommit(t, "release: bump version", map[string]string{
"config.example.yaml": "version: \"v1.1.0\"\n",
})
if _, err := Apply(context.Background(), opts(tr), nil); err != nil {
t.Fatal(err)
}
// Somebody pins their own version after the update; a rollback is not theirs to undo.
writeFile(t, filepath.Join(tr.install, "config.yaml"), "version: \"v9.9.9-mine\"\nserver:\n port: 8088\n")
bin := filepath.Join(tr.install, Binary)
writeFile(t, bin, "new")
writeFile(t, bin+".prev", "old")
if _, err := Rollback(context.Background(), Options{Root: tr.install}); err != nil {
t.Fatal(err)
}
if live := readFile(t, filepath.Join(tr.install, "config.yaml")); !strings.Contains(live, "v9.9.9-mine") {
t.Fatalf("a hand-set version must survive a rollback, got %q", live)
}
}
func TestApplyLeavesTheVersionAloneWhenTheTreeCarriesNone(t *testing.T) {
requireGit(t)
tr := newTree(t)
// The fixture's upstream has no config.example.yaml at all: nothing to sync, and the
// live config must not be rewritten with a guess.
writeFile(t, filepath.Join(tr.install, "config.yaml"), "version: \"v1.0.0\"\nserver:\n port: 8088\n")
tr.upstreamCommit(t, "second: bump service", map[string]string{
"internal_service.go": "package service\n\nconst Version = \"2\"\n",
})
if _, err := Apply(context.Background(), opts(tr), nil); err != nil {
t.Fatal(err)
}
if live := readFile(t, filepath.Join(tr.install, "config.yaml")); !strings.Contains(live, "v1.0.0") {
t.Fatalf("live config = %q, want it untouched", live)
}
if st, _ := readState(tr.install); st.VersionAfter != "" || st.VersionBefore != "" {
t.Fatalf("state = %+v, want no version fields when nothing changed", st)
}
}
func TestAdoptSyncsTheReleaseVersionIntoTheLiveConfig(t *testing.T) {
requireGit(t)
tr, dir := newAdoptFixture(t)
writeFile(t, filepath.Join(dir, "config.yaml"), "version: \"v0.9.0\"\nserver:\n port: 8088\n")
tr.upstreamCommit(t, "release: bump version", map[string]string{
"config.example.yaml": "version: \"v1.1.0\"\n",
})
res, err := Adopt(context.Background(), Options{Root: dir, Repo: tr.upstream, BinaryName: "none"}, nil)
if err != nil {
t.Fatalf("adopt failed: %v\nresult: %+v", err, res)
}
live := readFile(t, filepath.Join(dir, "config.yaml"))
if !strings.Contains(live, `version: "v1.1.0"`) || !strings.Contains(live, "port: 8088") {
t.Fatalf("live config after adopt = %q, want the connected code's version with the rest untouched", live)
}
}
// A tree that is already current still gets its bookkeeping aligned: the version field is
// the one thing an update may correct with no commits to move (that is exactly the state a
// pre-feature installation is in).
func TestApplyAlignsTheVersionEvenWithNothingToPull(t *testing.T) {
requireGit(t)
tr := newTree(t)
writeFile(t, filepath.Join(tr.install, "config.yaml"), "version: \"v0.9.0\"\nserver:\n port: 8088\n")
writeFile(t, filepath.Join(tr.install, "config.example.yaml"), "version: \"v1.1.0\"\n")
res, err := Apply(context.Background(), opts(tr), nil)
if err != nil {
t.Fatalf("no-op apply failed: %v", err)
}
if res.Commits != 0 {
t.Fatalf("res = %+v, want zero movement", res)
}
if live := readFile(t, filepath.Join(tr.install, "config.yaml")); !strings.Contains(live, `version: "v1.1.0"`) {
t.Fatalf("live config = %q, want the version aligned even though nothing moved", live)
}
// Bookkeeping only: a version-only correction writes no rollback state.
if _, ok := readState(tr.install); ok {
t.Fatal("a version-only alignment must not claim to be a rollback point")
}
}
+104
View File
@@ -0,0 +1,104 @@
package update
import (
"cyberstrike-ai/internal/config"
"fmt"
"path/filepath"
)
// The release version travels with the code: config.example.yaml ships in the repository
// and upstream bumps its version field in the release commit, but the live config.yaml is
// the operator's file and no merge ever touches it. So without the steps below an updated
// installation keeps showing the version it was born with - the header badge and the
// static-asset cache buster would both say v-old while the tree runs v-new. The fix is
// deliberately small: read the version the new code carries, write it into the live
// config's version line (byte-for-byte otherwise untouched, atomic rename), and let a
// rollback put the old number back with the old code.
// liveConfigFile names the operator's configuration inside an install tree: the
// conventional config.yaml, then config.yml. Empty when neither exists (a source
// checkout, where there is nothing to sync).
func liveConfigFile(root string) string {
for _, name := range []string{"config.yaml", "config.yml"} {
path := filepath.Join(root, name)
if fileExists(path) {
return path
}
}
return ""
}
// installVersion is the release version the code sitting in root carries, read from the
// shipped config.example.yaml; empty when the tree does not say.
func installVersion(root string) string {
version, err := config.FileVersion(filepath.Join(root, "config.example.yaml"))
if err != nil {
return ""
}
return version
}
// syncVersionToConfig writes the code's release version into the live configuration and
// returns (before, after, error) - both empty when there was nothing to do. A tree
// without a live config, or without a shipped version, is left alone; a current value
// that already matches is not rewritten.
func syncVersionToConfig(root string) (before, after string, err error) {
path := liveConfigFile(root)
if path == "" {
return "", "", nil
}
target := installVersion(root)
if target == "" {
return "", "", nil
}
current, err := config.FileVersion(path)
if err != nil {
return "", "", err
}
if current == target {
return "", "", nil
}
changed, err := config.WriteVersion(path, target)
if err != nil {
return current, "", err
}
if !changed {
return "", "", nil
}
return current, target, nil
}
// versionStep renders the sync outcome as one progress line; no lines for "nothing to
// do", a warning when the write failed (the update itself still stands).
func versionStep(step func(string, string), before, after string, err error) {
if err != nil {
step("version", "版本号写入 config.yaml 失败(不影响本次更新):"+err.Error())
return
}
if after == "" {
return
}
if before == "" {
step("version", fmt.Sprintf("版本号随新代码写入 config.yaml:%s(重启后页头生效)", after))
return
}
step("version", fmt.Sprintf("版本号已随新代码更新:%s → %s(config.yaml,重启后页头生效)", before, after))
}
// restoreVersionAfterRollback puts the old number back with the old code - but only while
// the live config still carries exactly what the update wrote: a version someone edited by
// hand after the update belongs to them, not to the rollback.
func restoreVersionAfterRollback(root string, st State) {
if st.VersionAfter == "" {
return
}
path := liveConfigFile(root)
if path == "" {
return
}
current, err := config.FileVersion(path)
if err != nil || current != st.VersionAfter {
return
}
_, _ = config.WriteVersion(path, st.VersionBefore)
}