Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
427acf2
feat(web): load optional analytics after application mount
xiaoyaner0201 Oct 29, 2026
7fb74e8
feat(deploy): configure same-origin optional analytics
xiaoyaner0201 Oct 29, 2026
4661d2e
docs(analytics): document configuration-only deployment and acceptance
xiaoyaner0201 Oct 29, 2026
7eab5c6
docs(analytics): require re-login after platform password rotation
xiaoyaner0201 Oct 29, 2026
6cee2fe
test(chart): pass analytics JSON through a values file
xiaoyaner0201 Oct 29, 2026
f8ab71e
fix(chart): isolate analytics config so only Web rolls
xiaoyaner0201 Nov 1, 2026
987068c
feat(analytics): register configuration-only DataFinder adapter
xiaoyaner0201 Nov 2, 2026
ca11585
docs(analytics): explain DataFinder setup and privacy boundaries
xiaoyaner0201 Nov 2, 2026
f436841
fix(analytics): generate DataFinder browser IDs on plain-HTTP origins
xiaoyaner0201 Nov 2, 2026
cfa48c9
docs(openspec): record full Helm template gate for DataFinder
xiaoyaner0201 Nov 2, 2026
ae62cb0
Merge upstream/main into feat/configurable-frontend-analytics-989
xiaoyaner0201 Nov 2, 2026
19418e6
Split analytics and SkillHub Compose network topology
xiaoyaner0201 Nov 2, 2026
abbe3c5
Add two-step analytics runtime lifecycle commands
xiaoyaner0201 Nov 2, 2026
356a7e9
Test analytics runtime isolation and idempotence
xiaoyaner0201 Nov 2, 2026
e499994
Provide independent Kubernetes Umami deployment unit
xiaoyaner0201 Nov 2, 2026
335b692
Document one-click existing-platform and Helm analytics paths
xiaoyaner0201 Nov 2, 2026
facd545
Replace legacy edge fixtures with production assertions
xiaoyaner0201 Nov 2, 2026
32b2965
Preserve independent analytics credentials on runtime clean
xiaoyaner0201 Nov 2, 2026
43283cb
Add real-cluster analytics storage and rollout smoke
xiaoyaner0201 Nov 2, 2026
d1d8d54
Record one-click deployment acceptance in OpenSpec
xiaoyaner0201 Nov 2, 2026
8418542
Keep runtime analytics permission test portable across macOS and Linux
xiaoyaner0201 Nov 2, 2026
ffc9c83
Drop identical analytics download branches in runtime
xiaoyaner0201 Nov 2, 2026
1e97905
fix(analytics): preserve deployment identity and Helm secrets
XiaoSeS Nov 3, 2026
84b0411
docs(analytics): prioritize current configuration-only setup
XiaoSeS Nov 3, 2026
ba9b521
docs(analytics): record focused guard validation
XiaoSeS Nov 3, 2026
1506f0f
docs(analytics): remove obsolete manual demo links
XiaoSeS Nov 3, 2026
0ca058a
merge: refresh analytics validation base
XiaoSeS Nov 3, 2026
560f5c8
fix(helm): share generated application secret render
XiaoSeS Nov 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .env.release.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,19 @@ SKILLHUB_TRUST_FORWARDED_PROTO=false
# Leave empty so a fixed-base image keeps its baked base; set to a sub-path to override.
SKILLHUB_WEB_BASE_PATH=

# 可选前端动作统计,默认关闭。配置方法见 docs/frontend-analytics-integration.md。
# 只放公开参数,不放平台密钥。JSON 保持一行;Compose .env 中可用单引号包裹。
# 修改后需重新创建 Web(up -d --force-recreate web),restart 不会重新读取本文件。
# Managed by runtime.sh analytics-enable/analytics-disable. For manual setup,
# restart Web after editing. Local Umami requires the external network overlay.
SKILLHUB_ANALYTICS_NETWORK_ENABLED=false
# Give each local installation a unique project and network name if sharing a host.
SKILLHUB_ANALYTICS_PROJECT=skillhub-analytics
SKILLHUB_ANALYTICS_NETWORK=skillhub-analytics
SKILLHUB_WEB_ANALYTICS_CONFIG=
# 仅 Web 代理使用的 HTTP(S) origin,无路径、凭证、查询或末尾斜杠。
SKILLHUB_WEB_ANALYTICS_UPSTREAM=

