fix(experience): consolidate demo account into sandbox@svc.plus

This commit is contained in:
Haitao Pan 2026-02-10 11:53:42 +08:00
parent c0bc2a04c9
commit b2cda61dd9
9 changed files with 364 additions and 25 deletions

View File

@ -0,0 +1,116 @@
# Skill: release-branch-policy
## Purpose
Standardize release branch policy across Cloud-Neutral Toolkit repos:
- `main` is the **preview** branch (fast iteration, integrates frequently).
- `release/*` branches are **production release lines** and must be protected.
- Updates to `release/*` happen via **local cherry-pick** by release managers (process gate).
This skill includes:
- A policy doc (this file)
- A ruleset JSON template (GitHub Rulesets API)
- A `gh` script to apply the ruleset to one or many repos
- A sync script to copy this skill into all local sub-repos
- A script to generate a cross-repo release manifest (for tag association)
Non-goals:
- This skill does NOT create/push `release/v0.1` or tags automatically.
## Policy
### Branch Roles
- `main`: preview
- Accepts PRs and merges normally.
- May be ahead of production at any time.
- `release/*`: production
- No force-push.
- Require linear history.
- Prefer "cherry-pick into release branch" as the only change mechanism (process).
- Restrict who can update release branches to release managers (enforced via GitHub Rulesets/Branch protection UI).
### “Cherry-Pick Only” Clarification
GitHub branch rules cannot reliably guarantee "only cherry-pick" as a technical constraint.
We treat it as a **process rule**:
1. A change lands in `main`.
2. Release manager cherry-picks specific commits onto `release/<version>`.
3. Release manager pushes the updated release branch.
### “No PR / No Push” Clarification
If you literally forbid both:
- PR merges to `release/*`, and
- any push to `release/*`
then the branch becomes non-updatable.
What we implement is:
- No force-push, no deletion, linear history (enforceable).
- Only release managers can update `release/*` (enforceable via "restrict updates" / bypass actors).
- "Cherry-pick only" (process rule).
### Tags
For milestone releases like `v0.1`:
- Use an annotated tag named `v0.1` (per-repo).
- Prefer tags on `release/<version>` tip.
If you need SemVer tags, follow governance: `<repo>-vX.Y.Z`.
### Cross-Repo Tag Association
Git tags are per-repo; GitHub does not provide a first-class "one tag links all repos" concept.
We represent "release v0.1 across repos" by committing a **release manifest** file in the control repo, generated from local git state:
- repo name
- release branch tip SHA
- tag tip SHA
Use: `skills/release-branch-policy/scripts/generate_release_manifest.sh v0.1`
## Ruleset Requirements (release/*)
Enforce at minimum:
- block deletion
- block force-push (non-fast-forward)
- require linear history
Optional (recommended if you have stable CI):
- require status checks
- require signed commits
## Tools
### 1) Apply Ruleset (GitHub Rulesets)
Script: `skills/release-branch-policy/scripts/apply_ruleset.sh`
- Applies (create/update) a repo ruleset targeting `refs/heads/release/*`
- Uses `gh api` and a JSON payload
- Does not modify branches/tags
### 2) Sync Skill Into All Local Sub-Repos
Script: `skills/release-branch-policy/scripts/sync_skill_to_subrepos.sh`
- Copies this skill folder into each local repo under `/Users/shenlan/workspaces/cloud-neutral-toolkit/*`
- Skips repos without `.git`
- Keeps existing files unless overwritten explicitly
### 3) Generate Release Manifest (Cross-Repo Association)
Script: `skills/release-branch-policy/scripts/generate_release_manifest.sh`
- Generates `releases/<version>.yaml` in the current working directory (default)
- Does not push or create refs
## Operator Checklist
- Confirm `main` is treated as preview across repos (docs + CI naming).
- Apply ruleset to every repo that has production releases.
- Document "cherry-pick only" in release runbooks.
- Verify bypass actors (release managers) in GitHub UI if needed.

View File

@ -0,0 +1,17 @@
{
"name": "Release Branch Protection (release/*)",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": {
"include": ["refs/heads/release/*"],
"exclude": []
}
},
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{ "type": "required_linear_history" }
]
}

View File

