feedBack-desktop/.github/workflows/nightly.yml
OmikronApex ec6a84c3c8 fix(nightly): dedupe publish assets across overlapping globs
The artifacts/**/*.zip glob overlaps artifacts/**/velopack/**/*, so a
zip inside a velopack dir would be uploaded twice and fail the release
create. Track seen paths and append each file once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 23:09:11 +02:00

408 lines
18 KiB
YAML

name: Nightly
# Trunk-based: nightly always builds main — the release/v* branch discovery
# from the old release-centric flow is gone (it pinned nightlies to the
# highest release branch forever, even after it shipped). Stabilization
# builds from release/** come from rc.yml instead.
on:
schedule:
- cron: '0 23 * * *'
workflow_dispatch:
# Serialize nightly runs so a manual dispatch overlapping the scheduled cron
# can't race on the rolling `nightly` release (the publish job deletes and
# recreates it; two concurrent runs could interleave into a "release already
# exists" failure). Newer run wins; the in-progress one is cancelled.
concurrency:
group: nightly-release
cancel-in-progress: true
jobs:
setup:
runs-on: ubuntu-latest
outputs:
date: ${{ steps.date.outputs.date }}
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Get date
id: date
run: echo "date=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
# Nightly Velopack version: <package.json base>-nightly.<UTC date>.
# Strip any prerelease suffix from package.json (0.3.0-alpha -> 0.3.0) so
# successive nights produce clean, SemVer-monotonic prerelease versions
# (0.3.0-nightly.20260706 < 0.3.0-nightly.20260707). The nightly channel
# has its own Velopack feed (win-x64-nightly / osx-arm64-nightly), so this
# never collides with the tag-driven alpha/beta/rc/stable feeds from
# build.yml. Note: two runs on the same UTC date (e.g. a manual dispatch
# after the scheduled build) produce identical versions — the client sees
# the second as "not newer" and skips it. Acceptable for a daily channel.
- name: Derive nightly version
id: version
run: |
set -euo pipefail
base="$(node -p "require('./package.json').version")"
base="${base%%-*}"
version="${base}-nightly.${{ steps.date.outputs.date }}"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "Nightly Velopack version: $version"
# build-core removed: core's own nightly.yml already builds + pushes
# ghcr.io/got-feedback/feedback:nightly on the same 02:00 UTC cron, so this
# was duplicate/racing work. Core owns its image now.
build:
needs: setup
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-22.04
platform: linux
arch: x64
- os: macos-14
platform: mac
arch: arm64
- os: windows-latest
platform: win
arch: x64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Read build configuration
id: config
shell: bash
run: |
NODE_VERSION=$(node -p "require('./.build-config.json').versions.node")
PYTHON_VERSION=$(node -p "require('./.build-config.json').versions.python")
DOTNET_VERSION=$(node -p "require('./.build-config.json').versions.dotnet")
echo "node=$NODE_VERSION" >> $GITHUB_OUTPUT
echo "python=$PYTHON_VERSION" >> $GITHUB_OUTPUT
echo "dotnet=$DOTNET_VERSION" >> $GITHUB_OUTPUT
echo "Node ${NODE_VERSION}, Python ${PYTHON_VERSION}, .NET ${DOTNET_VERSION}"
- uses: actions/setup-node@v4
with:
node-version: ${{ steps.config.outputs.node }}
- uses: actions/setup-python@v5
with:
python-version: ${{ steps.config.outputs.python }}
# Pin the .NET runtime for the Velopack CLI (vpk targets net8). Only the
# win + mac legs pack Velopack, so Linux doesn't need it. build.yml pins
# this for the identical vpk step — without it the pack works only by
# luck of the runner image happening to preinstall .NET 8.
- uses: actions/setup-dotnet@v4
if: matrix.platform != 'linux'
with:
dotnet-version: ${{ steps.config.outputs.dotnet }}.x
# macOS: import signing cert so build-macos.sh can codesign the .app.
# The nightly .app is signed but not notarized — testers clear Gatekeeper with
# xattr -dr com.apple.quarantine "fee[dB]ack.app"
# Same graceful fallback as release.yml: no-op when secrets are absent.
- name: Import Apple signing certificate
if: matrix.platform == 'mac'
env:
APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
set -euo pipefail
if [[ -z "${APPLE_CERTIFICATE_P12_BASE64:-}" ]]; then
echo "APPLE_CERTIFICATE_P12_BASE64 not set — skipping (unsigned build)"
exit 0
fi
printf '%s' "$APPLE_CERTIFICATE_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -lut 21600 build.keychain
security list-keychains -d user -s build.keychain login.keychain-db
security import "$RUNNER_TEMP/cert.p12" -k build.keychain \
-P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list \
-S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" build.keychain
rm -f "$RUNNER_TEMP/cert.p12"
security find-identity -v -p codesigning build.keychain | grep -q "Developer ID Application"
- name: Build
run: ./scripts/build-release.sh
shell: bash
env:
# PAT with read access to the private got-feedback repos so
# build-common.sh can clone core + the bundled plugins.
GH_CLONE_TOKEN: ${{ secrets.FEEDBACK_CLONE_TOKEN }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
# No notarization for nightlies — testers use xattr -dr com.apple.quarantine
CSC_FOR_PULL_REQUEST: "true"
- name: Setup upterm session
uses: owenthereal/action-upterm@v1
if: ${{ failure() }}
with:
wait-timeout-minutes: 5
# macOS: zip the .app for tester distribution (same as release.yml non-tag path).
- name: Zip macOS app
if: matrix.platform == 'mac'
shell: bash
run: |
set -euo pipefail
# The .app bundle is named after productName (e.g. fee[dB]ack.app),
# not the old "Slopsmith" name — find it dynamically so a rebrand
# can't break the tester zip.
app=$(ls -d release/mac*/*.app 2>/dev/null | head -n1 || true)
if [[ -z "${app:-}" || ! -d "$app" ]]; then
echo "::error::No .app bundle found under release/mac*/"
exit 1
fi
name=$(basename "$app" .app)
ditto -c -k --sequesterRsrc --keepParent "$app" "release/${name}-macos-arm64.zip"
echo "Zipped $app -> release/${name}-macos-arm64.zip"
# Windows: electron-builder's `dir` target leaves win-unpacked/ with no
# packaged installer (Velopack is skipped for nightlies). Zip the directory
# so testers can download and extract, then launch the app .exe directly.
- name: Zip win-unpacked
if: matrix.platform == 'win'
shell: pwsh
run: |
Compress-Archive -Path release\win-unpacked\* -DestinationPath release\feedback-windows-x64.zip
Write-Host "Zipped win-unpacked -> release\feedback-windows-x64.zip"
# Velopack: pack the nightly build into an auto-update feed on the rolling
# `nightly` channel (Windows + macOS only — Linux ships AppImage/deb with
# no Velopack pipeline). Mirrors build.yml's tag-driven pack, but the
# channel is fixed to `nightly` and the version comes from the setup job
# instead of a git tag. The client's update-manager.ts builds the same
# rid-scoped channel names (win-x64-nightly / osx-arm64-nightly).
- name: Install Velopack CLI (vpk)
if: matrix.platform != 'linux'
shell: bash
run: |
set -euo pipefail
# Pin vpk to the exact version of the velopack npm SDK used by the app
# (package.json) — Velopack ships the CLI and SDK in lockstep.
dotnet tool install -g vpk --version 0.0.1589-ga2c5a97
echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH"
# Windows: pack the electron-builder unpacked dir into Velopack release
# assets (per-machine MSI, *-full.nupkg, *-delta.nupkg,
# releases.win-x64-nightly.json). Unsigned (same as build.yml's win pack).
# --noPortable: vpk's Portable.zip (Update.exe stub wrapping the app) is
# suppressed — the plain feedback-windows-x64.zip above is the one
# portable tester download.
# See build.yml's "Velopack pack (Windows)" for the --msi / Setup.exe
# policy rationale — this is a channel-swapped copy of it.
- name: Velopack pack (Windows)
if: matrix.platform == 'win'
shell: bash
run: |
set -euo pipefail
# The launcher exe is the electron-builder 'dir' output, whose name is
# the SANITIZED productName (e.g. fee[dB]ack -> feedback.exe). Find it
# dynamically — there's a single .exe at the win-unpacked root.
mainexe=$(basename "$(ls release/win-unpacked/*.exe 2>/dev/null | head -n1)")
if [[ -z "${mainexe:-}" ]]; then
echo "::error::No .exe launcher in release/win-unpacked/"; ls -1 release/win-unpacked/ | head -20; exit 1
fi
vpk pack \
--packId feedback \
--packVersion "${{ needs.setup.outputs.version }}" \
--channel "win-x64-nightly" \
--packDir release/win-unpacked \
--mainExe "$mainexe" \
--noPortable \
--msi \
--instLocation PerMachine \
-o release/velopack
# Drop the per-user Setup.exe (default vpk output) so only the
# per-machine MSI ships. nocaseglob so a casing change can't slip an
# installer past the glob.
shopt -s nullglob nocaseglob
removed=0
for f in release/velopack/*setup.exe; do
echo "Removing per-user installer: $f"
rm -f "$f"
removed=$((removed+1))
done
if compgen -G "release/velopack/*setup.exe" > /dev/null; then
echo "::error::Setup.exe artifact(s) remain after cleanup; refusing to publish dual installer types."
ls -1 release/velopack/*setup.exe
exit 1
fi
if [[ "$removed" -eq 0 ]]; then
echo "::notice::No Setup.exe output from vpk; shipping MSI-only artifacts."
fi
if ! compgen -G "release/velopack/*.msi" > /dev/null; then
echo "::error::vpk pack --msi x64 did not produce a .msi file in release/velopack/"
exit 1
fi
# macOS: same, but pass Apple signing identity + notarization creds so
# Velopack codesigns + notarizes the bundle it generates (otherwise
# Gatekeeper blocks auto-applied updates). Unlike the tester .zip above
# (signed-not-notarized), the auto-update feed MUST be notarized to be
# applyable on user machines, so nightly notarizes here. Falls back to an
# unsigned pack if any Apple secret is missing (forks / partial configs).
# Runs before the keychain cleanup below (needs the signing cert).
# --noPortable: same as the Windows pack — the tester zip above is the
# one portable download; updates ride on the .nupkg feed.
- name: Velopack pack (macOS)
if: matrix.platform == 'mac'
shell: bash
env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
# The .app bundle + its executable are named after productName (e.g.
# fee[dB]ack.app), not the packId — resolve them dynamically.
app=$(ls -d release/mac-arm64/*.app 2>/dev/null | head -n1 || true)
if [[ -z "${app:-}" || ! -d "$app" ]]; then
echo "::error::No .app bundle in release/mac-arm64/"
exit 1
fi
mainexe=$(basename "$app" .app)
if [[ -z "${APPLE_SIGNING_IDENTITY:-}" || -z "${APPLE_ID:-}" \
|| -z "${APPLE_APP_SPECIFIC_PASSWORD:-}" \
|| -z "${APPLE_TEAM_ID:-}" ]]; then
echo "::warning::Apple signing/notarization secrets incomplete — packing macOS Velopack release UNSIGNED. Gatekeeper will block auto-updates on user machines."
vpk pack \
--packId feedback \
--packVersion "${{ needs.setup.outputs.version }}" \
--channel "osx-arm64-nightly" \
--packDir "$app" \
--mainExe "$mainexe" \
--noPortable \
-o release/velopack
else
# vpk notarizes through a notarytool *credential profile*. Create it
# first, then hand vpk --notaryProfile. Do NOT pass --keychain:
# notarytool defaults to the login keychain, matching vpk's own
# internal notarytool call.
# --signEntitlements is mandatory: vpk re-signs the bundle, and a
# codesign without an entitlements file STRIPS the ones
# electron-builder applied (audio-input, allow-jit, ...) — the
# hardened-runtime app then gets mic access auto-denied with no
# TCC prompt.
xcrun notarytool store-credentials "velopack-notary" \
--apple-id "$APPLE_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--team-id "$APPLE_TEAM_ID"
vpk pack \
--packId feedback \
--packVersion "${{ needs.setup.outputs.version }}" \
--channel "osx-arm64-nightly" \
--packDir "$app" \
--mainExe "$mainexe" \
--noPortable \
-o release/velopack \
--signAppIdentity "$APPLE_SIGNING_IDENTITY" \
--signEntitlements "$PWD/resources/entitlements.mac.entitlements" \
--notaryProfile "velopack-notary"
fi
- name: Clean up signing keychain
if: always() && matrix.platform == 'mac'
run: |
security delete-keychain build.keychain 2>/dev/null || true
- name: Upload nightly artifacts
uses: actions/upload-artifact@v4
with:
name: nightly-${{ needs.setup.outputs.date }}-${{ matrix.platform }}-${{ matrix.arch }}
path: |
release/*.AppImage
release/*.deb
release/*.zip
release/velopack/**/*
build/Release/*.pdb
if-no-files-found: warn
retention-days: 7
# Publish the Velopack nightly feed to a single ROLLING GitHub Release tagged
# `nightly`. The in-app updater on the nightly channel reads this release:
# Velopack's GitHub loader iterates recent releases looking for the
# releases.<channel>.json asset, and can ONLY see real Releases (not workflow
# artifacts) and ONLY non-prerelease rows (the JS SDK's AutoSource hardcodes
# prerelease=false). So we delete + recreate the `nightly` release each run
# (assets are version-stamped, so clobbering by name wouldn't reclaim old
# ones) with prerelease=false and --latest=false — the "Latest" badge stays
# pinned to the most recent stable tag. Full .nupkg only; no deltas (each run
# wipes the prior full package, which vpk would need to compute a delta).
publish:
needs: [setup, build]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
path: artifacts
- name: Publish rolling nightly release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.setup.outputs.version }}
DATE: ${{ needs.setup.outputs.date }}
run: |
set -euo pipefail
shopt -s globstar nullglob
# Collect the Velopack feed (win + mac) plus Linux distributables so
# testers can also download nightlies by hand. The `**` before
# velopack matches whether or not upload-artifact stripped the
# `release/` prefix (it depends on the least-common-ancestor of all
# files in each per-platform artifact).
assets=(
artifacts/**/velopack/**/*
artifacts/**/*.AppImage
artifacts/**/*.deb
artifacts/**/*.zip
)
# Filter to real files (globs that matched nothing expand to nothing
# under nullglob, but guard against directories sneaking in) and
# dedupe: the *.zip glob overlaps the velopack/** glob, and gh
# release create fails on duplicate asset names.
declare -A seen
files=()
for a in "${assets[@]}"; do
[[ -f "$a" && -z "${seen[$a]:-}" ]] || continue
seen["$a"]=1
files+=("$a")
done
if [[ ${#files[@]} -eq 0 ]]; then
echo "::error::No release assets found under artifacts/ — refusing to publish an empty nightly release."
exit 1
fi
echo "Publishing ${#files[@]} asset(s) to the rolling 'nightly' release:"
printf ' %s\n' "${files[@]}"
# Recreate the rolling release so the tag re-points to this run's SHA
# and stale assets from the prior night are dropped. --cleanup-tag so
# the recreated release's --target takes effect. Tolerate first-run
# (no existing release/tag).
gh release delete nightly --yes --cleanup-tag --repo "$GITHUB_REPOSITORY" 2>/dev/null || true
gh release create nightly \
--repo "$GITHUB_REPOSITORY" \
--target "$GITHUB_SHA" \
--title "Nightly ${VERSION}" \
--notes "Automated nightly build (${DATE}). Least-stable channel — auto-updates via the in-app **Nightly** update channel (Windows + macOS). Linux users download the AppImage/.deb below manually." \
--latest=false \
"${files[@]}"