POSTGRES_BIND_ADDRESS=127.0.0.1
POSTGRES_PORT=5432
POSTGRES_DB=skillhub
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/pr-helm-chart.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ jobs:
- name: Validate configuration contracts
run: bash tests/configuration-contracts.sh

- name: Verify analytics smoke isolation
run: bash tests/analytics-smoke-test.sh

- name: Validate chart metadata
run: |
CHART_VERSION=$(helm show chart . | grep '^version:' | awk '{print $2}')
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/pr-scripts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ on:
- '.env.release.example'
- '.env.release.draft'
- 'compose.release.yml'
- 'compose.release.analytics.yml'
- 'compose.analytics-umami.yml'
- 'compose.storage.*.yml'
- 'deploy/storage/**'
- 'deploy/runtime-mirror-images.txt'
Expand Down Expand Up @@ -55,6 +57,7 @@ jobs:
- run: python -m unittest discover -s scanner/tests -p 'test_*.py'
- run: bash scripts/tests/publish-cli-test.sh
- run: bash scripts/tests/runtime-secret-test.sh
- run: bash scripts/tests/runtime-analytics-test.sh
- run: bash scripts/tests/validate-release-config-test.sh
- run: bash scripts/tests/storage-compose-test.sh
- run: bash scripts/tests/storage-compose-render-test.sh
Expand Down
28 changes: 28 additions & 0 deletions charts/skillhub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,29 @@ PVC UID 与绑定 PV 在升级前后保持不变。四个场景分别覆盖默
Sentinel、实际 MinIO S3 连接,以及由 Kubernetes API 接受的 TLS Ingress 路由。
默认清理自己创建的 namespace;设置 `KEEP_HELM_SMOKE=true` 可保留现场用于排查。

### 可选前端动作统计

默认关闭。`web.analytics.config` 接受 JSON 字符串,`web.analytics.upstream` 接受仅部署可见的
HTTP(S) origin。支持 Umami 和 DataFinder,不需要编辑 JS 或 Nginx;Umami 先创建网站取得 UUID,
DataFinder 先取得应用 app_id 和采集域名。
值放在 Web 专用 ConfigMap,只改统计配置时仅 Web 滚动更新、后端 Pod 不替换;旧标签页不会自动更新。字段、授权接口、初始化、隐私及验收见
[前端动作统计接入指南](../../docs/frontend-analytics-integration.md)。

统计专项验收脚本会发送测试事件、查询 `stats` 中的独立数据库,并对指定的已安装 Release
执行真实 `helm upgrade --reuse-values`,将统计规则改为空对象;它不是只读检查,也不会自动恢复规则。
仅用于隔离验收安装,要求单个 Ready Web/Server Pod、独立 `stats` 平台和提前配置好的 Web port-forward。
从仓库根目录运行(先替换网站 UUID、values 和安装名称):

```sh
ANALYTICS_SMOKE_WEB_URL=http://127.0.0.1:18082 \
bash charts/skillhub/tests/analytics-smoke.sh "<网站UUID>" values-acceptance.yaml sh1037 skillhub
```

第二个参数必须是该安装使用的配置;脚本复用已有 Release 值,不会隐式加载
`tests/test-values.yaml`。显式文件仍可覆盖原值,勿混用其他环境的 values 或重新生成 Secret。
脚本成功只证明代理入库及本次统计配置升级时 Web 滚动、Server 未替换;浏览器授权、
业务回归和真实生产配置仍须按接入指南验证。验收后用原安装配置再次升级以恢复规则。

### 高可用模式

```bash
Expand Down Expand Up @@ -160,6 +183,11 @@ helm -n skillhub upgrade -i skillhub ./charts/skillhub \
--set existingSecret=my-custom-secret
```

### 自动生成凭据的升级边界

