feat: complete implementation (#500) #503
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | |
| ``` |