super-productivity/.github/workflows/build.yml
Johannes Millan 04982a28da fix(ci): preserve original filenames when replacing with signed executables
SignPath renames files (spaces become dots), causing duplicate assets on
GitHub releases. Normalize filenames by stripping spaces, dots, and
hyphens to match signed files back to their originals, then overwrite
in-place to preserve the correct asset names for website download links.
2026-03-03 11:27:20 +01:00

444 lines
18 KiB
YAML

name: Build All & Release
on:
push:
branches: [master, release/*, test/git-actions]
tags:
- v*
permissions:
contents: write
jobs:
create-release:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
steps:
- name: Create Draft Release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
tag_name: ${{ github.ref_name }}
draft: true
prerelease: ${{ contains(github.ref, 'RC') || contains(github.ref, 'beta') || contains(github.ref, 'alpha') }}
linux-bin-and-snap-release:
runs-on: ubuntu-latest
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
UNSPLASH_KEY: ${{ secrets.UNSPLASH_KEY }}
UNSPLASH_CLIENT_ID: ${{ secrets.UNSPLASH_CLIENT_ID }}
steps:
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
node-version: 22
- name: Check out Git repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
# work around for npm installs from git+https://github.com/johannesjo/J2M.git
- name: Reconfigure git to use HTTP authentication
run: >
git config --global url."https://github.com/".insteadOf
ssh://git@github.com/
- name: Get npm cache directory
id: npm-cache-dir
run: |
echo "::set-output name=dir::$(npm config get cache)"
- uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5
id: npm-cache # use this to check for `cache-hit` ==> if: steps.npm-cache.outputs.cache-hit != 'true'
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
key: ${{ runner.os }}-node22-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node22-
- name: Install npm Packages
# if: steps.npm-cache.outputs.cache-hit != 'true'
run: npm i
- name: Install Playwright Browsers
run: |
npx playwright install --with-deps chromium
npx playwright install-deps chromium
- run: npm run env # Generate env.generated.ts
- name: Lint
run: npm run lint
- name: Test Unit
run: npm run test
- name: Build Frontend for E2E
run: npm run buildFrontend:e2e
- name: Verify Build Output
run: |
test -f .tmp/angular-dist/browser/index.html || (echo "Build output missing" && exit 1)
test -f .tmp/angular-dist/browser/assets/bundled-plugins/api-test-plugin/manifest.json || (echo "Plugin assets missing from build" && exit 1)
- name: Test E2E
run: npm run e2e:ci
- name: 'Upload E2E results on failure'
if: ${{ failure() }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: e2eResults
path: .tmp/e2e-test-results/**/*.*
retention-days: 14
- name: Build Frontend & Electron
run: npm run build
- name: Install Snapcraft
uses: samuelmeuli/action-snapcraft@fceeb3c308e76f3487e72ef608618de625fb7fe8 # v3
- name: Build/Release Electron app
uses: johannesjo/action-electron-builder@9ea9e2d991c97668843d57337848e3e2b1ffab3d # v1
with:
build_script_name: empty
github_token: ${{ secrets.github_token }}
release: ${{ startsWith(github.ref, 'refs/tags/v') }}
# rename to possibly fix release issue
- run: find ./.tmp/app-builds -type f -name '*.snap' -exec sh -c 'x="{}"; mv "$x" ".tmp/app-builds/sp.snap"' \;
# Release to edge if no tag and to candidate if tag
- #otherwise it would be executed twice
if: false == startsWith(github.ref, 'refs/tags/v')
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3
with:
max_attempts: 2
timeout_minutes: 11
command: snapcraft upload .tmp/app-builds/sp.snap --release edge
# - run: snapcraft push .tmp/app-builds/sp.snap --release candidate
# if: startsWith(github.ref, 'refs/tags/v')
# - run: snapcraft promote superproductivity --from-channel latest/edge --to-channel latest/candidate --yes
# if: startsWith(github.ref, 'refs/tags/v')
mac-bin:
needs: [create-release]
runs-on: macos-latest
if: startsWith(github.ref, 'refs/tags/v')
env:
UNSPLASH_KEY: ${{ secrets.UNSPLASH_KEY }}
UNSPLASH_CLIENT_ID: ${{ secrets.UNSPLASH_CLIENT_ID }}
steps:
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
node-version: 22
- name: Echo is Release
run: echo "IS_RELEASE $IS_RELEASE, ${{ startsWith(github.ref, 'refs/tags/v') }}"
env:
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/v') }}
- name: Check out Git repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
# work around for npm installs from git+https://github.com/johannesjo/J2M.git
- name: Reconfigure git to use HTTP authentication
run: >
git config --global url."https://github.com/".insteadOf
ssh://git@github.com/
- name: Get npm cache directory
id: npm-cache-dir
run: |
echo "::set-output name=dir::$(npm config get cache)"
- uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5
id: npm-cache # use this to check for `cache-hit` ==> if: steps.npm-cache.outputs.cache-hit != 'true'
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
key: ${{ runner.os }}-node22-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node22-
- name: Workaround for nx issue and dmg licence issue
run: npm install @nx/nx-darwin-arm64 dmg-license
- name: Install npm Packages
# if: steps.npm-cache.outputs.cache-hit != 'true'
run: npm i
- run: 'echo "$PROVISION_PROFILE" | base64 --decode > embedded.provisionprofile'
shell: bash
env:
PROVISION_PROFILE: ${{secrets.dl_provision_profile}}
- name: Prepare for app notarization
# Import Apple API key for app notarization on macOS
run: |
mkdir -p ~/private_keys/
echo '${{ secrets.mac_api_key }}' > ~/private_keys/AuthKey_${{ secrets.mac_api_key_id }}.p8
- run: npm run env # Generate env.generated.ts
- name: Lint
run: npm run lint
- name: Test Unit
run: npm run test
env:
NODE_OPTIONS: '--max-old-space-size=4096'
# - uses: browser-actions/setup-chrome@v2
# id: setup-chrome
# - run: |
# echo Installed chromium version: ${{ steps.setup-chrome.outputs.chrome-version }}
# ${{ steps.setup-chrome.outputs.chrome-path }} --version
# Disabled because not working atm: https://github.com/super-productivity/super-productivity/actions/runs/5924016145/job/16060737982
# - name: Test E2E
# run: npm run e2e
- name: Build Frontend & Electron
run: npm run build
- name: Build/Release Electron app
uses: johannesjo/action-electron-builder@9ea9e2d991c97668843d57337848e3e2b1ffab3d # v1
with:
build_script_name: empty
github_token: ${{ secrets.github_token }}
mac_certs: ${{ secrets.mac_certs }}
mac_certs_password: ${{ secrets.mac_certs_password }}
release: ${{ startsWith(github.ref, 'refs/tags/v') }}
# macOS notarization API key
env:
API_KEY_ID: ${{ secrets.mac_api_key_id }}
API_KEY_ISSUER_ID: ${{ secrets.mac_api_key_issuer_id }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
windows-bin:
needs: [create-release]
runs-on: windows-latest
if: startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write
actions: read # Required by SignPath action
id-token: write # Required for SignPath origin verification
env:
UNSPLASH_KEY: ${{ secrets.UNSPLASH_KEY }}
UNSPLASH_CLIENT_ID: ${{ secrets.UNSPLASH_CLIENT_ID }}
steps:
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
node-version: 22
# required because setting via env.TZ does not work on windows
- name: Set timezone to Europe Standard Time
uses: szenius/set-timezone@1f9716b0f7120e344f0c62bb7b1ee98819aefd42 # v2.0
with:
timezoneWindows: 'W. Europe Standard Time'
- name: Check out Git repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
# work around for npm installs from git+https://github.com/johannesjo/J2M.git
- name: Reconfigure git to use HTTP authentication
run: >
git config --global url."https://github.com/".insteadOf
ssh://git@github.com/
- name: Get npm cache directory
id: npm-cache-dir
run: |
echo "::set-output name=dir::$(npm config get cache)"
- uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5
id: npm-cache # use this to check for `cache-hit` ==> if: steps.npm-cache.outputs.cache-hit != 'true'
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
key: ${{ runner.os }}-node22-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node22-
- name: Workaround for nx issue
run: npm install @nx/nx-win32-x64-msvc
- name: Install npm Packages
# if: steps.npm-cache.outputs.cache-hit != 'true'
run: npm i
- name: Setup Chrome
id: setup-chrome
uses: browser-actions/setup-chrome@4f8e94349a351df0f048634f25fec36c3c91eded # v2
- name: Export Chrome path for Karma
shell: pwsh
run: echo "CHROME_BIN=${{ steps.setup-chrome.outputs.chrome-path }}" >> $env:GITHUB_ENV
- run: npm run env # Generate env.generated.ts
- name: Lint
run: npm run lint
- name: Test Unit
run: npm run test
- name: Build Frontend & Electron
run: npm run build
- name: Build/Release Electron app
uses: johannesjo/action-electron-builder@9ea9e2d991c97668843d57337848e3e2b1ffab3d # v1
with:
build_script_name: empty
github_token: ${{ secrets.github_token }}
release: false # Don't publish unsigned; we sign first, then publish
# Publish unsigned Windows binaries first so the release is never missing Windows assets
- name: Publish unsigned Windows binaries to GitHub Release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
files: |
.tmp/app-builds/*.exe
.tmp/app-builds/*.blockmap
.tmp/app-builds/latest*.yml
draft: true
prerelease: ${{ contains(github.ref, 'RC') || contains(github.ref, 'beta') || contains(github.ref, 'alpha') }}
# --- Code signing (best-effort: signing failure won't block the release) ---
- name: Upload unsigned executables for SignPath
id: upload-unsigned
continue-on-error: true
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: unsigned-windows-executables
path: .tmp/app-builds/*.exe
if-no-files-found: error
retention-days: 1
- name: Sign Windows executables with SignPath
id: signpath
if: steps.upload-unsigned.outcome == 'success'
continue-on-error: true
uses: signpath/github-action-submit-signing-request@3f9250c56651ff692d6729a2fbb0603a42d7d322 # v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
project-slug: super-productivity
signing-policy-slug: 'release-signing'
artifact-configuration-slug: 'github-zip-pe'
github-artifact-id: ${{ steps.upload-unsigned.outputs.artifact-id }}
output-artifact-directory: '.tmp/app-builds-signed/'
wait-for-completion: true
wait-for-completion-timeout-in-seconds: 600
- name: Move signed executables back to build directory
if: steps.signpath.outcome == 'success'
shell: pwsh
run: |
$signed = Get-ChildItem -Path .tmp/app-builds-signed -Filter *.exe -Recurse
if ($signed.Count -eq 0) {
Write-Error "No signed executables found in SignPath output directory"
exit 1
}
# Build a map of original filenames for matching
# SignPath may replace spaces with dots in filenames, so we normalize
# spaces, dots, and hyphens to find the corresponding original file
$originals = Get-ChildItem -Path .tmp/app-builds -Filter *.exe
$signed | ForEach-Object {
$signedName = $_.Name
$normalizedSigned = $signedName -replace '[.\-\s]', ''
$match = $originals | Where-Object { ($_.Name -replace '[.\-\s]', '') -eq $normalizedSigned }
if ($match -is [array]) {
Write-Error "Ambiguous match for $signedName — matched multiple originals: $($match.Name -join ', ')"
exit 1
}
if ($match) {
Write-Host "Replacing $($match.Name) with signed version (was: $signedName)"
Move-Item -Force $_.FullName $match.FullName
} else {
Write-Error "No matching original found for signed file: $signedName"
exit 1
}
}
Write-Host "Final executables:"
Get-ChildItem -Path .tmp/app-builds -Filter *.exe | Format-Table -AutoSize
- name: Verify code signatures
if: steps.signpath.outcome == 'success'
shell: pwsh
run: |
$exeFiles = Get-ChildItem -Path .tmp/app-builds -Filter *.exe
foreach ($exe in $exeFiles) {
$sig = Get-AuthenticodeSignature $exe.FullName
Write-Host "$($exe.Name): $($sig.Status)"
if ($sig.Status -ne 'Valid') {
Write-Error "Signature verification failed for $($exe.Name): $($sig.Status)"
exit 1
}
}
- name: Regenerate blockmaps and update latest.yml for signed executables
if: steps.signpath.outcome == 'success'
shell: pwsh
run: |
$buildDir = ".tmp/app-builds"
$exeFiles = Get-ChildItem -Path $buildDir -Filter "*.exe"
foreach ($exe in $exeFiles) {
$blockmapPath = "$($exe.FullName).blockmap"
Write-Host "Regenerating blockmap for $($exe.Name)..."
$output = npx --yes app-builder-bin blockmap --input="$($exe.FullName)" --output="$blockmapPath" --compression=gzip
Write-Host " blockmap output: $output"
}
# Update latest.yml with new sha512 hashes and sizes from signed executables
node -e "
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
const crypto = require('crypto');
const buildDir = '.tmp/app-builds';
const ymlFiles = fs.readdirSync(buildDir).filter(f => f.startsWith('latest') && f.endsWith('.yml'));
for (const ymlFile of ymlFiles) {
const ymlPath = path.join(buildDir, ymlFile);
const ymlData = yaml.load(fs.readFileSync(ymlPath, 'utf8'));
if (ymlData.files) {
for (const fileEntry of ymlData.files) {
const filePath = path.join(buildDir, fileEntry.url);
if (fs.existsSync(filePath)) {
const fileBuffer = fs.readFileSync(filePath);
const sha512 = crypto.createHash('sha512').update(fileBuffer).digest('base64');
const size = fileBuffer.length;
console.log(ymlFile + ': Updated ' + fileEntry.url + ' sha512=' + sha512.substring(0, 20) + '... size=' + size);
fileEntry.sha512 = sha512;
fileEntry.size = size;
}
}
}
// Update top-level sha512 to match primary file
if (ymlData.path) {
const primary = ymlData.files && ymlData.files.find(f => f.url === ymlData.path);
if (primary) {
ymlData.sha512 = primary.sha512;
}
}
fs.writeFileSync(ymlPath, yaml.dump(ymlData, { lineWidth: -1 }));
console.log(ymlFile + ' updated successfully');
}
"
# Overwrite unsigned release assets with signed versions
- name: Publish signed Windows binaries to GitHub Release
if: steps.signpath.outcome == 'success'
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
files: |
.tmp/app-builds/*.exe
.tmp/app-builds/*.blockmap
.tmp/app-builds/latest*.yml
draft: true
prerelease: ${{ contains(github.ref, 'RC') || contains(github.ref, 'beta') || contains(github.ref, 'alpha') }}