应用 Secret 与 Server/GitHub Sync 的校验值在一次渲染中共用同一结果,真正修改凭据仍会触发相应滚动。
旧版 Chart 曾分别生成随机值;若已安装实例采用自动生成应用凭据,首次升级到此修复版本可能纠正校验值并额外滚动一次后端,Secret 内容保持不变。预留滚动窗口,不要为避免滚动重新生成或替换凭据。

### GitOps 稳定 Secret

Argo CD 等 GitOps 工具使用离线 `helm template`,无法通过 Helm `lookup` 读取集群
Expand Down
9 changes: 9 additions & 0 deletions charts/skillhub/templates/secret.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{{- define "skillhub.appSecretManifest" -}}
{{- /*
SkillHub 应用 Secret
- 内置 PostgreSQL/Redis:密码由 Bitnami 管理,从对应 Secret 读取
Expand Down Expand Up @@ -106,3 +107,11 @@ stringData:
skillhub-storage-s3-secret-key: {{ .Values.s3.secretKey | quote }}
{{- end }}
{{- end }}
{{ end -}}

{{- /* Reuse one render across the Secret resource and all checksum consumers.
Keep this cache on the render context, never in persisted user Values. */ -}}
{{- if not (hasKey . "_skillhubAppSecretManifest") -}}
{{- $_ := set . "_skillhubAppSecretManifest" (include "skillhub.appSecretManifest" .) -}}
{{- end -}}
{{- index . "_skillhubAppSecretManifest" -}}
2 changes: 1 addition & 1 deletion charts/skillhub/templates/validate.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
{{- fail (printf "web.basePath must be '/' or a normalized sub-path that starts and ends with '/' and has no '.'/'..' or empty segments (matches the runtime and release-config checks): %s" $webBasePath) -}}
{{- end -}}
{{- $firstSegment := index (splitList "/" $webBasePath) 1 -}}
{{- if has $firstSegment (list "api" "oauth2" "login" "assets" "registry" "nginx-health" ".well-known" "runtime-config.js") -}}
{{- if has $firstSegment (list "api" "oauth2" "login" "assets" "registry" "nginx-health" ".well-known" "runtime-config.js" "_skillhub-analytics") -}}
{{- fail (printf "web.basePath must not start with a segment reserved by the SkillHub server (%s); it would shadow the server's own Nginx location: %s" $firstSegment $webBasePath) -}}
{{- end -}}
{{- $suffix := trimSuffix "/" $webBasePath -}}
Expand Down
13 changes: 13 additions & 0 deletions charts/skillhub/templates/web-analytics-configmap.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{{- /*
Web-only analytics settings. Kept out of the shared ConfigMap so that changing
analytics only rolls Web; the server checksum hashes the shared ConfigMap.
*/}}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "skillhub.fullname" . }}-web-analytics
labels:
{{- include "skillhub.web.labels" . | nindent 4 }}
data:
config: {{ dig "analytics" "config" "" .Values.web | quote }}
upstream: {{ dig "analytics" "upstream" "" .Values.web | quote }}
10 changes: 10 additions & 0 deletions charts/skillhub/templates/web-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ spec:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-config
key: web-api-base-url
- name: SKILLHUB_WEB_ANALYTICS_CONFIG
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-web-analytics
key: config
- name: SKILLHUB_WEB_ANALYTICS_UPSTREAM
valueFrom:
configMapKeyRef:
name: {{ include "skillhub.fullname" . }}-web-analytics
key: upstream
{{- with .Values.web.extraEnv }}
{{- toYaml . | nindent 12 }}
{{- end }}
Expand Down
190 changes: 190 additions & 0 deletions charts/skillhub/tests/analytics-smoke-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
#!/usr/bin/env bash
# Exercise the installed-release smoke without a cluster or third-party packages.
set -euo pipefail
TEST_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
python3 - "${ANALYTICS_SMOKE_SCRIPT:-$TEST_DIR/analytics-smoke.sh}" <<'PY'
import copy
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile

