name: Cross-platform packages on: workflow_dispatch: push: branches: - main tags: - 'v*' permissions: contents: read concurrency: group: packages-${{ github.ref }} cancel-in-progress: ${{ github.ref_type != 'tag' }} jobs: validate: name: Validate source runs-on: ubuntu-24.04 timeout-minutes: 20 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 24.19.0 cache: npm - name: Verify release tag version if: github.ref_type == 'tag' run: node -e "const p=require('./package.json'); const expected='v'+p.version; if(process.env.GITHUB_REF_NAME!==expected){throw new Error('Expected tag '+expected+', received '+process.env.GITHUB_REF_NAME)}" - name: Verify bilingual release notes if: github.ref_type == 'tag' run: npm run release:notes:verify - name: Install dependencies run: npm ci - name: Run validators run: | npm test npm run typecheck npm run lint - name: Build production bundle run: npm run build:bundle - name: Upload production bundle if: >- github.ref_type == 'tag' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') uses: actions/upload-artifact@v7 with: name: goodbuddy-production-bundle path: out if-no-files-found: error retention-days: 1 authorize-agent-signing: name: Authorize production Agent signing if: >- github.ref_type == 'tag' || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') needs: validate runs-on: ubuntu-24.04 timeout-minutes: 5 outputs: authorized: ${{ steps.policy.outputs.authorized }} steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Verify immutable release ref policy id: policy shell: bash run: | set -euo pipefail git fetch --no-tags origin \ "+refs/heads/main:refs/remotes/origin/main" git merge-base --is-ancestor \ "$GITHUB_SHA" refs/remotes/origin/main case "$GITHUB_EVENT_NAME" in push) test "$GITHUB_REF_TYPE" = "tag" test "$(node -p "String(require(process.env.GITHUB_EVENT_PATH).created)")" = "true" test "$(node -p "String(require(process.env.GITHUB_EVENT_PATH).forced)")" = "false" test "$(node -p "String(require(process.env.GITHUB_EVENT_PATH).deleted)")" = "false" expected="v$(node -p "require('./package.json').version")" test "$GITHUB_REF" = "refs/tags/$expected" test "$GITHUB_REF_NAME" = "$expected" git fetch --no-tags origin \ "+refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}" test "$(git cat-file -t "refs/tags/$GITHUB_REF_NAME")" = "tag" test "$(git rev-parse "refs/tags/$GITHUB_REF_NAME^{commit}")" = "$GITHUB_SHA" ;; workflow_dispatch) test "$GITHUB_REF" = "refs/heads/main" test "$GITHUB_REF_TYPE" = "branch" test "$GITHUB_SHA" = "$(git rev-parse refs/remotes/origin/main)" ;; *) echo "Production Agent signing is limited to immutable version-tag pushes or a manual run of refs/heads/main." >&2 exit 1 ;; esac echo "authorized=true" >> "$GITHUB_OUTPUT" agent-runtime: name: Acquire Linux ${{ matrix.arch }} Agent runtime if: >- needs.authorize-agent-signing.result == 'success' && needs.authorize-agent-signing.outputs.authorized == 'true' needs: - validate - authorize-agent-signing strategy: fail-fast: false matrix: include: - arch: x64 node-url: https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-x64.tar.gz node-archive: node-v24.19.0-linux-x64.tar.gz node-sha256: f625d97cd707df4ff96254916fbc5ff014f09c09effe5a1e0ca8f6d41a8789d4 - arch: arm64 node-url: https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-arm64.tar.gz node-archive: node-v24.19.0-linux-arm64.tar.gz node-sha256: d28c8a5bf0a808f0ed434a1dce8c54ae98f0371c0bd86ac58abc613f73e6643f runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - uses: actions/checkout@v7 - name: Verify locked Node runtime metadata env: AGENT_ARCH: ${{ matrix.arch }} NODE_ARCHIVE: ${{ matrix.node-archive }} NODE_SHA256: ${{ matrix.node-sha256 }} NODE_URL: ${{ matrix.node-url }} run: | node - <<'NODE' const lock = require('./agent-runtime-lock.json') const input = lock.node?.targets?.[`linux-${process.env.AGENT_ARCH}`] const expectedUrl = `${lock.node?.source ?? ''}${input?.archive ?? ''}` if ( input?.archive !== process.env.NODE_ARCHIVE || input?.sha256 !== process.env.NODE_SHA256 || expectedUrl !== process.env.NODE_URL || !expectedUrl.startsWith('https://') ) { throw new Error('Agent runtime acquisition metadata does not match agent-runtime-lock.json') } NODE - name: Restore verified Node runtime cache id: node-runtime-cache uses: actions/cache/restore@v6 with: path: ${{ runner.temp }}/goodbuddy-agent-node key: goodbuddy-agent-node-${{ matrix.arch }}-${{ matrix.node-sha256 }} - name: Download locked official Node runtime if: steps.node-runtime-cache.outputs.cache-hit != 'true' shell: bash env: NODE_ARCHIVE: ${{ matrix.node-archive }} NODE_URL: ${{ matrix.node-url }} run: | set -euo pipefail directory="$RUNNER_TEMP/goodbuddy-agent-node" archive="$directory/$NODE_ARCHIVE" mkdir -p "$directory" curl --proto '=https' --tlsv1.2 \ --fail --silent --show-error --location \ --retry 3 --retry-all-errors \ --connect-timeout 15 --max-time 300 \ --max-filesize 104857600 \ "$NODE_URL" --output "$archive.partial" mv "$archive.partial" "$archive" - name: Verify locked Node runtime digest shell: bash env: NODE_ARCHIVE: ${{ matrix.node-archive }} NODE_SHA256: ${{ matrix.node-sha256 }} run: | set -euo pipefail archive="$RUNNER_TEMP/goodbuddy-agent-node/$NODE_ARCHIVE" test -f "$archive" printf '%s %s\n' "$NODE_SHA256" "$archive" | sha256sum --check --strict - - name: Save verified Node runtime cache if: steps.node-runtime-cache.outputs.cache-hit != 'true' uses: actions/cache/save@v6 with: path: ${{ runner.temp }}/goodbuddy-agent-node key: goodbuddy-agent-node-${{ matrix.arch }}-${{ matrix.node-sha256 }} - name: Upload verified Node runtime input uses: actions/upload-artifact@v7 with: name: goodbuddy-agent-node-${{ matrix.arch }} path: ${{ runner.temp }}/goodbuddy-agent-node/${{ matrix.node-archive }} if-no-files-found: error compression-level: 0 retention-days: 1 agent-signing: name: Linux ${{ matrix.arch }} signed Agent bundle if: >- needs.authorize-agent-signing.result == 'success' && needs.authorize-agent-signing.outputs.authorized == 'true' needs: - validate - authorize-agent-signing - agent-runtime strategy: fail-fast: false matrix: include: - arch: x64 runner: ubuntu-24.04 node-archive: node-v24.19.0-linux-x64.tar.gz node-sha256: f625d97cd707df4ff96254916fbc5ff014f09c09effe5a1e0ca8f6d41a8789d4 - arch: arm64 runner: ubuntu-24.04-arm node-archive: node-v24.19.0-linux-arm64.tar.gz node-sha256: d28c8a5bf0a808f0ed434a1dce8c54ae98f0371c0bd86ac58abc613f73e6643f runs-on: ${{ matrix.runner }} timeout-minutes: 20 environment: name: agent-signing steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - uses: actions/setup-node@v7 with: node-version: 24.19.0 - name: Install dependencies run: npm ci - name: Preflight production Agent signing registry env: GOODBUDDY_AGENT_SIGNING_KEY_ID: ${{ vars.GOODBUDDY_AGENT_SIGNING_KEY_ID }} run: node build/agent-bundle.cjs preflight - name: Download verified official Node runtime input uses: actions/download-artifact@v8 with: name: goodbuddy-agent-node-${{ matrix.arch }} path: ${{ runner.temp }}/goodbuddy-agent-node - name: Build and sign Agent bundle shell: bash env: GOODBUDDY_AGENT_SIGNING_KEY_ID: ${{ vars.GOODBUDDY_AGENT_SIGNING_KEY_ID }} GOODBUDDY_AGENT_SIGNING_PRIVATE_KEY: ${{ secrets.GOODBUDDY_AGENT_SIGNING_PRIVATE_KEY }} run: | set -euo pipefail set +x node build/agent-bundle.cjs build \ --arch "${{ matrix.arch }}" \ --runtime-archive "$RUNNER_TEMP/goodbuddy-agent-node/${{ matrix.node-archive }}" \ --archive "dist/agent-bundles/goodbuddy-agent-linux-${{ matrix.arch }}.tar" - name: Upload signed Agent bundle uses: actions/upload-artifact@v7 with: name: goodbuddy-agent-linux-${{ matrix.arch }} path: dist/agent-bundles/goodbuddy-agent-linux-${{ matrix.arch }}.tar if-no-files-found: error compression-level: 0 retention-days: 1 package: name: ${{ matrix.platform }} ${{ matrix.arch }} if: >- needs.authorize-agent-signing.result == 'success' && needs.authorize-agent-signing.outputs.authorized == 'true' needs: - validate - authorize-agent-signing - agent-signing strategy: fail-fast: false matrix: include: - platform: windows arch: x64 runner: windows-2025 - platform: windows arch: arm64 runner: windows-2025 - platform: macos arch: x64 runner: macos-15-intel - platform: macos arch: arm64 runner: macos-15 - platform: linux arch: x64 runner: ubuntu-24.04 - platform: linux arch: arm64 runner: ubuntu-24.04-arm runs-on: ${{ matrix.runner }} timeout-minutes: 75 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 24.19.0 cache: npm - name: Cache packaging toolsets uses: actions/cache@v6 with: path: | ${{ runner.temp }}/electron ${{ runner.temp }}/electron-builder key: packaging-${{ runner.os }}-${{ matrix.arch }}-${{ hashFiles('package-lock.json') }} restore-keys: packaging-${{ runner.os }}-${{ matrix.arch }}- - name: Install dependencies run: npm ci env: ELECTRON_CACHE: ${{ runner.temp }}/electron - name: Download production bundle uses: actions/download-artifact@v8 with: name: goodbuddy-production-bundle path: out - name: Download signed Agent bundles uses: actions/download-artifact@v8 with: pattern: goodbuddy-agent-linux-* path: dist/agent-downloads merge-multiple: true - name: Import and verify both Agent bundles shell: bash run: | set -euo pipefail node build/agent-bundle.cjs import \ --arch x64 \ --archive dist/agent-downloads/goodbuddy-agent-linux-x64.tar node build/agent-bundle.cjs import \ --arch arm64 \ --archive dist/agent-downloads/goodbuddy-agent-linux-arm64.tar - name: Resolve macOS signing mode id: macos-signing if: matrix.platform == 'macos' shell: bash env: MACOS_CERTIFICATE_BASE64: ${{ secrets.MACOS_CERTIFICATE_BASE64 }} MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} run: | set -euo pipefail names=( MACOS_CERTIFICATE_BASE64 MACOS_CERTIFICATE_PASSWORD APPLE_API_KEY_BASE64 APPLE_API_KEY_ID APPLE_API_ISSUER ) configured=0 missing=() for name in "${names[@]}"; do if [[ -n "${!name}" ]]; then configured=$((configured + 1)) else missing+=("$name") fi done if [[ "$configured" -eq 0 ]]; then echo "enabled=false" >> "$GITHUB_OUTPUT" echo "::warning title=Unsigned macOS packages::Apple signing credentials are not configured. The macOS DMG and ZIP will be unsigned and unnotarized." { echo "### macOS signing" echo echo "Apple signing credentials are not configured. This target produces unsigned and unnotarized packages that Gatekeeper may block on first launch." } >> "$GITHUB_STEP_SUMMARY" exit 0 fi if [[ "$configured" -ne "${#names[@]}" ]]; then missing_names="$(IFS=,; echo "${missing[*]}")" echo "::error title=Incomplete macOS signing credentials::Missing: $missing_names" exit 1 fi echo "enabled=true" >> "$GITHUB_OUTPUT" printf '%s' "$MACOS_CERTIFICATE_BASE64" | base64 -D > "$RUNNER_TEMP/goodbuddy-developer-id.p12" printf '%s' "$APPLE_API_KEY_BASE64" | base64 -D > "$RUNNER_TEMP/AuthKey.p8" test -s "$RUNNER_TEMP/goodbuddy-developer-id.p12" test -s "$RUNNER_TEMP/AuthKey.p8" chmod 600 "$RUNNER_TEMP/goodbuddy-developer-id.p12" "$RUNNER_TEMP/AuthKey.p8" { echo "### macOS signing" echo echo "Complete Apple signing credentials were detected. This target will be signed, notarized, and verified." } >> "$GITHUB_STEP_SUMMARY" - name: Build, sign and notarize macOS release packages if: matrix.platform == 'macos' && steps.macos-signing.outputs.enabled == 'true' run: npm run release:package -- --platform ${{ matrix.platform }} --arch ${{ matrix.arch }} --skip-build env: ELECTRON_CACHE: ${{ runner.temp }}/electron ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/electron-builder CSC_LINK: ${{ runner.temp }}/goodbuddy-developer-id.p12 CSC_KEY_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} CSC_IDENTITY_AUTO_DISCOVERY: 'true' APPLE_API_KEY: ${{ runner.temp }}/AuthKey.p8 APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - name: Build unsigned macOS release packages if: matrix.platform == 'macos' && steps.macos-signing.outputs.enabled == 'false' run: npm run release:package -- --platform ${{ matrix.platform }} --arch ${{ matrix.arch }} --skip-build --unsigned env: ELECTRON_CACHE: ${{ runner.temp }}/electron ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/electron-builder CSC_IDENTITY_AUTO_DISCOVERY: 'false' - name: Verify macOS signature and notarization ticket if: matrix.platform == 'macos' && steps.macos-signing.outputs.enabled == 'true' shell: bash run: | set -euo pipefail dmg="$(find "dist/release/macos-${{ matrix.arch }}" -maxdepth 1 -type f -name '*.dmg' -print -quit)" test -n "$dmg" mount_point="$RUNNER_TEMP/goodbuddy-dmg" mkdir "$mount_point" cleanup() { hdiutil detach "$mount_point" -quiet || true } trap cleanup EXIT hdiutil attach "$dmg" -nobrowse -readonly -mountpoint "$mount_point" -quiet app="$(find "$mount_point" -maxdepth 1 -type d -name '*.app' -print -quit)" test -n "$app" codesign --verify --deep --strict --verbose=2 "$app" spctl --assess --type execute --verbose=4 "$app" xcrun stapler validate "$app" - name: Build and verify non-macOS release packages if: matrix.platform != 'macos' run: npm run release:package -- --platform ${{ matrix.platform }} --arch ${{ matrix.arch }} --skip-build env: ELECTRON_CACHE: ${{ runner.temp }}/electron ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/electron-builder - name: Upload release packages uses: actions/upload-artifact@v7 with: name: goodbuddy-${{ matrix.platform }}-${{ matrix.arch }} path: dist/release/${{ matrix.platform }}-${{ matrix.arch }} if-no-files-found: error compression-level: 0 retention-days: 30 release: name: Publish GitHub and OSS release if: >- github.event_name == 'push' && github.ref_type == 'tag' && needs.authorize-agent-signing.outputs.authorized == 'true' needs: - authorize-agent-signing - package runs-on: ubuntu-24.04 timeout-minutes: 35 environment: name: aliyun-oss-release permissions: contents: write actions: read id-token: write steps: - uses: actions/checkout@v7 with: fetch-depth: 0 - uses: actions/setup-node@v7 with: node-version: 24.19.0 - name: Verify release tag shell: bash run: | set -euo pipefail expected="v$(node -p "require('./package.json').version")" test "$GITHUB_REF_NAME" = "$expected" test "$(git rev-parse "refs/tags/$GITHUB_REF_NAME^{commit}")" = "$GITHUB_SHA" - name: Prepare bilingual release notes run: node build/release-notes.cjs --output release-notes.md - name: Download Windows packages uses: actions/download-artifact@v8 with: pattern: goodbuddy-windows-* path: dist/release-downloads - name: Download macOS packages uses: actions/download-artifact@v8 with: pattern: goodbuddy-macos-* path: dist/release-downloads - name: Download Linux packages uses: actions/download-artifact@v8 with: pattern: goodbuddy-linux-* path: dist/release-downloads - name: Verify and aggregate release assets run: node build/aggregate-release.cjs --input dist/release-downloads --output dist/release-upload - name: Verify OSS release configuration shell: bash env: OSS_BUCKET: ${{ vars.ALIYUN_OSS_BUCKET }} OSS_ENDPOINT: ${{ vars.ALIYUN_OSS_ENDPOINT }} OIDC_PROVIDER_ARN: ${{ vars.ALIYUN_OIDC_PROVIDER_ARN }} ROLE_ARN: ${{ vars.ALIYUN_ROLE_ARN }} run: | set -euo pipefail test -n "$OSS_BUCKET" test -n "$OSS_ENDPOINT" test -n "$OIDC_PROVIDER_ARN" test -n "$ROLE_ARN" case "$OSS_BUCKET" in goodbuddy) ;; *) echo "OSS Bucket 必须与应用内置镜像地址一致" >&2; exit 1 ;; esac case "$OSS_ENDPOINT" in https://oss-cn-beijing.aliyuncs.com) ;; *) echo "OSS Endpoint 必须与应用内置镜像地址一致" >&2; exit 1 ;; esac case "$OIDC_PROVIDER_ARN" in acs:ram::*:oidc-provider/*) ;; *) echo "OIDC Provider ARN 无效" >&2; exit 1 ;; esac case "$ROLE_ARN" in acs:ram::*:role/*) ;; *) echo "RAM Role ARN 无效" >&2; exit 1 ;; esac - name: Authenticate to Alibaba Cloud uses: aliyun/configure-aliyun-credentials-action@v1 with: role-to-assume: ${{ vars.ALIYUN_ROLE_ARN }} oidc-provider-arn: ${{ vars.ALIYUN_OIDC_PROVIDER_ARN }} role-session-name: goodbuddy-release-${{ github.run_id }} role-session-expiration: 3600 audience: sts.aliyuncs.com - name: Install ossutil shell: bash run: | set -euo pipefail version="2.3.0" archive="$RUNNER_TEMP/ossutil.zip" directory="$RUNNER_TEMP/ossutil" curl --fail --silent --show-error --location \ "https://gosspublic.alicdn.com/ossutil/v2/$version/ossutil-$version-linux-amd64.zip" \ --output "$archive" mkdir "$directory" unzip -q "$archive" -d "$directory" binary="$(find "$directory" -type f -name ossutil -print -quit)" test -n "$binary" chmod +x "$binary" echo "$(dirname "$binary")" >> "$GITHUB_PATH" - name: Prepare OSS website release index id: oss-release shell: bash env: OSS_BUCKET: ${{ vars.ALIYUN_OSS_BUCKET }} OSS_ENDPOINT: ${{ vars.ALIYUN_OSS_ENDPOINT }} run: | set -euo pipefail endpoint_host="${OSS_ENDPOINT#https://}" oss_region="${endpoint_host#oss-}" oss_region="${oss_region%.aliyuncs.com}" case "$oss_region" in *[!a-z0-9-]*|'') echo "无法从 OSS Endpoint 推导 Region" >&2; exit 1 ;; esac base_url="https://${OSS_BUCKET}.${endpoint_host}/releases/${GITHUB_REF_NAME}/" node build/create-site-release.cjs \ --manifest dist/release-upload/release-manifest.json \ --base-url "$base_url" \ --output dist/site-release.json echo "base-url=$base_url" >> "$GITHUB_OUTPUT" echo "region=$oss_region" >> "$GITHUB_OUTPUT" - name: Upload immutable release assets to OSS shell: bash env: OSS_BUCKET: ${{ vars.ALIYUN_OSS_BUCKET }} OSS_ENDPOINT: ${{ vars.ALIYUN_OSS_ENDPOINT }} OSS_REGION: ${{ steps.oss-release.outputs.region }} run: | set -euo pipefail export OSS_ACCESS_KEY_ID="$ALIBABA_CLOUD_ACCESS_KEY_ID" export OSS_ACCESS_KEY_SECRET="$ALIBABA_CLOUD_ACCESS_KEY_SECRET" export OSS_SESSION_TOKEN="$ALIBABA_CLOUD_SECURITY_TOKEN" test -n "$OSS_ACCESS_KEY_ID" test -n "$OSS_ACCESS_KEY_SECRET" test -n "$OSS_SESSION_TOKEN" test -n "$OSS_REGION" for file in dist/release-upload/* dist/site-release.json; do name="$(basename "$file")" ossutil cp "$file" \ "oss://${OSS_BUCKET}/releases/${GITHUB_REF_NAME}/${name}" \ --endpoint "$OSS_ENDPOINT" \ --region "$OSS_REGION" \ --update done - name: Verify public OSS release assets run: node build/verify-site-release.cjs --manifest dist/site-release.json - name: Create or update draft GitHub release shell: bash env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail tag="$GITHUB_REF_NAME" version="$(node -p "require('./package.json').version")" if gh release view "$tag" >/dev/null 2>&1; then gh release edit "$tag" --draft --title "GoodBuddy $version" --notes-file release-notes.md else gh release create "$tag" --draft --verify-tag --title "GoodBuddy $version" --notes-file release-notes.md fi gh release upload "$tag" dist/release-upload/* --clobber gh release edit "$tag" --draft=false --latest - name: Point website to verified OSS release shell: bash env: OSS_BUCKET: ${{ vars.ALIYUN_OSS_BUCKET }} OSS_ENDPOINT: ${{ vars.ALIYUN_OSS_ENDPOINT }} OSS_REGION: ${{ steps.oss-release.outputs.region }} run: | set -euo pipefail export OSS_ACCESS_KEY_ID="$ALIBABA_CLOUD_ACCESS_KEY_ID" export OSS_ACCESS_KEY_SECRET="$ALIBABA_CLOUD_ACCESS_KEY_SECRET" export OSS_SESSION_TOKEN="$ALIBABA_CLOUD_SECURITY_TOKEN" test -n "$OSS_REGION" ossutil cp dist/site-release.json \ "oss://${OSS_BUCKET}/releases/latest.json" \ --endpoint "$OSS_ENDPOINT" \ --region "$OSS_REGION" \ --force