fix(release,hy2): sane auto release notes; Loon-friendly HY2 share links

Release workflow: previous stable tag only, merge-base range, first-parent
log, noise filter, and per-section caps so notes stay readable.

Hysteria2 share/URI: resolve SNI from access profile/public host, emit
allowInsecure alongside insecure, encode userinfo via url.User so Loon
and similar clients can complete TLS.
This commit is contained in:
kazeyukiro committed 2026-10-01 08:41:28 +00:00
1 parent 81e465cc43
commit bbe10da416
3 files changed
+112 -27

No files matched your search

+42 -17
View File
@@ -159,32 +159,57 @@ jobs:
SHA: ${{ needs.metadata.outputs.sha }}
run: |
set -euo pipefail
# Previous formal SemVer tag (ignore pre / the tag we are publishing).
PREV="$(git tag -l 'v*' --sort=-version:refname | grep -E '^v[0-9]' | grep -vxF "$TAG" | head -n1 || true)"
# Previous *stable* SemVer only (vMAJOR.MINOR.PATCH). Ignore RC tags and
# the tag we are publishing. version:refname can otherwise pair a new
# release with a divergent or pre-release tip and dump the whole history.
PREV="$(git tag -l 'v*' --sort=-version:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| grep -vxF "$TAG" \
| head -n1 || true)"
{
if [ -n "${PREV}" ]; then
if [ -n "${PREV}" ] && git rev-parse "${PREV}^{commit}" >/dev/null 2>&1; then
echo "## Changes since ${PREV}"
echo
RANGE="${PREV}..${SHA}"
# Use merge-base so rewritten / non-linear history does not list
# every commit reachable from SHA but not from the tag tip alone.
MB="$(git merge-base "${PREV}^{commit}" "$SHA" 2>/dev/null || true)"
if [ -n "$MB" ]; then
RANGE="${MB}..${SHA}"
else
RANGE="${PREV}..${SHA}"
fi
else
echo "## Changes"
echo
RANGE="${SHA}"
fi
# Categorize conventional commits (same shape as v1.3.7 notes).
feats=$(git log "$RANGE" --pretty=format:'- %s (%h)' --no-merges | grep -E '^- feat' || true)
fixes=$(git log "$RANGE" --pretty=format:'- %s (%h)' --no-merges | grep -E '^- fix' || true)
perfs=$(git log "$RANGE" --pretty=format:'- %s (%h)' --no-merges | grep -E '^- perf' || true)
docs=$(git log "$RANGE" --pretty=format:'- %s (%h)' --no-merges | grep -E '^- docs' || true)
chores=$(git log "$RANGE" --pretty=format:'- %s (%h)' --no-merges | grep -E '^- (chore|ci|build|refactor|style|test)' || true)
other=$(git log "$RANGE" --pretty=format:'- %s (%h)' --no-merges | grep -Ev '^- (feat|fix|perf|docs|chore|ci|build|refactor|style|test)' || true)
if [ -n "$feats" ]; then echo "### Features"; echo "$feats"; echo; fi
if [ -n "$fixes" ]; then echo "### Fixes"; echo "$fixes"; echo; fi
if [ -n "$perfs" ]; then echo "### Performance"; echo "$perfs"; echo; fi
if [ -n "$docs" ]; then echo "### Docs"; echo "$docs"; echo; fi
if [ -n "$chores" ]; then echo "### Chore / CI"; echo "$chores"; echo; fi
# Conventional commits on the first-parent path only; drop pure
# formatting / CI noise that bloats notes (as seen on v1.3.9).
log_lines() {
git log "$RANGE" --first-parent --pretty=format:'- %s (%h)' --no-merges \
| grep -Ev '^- (style:|style\(|chore: gofmt|ci: |test: format|test: provide|test: cover|test: preserve)' \
|| true
}
ALL="$(log_lines)"
# Cap each section so a large range stays readable in the GitHub UI.
section() {
local title="$1" pattern="$2"
local body
body="$(printf '%s\n' "$ALL" | grep -E "$pattern" | head -n 40 || true)"
if [ -n "$body" ]; then
echo "### ${title}"
echo "$body"
echo
fi
}
section "Features" '^- feat'
section "Fixes" '^- fix'
section "Performance" '^- perf'
section "Docs" '^- docs'
section "Chore / CI" '^- (chore|ci|build|refactor|test)'
other="$(printf '%s\n' "$ALL" | grep -Ev '^- (feat|fix|perf|docs|chore|ci|build|refactor|style|test)' | head -n 20 || true)"
if [ -n "$other" ]; then echo "### Other"; echo "$other"; echo; fi
if [ -z "$feats$fixes$perfs$docs$chores$other" ]; then
if [ -z "$(printf '%s' "$ALL" | tr -d '[:space:]')" ]; then
echo "_No conventional commits in range._"
echo
fi
+27 -2
View File
@@ -297,6 +297,11 @@ func hysteria2URIs(name, host, port string, cfg map[string]interface{}) ([]strin
if v, ok := cfg["sni"].(string); ok && v != "" {
params["sni"] = v
}
if params["sni"] == "" {
if s := uriTLSServerName(host); s != "" {
params["sni"] = s
}
}
// Matches the config-embedded users branch below: same listener must
// produce the same link whichever way its users are stored.
if clientSkipCert(cfg, host) {
@@ -318,7 +323,8 @@ func hysteria2URIs(name, host, port string, cfg map[string]interface{}) ([]strin
if v, ok := cfg["down"].(string); ok && v != "" {
params["down"] = v
}
result = append(result, addName(query("hysteria2://"+url.PathEscape(password)+"@"+netutil.JoinHostPort(host, port), params), name))
userinfo := url.User(password).String()
result = append(result, addName(query("hysteria2://"+userinfo+"@"+netutil.JoinHostPort(host, port), params), name))
}
if len(result) > 0 {
return result, nil
@@ -338,6 +344,11 @@ func hysteria2URIs(name, host, port string, cfg map[string]interface{}) ([]strin
if v, ok := cfg["sni"].(string); ok && v != "" {
params["sni"] = v
}
if params["sni"] == "" {
if s := uriTLSServerName(host); s != "" {
params["sni"] = s
}
}
if clientSkipCert(cfg, host) {
params["insecure"] = "1"
params["allowInsecure"] = "1"
@@ -355,7 +366,8 @@ func hysteria2URIs(name, host, port string, cfg map[string]interface{}) ([]strin
params["down"] = v
}
_ = username
result = append(result, addName(query("hysteria2://"+url.PathEscape(password)+"@"+netutil.JoinHostPort(host, port), params), name))
userinfo := url.User(password).String()
result = append(result, addName(query("hysteria2://"+userinfo+"@"+netutil.JoinHostPort(host, port), params), name))
}
return result, nil
}
@@ -594,3 +606,16 @@ func stringValue(v interface{}, fallback string) string {
}
return fallback
}
// uriTLSServerName returns the first non-IP hostname among candidates (for SNI).
func uriTLSServerName(candidates ...string) string {
for _, c := range candidates {
c = strings.TrimSpace(c)
c = strings.Trim(c, "[]")
if c == "" || net.ParseIP(c) != nil {
continue
}
return c
}
return ""
}
+43 -8
View File
@@ -299,12 +299,24 @@ func (Hysteria2Compiler) BuildShare(in ShareInput) (Share, error) {
if pass == "" {
return Share{}, fmt.Errorf("hysteria2 share requires password")
}
params := map[string]string{}
if spec.SNI != "" {
params["sni"] = spec.SNI
// SNI: listener field → access profile → public host / dial host when not an IP.
// Loon and similar clients fail TLS handshake on bare-IP HY2 links without sni.
sni := strings.TrimSpace(spec.SNI)
if sni == "" {
sni = strings.TrimSpace(in.Node.AccessSNI)
}
if spec.SkipCert {
if sni == "" {
sni = shareTLSServerName(host, in.Node.PublicHost)
}
skipCert := spec.SkipCert || shareHostLooksLikeIP(host)
params := map[string]string{}
if sni != "" {
params["sni"] = sni
}
if skipCert {
// insecure = Meta/Clash; allowInsecure = Loon / some mobile parsers
params["insecure"] = "1"
params["allowInsecure"] = "1"
}
if spec.Obfs != "" {
params["obfs"] = spec.Obfs
@@ -319,15 +331,17 @@ func (Hysteria2Compiler) BuildShare(in ShareInput) (Share, error) {
params["down"] = spec.Down
}
applyALPNParams(params, spec.ALPN)
// url.User encodes userinfo correctly (PathEscape alone breaks special passwords).
userinfo := url.User(pass).String()
uri := shareName(
shareQuery("hysteria2://"+url.PathEscape(pass)+"@"+netutil.JoinHostPort(host, port), params),
shareQuery("hysteria2://"+userinfo+"@"+netutil.JoinHostPort(host, port), params),
in.Node.Name,
)
extra := map[string]interface{}{"password": pass}
if spec.SNI != "" {
extra["sni"] = spec.SNI
if sni != "" {
extra["sni"] = sni
}
if spec.SkipCert {
if skipCert {
extra["skip-cert-verify"] = true
}
if spec.Obfs != "" {
@@ -880,6 +894,27 @@ func applyTransportParams(params map[string]string, t TransportSpec) {
}
}
// shareTLSServerName picks a hostname suitable for TLS SNI on share links.
// Prefers non-IP candidates so clients (Loon, etc.) can complete the handshake
// when the published endpoint is a raw address.
func shareTLSServerName(candidates ...string) string {
for _, c := range candidates {
c = strings.TrimSpace(c)
c = strings.Trim(c, "[]")
if c == "" || net.ParseIP(c) != nil {
continue
}
return c
}
return ""
}
func shareHostLooksLikeIP(host string) bool {
h := strings.TrimSpace(host)
h = strings.Trim(h, "[]")
return h != "" && net.ParseIP(h) != nil
}
func applyALPNParams(params map[string]string, alpn []string) {
clean := make([]string, 0, len(alpn))
for _, value := range alpn {