script = Path(sys.argv[1]).resolve()
website = '00000000-0000-4000-8000-000000000037'
# JSON is valid Helm YAML. The caller file deliberately omits settings supplied
# at installation through other files/--set, especially private credentials.
original = {
'secrets': {'bootstrapAdminPassword': 'synthetic-private-bootstrap',
'downloadAnonCookieSecret': 'synthetic-private-cookie-secret'},
'postgresql': {'auth': {'password': 'synthetic-private-postgres'}},
'redis': {'auth': {'password': 'synthetic-private-redis'}},
'server': {'replicaCount': 1, 'image': {'tag': 'installed-server-tag'}},
'web': {'basePath': '/registry/', 'analytics': {
'upstream': 'http://original-stats.internal:3000', 'config': '{}'}},
'existingSecret': '',
}
caller = {'web': {'analytics': {'upstream': 'http://original-stats.internal:3000'}}}

# These fakes model Helm value layering and process state, not chart rendering.
# They fail on unexpected operations so this test cannot reach a real cluster.
fake = r'''#!/usr/bin/env python3
import copy, json, os, sys
from pathlib import Path
root = Path(os.environ['FAKE_STATE'])
name = Path(sys.argv[0]).name
args = sys.argv[1:]
scenario = os.environ['FAKE_SCENARIO']

def check(condition, message):
if not condition:
sys.exit(message)

def record(value):
with (root / 'calls').open('a') as f:
f.write(value + '\n')

def merge(base, values):
for key, value in values.items():
if isinstance(value, dict) and isinstance(base.get(key), dict):
merge(base[key], value)
else:
base[key] = copy.deepcopy(value)

if name == 'helm':
check(args[:2] == ['upgrade', os.environ['FAKE_RELEASE']], 'unexpected Helm operation')
check(args[args.index('-n') + 1] == os.environ['FAKE_NAMESPACE'], 'wrong release namespace')
check('--wait' in args and args[args.index('--timeout') + 1] == '5m', 'upgrade must wait')
original = json.loads((root / 'original.json').read_text())
effective = copy.deepcopy(original) if '--reuse-values' in args else {}
files = [Path(args[i + 1]) for i, arg in enumerate(args) if arg == '-f']
check(Path(os.environ['FAKE_CALLER']) in files, 'caller values were omitted')
for path in files:
# Reading the known repository fixture models the old bug as well.
text = path.read_text()
if text.startswith('{'):
values = json.loads(text)
else:
values = {}
stack = [(-1, values)]
for line in text.splitlines():
if not line.strip() or line.lstrip().startswith('#'):
continue
indent = len(line) - len(line.lstrip())
key, value = line.strip().split(':', 1)
while stack[-1][0] >= indent:
stack.pop()
parent = stack[-1][1]
value = value.strip()
parent[key] = value.strip("'\"") if value else {}
if not value:
stack.append((indent, parent[key]))
merge(effective, values)
expected = copy.deepcopy(original)
expected['web']['analytics']['config'] = effective.get('web', {}).get('analytics', {}).get('config')
if scenario == 'generated-profile':
check(effective.get('secrets') == original['secrets'],
'generated secret profile must retain empty values for lookup')
check(effective == expected, 'upgrade changed unrelated release values or secrets')
check(all(p.name != 'test-values.yaml' for p in files), 'repository fixture values injected')
config = json.loads(effective['web']['analytics']['config'])
check(config == {'enabled': True, 'adapter': 'umami', 'consent': 'granted',
'rules': {}, 'adapterConfig': {'websiteId': os.environ['FAKE_WEBSITE']}},
'wrong analytics upgrade')
(root / 'upgraded').touch()
record('upgrade: analytics only; installed values and secrets retained; no test fixture')
elif name == 'kubectl':
check(args[0] == '-n', 'namespace must be explicit')
namespace, command = args[1], args[2:]
if command[:2] == ['get', 'pods']:
check(namespace == os.environ['FAKE_NAMESPACE'], 'wrong pod namespace')
selector = command[command.index('-l') + 1]
component = selector.rsplit('=', 1)[-1]
check(component in ('server', 'web'), 'unexpected component')
upgraded = (root / 'upgraded').exists()
changed = upgraded and ((component == 'web' and scenario != 'web-stable') or
(component == 'server' and scenario == 'server-roll'))
uid = component + ('-after' if changed else '-before')
record('uid: ' + uid)
print(json.dumps({'items': [{'metadata': {'uid': uid}, 'status': {
'conditions': [{'type': 'Ready', 'status': 'True'}]}}]}))
elif command[:3] == ['exec', 'deployment/umami-postgres', '--']:
check(namespace == 'stats', 'event query must use independent stats namespace')
check(os.environ['FAKE_WEBSITE'] in command[-1] and "event_name='skill.card.open'" in command[-1],
'wrong event query')
print(11 if (root / 'sent').exists() and scenario != 'no-event' else 10)
record('event count')
elif command[:2] == ['rollout', 'status']:
check(command[2] == 'deployment/' + os.environ['FAKE_RELEASE'] + '-skillhub-web', 'wrong rollout')
check((root / 'upgraded').exists(), 'rollout before upgrade')
record('rollout: web')
else:
sys.exit('unexpected kubectl operation')
elif name == 'curl':
check(args[-1].startswith('http://127.0.0.1:18082/') or '--data' in args, 'unexpected Web URL')
if '-X' in args:
check(args[args.index('-X') + 1] == 'POST', 'expected event POST')
payload = json.loads(args[args.index('--data') + 1])['payload']
check(payload['website'] == os.environ['FAKE_WEBSITE'] and payload['name'] == 'skill.card.open',
'wrong event payload')
(root / 'sent').touch()
record('HTTP: event POST')
else:
check(args[-1].endswith('/_skillhub-analytics/script.js'), 'wrong tracker path')
record('HTTP: tracker GET')
print('200', end='')
else:
sys.exit('unexpected fake command')
'''

