Skip to content

forge .exe build

forge .exe build #170

# 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