Skip to content

feat: complete implementation (#500) #503

feat: complete implementation (#500)

feat: complete implementation (#500) #503

Workflow file for this run

name: CD - Build and Deploy
on:
push:
branches: [main]
tags:
- 'v*'
workflow_dispatch:
inputs:
channel:
description: 'Build channel'
required: false
default: 'beta'
type: choice
options:
- beta
- stable
# 设置 GITHUB_TOKEN 权限
permissions:
contents: write
pages: write
id-token: write
# 只允许一个并发部署
concurrency:
group: 'pages'
cancel-in-progress: false
env:
NODE_VERSION: '24'
PNPM_VERSION: '10.28.0'
jobs:
# ==================== Self-hosted 快速构建 (无 action 下载) ====================
build-fast:
if: vars.USE_SELF_HOSTED == 'true'
runs-on: self-hosted
timeout-minutes: 30
outputs:
version: ${{ steps.version.outputs.version }}
channel: ${{ steps.channel.outputs.channel }}
steps:
- name: Git checkout
run: |
COMMIT="${{ github.sha }}"
EXCLUDES="-e .turbo -e '**/.turbo' -e node_modules -e '**/node_modules' -e 'packages/*/dist' -e 'miniapps/*/dist'"
if [ ! -d ".git" ]; then
git clone --depth=1 https://github.com/${{ github.repository }}.git .
git fetch origin $COMMIT --depth=1 --tags --force
git checkout -f $COMMIT
else
git fetch origin $COMMIT --depth=1 --tags --force
git checkout -f $COMMIT
eval "git clean -fdx $EXCLUDES"
fi
- name: Determine channel
id: channel
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
echo "channel=stable" >> $GITHUB_OUTPUT
echo "Channel: stable (tag trigger)"
elif [[ "${{ github.event.inputs.channel }}" == "stable" ]]; then
echo "channel=stable" >> $GITHUB_OUTPUT
echo "Channel: stable (manual trigger)"
else
echo "channel=beta" >> $GITHUB_OUTPUT
echo "Channel: beta"
fi
- name: Get version
id: version
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
else
VERSION=$(node -p "require('./package.json').version")
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Install & Build
env:
GH_TOKEN: ${{ github.token }}
CHANNEL: ${{ steps.channel.outputs.channel }}
VITEPRESS_BASE: ${{ vars.VITEPRESS_BASE || format('/{0}/', github.event.repository.name) }}
SITE_ORIGIN: ${{ format('https://{0}.github.io', github.repository_owner) }}
VITE_DEV_MODE: ${{ steps.channel.outputs.channel == 'beta' && 'true' || 'false' }}
run: |
pnpm install --frozen-lockfile
# 类型检查和测试
pnpm turbo run typecheck:run test:run
# 构建 Web 和 DWEB 版本
SERVICE_IMPL=web pnpm build
rm -rf dist-web
mv dist dist-web
SERVICE_IMPL=dweb pnpm build
rm -rf dist-dweb
mv dist dist-dweb
# Plaoc bundle
if command -v plaoc &> /dev/null; then
plaoc bundle ./dist-dweb -c ./ -o ./dists
else
mkdir -p dists
cp -r dist-dweb/* dists/
fi
# 准备 webapp 目录
mkdir -p docs/public/webapp docs/public/webapp-dev
if [[ "$CHANNEL" == "stable" ]]; then
cp -r dist-web/* docs/public/webapp/
if gh release download --pattern 'bfmpay-web-beta.zip' --dir /tmp -R ${{ github.repository }} 2>/dev/null; then
unzip -q /tmp/bfmpay-web-beta.zip -d docs/public/webapp-dev/
else
cp -r dist-web/* docs/public/webapp-dev/
fi
else
cp -r dist-web/* docs/public/webapp-dev/
if gh release download --pattern 'bfmpay-web.zip' --dir /tmp -R ${{ github.repository }} 2>/dev/null; then
unzip -q /tmp/bfmpay-web.zip -d docs/public/webapp/
else
cp -r dist-web/* docs/public/webapp/
fi
fi
# 生成 Web ZIP 下载文件(供 download 页面直接下载)
rm -f docs/public/webapp/bfmpay-web.zip docs/public/webapp-dev/bfmpay-web-beta.zip
(cd docs/public/webapp && zip -qr bfmpay-web.zip . -x "*.zip")
(cd docs/public/webapp-dev && zip -qr bfmpay-web-beta.zip . -x "*.zip")
# 构建 Storybook
pnpm build-storybook
mkdir -p docs/public/storybook
cp -r storybook-static/* docs/public/storybook/
# 构建 VitePress
pnpm docs:build
# 准备 GitHub Pages
mkdir -p gh-pages
cp -r docs/.vitepress/dist/* gh-pages/
touch gh-pages/.nojekyll
CHANNEL="${{ steps.channel.outputs.channel }}"
# 准备当前渠道的 DWEB 资源
if [ -d "dists" ] && [ "$(ls -A dists)" ]; then
if [[ "$CHANNEL" == "stable" ]]; then
DWEB_DIR="gh-pages/dweb"
DWEB_ZIP="bfmpay-dweb.zip"
DWEB_PATH="dweb"
else
DWEB_DIR="gh-pages/dweb-dev"
DWEB_ZIP="bfmpay-dweb-beta.zip"
DWEB_PATH="dweb-dev"
fi
mkdir -p "$DWEB_DIR"
cp -r dists/* "$DWEB_DIR/"
if [ -d "public/logos" ]; then
mkdir -p "$DWEB_DIR/logos"
cp -r public/logos/* "$DWEB_DIR/logos/"
if [ -f "public/logos/logo-256.webp" ]; then
cp public/logos/logo-256.webp "$DWEB_DIR/logo-256.webp"
fi
fi
if [ -f "$DWEB_DIR/metadata.json" ]; then
DWEB_DIR="$DWEB_DIR" DWEB_ZIP="$DWEB_ZIP" DWEB_PATH="$DWEB_PATH" SITE_ORIGIN="$SITE_ORIGIN" VITEPRESS_BASE="$VITEPRESS_BASE" node - <<'NODE'
const fs = require('fs');
const path = require('path');
const dwebDir = process.env.DWEB_DIR;
const zipName = process.env.DWEB_ZIP;
const dwebPath = process.env.DWEB_PATH || '';
const siteOrigin = process.env.SITE_ORIGIN || '';
const basePath = process.env.VITEPRESS_BASE || '/';
const metadataPath = path.join(dwebDir, 'metadata.json');
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
const logoFileName = 'logo-256.webp';
const normalizedPath = dwebPath.replace(/^\/+/, '').replace(/\/$/, '');
const baseUrl = siteOrigin ? new URL(basePath, siteOrigin).toString() : '';
const logoPath = normalizedPath ? `${normalizedPath}/${logoFileName}` : logoFileName;
const logoUrl = baseUrl ? new URL(logoPath, baseUrl).toString() : logoFileName;
if (metadata.logo) {
const lower = String(metadata.logo).toLowerCase();
if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) {
metadata.logo = logoUrl;
}
} else {
metadata.logo = logoUrl;
}
if (Array.isArray(metadata.icons)) {
metadata.icons = metadata.icons.map((icon) => {
if (!icon?.src) return icon;
const src = String(icon.src);
const lower = src.toLowerCase();
if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) {
return { ...icon, src: logoUrl };
}
return icon;
});
}
const bundleUrl = typeof metadata.bundle_url === 'string' ? metadata.bundle_url : '';
if (bundleUrl) {
const bundleFile = bundleUrl.replace(/^\.\//, '');
const bundlePath = path.join(dwebDir, bundleFile);
if (fs.existsSync(bundlePath)) {
fs.copyFileSync(bundlePath, path.join(dwebDir, zipName));
} else {
console.warn(`DWEB bundle not found: ${bundlePath}`);
}
} else {
console.warn('metadata.json missing bundle_url');
}
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
NODE
else
echo "metadata.json missing in $DWEB_DIR"
fi
else
echo "No DWEB assets found, skipping gh-pages DWEB publish"
fi
# beta 渠道尝试同步 stable DWEB 资源(从 release)
if [[ "$CHANNEL" != "stable" ]]; then
STABLE_TAG=$(gh release list --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName' -R ${{ github.repository }} || true)
if [ -n "$STABLE_TAG" ]; then
mkdir -p /tmp/stable-dweb
gh release download "$STABLE_TAG" --pattern 'metadata.json' --dir /tmp/stable-dweb -R ${{ github.repository }} || true
if [ -f "/tmp/stable-dweb/metadata.json" ]; then
BUNDLE_FILE=$(node -e "const fs=require('fs');const m=JSON.parse(fs.readFileSync('/tmp/stable-dweb/metadata.json','utf8'));console.log((m.bundle_url||'').replace(/^\\.\\//,''));")
if [ -n "$BUNDLE_FILE" ]; then
gh release download "$STABLE_TAG" --pattern "$BUNDLE_FILE" --dir /tmp/stable-dweb -R ${{ github.repository }} || true
fi
mkdir -p gh-pages/dweb
cp /tmp/stable-dweb/metadata.json gh-pages/dweb/
if [ -n "$BUNDLE_FILE" ] && [ -f "/tmp/stable-dweb/$BUNDLE_FILE" ]; then
cp "/tmp/stable-dweb/$BUNDLE_FILE" gh-pages/dweb/
cp "/tmp/stable-dweb/$BUNDLE_FILE" gh-pages/dweb/bfmpay-dweb.zip
fi
if [ -d "public/logos" ]; then
mkdir -p gh-pages/dweb/logos
cp -r public/logos/* gh-pages/dweb/logos/
if [ -f "public/logos/logo-256.webp" ]; then
cp public/logos/logo-256.webp gh-pages/dweb/logo-256.webp
fi
fi
if [ -f "gh-pages/dweb/metadata.json" ]; then
DWEB_PATH="dweb" SITE_ORIGIN="$SITE_ORIGIN" VITEPRESS_BASE="$VITEPRESS_BASE" node - <<'NODE'
const fs = require('fs');
const path = require('path');
const metadataPath = path.join('gh-pages', 'dweb', 'metadata.json');
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
const logoFileName = 'logo-256.webp';
const dwebPath = process.env.DWEB_PATH || '';
const siteOrigin = process.env.SITE_ORIGIN || '';
const basePath = process.env.VITEPRESS_BASE || '/';
const normalizedPath = dwebPath.replace(/^\/+/, '').replace(/\/$/, '');
const baseUrl = siteOrigin ? new URL(basePath, siteOrigin).toString() : '';
const logoPath = normalizedPath ? `${normalizedPath}/${logoFileName}` : logoFileName;
const logoUrl = baseUrl ? new URL(logoPath, baseUrl).toString() : logoFileName;
if (metadata.logo) {
const lower = String(metadata.logo).toLowerCase();
if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) {
metadata.logo = logoUrl;
}
} else {
metadata.logo = logoUrl;
}
if (Array.isArray(metadata.icons)) {
metadata.icons = metadata.icons.map((icon) => {
if (!icon?.src) return icon;
const src = String(icon.src);
const lower = src.toLowerCase();
if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) {
return { ...icon, src: logoUrl };
}
return icon;
});
}
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
NODE
fi
fi
fi
fi
- name: Create release artifacts
if: steps.channel.outputs.channel == 'stable'
env:
CHANNEL: ${{ steps.channel.outputs.channel }}
run: |
mkdir -p release
VERSION="${{ steps.version.outputs.version }}"
RELEASE_TAG="v${VERSION}"
RELEASE_BASE_URL="https://github.com/${{ github.repository }}/releases/download/${RELEASE_TAG}/"
RELEASE_TAG="v${VERSION}"
RELEASE_BASE_URL="https://github.com/${{ github.repository }}/releases/download/${RELEASE_TAG}/"
if [[ "$CHANNEL" == "stable" ]]; then
cd dist-web && zip -r ../release/bfmpay-web.zip . && cd ..
cp release/bfmpay-web.zip "release/bfmpay-web-${VERSION}.zip"
if [ -d "dists" ] && [ "$(ls -A dists)" ]; then
cd dists && zip -r ../release/bfmpay-dweb.zip . && cd ..
else
cd dist-dweb && zip -r ../release/bfmpay-dweb.zip . && cd ..
fi
cp release/bfmpay-dweb.zip "release/bfmpay-dweb-${VERSION}.zip"
else
cd dist-web && zip -r ../release/bfmpay-web-beta.zip . && cd ..
cp release/bfmpay-web-beta.zip "release/bfmpay-web-${VERSION}-beta.zip"
if [ -d "dists" ] && [ "$(ls -A dists)" ]; then
cd dists && zip -r ../release/bfmpay-dweb-beta.zip . && cd ..
else
cd dist-dweb && zip -r ../release/bfmpay-dweb-beta.zip . && cd ..
fi
cp release/bfmpay-dweb-beta.zip "release/bfmpay-dweb-${VERSION}-beta.zip"
fi
# Copy metadata.json for dweb://install link
if [ -f "dists/metadata.json" ]; then
cp dists/metadata.json release/metadata.json
fi
# Copy dweb bundle + logos for release install assets
if [ -d "dists" ] && [ "$(ls -A dists)" ]; then
cp -r dists/* release/
fi
if [ -f "public/logos/logo-256.webp" ]; then
cp public/logos/logo-256.webp release/logo-256.webp
fi
if [ -f "release/metadata.json" ]; then
RELEASE_BASE_URL="$RELEASE_BASE_URL" node - <<'NODE'
const fs = require('fs');
const path = require('path');
const metadataPath = path.join('release', 'metadata.json');
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
const logoFileName = 'logo-256.webp';
const releaseBaseUrl = process.env.RELEASE_BASE_URL || '';
const logoUrl = releaseBaseUrl ? new URL(logoFileName, releaseBaseUrl).toString() : logoFileName;
if (metadata.logo) {
const lower = String(metadata.logo).toLowerCase();
if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) {
metadata.logo = logoUrl;
}
} else {
metadata.logo = logoUrl;
}
if (Array.isArray(metadata.icons)) {
metadata.icons = metadata.icons.map((icon) => {
if (!icon?.src) return icon;
const src = String(icon.src);
const lower = src.toLowerCase();
if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) {
return { ...icon, src: logoUrl };
}
return icon;
});
}
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
NODE
fi
# 上传 DWEB 到 SFTP 服务器
- name: Upload DWEB to SFTP
env:
CHANNEL: ${{ steps.channel.outputs.channel }}
DWEB_SFTP_USER: ${{ secrets.DWEB_SFTP_USER }}
DWEB_SFTP_PASS: ${{ secrets.DWEB_SFTP_PASS }}
DWEB_SFTP_USER_DEV: ${{ secrets.DWEB_SFTP_USER_DEV }}
DWEB_SFTP_PASS_DEV: ${{ secrets.DWEB_SFTP_PASS_DEV }}
run: |
if [[ "$CHANNEL" == "stable" ]]; then
bun scripts/build.ts dweb --upload --stable --skip-typecheck --skip-test
else
bun scripts/build.ts dweb --upload --skip-typecheck --skip-test
fi
# 直接推送到 gh-pages 分支,避免使用 upload-pages-artifact(self-hosted 上容易卡住)
- name: Deploy to GitHub Pages
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
cd gh-pages
git init
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
if git diff --cached --quiet; then
echo "No changes to deploy"
else
git commit -m "Deploy to GitHub Pages - ${{ github.sha }}"
git push -f "https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }}.git" HEAD:gh-pages
echo "Deployed to gh-pages branch"
fi
# 直接在 build job 中创建 release,避免跨 job 传递 artifact(self-hosted 下载很慢)
# 使用重试逻辑处理网络不稳定问题
- name: Create or Update Release
if: steps.channel.outputs.channel == 'stable'
uses: nick-fields/retry@v3
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
retry_on: error
command: |
release_files=()
while IFS= read -r -d '' file; do
release_files+=("$file")
done < <(find release -type f -print0)
if [ "${#release_files[@]}" -eq 0 ]; then
echo "No release assets found"
exit 1
fi
if gh release view "$TAG_NAME" -R "$REPO" >/dev/null 2>&1; then
release_json=$(gh api "repos/${{ github.repository }}/releases/tags/$TAG_NAME")
upload_files=()
for file in "${release_files[@]}"; do
name=$(basename "$file")
asset_id=$(echo "$release_json" | jq -r --arg name "$name" '.assets[] | select(.name==$name) | .id')
if [ -n "$asset_id" ] && [ "$asset_id" != "null" ]; then
echo "Release asset exists, skip upload: $name"
continue
fi
upload_files+=("$file")
done
if [ "${#upload_files[@]}" -eq 0 ]; then
echo "All release assets already exist, skip upload"
else
gh release upload "$TAG_NAME" -R "$REPO" "${upload_files[@]}"
fi
else
gh release create "$TAG_NAME" \
-R "$REPO" \
--title "$RELEASE_TITLE" \
--notes "$RELEASE_NOTES" \
${{ steps.channel.outputs.channel == 'beta' && '--prerelease' || '' }} \
--target ${{ github.sha }} \
"${release_files[@]}"
fi
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
TAG_NAME: ${{ steps.channel.outputs.channel == 'stable' && format('v{0}', steps.version.outputs.version) || 'beta' }}
RELEASE_TITLE: ${{ steps.channel.outputs.channel == 'stable' && format('BFM Pay v{0}', steps.version.outputs.version) || 'BFM Pay Beta' }}
RELEASE_NOTES: |
## BFM Pay ${{ steps.channel.outputs.channel == 'stable' && format('v{0}', steps.version.outputs.version) || 'Beta' }}
### 下载
- **Web 版本**: `bfmpay-web${{ steps.channel.outputs.channel == 'beta' && '-beta' || '' }}.zip`
- **DWEB 版本**: `bfmpay-dweb${{ steps.channel.outputs.channel == 'beta' && '-beta' || '' }}.zip`
### 在线访问
- Web 应用 (stable): https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/webapp/
- Web 应用 (beta): https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/webapp-dev/
- 文档首页: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/
### DWEB 安装
在 DWEB 浏览器中打开以下链接安装:
```
dweb://install?url=https://github.com/${{ github.repository }}/releases/download/${{ steps.channel.outputs.channel == 'stable' && format('v{0}', steps.version.outputs.version) || 'beta' }}/metadata.json
```
# ==================== GitHub-hosted 标准构建 ====================
build-standard:
if: vars.USE_SELF_HOSTED != 'true'
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
channel: ${{ steps.channel.outputs.channel }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # 需要完整历史来下载 release
- name: Determine channel
id: channel
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
echo "channel=stable" >> $GITHUB_OUTPUT
echo "Channel: stable (tag trigger)"
elif [[ "${{ github.event.inputs.channel }}" == "stable" ]]; then
echo "channel=stable" >> $GITHUB_OUTPUT
echo "Channel: stable (manual trigger)"
else
echo "channel=beta" >> $GITHUB_OUTPUT
echo "Channel: beta"
fi
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Get version
id: version
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
else
VERSION=$(node -p "require('./package.json').version")
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Type check
run: pnpm typecheck
- name: Run tests
run: pnpm test
# ===== 构建当前渠道的 Web 版本 =====
- name: Build Web version
env:
VITE_DEV_MODE: ${{ steps.channel.outputs.channel == 'beta' && 'true' || 'false' }}
run: |
SERVICE_IMPL=web pnpm build
mv dist dist-web
# ===== 构建 DWEB 版本 =====
- name: Build DWEB version
env:
VITE_DEV_MODE: ${{ steps.channel.outputs.channel == 'beta' && 'true' || 'false' }}
run: |
SERVICE_IMPL=dweb pnpm build
mv dist dist-dweb
- name: Install Plaoc CLI
run: npm install -g @aspect/plaoc-cli || echo "Plaoc CLI not available"
- name: Bundle DWEB with Plaoc
continue-on-error: true
run: |
if command -v plaoc &> /dev/null; then
plaoc bundle ./dist-dweb -c ./ -o ./dists
echo "Plaoc bundle completed"
else
echo "Plaoc CLI not installed, using raw dist-dweb"
mkdir -p dists
cp -r dist-dweb/* dists/
fi
# ===== 准备 VitePress 的 webapp 目录 =====
- name: Prepare webapp for VitePress
env:
CHANNEL: ${{ steps.channel.outputs.channel }}
GH_TOKEN: ${{ github.token }}
run: |
echo "Current channel: $CHANNEL"
# 当前构建的版本放到对应渠道目录
if [[ "$CHANNEL" == "stable" ]]; then
# stable 构建:当前版本放 webapp/,尝试下载 beta
mkdir -p docs/public/webapp
cp -r dist-web/* docs/public/webapp/
echo "Copied current build to docs/public/webapp/ (stable)"
# 尝试下载最新的 beta 版本(从 latest prerelease)
mkdir -p docs/public/webapp-dev
if gh release download --pattern 'bfmpay-web-beta.zip' --dir /tmp -R ${{ github.repository }} 2>/dev/null; then
unzip -q /tmp/bfmpay-web-beta.zip -d docs/public/webapp-dev/
echo "Downloaded beta version from release"
else
# 如果没有 beta release,使用当前构建
cp -r dist-web/* docs/public/webapp-dev/
echo "No beta release found, using current build for webapp-dev"
fi
else
# beta 构建:当前版本放 webapp-dev/,尝试下载 stable
mkdir -p docs/public/webapp-dev
cp -r dist-web/* docs/public/webapp-dev/
echo "Copied current build to docs/public/webapp-dev/ (beta)"
# 尝试下载最新的 stable 版本
mkdir -p docs/public/webapp
if gh release download --pattern 'bfmpay-web.zip' --dir /tmp -R ${{ github.repository }} 2>/dev/null; then
unzip -q /tmp/bfmpay-web.zip -d docs/public/webapp/
echo "Downloaded stable version from release"
else
# 如果没有 stable release,使用当前构建
cp -r dist-web/* docs/public/webapp/
echo "No stable release found, using current build for webapp"
fi
fi
# 生成 Web ZIP 下载文件(供 download 页面直接下载)
rm -f docs/public/webapp/bfmpay-web.zip docs/public/webapp-dev/bfmpay-web-beta.zip
(cd docs/public/webapp && zip -qr bfmpay-web.zip . -x "*.zip")
(cd docs/public/webapp-dev && zip -qr bfmpay-web-beta.zip . -x "*.zip")
# 显示准备好的目录
echo "=== webapp directory ==="
ls -la docs/public/webapp/ | head -5
echo "=== webapp-dev directory ==="
ls -la docs/public/webapp-dev/ | head -5
# ===== 构建 Storybook =====
- name: Build Storybook
run: pnpm build-storybook
- name: Prepare Storybook for VitePress
run: |
mkdir -p docs/public/storybook
cp -r storybook-static/* docs/public/storybook/
echo "Storybook copied to docs/public/storybook/"
# ===== 构建 VitePress 站点 =====
# Base path: 仓库变量 VITEPRESS_BASE,默认使用 /{repo_name}/
- name: Build VitePress site
env:
VITEPRESS_BASE: ${{ vars.VITEPRESS_BASE || format('/{0}/', github.event.repository.name) }}
SITE_ORIGIN: ${{ format('https://{0}.github.io', github.repository_owner) }}
run: |
echo "Building with VITEPRESS_BASE=$VITEPRESS_BASE"
pnpm docs:build
# ===== 准备 GitHub Pages =====
- name: Prepare GitHub Pages
env:
GH_TOKEN: ${{ github.token }}
VITEPRESS_BASE: ${{ vars.VITEPRESS_BASE || format('/{0}/', github.event.repository.name) }}
SITE_ORIGIN: ${{ format('https://{0}.github.io', github.repository_owner) }}
run: |
# VitePress 输出目录
mkdir -p gh-pages
cp -r docs/.vitepress/dist/* gh-pages/
# 禁用 Jekyll
touch gh-pages/.nojekyll
CHANNEL="${{ steps.channel.outputs.channel }}"
# 准备当前渠道的 DWEB 资源
if [ -d "dists" ] && [ "$(ls -A dists)" ]; then
if [[ "$CHANNEL" == "stable" ]]; then
DWEB_DIR="gh-pages/dweb"
DWEB_ZIP="bfmpay-dweb.zip"
DWEB_PATH="dweb"
else
DWEB_DIR="gh-pages/dweb-dev"
DWEB_ZIP="bfmpay-dweb-beta.zip"
DWEB_PATH="dweb-dev"
fi
mkdir -p "$DWEB_DIR"
cp -r dists/* "$DWEB_DIR/"
if [ -d "public/logos" ]; then
mkdir -p "$DWEB_DIR/logos"
cp -r public/logos/* "$DWEB_DIR/logos/"
if [ -f "public/logos/logo-256.webp" ]; then
cp public/logos/logo-256.webp "$DWEB_DIR/logo-256.webp"
fi
fi
if [ -f "$DWEB_DIR/metadata.json" ]; then
DWEB_DIR="$DWEB_DIR" DWEB_ZIP="$DWEB_ZIP" DWEB_PATH="$DWEB_PATH" SITE_ORIGIN="$SITE_ORIGIN" VITEPRESS_BASE="$VITEPRESS_BASE" node - <<'NODE'
const fs = require('fs');
const path = require('path');
const dwebDir = process.env.DWEB_DIR;
const zipName = process.env.DWEB_ZIP;
const dwebPath = process.env.DWEB_PATH || '';
const siteOrigin = process.env.SITE_ORIGIN || '';
const basePath = process.env.VITEPRESS_BASE || '/';
const metadataPath = path.join(dwebDir, 'metadata.json');
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
const logoFileName = 'logo-256.webp';
const normalizedPath = dwebPath.replace(/^\/+/, '').replace(/\/$/, '');
const baseUrl = siteOrigin ? new URL(basePath, siteOrigin).toString() : '';
const logoPath = normalizedPath ? `${normalizedPath}/${logoFileName}` : logoFileName;
const logoUrl = baseUrl ? new URL(logoPath, baseUrl).toString() : logoFileName;
if (metadata.logo) {
const lower = String(metadata.logo).toLowerCase();
if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) {
metadata.logo = logoUrl;
}
} else {
metadata.logo = logoUrl;
}
if (Array.isArray(metadata.icons)) {
metadata.icons = metadata.icons.map((icon) => {
if (!icon?.src) return icon;
const src = String(icon.src);
const lower = src.toLowerCase();
if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) {
return { ...icon, src: logoUrl };
}
return icon;
});
}
const bundleUrl = typeof metadata.bundle_url === 'string' ? metadata.bundle_url : '';
if (bundleUrl) {
const bundleFile = bundleUrl.replace(/^\.\//, '');
const bundlePath = path.join(dwebDir, bundleFile);
if (fs.existsSync(bundlePath)) {
fs.copyFileSync(bundlePath, path.join(dwebDir, zipName));
} else {
console.warn(`DWEB bundle not found: ${bundlePath}`);
}
} else {
console.warn('metadata.json missing bundle_url');
}
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
NODE
else
echo "metadata.json missing in $DWEB_DIR"
fi
else
echo "No DWEB assets found, skipping gh-pages DWEB publish"
fi
# beta 渠道尝试同步 stable DWEB 资源(从 release)
if [[ "$CHANNEL" != "stable" ]]; then
STABLE_TAG=$(gh release list --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName' -R ${{ github.repository }} || true)
if [ -n "$STABLE_TAG" ]; then
mkdir -p /tmp/stable-dweb
gh release download "$STABLE_TAG" --pattern 'metadata.json' --dir /tmp/stable-dweb -R ${{ github.repository }} || true
if [ -f "/tmp/stable-dweb/metadata.json" ]; then
BUNDLE_FILE=$(node -e "const fs=require('fs');const m=JSON.parse(fs.readFileSync('/tmp/stable-dweb/metadata.json','utf8'));console.log((m.bundle_url||'').replace(/^\\.\\//,''));")
if [ -n "$BUNDLE_FILE" ]; then
gh release download "$STABLE_TAG" --pattern "$BUNDLE_FILE" --dir /tmp/stable-dweb -R ${{ github.repository }} || true
fi
mkdir -p gh-pages/dweb
cp /tmp/stable-dweb/metadata.json gh-pages/dweb/
if [ -n "$BUNDLE_FILE" ] && [ -f "/tmp/stable-dweb/$BUNDLE_FILE" ]; then
cp "/tmp/stable-dweb/$BUNDLE_FILE" gh-pages/dweb/
cp "/tmp/stable-dweb/$BUNDLE_FILE" gh-pages/dweb/bfmpay-dweb.zip
fi
if [ -d "public/logos" ]; then
mkdir -p gh-pages/dweb/logos
cp -r public/logos/* gh-pages/dweb/logos/
if [ -f "public/logos/logo-256.webp" ]; then
cp public/logos/logo-256.webp gh-pages/dweb/logo-256.webp
fi
fi
if [ -f "gh-pages/dweb/metadata.json" ]; then
DWEB_PATH="dweb" SITE_ORIGIN="$SITE_ORIGIN" VITEPRESS_BASE="$VITEPRESS_BASE" node - <<'NODE'
const fs = require('fs');
const path = require('path');
const metadataPath = path.join('gh-pages', 'dweb', 'metadata.json');
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
const logoFileName = 'logo-256.webp';
const dwebPath = process.env.DWEB_PATH || '';
const siteOrigin = process.env.SITE_ORIGIN || '';
const basePath = process.env.VITEPRESS_BASE || '/';
const normalizedPath = dwebPath.replace(/^\/+/, '').replace(/\/$/, '');
const baseUrl = siteOrigin ? new URL(basePath, siteOrigin).toString() : '';
const logoPath = normalizedPath ? `${normalizedPath}/${logoFileName}` : logoFileName;
const logoUrl = baseUrl ? new URL(logoPath, baseUrl).toString() : logoFileName;
if (metadata.logo) {
const lower = String(metadata.logo).toLowerCase();
if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) {
metadata.logo = logoUrl;
}
} else {
metadata.logo = logoUrl;
}
if (Array.isArray(metadata.icons)) {
metadata.icons = metadata.icons.map((icon) => {
if (!icon?.src) return icon;
const src = String(icon.src);
const lower = src.toLowerCase();
if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) {
return { ...icon, src: logoUrl };
}
return icon;
});
}
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
NODE
fi
fi
fi
fi
echo "=== GitHub Pages structure ==="
find gh-pages -maxdepth 2 -type d
# ===== 创建 Release 产物 =====
- name: Create release artifacts
if: steps.channel.outputs.channel == 'stable'
env:
CHANNEL: ${{ steps.channel.outputs.channel }}
run: |
mkdir -p release
VERSION="${{ steps.version.outputs.version }}"
if [[ "$CHANNEL" == "stable" ]]; then
# Stable: 创建不带 beta 后缀的文件
cd dist-web && zip -r ../release/bfmpay-web.zip . && cd ..
cp release/bfmpay-web.zip "release/bfmpay-web-${VERSION}.zip"
if [ -d "dists" ] && [ "$(ls -A dists)" ]; then
cd dists && zip -r ../release/bfmpay-dweb.zip . && cd ..
else
cd dist-dweb && zip -r ../release/bfmpay-dweb.zip . && cd ..
fi
cp release/bfmpay-dweb.zip "release/bfmpay-dweb-${VERSION}.zip"
else
# Beta: 创建带 beta 后缀的文件
cd dist-web && zip -r ../release/bfmpay-web-beta.zip . && cd ..
cp release/bfmpay-web-beta.zip "release/bfmpay-web-${VERSION}-beta.zip"
if [ -d "dists" ] && [ "$(ls -A dists)" ]; then
cd dists && zip -r ../release/bfmpay-dweb-beta.zip . && cd ..
else
cd dist-dweb && zip -r ../release/bfmpay-dweb-beta.zip . && cd ..
fi
cp release/bfmpay-dweb-beta.zip "release/bfmpay-dweb-${VERSION}-beta.zip"
fi
# Copy metadata.json for dweb://install link
if [ -f "dists/metadata.json" ]; then
cp dists/metadata.json release/metadata.json
fi
# Copy dweb bundle + logos for release install assets
if [ -d "dists" ] && [ "$(ls -A dists)" ]; then
cp -r dists/* release/
fi
if [ -f "public/logos/logo-256.webp" ]; then
cp public/logos/logo-256.webp release/logo-256.webp
fi
if [ -f "release/metadata.json" ]; then
RELEASE_BASE_URL="$RELEASE_BASE_URL" node - <<'NODE'
const fs = require('fs');
const path = require('path');
const metadataPath = path.join('release', 'metadata.json');
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
const logoFileName = 'logo-256.webp';
const releaseBaseUrl = process.env.RELEASE_BASE_URL || '';
const logoUrl = releaseBaseUrl ? new URL(logoFileName, releaseBaseUrl).toString() : logoFileName;
if (metadata.logo) {
const lower = String(metadata.logo).toLowerCase();
if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) {
metadata.logo = logoUrl;
}
} else {
metadata.logo = logoUrl;
}
if (Array.isArray(metadata.icons)) {
metadata.icons = metadata.icons.map((icon) => {
if (!icon?.src) return icon;
const src = String(icon.src);
const lower = src.toLowerCase();
if (lower.endsWith(`/${logoFileName}`) || lower.endsWith(logoFileName)) {
return { ...icon, src: logoUrl };
}
return icon;
});
}
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
NODE
fi
echo "=== Release artifacts ==="
ls -la release/
# ===== 上传 DWEB 到 SFTP 服务器 =====
- name: Upload DWEB to SFTP
env:
CHANNEL: ${{ steps.channel.outputs.channel }}
DWEB_SFTP_USER: ${{ secrets.DWEB_SFTP_USER }}
DWEB_SFTP_PASS: ${{ secrets.DWEB_SFTP_PASS }}
DWEB_SFTP_USER_DEV: ${{ secrets.DWEB_SFTP_USER_DEV }}
DWEB_SFTP_PASS_DEV: ${{ secrets.DWEB_SFTP_PASS_DEV }}
run: |
if [[ "$CHANNEL" == "stable" ]]; then
bun scripts/build.ts dweb --upload --stable --skip-typecheck --skip-test
else
bun scripts/build.ts dweb --upload --skip-typecheck --skip-test
fi
# ===== 部署到 GitHub Pages (Force Push) =====
- name: Deploy to GitHub Pages
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
cd gh-pages
git init
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
if git diff --cached --quiet; then
echo "No changes to deploy"
else
git commit -m "Deploy to GitHub Pages - ${{ github.sha }}"
git push -f "https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }}.git" HEAD:gh-pages
echo "Deployed to gh-pages branch"
fi
- name: Upload release artifacts
if: steps.channel.outputs.channel == 'stable'
uses: actions/upload-artifact@v4
with:
name: release-${{ steps.channel.outputs.channel }}-${{ steps.version.outputs.version }}
path: release/
retention-days: 30
# ==================== 创建 Release (GitHub-hosted) ====================
create-release-standard:
if: vars.USE_SELF_HOSTED != 'true' && needs.build-standard.outputs.channel == 'stable'
needs: build-standard
runs-on: ubuntu-latest
steps:
- name: Download release artifacts
uses: actions/download-artifact@v4
with:
name: release-${{ needs.build-standard.outputs.channel }}-${{ needs.build-standard.outputs.version }}
path: release/
# 使用重试逻辑处理网络不稳定问题
- name: Create or Update Release
uses: nick-fields/retry@v3
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
retry_on: error
command: |
release_files=()
while IFS= read -r -d '' file; do
release_files+=("$file")
done < <(find release -type f -print0)
if [ "${#release_files[@]}" -eq 0 ]; then
echo "No release assets found"
exit 1
fi
if gh release view "$TAG_NAME" -R "$REPO" >/dev/null 2>&1; then
release_json=$(gh api "repos/${{ github.repository }}/releases/tags/$TAG_NAME")
upload_files=()
for file in "${release_files[@]}"; do
name=$(basename "$file")
asset_id=$(echo "$release_json" | jq -r --arg name "$name" '.assets[] | select(.name==$name) | .id')
if [ -n "$asset_id" ] && [ "$asset_id" != "null" ]; then
echo "Release asset exists, skip upload: $name"
continue
fi
upload_files+=("$file")
done
if [ "${#upload_files[@]}" -eq 0 ]; then
echo "All release assets already exist, skip upload"
else
gh release upload "$TAG_NAME" -R "$REPO" "${upload_files[@]}"
fi
else
gh release create "$TAG_NAME" \
-R "$REPO" \
--title "$RELEASE_TITLE" \
--notes "$RELEASE_NOTES" \
${{ needs.build-standard.outputs.channel == 'beta' && '--prerelease' || '' }} \
--target ${{ github.sha }} \
"${release_files[@]}"
fi
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
TAG_NAME: ${{ needs.build-standard.outputs.channel == 'stable' && format('v{0}', needs.build-standard.outputs.version) || 'beta' }}
RELEASE_TITLE: ${{ needs.build-standard.outputs.channel == 'stable' && format('BFM Pay v{0}', needs.build-standard.outputs.version) || 'BFM Pay Beta' }}
RELEASE_NOTES: |
## BFM Pay ${{ needs.build-standard.outputs.channel == 'stable' && format('v{0}', needs.build-standard.outputs.version) || 'Beta' }}
### 下载
- **Web 版本**: `bfmpay-web${{ needs.build-standard.outputs.channel == 'beta' && '-beta' || '' }}.zip`
- **DWEB 版本**: `bfmpay-dweb${{ needs.build-standard.outputs.channel == 'beta' && '-beta' || '' }}.zip`
### 在线访问
- Web 应用 (stable): https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/webapp/
- Web 应用 (beta): https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/webapp-dev/
- 文档首页: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/
### DWEB 安装
在 DWEB 浏览器中打开以下链接安装:
```
dweb://install?url=https://github.com/${{ github.repository }}/releases/download/${{ needs.build-standard.outputs.channel == 'stable' && format('v{0}', needs.build-standard.outputs.version) || 'beta' }}/metadata.json
```