forge .exe build #170
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
| # Build Windows installer with Electron Forge on CI, publish GitHub Release, notify /api/releases. | |
| name: forge .exe build | |
| on: | |
| workflow_dispatch: | |
| # No manual inputs: release tag/build folder id is computed from current UTC time. | |
| permissions: | |
| contents: write | |
| jobs: | |
| publish: | |
| runs-on: windows-latest | |
| defaults: | |
| run: | |
| shell: bash | |
| env: | |
| CSC_IDENTITY_AUTO_DISCOVERY: false | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v5 | |
| - name: Setup Node | |
| uses: actions/setup-node@v5 | |
| with: | |
| node-version: 22 | |
| cache: npm | |
| cache-dependency-path: package-lock.json | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Compute release_id from UTC time | |
| id: meta | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| rid="build_$(date -u +%m%d%Y_%H%M)_forge" | |
| echo "release_id=$rid" >> "$GITHUB_OUTPUT" | |
| echo "RELEASE_BUILD_ID=$rid" >> "$GITHUB_ENV" | |
| - name: Build Forge Windows artifacts | |
| env: | |
| ELECTRON_PUBLISH: never | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: npm run make:win:forge | |
| # forge-cleanup writes releases/forge/<build_id>_forge/ (installer at root; zip + yml in dev/). | |
| # Stage flat copies under releases/electron/<build_id>_forge/ for GitHub upload. | |
| - name: Locate Forge artifacts and stage under releases/electron | |
| id: artifact | |
| run: | | |
| set -euo pipefail | |
| rid="${{ steps.meta.outputs.release_id }}" | |
| src_dir="releases/forge/$rid" | |
| dev_dir="$src_dir/dev" | |
| stage_dir="releases/electron/$rid" | |
| mkdir -p "$stage_dir" | |
| exe="$(ls -1 "$src_dir"/HyperlinksSpaceProgramInstaller_*.exe 2>/dev/null | head -n 1 || true)" | |
| if [[ -z "$exe" || ! -f "$exe" ]]; then | |
| echo "Expected timestamped installer in $src_dir (forge-cleanup; set RELEASE_BUILD_ID=$rid)." | |
| find releases -maxdepth 6 -type f 2>/dev/null || true | |
| exit 1 | |
| fi | |
| zip="$(ls -1 "$dev_dir"/HyperlinksSpaceProgram_*.zip 2>/dev/null | head -n 1 || true)" | |
| if [[ -z "$zip" || ! -f "$zip" ]]; then | |
| echo "Portable zip required in $dev_dir (Forge zip maker + forge-cleanup)." | |
| find "$dev_dir" -maxdepth 1 -type f 2>/dev/null || true | |
| exit 1 | |
| fi | |
| yml="$dev_dir/latest.yml" | |
| zip_yml="$dev_dir/zip-latest.yml" | |
| if [[ ! -f "$yml" ]]; then | |
| echo "latest.yml missing next to installer — electron-updater needs it on the GitHub Release." | |
| exit 1 | |
| fi | |
| exe_name="$(basename "$exe")" | |
| zip_name="$(basename "$zip")" | |
| exe_out="$stage_dir/$exe_name" | |
| zip_out="$stage_dir/$zip_name" | |
| yml_out="$stage_dir/latest.yml" | |
| zip_yml_out="$stage_dir/zip-latest.yml" | |
| cp "$exe" "$exe_out" | |
| cp "$zip" "$zip_out" | |
| cp "$yml" "$yml_out" | |
| if [[ -f "$zip_yml" ]]; then | |
| cp "$zip_yml" "$zip_yml_out" | |
| fi | |
| echo "exe_path=$exe_out" >> "$GITHUB_OUTPUT" | |
| echo "exe_name=$exe_name" >> "$GITHUB_OUTPUT" | |
| echo "zip_path=$zip_out" >> "$GITHUB_OUTPUT" | |
| echo "zip_name=$zip_name" >> "$GITHUB_OUTPUT" | |
| echo "yml_path=$yml_out" >> "$GITHUB_OUTPUT" | |
| echo "zip_yml_path=$zip_yml_out" >> "$GITHUB_OUTPUT" | |
| echo "release_id=$rid" >> "$GITHUB_OUTPUT" | |
| - name: Check if GitHub release already exists | |
| id: exists | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| working-directory: ${{ github.workspace }} | |
| run: | | |
| set -euo pipefail | |
| rid="${{ steps.artifact.outputs.release_id }}" | |
| if gh release view "$rid" --repo "${{ github.repository }}" >/dev/null 2>&1; then | |
| echo "exists=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "exists=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Preflight checks and artifact summary | |
| if: steps.exists.outputs.exists != 'true' | |
| id: preflight | |
| run: | | |
| set -euo pipefail | |
| rid="${{ steps.artifact.outputs.release_id }}" | |
| exe="${{ steps.artifact.outputs.exe_path }}" | |
| zip="${{ steps.artifact.outputs.zip_path }}" | |
| yml="${{ steps.artifact.outputs.yml_path }}" | |
| zip_yml="${{ steps.artifact.outputs.zip_yml_path }}" | |
| stage_dir="releases/electron/$rid" | |
| [[ -d "$stage_dir" ]] || { echo "Stage dir missing: $stage_dir"; exit 1; } | |
| [[ -f "$exe" ]] || { echo "Installer missing: $exe"; exit 1; } | |
| [[ -f "$zip" ]] || { echo "Portable zip missing: $zip"; exit 1; } | |
| [[ -f "$yml" ]] || { echo "latest.yml missing: $yml"; exit 1; } | |
| exe_name="$(basename "$exe")" | |
| zip_name="$(basename "$zip")" | |
| yml_name="$(basename "$yml")" | |
| [[ "$exe_name" == HyperlinksSpaceProgramInstaller_*.exe ]] || { | |
| echo "Unexpected installer name: $exe_name"; exit 1; | |
| } | |
| [[ "$zip_name" == HyperlinksSpaceProgram_*.zip ]] || { | |
| echo "Unexpected zip name: $zip_name"; exit 1; | |
| } | |
| [[ "$yml_name" == "latest.yml" ]] || { | |
| echo "Unexpected yml name: $yml_name"; exit 1; | |
| } | |
| app_version="$(echo "$zip_name" | sed -E 's/^HyperlinksSpaceProgram_(.+)\.zip$/\1/')" | |
| if [[ -z "$app_version" || "$app_version" == "$zip_name" ]]; then | |
| app_version="unknown" | |
| fi | |
| echo "app_version=$app_version" >> "$GITHUB_OUTPUT" | |
| echo "Preflight OK (Forge):" | |
| echo " release_id: $rid" | |
| echo " app_version: $app_version" | |
| echo " stage_dir: $stage_dir" | |
| echo " installer: $exe" | |
| echo " portable_zip: $zip" | |
| echo " latest_yml: $yml" | |
| if [[ -n "$zip_yml" && -f "$zip_yml" ]]; then | |
| echo " zip_latest_yml: $zip_yml" | |
| else | |
| echo " zip_latest_yml: (missing, optional)" | |
| fi | |
| - name: Create GitHub release and upload Forge artifacts | |
| if: steps.exists.outputs.exists != 'true' | |
| id: create | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| rid="${{ steps.artifact.outputs.release_id }}" | |
| exe="${{ steps.artifact.outputs.exe_path }}" | |
| zip="${{ steps.artifact.outputs.zip_path }}" | |
| yml="${{ steps.artifact.outputs.yml_path }}" | |
| zip_yml="${{ steps.artifact.outputs.zip_yml_path }}" | |
| upload=( "$exe" "$yml" "$zip" ) | |
| if [[ -f "$zip_yml" ]]; then | |
| upload+=( "$zip_yml" ) | |
| else | |
| echo "Warning: zip-latest.yml missing (forge-cleanup should generate it when zip exists)." | |
| fi | |
| echo "Publishing release $rid (app_version=${{ steps.preflight.outputs.app_version }})" | |
| printf 'Assets:\n- %s\n' "${upload[@]}" | |
| gh release create "$rid" "${upload[@]}" \ | |
| --repo "${{ github.repository }}" \ | |
| --title "$rid" \ | |
| --notes "Windows Forge build on GitHub Actions (workflow run ${{ github.run_id }}). Same asset names as electron-builder; tag ends with _forge." | |
| release_url="https://github.com/${{ github.repository }}/releases/tag/$rid" | |
| echo "release_url=$release_url" >> "$GITHUB_OUTPUT" | |
| - name: Notify app webhook | |
| if: steps.exists.outputs.exists != 'true' | |
| env: | |
| RELEASE_WEBHOOK_URL: ${{ secrets.RELEASE_WEBHOOK_URL }} | |
| VERCEL_PROJECT_PRODUCTION_URL: ${{ vars.VERCEL_PROJECT_PRODUCTION_URL }} | |
| VERCEL_PROJECT_PRODUCTION_URL_SECRET: ${{ secrets.VERCEL_PROJECT_PRODUCTION_URL }} | |
| WEBHOOK_TOKEN: ${{ secrets.RELEASE_WEBHOOK_TOKEN }} | |
| RELEASE_ID: ${{ steps.artifact.outputs.release_id }} | |
| RELEASE_URL: ${{ steps.create.outputs.release_url }} | |
| EXE_NAME: ${{ steps.artifact.outputs.exe_name }} | |
| working-directory: ${{ github.workspace }} | |
| run: | | |
| set -euo pipefail | |
| webhook_url="${RELEASE_WEBHOOK_URL:-}" | |
| if [[ -z "$webhook_url" ]]; then | |
| if [[ -n "${VERCEL_PROJECT_PRODUCTION_URL:-}" ]]; then | |
| webhook_url="https://${VERCEL_PROJECT_PRODUCTION_URL}/api/releases" | |
| elif [[ -n "${VERCEL_PROJECT_PRODUCTION_URL_SECRET:-}" ]]; then | |
| webhook_url="https://${VERCEL_PROJECT_PRODUCTION_URL_SECRET}/api/releases" | |
| fi | |
| fi | |
| if [[ -z "$webhook_url" ]]; then | |
| echo "No webhook URL configured; skipping." | |
| exit 0 | |
| fi | |
| published_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| export PUBLISHED_AT="$published_at" | |
| asset_url="${RELEASE_URL/\/tag\//\/download\/}/${EXE_NAME}" | |
| export ASSET_URL="$asset_url" | |
| payload="$(python3 -c "import json, os; print(json.dumps({'release_id': os.environ['RELEASE_ID'], 'published_at': os.environ['PUBLISHED_AT'], 'platform': 'windows', 'assets': [{'name': os.environ['EXE_NAME'], 'url': os.environ['ASSET_URL']}], 'github_release_url': os.environ['RELEASE_URL']}))")" | |
| if [[ -n "${WEBHOOK_TOKEN:-}" ]]; then | |
| curl --fail --show-error --silent \ | |
| -X POST "$webhook_url" \ | |
| -H "Content-Type: application/json" \ | |
| -H "x-release-token: $WEBHOOK_TOKEN" \ | |
| --data "$payload" | |
| else | |
| curl --fail --show-error --silent \ | |
| -X POST "$webhook_url" \ | |
| -H "Content-Type: application/json" \ | |
| --data "$payload" | |
| fi | |
| - name: Summary | |
| working-directory: ${{ github.workspace }} | |
| run: | | |
| if [[ "${{ steps.exists.outputs.exists }}" == "true" ]]; then | |
| echo "Skipped: release already exists for ${{ steps.artifact.outputs.release_id }}." | |
| else | |
| echo "Published ${{ steps.artifact.outputs.release_id }} (${{ steps.artifact.outputs.exe_path }})" | |
| fi |