name: Release on: push: tags: - 'v*' workflow_dispatch: inputs: tag: description: 'Tag to release (or a branch/ref for dry run)' required: true type: string simple_release: description: 'Simple release: only x86_64 GHCR image, skip other artifacts' required: false type: boolean default: false dry_run: description: 'Build and verify all selected artifacts without publishing' required: false type: boolean default: false concurrency: group: release-${{ github.repository }} cancel-in-progress: false permissions: contents: read env: SIMPLE_RELEASE: ${{ inputs.simple_release == true || vars.SIMPLE_RELEASE == 'true' }} DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == true }} jobs: prepare: runs-on: ubuntu-latest outputs: sha: ${{ steps.plan.outputs.sha }} owner_lower: ${{ steps.plan.outputs.owner_lower }} tag: ${{ steps.plan.outputs.tag }} version: ${{ steps.plan.outputs.version }} date: ${{ steps.plan.outputs.date }} matrix: ${{ steps.plan.outputs.matrix }} simple: ${{ steps.plan.outputs.simple }} dry_run: ${{ steps.plan.outputs.dry_run }} steps: - name: Checkout workflow tooling uses: actions/checkout@v6 with: ref: ${{ github.sha }} - name: Preserve tooling for releases of older tags run: cp -R .github/release-tools "$RUNNER_TEMP/release-tools" - name: Checkout selected application source uses: actions/checkout@v6 with: ref: ${{ inputs.tag || github.ref }} fetch-depth: 0 - uses: actions/setup-python@v6 with: python-version: '3.12' - run: python -m pip install -r "$RUNNER_TEMP/release-tools/requirements-release.txt" - name: Resolve one source commit and target matrix id: plan env: REQUESTED_REF: ${{ inputs.tag || github.ref_name }} run: | args=(--ref "$REQUESTED_REF") if [[ "$SIMPLE_RELEASE" == true ]]; then args+=(--simple); fi if [[ "$DRY_RUN" == true ]]; then args+=(--dry-run); fi python "$RUNNER_TEMP/release-tools/release_matrix.py" plan "${args[@]}" - uses: actions/upload-artifact@v7 with: name: release-tooling path: ${{ runner.temp }}/release-tools/ retention-days: 1 - uses: actions/upload-artifact@v7 with: name: version-file path: backend/cmd/server/VERSION retention-days: 1 build-frontend: needs: prepare runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: ref: ${{ needs.prepare.outputs.sha }} - uses: pnpm/action-setup@v6 with: version: 9 - uses: actions/setup-node@v6 with: node-version: '20' cache: pnpm cache-dependency-path: frontend/pnpm-lock.yaml - run: pnpm install --frozen-lockfile working-directory: frontend - run: pnpm run build working-directory: frontend - uses: actions/upload-artifact@v7 with: name: frontend-dist path: backend/internal/web/dist/ retention-days: 1 build-binaries: needs: [prepare, build-frontend] runs-on: ubuntu-latest strategy: fail-fast: false matrix: ${{ fromJSON(needs.prepare.outputs.matrix) }} env: RELEASE_VERSION: ${{ needs.prepare.outputs.version }} RELEASE_DATE: ${{ needs.prepare.outputs.date }} steps: - uses: actions/checkout@v6 with: ref: ${{ needs.prepare.outputs.sha }} fetch-depth: 0 - uses: actions/download-artifact@v8 with: name: release-tooling path: ${{ runner.temp }}/release-tools/ - uses: actions/download-artifact@v8 with: name: version-file path: backend/cmd/server/ - uses: actions/download-artifact@v8 with: name: frontend-dist path: backend/internal/web/dist/ - uses: actions/setup-go@v6 with: go-version-file: backend/go.mod check-latest: false cache: false - name: Verify Go version run: go version | grep -q 'go1.27.2' - name: Restore target-specific Go cache id: go-cache uses: actions/cache/restore@v6 with: path: | ~/go/pkg/mod ~/.cache/go-build key: release-go-${{ runner.os }}-${{ runner.arch }}-${{ matrix.goos }}-${{ matrix.goarch }}-${{ hashFiles('backend/go.mod', 'backend/go.sum') }}-${{ needs.prepare.outputs.sha }} restore-keys: | release-go-${{ runner.os }}-${{ runner.arch }}-${{ matrix.goos }}-${{ matrix.goarch }}-${{ hashFiles('backend/go.mod', 'backend/go.sum') }}- release-go-${{ runner.os }}-${{ runner.arch }}-${{ matrix.goos }}-${{ matrix.goarch }}- - uses: actions/setup-python@v6 with: python-version: '3.12' - run: python -m pip install -r "$RUNNER_TEMP/release-tools/requirements-release.txt" - name: Select one GoReleaser target env: TARGET_OS: ${{ matrix.goos }} TARGET_ARCH: ${{ matrix.goarch }} run: python "$RUNNER_TEMP/release-tools/release_matrix.py" config build --goos "$TARGET_OS" --goarch "$TARGET_ARCH" --output "$RUNNER_TEMP/goreleaser.matrix.yaml" - name: Build and archive without publishing uses: goreleaser/goreleaser-action@v7 with: version: '~> v2' args: release --snapshot --clean --skip=validate,publish,docker,announce --config=${{ runner.temp }}/goreleaser.matrix.yaml env: GITHUB_REPO_OWNER: ${{ github.repository_owner }} GITHUB_REPO_OWNER_LOWER: ${{ needs.prepare.outputs.owner_lower }} GITHUB_REPO_NAME: ${{ github.event.repository.name }} DOCKERHUB_USERNAME: skip TAG_MESSAGE: '' - name: Verify and collect the target archive env: TARGET_OS: ${{ matrix.goos }} TARGET_ARCH: ${{ matrix.goarch }} RELEASE_SHA: ${{ needs.prepare.outputs.sha }} run: python "$RUNNER_TEMP/release-tools/release_matrix.py" collect --version "$RELEASE_VERSION" --sha "$RELEASE_SHA" --goos "$TARGET_OS" --goarch "$TARGET_ARCH" --output matrix-output - uses: actions/upload-artifact@v7 with: name: release-binary-${{ matrix.goos }}-${{ matrix.goarch }} path: matrix-output/ if-no-files-found: error retention-days: 1 - name: Save updated target cache if: ${{ steps.go-cache.outputs.cache-hit != 'true' }} uses: actions/cache/save@v6 with: path: | ~/go/pkg/mod ~/.cache/go-build key: ${{ steps.go-cache.outputs.cache-primary-key }} release: needs: [prepare, build-binaries] runs-on: ubuntu-latest permissions: contents: write packages: write env: SIMPLE_RELEASE: ${{ needs.prepare.outputs.simple }} DRY_RUN: ${{ needs.prepare.outputs.dry_run }} RELEASE_TAG: ${{ needs.prepare.outputs.tag }} RELEASE_VERSION: ${{ needs.prepare.outputs.version }} RELEASE_SHA: ${{ needs.prepare.outputs.sha }} steps: - uses: actions/checkout@v6 with: ref: ${{ needs.prepare.outputs.sha }} fetch-depth: 0 - uses: actions/download-artifact@v8 with: name: release-tooling path: ${{ runner.temp }}/release-tools/ - uses: actions/download-artifact@v8 with: pattern: release-binary-* path: release-input merge-multiple: true - uses: actions/setup-python@v6 with: python-version: '3.12' - run: python -m pip install -r "$RUNNER_TEMP/release-tools/requirements-release.txt" - name: Verify complete matrix and prepare Docker contexts run: | args=(--version "$RELEASE_VERSION" --sha "$RELEASE_SHA" --input release-input --output .release-context) if [[ "$SIMPLE_RELEASE" == true ]]; then args+=(--simple); fi python "$RUNNER_TEMP/release-tools/release_matrix.py" contexts "${args[@]}" - uses: actions/setup-go@v6 with: go-version-file: backend/go.mod cache: false - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to DockerHub if: ${{ env.DRY_RUN != 'true' && env.SIMPLE_RELEASE != 'true' && env.DOCKERHUB_USERNAME != '' }} uses: docker/login-action@v3 env: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry if: ${{ env.DRY_RUN != 'true' }} uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build images and publish manifests env: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME || 'skip' }} run: bash "$RUNNER_TEMP/release-tools/release-images.sh" - name: Get annotated tag notes id: tag_message run: | TAG_MESSAGE=$(git tag -l --format='%(contents:body)' "$RELEASE_TAG") DELIMITER="TAG_MESSAGE_$(openssl rand -hex 16)" { echo "message<<$DELIMITER" echo "$TAG_MESSAGE" echo "$DELIMITER" } >> "$GITHUB_OUTPUT" - name: Prepare publication-only GoReleaser config run: | args=(publish --output "$RUNNER_TEMP/goreleaser.publisher.yaml") if [[ "$SIMPLE_RELEASE" == true ]]; then args+=(--simple); fi python "$RUNNER_TEMP/release-tools/release_matrix.py" config "${args[@]}" - name: Publish existing archives and release notes uses: goreleaser/goreleaser-action@v7 with: version: '~> v2' args: release --clean --skip=validate,before,docker ${{ env.DRY_RUN == 'true' && '--snapshot --skip=publish,announce' || '' }} --config=${{ runner.temp }}/goreleaser.publisher.yaml env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GORELEASER_CURRENT_TAG: ${{ needs.prepare.outputs.tag }} TAG_MESSAGE: ${{ steps.tag_message.outputs.message }} GITHUB_REPO_OWNER: ${{ github.repository_owner }} GITHUB_REPO_NAME: ${{ github.event.repository.name }} GITHUB_REPO_OWNER_LOWER: ${{ needs.prepare.outputs.owner_lower }} DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME || 'skip' }} - name: Upload dry-run checksums and metadata if: ${{ env.DRY_RUN == 'true' }} uses: actions/upload-artifact@v7 with: name: release-dry-run-report path: | dist/checksums.txt dist/artifacts.json dist/metadata.json if-no-files-found: error retention-days: 3 # Update DockerHub description - name: Update DockerHub description if: ${{ env.DRY_RUN != 'true' && env.SIMPLE_RELEASE != 'true' && env.DOCKERHUB_USERNAME != '' }} uses: peter-evans/dockerhub-description@v5 env: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} repository: ${{ secrets.DOCKERHUB_USERNAME }}/sub2api short-description: "Sub2API - AI API Gateway Platform" readme-filepath: ./deploy/DOCKER.md # Send Telegram notification - name: Send Telegram Notification if: ${{ env.DRY_RUN != 'true' && env.SIMPLE_RELEASE != 'true' }} env: TAG_MESSAGE: ${{ steps.tag_message.outputs.message }} TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} continue-on-error: true run: | # 检查必要的环境变量 if [ -z "$TELEGRAM_BOT_TOKEN" ] || [ -z "$TELEGRAM_CHAT_ID" ]; then echo "Telegram credentials not configured, skipping notification" exit 0 fi TAG_NAME="$RELEASE_TAG" VERSION=${TAG_NAME#v} REPO="${{ github.repository }}" GHCR_IMAGE="ghcr.io/${REPO,,}" # ${,,} converts to lowercase # 获取 tag message 内容并转义 Markdown 特殊字符 TAG_MESSAGE=$(echo "$TAG_MESSAGE" | sed 's/\([_*`\[]\)/\\\1/g') # 限制消息长度(Telegram 消息限制 4096 字符,预留空间给头尾固定内容) if [ ${#TAG_MESSAGE} -gt 3500 ]; then TAG_MESSAGE="${TAG_MESSAGE:0:3500}..." fi # 构建消息内容 MESSAGE="🚀 *Sub2API 新版本发布!*"$'\n'$'\n' MESSAGE+="📦 版本号: \`${VERSION}\`"$'\n'$'\n' # 添加更新内容 if [ -n "$TAG_MESSAGE" ]; then MESSAGE+="${TAG_MESSAGE}"$'\n'$'\n' fi MESSAGE+="🐳 *Docker 部署:*"$'\n' MESSAGE+="\`\`\`bash"$'\n' # 根据是否配置 DockerHub 动态生成 if [ -n "$DOCKERHUB_USERNAME" ]; then DOCKER_IMAGE="${DOCKERHUB_USERNAME}/sub2api" MESSAGE+="# Docker Hub"$'\n' MESSAGE+="docker pull ${DOCKER_IMAGE}:${VERSION}"$'\n' MESSAGE+="# GitHub Container Registry"$'\n' fi MESSAGE+="docker pull ${GHCR_IMAGE}:${VERSION}"$'\n' MESSAGE+="\`\`\`"$'\n'$'\n' MESSAGE+="🔗 *相关链接:*"$'\n' MESSAGE+="• [GitHub Release](https://github.com/${REPO}/releases/tag/${TAG_NAME})"$'\n' if [ -n "$DOCKERHUB_USERNAME" ]; then MESSAGE+="• [Docker Hub](https://hub.docker.com/r/${DOCKER_IMAGE})"$'\n' fi MESSAGE+="• [GitHub Packages](https://github.com/${REPO}/pkgs/container/sub2api)"$'\n'$'\n' MESSAGE+="#Sub2API #Release #${TAG_NAME//./_}" # 发送消息 curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ -H "Content-Type: application/json" \ -d "$(jq -n \ --arg chat_id "${TELEGRAM_CHAT_ID}" \ --arg text "${MESSAGE}" \ '{ chat_id: $chat_id, text: $text, parse_mode: "Markdown", disable_web_page_preview: true }')" sync-version-file: needs: [prepare, release] if: ${{ needs.release.result == 'success' && needs.prepare.outputs.dry_run != 'true' }} runs-on: ubuntu-latest permissions: contents: write env: RELEASE_VERSION: ${{ needs.prepare.outputs.version }} steps: - name: Checkout default branch uses: actions/checkout@v6 with: ref: ${{ github.event.repository.default_branch }} - name: Sync VERSION file to released tag run: | VERSION="$RELEASE_VERSION" CURRENT_VERSION=$(tr -d '\r\n' < backend/cmd/server/VERSION || true) if [ "$CURRENT_VERSION" = "$VERSION" ]; then echo "VERSION file already matches $VERSION" exit 0 fi echo "$VERSION" > backend/cmd/server/VERSION git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add backend/cmd/server/VERSION git commit -m "chore: sync VERSION to ${VERSION} [skip ci]" git push origin HEAD:${{ github.event.repository.default_branch }}