@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Apply GitHub Ruleset to protect release/* branches.
Usage:
apply_ruleset.sh <owner/repo> [<owner/repo> ...]
Notes:
- Requires: gh (authenticated), jq
- Does NOT create/push branches or tags.
- Ruleset payload is in: skills/release-branch-policy/references/ruleset.release-branches.json
EOF
}
if [[ $# -lt 1 || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
if ! command -v gh >/dev/null 2>&1; then
echo "missing: gh" >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "missing: jq" >&2
exit 1
fi
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PAYLOAD_FILE="${SKILL_DIR}/references/ruleset.release-branches.json"
if [[ ! -f "${PAYLOAD_FILE}" ]]; then
echo "payload not found: ${PAYLOAD_FILE}" >&2
exit 1
fi
NAME="$(jq -r '.name' < "${PAYLOAD_FILE}")"
for OWNER_REPO in "$@"; do
echo ">>> ${OWNER_REPO}"
# Find existing ruleset by name.
existing_id="$(
gh api "repos/${OWNER_REPO}/rulesets" --jq ".[] | select(.name == \"${NAME}\") | .id" 2>/dev/null || true
)"
if [[ -n "${existing_id}" ]]; then
echo "Updating ruleset id=${existing_id}"
gh api -X PUT "repos/${OWNER_REPO}/rulesets/${existing_id}" --input "${PAYLOAD_FILE}" >/dev/null
else
echo "Creating ruleset"
gh api -H "Accept: application/vnd.github+json" -X POST "repos/${OWNER_REPO}/rulesets" --input "${PAYLOAD_FILE}" >/dev/null
fi
done

View File

@ -0,0 +1,116 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Generate a cross-repo release manifest (read-only) from local git state.
Usage:
generate_release_manifest.sh <version> [--base <dir>] [--out <file>]
Examples:
generate_release_manifest.sh v0.1
generate_release_manifest.sh v0.1 --out releases/v0.1.yaml
generate_release_manifest.sh v0.1 --base /Users/shenlan/workspaces/cloud-neutral-toolkit
Notes:
- This script does NOT create/push branches or tags.
- It inspects local refs only; if your local remotes are stale, run 'git fetch --all --tags' per repo first.
- "Cross-repo association" is represented by this manifest file (repo -> release branch tip + tag tip).
EOF
}
if [[ $# -lt 1 || "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
VERSION="$1"
shift || true
BASE="/Users/shenlan/workspaces/cloud-neutral-toolkit"
OUT=""
while [[ $# -gt 0 ]]; do
case "$1" in
--base)
BASE="${2:-}"
shift 2
;;
--out)
OUT="${2:-}"
shift 2
;;
*)
echo "unknown arg: $1" >&2
usage >&2
exit 2
;;
esac
done
if [[ -z "${OUT}" ]]; then
mkdir -p "releases"
OUT="releases/${VERSION}.yaml"
fi
if [[ ! -d "${BASE}" ]]; then
echo "missing base dir: ${BASE}" >&2
exit 1
fi
REL_BRANCH="release/${VERSION}"
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
{
echo "version: ${VERSION}"
echo "generated_at_utc: \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\""
echo "base_dir: \"${BASE}\""
echo "release_branch: \"${REL_BRANCH}\""
echo "repos:"
} >"$tmp"
for d in "${BASE}"/*; do
[[ -d "$d" ]] || continue
[[ -d "$d/.git" ]] || continue
name="$(basename "$d")"
remote_url="$(cd "$d" && git config --get remote.origin.url 2>/dev/null || true)"
rel_ref=""
rel_sha=""
if (cd "$d" && git show-ref --verify --quiet "refs/remotes/origin/${REL_BRANCH}"); then
rel_ref="refs/remotes/origin/${REL_BRANCH}"
rel_sha="$(cd "$d" && git rev-parse "refs/remotes/origin/${REL_BRANCH}")"
elif (cd "$d" && git show-ref --verify --quiet "refs/heads/${REL_BRANCH}"); then
rel_ref="refs/heads/${REL_BRANCH}"
rel_sha="$(cd "$d" && git rev-parse "refs/heads/${REL_BRANCH}")"
fi
tag_sha=""
if (cd "$d" && git show-ref --tags --quiet --verify "refs/tags/${VERSION}"); then
tag_sha="$(cd "$d" && git rev-parse "${VERSION}^{}" 2>/dev/null || git rev-parse "${VERSION}" 2>/dev/null || true)"
fi
{
echo " - name: \"${name}\""
echo " path: \"${d}\""
if [[ -n "${remote_url}" ]]; then
echo " remote: \"${remote_url}\""
else
echo " remote: \"\""
fi
echo " release:"
echo " branch: \"${REL_BRANCH}\""
echo " ref: \"${rel_ref}\""
echo " sha: \"${rel_sha}\""
echo " tag:"
echo " name: \"${VERSION}\""
echo " sha: \"${tag_sha}\""
} >>"$tmp"
done
mv "$tmp" "$OUT"
echo "wrote: ${OUT}"

View File

@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Copy this skill into all local Cloud-Neutral Toolkit sub-repos.
Usage:
sync_skill_to_subrepos.sh
Copies:
skills/release-branch-policy -> <repo>/skills/release-branch-policy
Notes:
- Local path root: /Users/shenlan/workspaces/cloud-neutral-toolkit
- Skips directories without .git
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
SRC_SKILL="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SRC_REPO_ROOT="$(cd "${SRC_SKILL}/../.." && pwd)"
realpath_py() {
python3 - "$1" <<'PY'
import os, sys
print(os.path.realpath(sys.argv[1]))
PY
}
BASE="/Users/shenlan/workspaces/cloud-neutral-toolkit"
if [[ ! -d "${BASE}" ]]; then
echo "missing base dir: ${BASE}" >&2
exit 1
fi
for d in "${BASE}"/*; do
[[ -d "$d" ]] || continue
[[ -d "$d/.git" ]] || continue
# Don't delete our own source while syncing.
if [[ "$(realpath_py "$d")" == "$(realpath_py "$SRC_REPO_ROOT")" ]]; then
echo ">>> skipping source repo $d"
continue
fi
mkdir -p "$d/skills"
echo ">>> syncing to $d"
rm -rf "$d/skills/release-branch-policy"
cp -R "${SRC_SKILL}" "$d/skills/release-branch-policy"
done

View File

@ -184,22 +184,12 @@ export async function GET(request: NextRequest) {
const normalizedUsernameLower = String(rawUser.username ?? '').trim().toLowerCase()
const normalizedNameLower = String(rawUser.name ?? '').trim().toLowerCase()
const identifierLower = (identifier ?? '').toLowerCase()
const isNamedDemo =
normalizedUsernameLower === 'demo' ||
normalizedUsernameLower.startsWith('demo-') ||
normalizedNameLower === 'demo' ||
normalizedNameLower.startsWith('demo-') ||
identifierLower === 'demo' ||
identifierLower.startsWith('demo-')
const normalizedReadOnly =
Boolean(rawUser.readOnly) ||
normalizedGroups.some((group) => group.toLowerCase() === 'readonly role') ||
rawRole === 'readonly' ||
rawRole === 'read_only' ||
String(rawUser.email ?? '').trim().toLowerCase() === 'demo@svc.plus' ||
String(rawUser.email ?? '').trim().toLowerCase() === 'sandbox@svc.plus' ||
isNamedDemo
String(rawUser.email ?? '').trim().toLowerCase() === 'sandbox@svc.plus'
const normalizedProxyUuid =
typeof rawUser.proxyUuid === 'string' && rawUser.proxyUuid.trim().length > 0
? rawUser.proxyUuid.trim()

View File

@ -259,22 +259,10 @@ async function fetchSessionUser(): Promise<User | null> {
.map((value) => value.trim())
: []
const normalizedEmail = typeof email === 'string' ? email.trim().toLowerCase() : ''
const normalizedUsernameLower = normalizedUsername?.trim().toLowerCase() ?? ''
const normalizedNameLower = normalizedName?.trim().toLowerCase() ?? ''
const identifierLower = identifier.toLowerCase()
const isNamedDemo =
normalizedUsernameLower === 'demo' ||
normalizedUsernameLower.startsWith('demo-') ||
normalizedNameLower === 'demo' ||
normalizedNameLower.startsWith('demo-') ||
identifierLower === 'demo' ||
identifierLower.startsWith('demo-')
const inferredReadOnly =
rawRole === 'readonly' ||
rawRole === 'read_only' ||
normalizedEmail === 'demo@svc.plus' ||
normalizedEmail === 'sandbox@svc.plus' ||
isNamedDemo ||
normalizedGroups.some((value) => value.toLowerCase() === 'readonly role')
const normalizedReadOnly = Boolean(sessionUser.readOnly) || inferredReadOnly
const normalizedProxyUuid =

View File

@ -59,7 +59,7 @@ export default function UserOverview({ hideMfaMainPrompt = false }: UserOverview
const docsUrl = mfaCopy.actions.docsUrl
const normalizedEmail = user?.email?.toLowerCase() ?? ''
const isGuestSandboxReadOnly = Boolean(
user?.isReadOnly && (normalizedEmail === 'sandbox@svc.plus' || normalizedEmail === 'demo@svc.plus'),
user?.isReadOnly && (normalizedEmail === 'sandbox@svc.plus'),
)
const guestUuidExpiresAtText = useMemo(() => {
if (!isGuestSandboxReadOnly || !user?.proxyUuidExpiresAt) {

View File

@ -65,7 +65,7 @@ export default function UserCenterAgentRoute() {
const [boundNode, setBoundNode] = useState<VlessNode | null>(null)
const normalizedEmail = user?.email?.toLowerCase() ?? ''
const isGuestSandboxReadOnly = Boolean(
user?.isReadOnly && (normalizedEmail === 'sandbox@svc.plus' || normalizedEmail === 'demo@svc.plus'),
user?.isReadOnly && (normalizedEmail === 'sandbox@svc.plus'),
)
const visibleNodes = useMemo(() => {
return (nodes ?? []).filter((node) => {