From e6fcdf3cd4e0a5bb5807bbae67f6902e847a84a7 Mon Sep 17 00:00:00 2001 From: Ehco Date: Sat, 2 May 2026 07:21:39 +0800 Subject: [PATCH 1/3] fix(cli): make update command nightly-aware and prevent downgrade (#440) --- go.mod | 2 +- internal/cli/update.go | 151 +++++++++++++++++++++++++++++++++++------ 2 files changed, 132 insertions(+), 21 deletions(-) diff --git a/go.mod b/go.mod index 774d89f1d..1709ec57b 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/xtls/xray-core v1.260206.0 go.uber.org/atomic v1.11.0 go.uber.org/zap v1.27.1 + golang.org/x/mod v0.34.0 golang.org/x/sync v0.20.0 golang.org/x/time v0.15.0 google.golang.org/grpc v1.79.2 @@ -96,7 +97,6 @@ require ( go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect - golang.org/x/mod v0.34.0 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect diff --git a/internal/cli/update.go b/internal/cli/update.go index 139e61525..9e77a6a27 100644 --- a/internal/cli/update.go +++ b/internal/cli/update.go @@ -15,11 +15,19 @@ import ( "github.com/Ehco1996/ehco/internal/constant" cli "github.com/urfave/cli/v2" + "golang.org/x/mod/semver" ) const ( githubLatestReleaseAPI = "https://api.github.com/repos/Ehco1996/ehco/releases/latest" + githubReleasesAPI = "https://api.github.com/repos/Ehco1996/ehco/releases?per_page=30" systemdServiceName = "ehco" + + channelAuto = "auto" + channelStable = "stable" + channelNightly = "nightly" + + nightlyTagSuffix = "-next" ) type ghAsset struct { @@ -28,8 +36,11 @@ type ghAsset struct { } type ghRelease struct { - TagName string `json:"tag_name"` - Assets []ghAsset `json:"assets"` + TagName string `json:"tag_name"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` + PublishedAt time.Time `json:"published_at"` + Assets []ghAsset `json:"assets"` } var UpdateCMD = &cli.Command{ @@ -38,12 +49,17 @@ var UpdateCMD = &cli.Command{ Flags: []cli.Flag{ &cli.BoolFlag{ Name: "force", - Usage: "force update even if already at the latest version", + Usage: "force update even if already at the latest version, or to allow downgrade / channel switch", }, &cli.BoolFlag{ Name: "no-restart", Usage: "skip systemctl restart after replacing the binary", }, + &cli.StringFlag{ + Name: "channel", + Value: channelAuto, + Usage: "release channel to track: auto (match current build), stable, or nightly", + }, }, Action: runUpdate, } @@ -52,15 +68,28 @@ func runUpdate(c *cli.Context) error { ctx, cancel := context.WithTimeout(c.Context, 5*time.Minute) defer cancel() - rel, err := fetchLatestRelease(ctx) + channel, err := resolveChannel(c.String("channel"), constant.Version) + if err != nil { + return err + } + + rel, err := fetchTargetRelease(ctx, channel) if err != nil { - return fmt.Errorf("fetch latest release: %w", err) + return fmt.Errorf("fetch %s release: %w", channel, err) } latest := strings.TrimPrefix(rel.TagName, "v") - cliLogger.Infof("current version=%s latest version=%s", constant.Version, latest) - if !c.Bool("force") && latest == constant.Version { - cliLogger.Info("already up to date, nothing to do") - return nil + cliLogger.Infof("channel=%s current version=%s latest version=%s", channel, constant.Version, latest) + + force := c.Bool("force") + if !force { + if latest == constant.Version { + cliLogger.Info("already up to date, nothing to do") + return nil + } + if cmp := compareVersions(latest, constant.Version); cmp < 0 { + return fmt.Errorf("refusing to downgrade from %s to %s; rerun with --force to override", + constant.Version, latest) + } } asset, err := pickReleaseAsset(rel.Assets) @@ -98,29 +127,111 @@ func runUpdate(c *cli.Context) error { return restartSystemdService() } -func fetchLatestRelease(ctx context.Context) (*ghRelease, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubLatestReleaseAPI, nil) - if err != nil { +func resolveChannel(flagVal, currentVersion string) (string, error) { + switch flagVal { + case channelStable, channelNightly: + return flagVal, nil + case channelAuto, "": + if isNightlyVersion(currentVersion) { + return channelNightly, nil + } + return channelStable, nil + default: + return "", fmt.Errorf("invalid --channel %q (want one of auto, stable, nightly)", flagVal) + } +} + +func isNightlyVersion(v string) bool { + // goreleaser injects bare versions like "1.1.7-next" or "1.1.6"; both + // stable and nightly builds skip the leading "v". A nightly is anything + // carrying a prerelease suffix (currently "-next"), but we use a generic + // "contains a dash" check so future suffixes (e.g. "-rc.1") still work. + return strings.Contains(v, "-") +} + +func fetchTargetRelease(ctx context.Context, channel string) (*ghRelease, error) { + switch channel { + case channelStable: + return fetchLatestStableRelease(ctx) + case channelNightly: + return fetchLatestNightlyRelease(ctx) + default: + return nil, fmt.Errorf("unknown channel %q", channel) + } +} + +func fetchLatestStableRelease(ctx context.Context) (*ghRelease, error) { + var rel ghRelease + if err := getJSON(ctx, githubLatestReleaseAPI, &rel); err != nil { + return nil, err + } + if rel.TagName == "" { + return nil, fmt.Errorf("empty tag name in github response") + } + return &rel, nil +} + +func fetchLatestNightlyRelease(ctx context.Context) (*ghRelease, error) { + // /releases/latest excludes prereleases by design, so list recent + // releases and pick the freshest nightly ourselves. + var all []ghRelease + if err := getJSON(ctx, githubReleasesAPI, &all); err != nil { return nil, err } + var best *ghRelease + for i := range all { + r := &all[i] + if r.Draft || !r.Prerelease { + continue + } + if !strings.HasSuffix(r.TagName, nightlyTagSuffix) { + continue + } + if best == nil || r.PublishedAt.After(best.PublishedAt) { + best = r + } + } + if best == nil { + return nil, fmt.Errorf("no nightly release found (looking for tags ending in %q)", nightlyTagSuffix) + } + return best, nil +} + +func getJSON(ctx context.Context, url string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } req.Header.Set("Accept", "application/vnd.github+json") resp, err := http.DefaultClient.Do(req) if err != nil { - return nil, err + return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return nil, fmt.Errorf("github api %s: %s", resp.Status, strings.TrimSpace(string(body))) + return fmt.Errorf("github api %s: %s", resp.Status, strings.TrimSpace(string(body))) } - var rel ghRelease - if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { - return nil, err + return json.NewDecoder(resp.Body).Decode(out) +} + +// compareVersions returns -1/0/1 like semver.Compare. Inputs may have or +// omit the leading "v"; unparseable inputs fall back to string compare so a +// malformed version never crashes the updater (it just disables the +// downgrade guard for that case, which --force handles). +func compareVersions(a, b string) int { + va, vb := ensureV(a), ensureV(b) + if semver.IsValid(va) && semver.IsValid(vb) { + return semver.Compare(va, vb) } - if rel.TagName == "" { - return nil, fmt.Errorf("empty tag name in github response") + return strings.Compare(a, b) +} + +func ensureV(s string) string { + if strings.HasPrefix(s, "v") { + return s } - return &rel, nil + return "v" + s } func pickReleaseAsset(assets []ghAsset) (*ghAsset, error) { From 9b0014c1fd574c444c79cc8c099f22eaa4453698 Mon Sep 17 00:00:00 2001 From: Ehco1996 Date: Sat, 2 May 2026 07:26:33 +0800 Subject: [PATCH 2/3] remove old ci --- .github/workflows/stale.yml | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 .github/workflows/stale.yml diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml deleted file mode 100644 index ddcee396a..000000000 --- a/.github/workflows/stale.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: stale - -on: - workflow_dispatch: - -permissions: - issues: write - pull-requests: write - -jobs: - stale: - runs-on: ubuntu-latest - steps: - - uses: actions/stale@v9 - with: - exempt-all-pr-assignees: true From c5e23028fb4647332b41ea4d8fe9b5e4582d9f31 Mon Sep 17 00:00:00 2001 From: Ehco Date: Sat, 2 May 2026 08:00:54 +0800 Subject: [PATCH 3/3] build/cli: inject Version from nightly tag, plus show help on bare `ehco` (#441) * build: derive Version from git in Makefile so master builds aren't stale The Makefile only injected GitBranch/GitRevision/BuildTime via ldflags; Version came from the hardcoded "1.1.6" literal in internal/constant/constant.go, last bumped at the v1.1.6 release. Any master binary produced via `make build` (or anything that shells out to the Makefile) self-reported as 1.1.6 even when it was several commits ahead. That defeated the update command's nightly auto-detection: a post-1.1.6 master binary looked identical to released 1.1.6 and would either silently no-op or, in combination with the prerelease bug fixed in #440, get rolled backward. Compute a semver-valid VERSION from `git describe`: - on a stable tag exactly -> X.Y.Z - N commits past last stable tag -> X.Y.Z-dev.N+gSHA - no stable tag reachable -> falls back to the source default The output is valid semver (verified against golang.org/x/mod/semver), so the update command's compareVersions / channel detection treat it correctly: any -dev build auto-routes to the nightly channel and never gets downgraded to a strictly older stable. Also bump the source-default Version from 1.1.6 to 1.1.7-next so raw `go build cmd/ehco/main.go` (no Makefile, no goreleaser) on master still self-identifies as a nightly-channel build, matching the existing tag naming convention. Co-Authored-By: Claude Opus 4.7 (1M context) * build: pin VERSION to latest nightly tag instead of stable+dev.N The previous approach derived VERSION as `X.Y.Z-dev.N+gSHA` from the last stable tag. That doesn't match the project's nightly convention: master between v1.1.6 and v1.1.7 should self-report as `1.1.7-next` (matching the rolling nightly tag created by .github/workflows/nightly.yml), not `1.1.6-dev.8+g...`. Switch to: take the most recent reachable v*-next tag verbatim, fall back to the latest stable tag for the brief window between a release and the next nightly cron, then to the source default if no tags exist. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(cli): show help on bare \`ehco\` invocation instead of fataling Running \`ehco\` with no args, no -c config path, and no -l listen address used to fall through to startAction -> InitConfigAndComponents -> Fatalf("invalid listen"). Confusing for first-time users. Detect "no config source given" up front and print app help instead. Existing flows (-c config_file, -l/-r inline relay, env vars) are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- Makefile | 21 ++++++++++++++++++++- internal/cli/app.go | 7 +++++++ internal/constant/constant.go | 6 +++++- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index cb194df40..427a9fde6 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,25 @@ BUILDTIME=$(shell date +"%Y-%m-%d-%T") BRANCH=$(shell git rev-parse --abbrev-ref HEAD | tr -d '\040\011\012\015\n') REVISION=$(shell git rev-parse HEAD) +# Pin VERSION to the most recent nightly tag so `make build` (and Docker, etc.) +# self-reports the current in-progress release line, e.g. `1.1.7-next` while +# v1.1.7 is being prepared. goreleaser still injects its own GORELEASER_CURRENT_TAG +# for actual release artifacts; this only kicks in for non-goreleaser builds. +# +# Resolution order: +# 1. nearest reachable nightly tag matching v*-next +# 2. nearest reachable stable tag (rare: only between a release and the next nightly cron) +# 3. empty -> falls back to the constant.Version source default +GIT_DESCRIBE_VERSION := $(shell git describe --tags --abbrev=0 --match 'v*-next' 2>/dev/null \ + || git describe --tags --abbrev=0 --match 'v*' --exclude='*-*' 2>/dev/null) +VERSION := $(patsubst v%,%,$(GIT_DESCRIBE_VERSION)) + +ifeq ($(VERSION),) +VERSION_LDFLAG := +else +VERSION_LDFLAG := -X $(PACKAGE).Version=$(VERSION) +endif + PACKAGE_LIST := go list ./... FILES := $(shell find . -name "*.go" -type f) @@ -24,7 +43,7 @@ endif # -w -s 参数的解释:You will get the smallest binaries if you compile with -ldflags '-w -s'. The -w turns off DWARF debugging information # for more information, please refer to https://stackoverflow.com/questions/22267189/what-does-the-w-flag-mean-when-passed-in-via-the-ldflags-option-to-the-go-comman -GOBUILD=CGO_ENABLED=0 go build -tags ${BUILD_TAG_FOR_NODE_EXPORTER} -trimpath -ldflags="-w -s -X ${PACKAGE}.GitBranch=${BRANCH} -X ${PACKAGE}.GitRevision=${REVISION} -X ${PACKAGE}.BuildTime=${BUILDTIME}" +GOBUILD=CGO_ENABLED=0 go build -tags ${BUILD_TAG_FOR_NODE_EXPORTER} -trimpath -ldflags="-w -s ${VERSION_LDFLAG} -X ${PACKAGE}.GitBranch=${BRANCH} -X ${PACKAGE}.GitRevision=${REVISION} -X ${PACKAGE}.BuildTime=${BUILDTIME}" tools: diff --git a/internal/cli/app.go b/internal/cli/app.go index 3b09ffc2f..727de7c39 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -13,6 +13,13 @@ import ( var cliLogger = log.MustNewLogger("info").Sugar().Named("cli") func startAction(ctx *cli.Context) error { + // No subcommand and no config source given -> the user almost certainly + // just typed `ehco` to see what it does. Show help instead of fataling + // with "invalid listen". + if ConfigPath == "" && LocalAddr == "" { + return cli.ShowAppHelp(ctx) + } + cfg, err := InitConfigAndComponents() if err != nil { cliLogger.Fatalf("InitConfigAndComponents meet err=%s", err.Error()) diff --git a/internal/constant/constant.go b/internal/constant/constant.go index c931de961..ed4fd7d5d 100644 --- a/internal/constant/constant.go +++ b/internal/constant/constant.go @@ -5,7 +5,11 @@ import "time" type RelayType string var ( - Version = "1.1.6" + // Version is overridden at link time by Makefile / goreleaser ldflags. + // The literal here is a fallback for raw `go build` invocations on master, + // kept slightly newer than the most recent stable tag so the update + // command's nightly auto-detection and downgrade guard behave sanely. + Version = "1.1.7-next" GitBranch string GitRevision string BuildTime string