with tempfile.TemporaryDirectory(prefix='analytics-smoke-test.') as tmp:
root = Path(tmp)
bin_dir = root / 'bin'
bin_dir.mkdir()
shim = bin_dir / 'fake'
shim.write_text(fake)
shim.chmod(0o755)
for name in ('helm', 'kubectl', 'curl'):
(bin_dir / name).symlink_to(shim)
caller_file = root / 'installed-overrides.json'
caller_file.write_text(json.dumps(caller))
for scenario, failure in (
('generated-profile', None),
('success', None),
('server-roll', 'Server pod rolled unexpectedly'),
('web-stable', 'Web pod did not roll'),
('no-event', 'HTTP 200 but event not stored'),
):
state = root / scenario
state.mkdir()
installed = copy.deepcopy(original)
if scenario == 'generated-profile':
# Empty application-secret values preserve the chart's existing-Secret
# lookup path. This value-layer model does not perform that lookup.
installed['secrets'] = {'allowAutoGenerated': True,
'bootstrapAdminPassword': '',
'downloadAnonCookieSecret': ''}
(state / 'original.json').write_text(json.dumps(installed))
env = dict(os.environ, PATH=str(bin_dir) + os.pathsep + os.environ['PATH'],
FAKE_STATE=str(state), FAKE_SCENARIO=scenario,
FAKE_WEBSITE=website, FAKE_CALLER=str(caller_file),
FAKE_RELEASE='original-release', FAKE_NAMESPACE='original-namespace',
ANALYTICS_SMOKE_WEB_URL='http://127.0.0.1:18082', TMPDIR=str(state))
result = subprocess.run(['bash', str(script), website, str(caller_file),
env['FAKE_RELEASE'], env['FAKE_NAMESPACE']],
env=env, text=True, capture_output=True)
if failure is None:
assert result.returncode == 0, result.stdout + result.stderr
calls = (state / 'calls').read_text().splitlines()
assert calls.count('event count') == 2, calls
for call in ('uid: server-before', 'uid: web-before', 'uid: web-after', 'rollout: web'):
assert call in calls, call
assert calls.count('uid: server-before') == 2, calls
assert 'server UID unchanged' in result.stdout, result.stdout
else:
assert result.returncode != 0 and failure in result.stderr, result.stdout + result.stderr
assert not list(state.glob('skillhub-analytics-smoke.*')), 'temporary override was not cleaned'
print('PASS: ' + scenario)
print('Analytics smoke regression tests passed (fake Helm/kubectl/curl; no deployment).')
PY
Loading
Loading