feat(deploy): SemVer 版本管理 + 自动化发版流水线
- deploy.yml: tag 触发(v*.*.*),四阶段调脚本(compile/test/release/deploy) - manual.yml: workflow_dispatch 支持手动回滚,checkout 指定 tag - scripts/ci/compile.sh: 构建 Go 二进制 + Flutter Web,打包 dist/ - scripts/ci/test.sh: go test + flutter analyze - scripts/ci/release.sh: 解析 CHANGELOG.md → 更新 version.yaml → 创建 Forgejo Release - scripts/ci/deploy.sh: 从 Release 下载产物(自动/手动均可)→ 部署到 EC2 - CHANGELOG.md: Keep a Changelog 格式,初始 v1.0.0 条目 - backend: GetRelease 改读 version.yaml,移除 ENV 变量依赖 - backend/config/version.yaml: 重置为 v1.0.0 - web/download.html: 动态拉取 /api/v1/public/release 展示版本号和更新内容 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
# compile.sh <tag> — build backend + Flutter web, package into dist/
|
||||
set -euo pipefail
|
||||
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
export GOPROXY="${GOPROXY:-https://goproxy.cn,direct}"
|
||||
export PUB_HOSTED_URL="${PUB_HOSTED_URL:-https://pub.flutter-io.cn}"
|
||||
export FLUTTER_STORAGE_BASE_URL="${FLUTTER_STORAGE_BASE_URL:-https://storage.flutter-io.cn}"
|
||||
|
||||
TAG="$1"
|
||||
VER="${TAG#v}"
|
||||
|
||||
echo "==> compile: version=$VER"
|
||||
|
||||
# Sync Flutter pubspec version
|
||||
sed -i '' "s/^version:.*/version: ${VER}+1/" client/pubspec.yaml
|
||||
|
||||
# Build Go backend (linux/amd64 for EC2)
|
||||
cd backend
|
||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o jiu-server .
|
||||
cd ..
|
||||
|
||||
# Build Flutter Web
|
||||
cd client
|
||||
flutter build web --release \
|
||||
--base-href=/app/ \
|
||||
"--dart-define=BASE_URL=https://jiu.51yanmei.com" \
|
||||
"--dart-define=PUBLIC_URL=https://jiu.51yanmei.com" \
|
||||
"--dart-define=APP_VERSION=${TAG}"
|
||||
cd ..
|
||||
|
||||
# Package artifacts
|
||||
mkdir -p dist
|
||||
mv backend/jiu-server dist/jiu-server
|
||||
tar -czf dist/web.tar.gz -C client/build web
|
||||
|
||||
echo "==> compile: done — dist/ contents:"
|
||||
ls -lh dist/
|
||||
Executable
+131
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy.sh <tag> — deploy release to EC2
|
||||
# Works in two modes:
|
||||
# 1. Automated (after compile.sh): uses dist/ built in the same job
|
||||
# 2. Manual/rollback: downloads assets from Forgejo Release by tag
|
||||
set -euo pipefail
|
||||
|
||||
TAG="$1"
|
||||
|
||||
echo "==> deploy: tag=${TAG}"
|
||||
|
||||
# Download from Forgejo if dist/ was not built in this job
|
||||
if [ ! -f dist/jiu-server ] || [ ! -f dist/web.tar.gz ] || [ ! -f dist/configs.tar.gz ]; then
|
||||
echo "==> deploy: dist/ incomplete — downloading assets for ${TAG} from Forgejo"
|
||||
mkdir -p dist
|
||||
|
||||
curl -sf \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
"${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/tags/${TAG}" \
|
||||
-o /tmp/release-info.json
|
||||
|
||||
python3 - <<'PYEOF'
|
||||
import json, urllib.request, os, sys
|
||||
|
||||
with open('/tmp/release-info.json') as f:
|
||||
release = json.load(f)
|
||||
|
||||
token = os.environ['FORGEJO_TOKEN']
|
||||
forgejo_url = os.environ['FORGEJO_URL']
|
||||
repo = os.environ['GITEA_REPOSITORY']
|
||||
release_id = release['id']
|
||||
|
||||
for asset in release.get('assets', []):
|
||||
url = f"{forgejo_url}/api/v1/repos/{repo}/releases/{release_id}/assets/{asset['id']}"
|
||||
req = urllib.request.Request(url, headers={'Authorization': f'token {token}'})
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
with open(f"dist/{asset['name']}", 'wb') as out:
|
||||
out.write(resp.read())
|
||||
print(f"Downloaded: {asset['name']}")
|
||||
PYEOF
|
||||
fi
|
||||
|
||||
echo "==> deploy: dist/ contents:"
|
||||
ls -lh dist/
|
||||
|
||||
# Extract configs (nginx, version.yaml, etc.)
|
||||
rm -rf /tmp/jiu-configs
|
||||
mkdir -p /tmp/jiu-configs
|
||||
tar -xzf dist/configs.tar.gz -C /tmp/jiu-configs
|
||||
|
||||
# Extract Flutter web build
|
||||
rm -rf /tmp/jiu-web-new
|
||||
mkdir -p /tmp/jiu-web-new
|
||||
tar -xzf dist/web.tar.gz -C /tmp/jiu-web-new --strip-components=1
|
||||
|
||||
# Setup SSH
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s' "${EC2_SSH_KEY}" > ~/.ssh/ec2_deploy.pem
|
||||
chmod 600 ~/.ssh/ec2_deploy.pem
|
||||
ssh-keyscan -H "${EC2_HOST}" >> ~/.ssh/known_hosts
|
||||
|
||||
SSH="ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no"
|
||||
SCP="scp -O -i ~/.ssh/ec2_deploy.pem"
|
||||
|
||||
echo "==> deploy: uploading files to EC2"
|
||||
|
||||
# Upload backend binary
|
||||
${SCP} dist/jiu-server "${EC2_USER}@${EC2_HOST}:/tmp/jiu-server"
|
||||
|
||||
# Upload version.yaml
|
||||
${SCP} /tmp/jiu-configs/backend/config/version.yaml "${EC2_USER}@${EC2_HOST}:/tmp/version.yaml"
|
||||
|
||||
# Upload nginx config
|
||||
${SCP} /tmp/jiu-configs/deploy/nginx-jiu.conf "${EC2_USER}@${EC2_HOST}:/tmp/nginx-jiu.conf"
|
||||
|
||||
# Upload Flutter web
|
||||
rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \
|
||||
/tmp/jiu-web-new/ "${EC2_USER}@${EC2_HOST}:/tmp/jiu-web-new/"
|
||||
|
||||
# Upload marketing site (from repo checkout)
|
||||
rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \
|
||||
web/ "${EC2_USER}@${EC2_HOST}:/tmp/jiu-marketing-new/"
|
||||
|
||||
echo "==> deploy: running remote deploy"
|
||||
|
||||
${SSH} "${EC2_USER}@${EC2_HOST}" << 'ENDSSH'
|
||||
set -e
|
||||
|
||||
# Replace backend binary
|
||||
sudo systemctl stop jiu
|
||||
cp /tmp/jiu-server /opt/jiu/backend/jiu-server
|
||||
chmod +x /opt/jiu/backend/jiu-server
|
||||
|
||||
# Update version config
|
||||
mkdir -p /opt/jiu/backend/config
|
||||
cp /tmp/version.yaml /opt/jiu/backend/config/version.yaml
|
||||
|
||||
# Start and health check
|
||||
sudo systemctl start jiu
|
||||
echo "Waiting for health check..."
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:8080/health > /dev/null 2>&1; then
|
||||
echo "Health check passed"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
curl -sf http://localhost:8080/health > /dev/null || { echo "Health check failed!"; exit 1; }
|
||||
|
||||
# Swap Flutter web app (atomic)
|
||||
rm -rf /opt/jiu/web-old
|
||||
mv /opt/jiu/web /opt/jiu/web-old 2>/dev/null || true
|
||||
mv /tmp/jiu-web-new /opt/jiu/web
|
||||
|
||||
# Update marketing site
|
||||
mkdir -p /opt/jiu/marketing
|
||||
rsync -a --delete /tmp/jiu-marketing-new/ /opt/jiu/marketing/
|
||||
rm -rf /tmp/jiu-marketing-new
|
||||
|
||||
# Update nginx config and reload
|
||||
sudo cp /tmp/nginx-jiu.conf /etc/nginx/conf.d/jiu.conf
|
||||
sudo nginx -t && sudo nginx -s reload
|
||||
|
||||
echo "Deploy complete!"
|
||||
ENDSSH
|
||||
|
||||
# Cleanup SSH key
|
||||
rm -f ~/.ssh/ec2_deploy.pem
|
||||
rm -rf /tmp/jiu-configs /tmp/jiu-web-new
|
||||
|
||||
echo "==> deploy: done"
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env bash
|
||||
# release.sh <tag> — update version.yaml, package configs, create Forgejo Release
|
||||
set -euo pipefail
|
||||
|
||||
TAG="$1"
|
||||
VER="${TAG#v}"
|
||||
|
||||
echo "==> release: tag=${TAG} version=${VER}"
|
||||
|
||||
# Extract release notes from CHANGELOG.md (first summary line of the latest section)
|
||||
RELEASE_NOTES=$(python3 - <<'PYEOF'
|
||||
import re, sys
|
||||
try:
|
||||
with open('CHANGELOG.md') as f:
|
||||
content = f.read()
|
||||
parts = re.split(r'\n## ', '\n' + content)
|
||||
if len(parts) > 1:
|
||||
section = parts[1]
|
||||
lines = section.strip().split('\n')
|
||||
notes = []
|
||||
for line in lines[1:]:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith('#'):
|
||||
break
|
||||
if stripped:
|
||||
notes.append(stripped)
|
||||
print('\n'.join(notes) if notes else lines[0])
|
||||
else:
|
||||
print('')
|
||||
except Exception as e:
|
||||
print('', file=sys.stderr)
|
||||
print('')
|
||||
PYEOF
|
||||
)
|
||||
|
||||
echo "==> release: release_notes=${RELEASE_NOTES}"
|
||||
|
||||
# Update backend/config/version.yaml
|
||||
python3 - "${VER}" "${RELEASE_NOTES}" <<'PYEOF'
|
||||
import sys
|
||||
ver = sys.argv[1]
|
||||
notes = sys.argv[2]
|
||||
lines = []
|
||||
with open('backend/config/version.yaml') as f:
|
||||
for line in f:
|
||||
if line.startswith('version:'):
|
||||
lines.append('version: "' + ver + '"\n')
|
||||
elif line.startswith('build_number:'):
|
||||
try:
|
||||
build = int(line.split(':', 1)[1].strip()) + 1
|
||||
except Exception:
|
||||
build = 1
|
||||
lines.append('build_number: ' + str(build) + '\n')
|
||||
elif line.startswith('release_notes:'):
|
||||
lines.append('release_notes: "' + notes.replace('\\', '\\\\').replace('"', '\\"') + '"\n')
|
||||
else:
|
||||
lines.append(line)
|
||||
with open('backend/config/version.yaml', 'w') as f:
|
||||
f.writelines(lines)
|
||||
print('version.yaml updated to', ver)
|
||||
PYEOF
|
||||
|
||||
# Package configs.tar.gz (includes updated version.yaml)
|
||||
tar -czf dist/configs.tar.gz \
|
||||
deploy/nginx-jiu.conf \
|
||||
deploy/jiu.service \
|
||||
deploy/production.env.template \
|
||||
deploy/setup-ec2.sh \
|
||||
deploy/docker-compose.yml \
|
||||
deploy/docker-compose.jiu.yml \
|
||||
backend/config/version.yaml
|
||||
|
||||
echo "==> release: creating Forgejo Release ${TAG}"
|
||||
|
||||
# Build release body from CHANGELOG excerpt
|
||||
BODY=$(python3 - "${TAG}" "${RELEASE_NOTES}" <<'PYEOF'
|
||||
import sys, json
|
||||
tag = sys.argv[1]
|
||||
notes = sys.argv[2]
|
||||
body = '## ' + tag + '\n\n' + notes
|
||||
print(json.dumps(body))
|
||||
PYEOF
|
||||
)
|
||||
|
||||
RESP=$(curl -sf -X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases" \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${TAG}\",\"name\":\"Release ${TAG}\",\"body\":${BODY},\"draft\":false,\"prerelease\":false}")
|
||||
|
||||
echo "==> release: API response: ${RESP}"
|
||||
|
||||
RELEASE_ID=$(python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" <<< "${RESP}")
|
||||
echo "==> release: release_id=${RELEASE_ID}"
|
||||
|
||||
# Upload assets
|
||||
curl -sf -X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets" \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-F "attachment=@dist/jiu-server"
|
||||
echo "==> release: uploaded jiu-server"
|
||||
|
||||
curl -sf -X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets" \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-F "attachment=@dist/web.tar.gz"
|
||||
echo "==> release: uploaded web.tar.gz"
|
||||
|
||||
curl -sf -X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets" \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
-F "attachment=@dist/configs.tar.gz"
|
||||
echo "==> release: uploaded configs.tar.gz"
|
||||
|
||||
echo "==> release: done — Release ${TAG} created"
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# test.sh — run backend and frontend checks
|
||||
set -euo pipefail
|
||||
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
export GOPROXY="${GOPROXY:-https://goproxy.cn,direct}"
|
||||
export PUB_HOSTED_URL="${PUB_HOSTED_URL:-https://pub.flutter-io.cn}"
|
||||
export FLUTTER_STORAGE_BASE_URL="${FLUTTER_STORAGE_BASE_URL:-https://storage.flutter-io.cn}"
|
||||
|
||||
echo "==> test: go test"
|
||||
cd backend
|
||||
go test ./...
|
||||
cd ..
|
||||
|
||||
echo "==> test: flutter analyze"
|
||||
cd client
|
||||
flutter analyze
|
||||
cd ..
|
||||
|
||||
echo "==> test: done"
|
||||
Reference in New Issue
Block a user