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: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 - name: Generate release body env: GH_REPOSITORY: ${{ github.repository }} GH_REF_NAME: ${{ github.ref_name }} run: | PREV_TAG=$(git tag --sort=-v:refname | grep '^v' | sed -n '2p') { cat build/release-notes.md echo "" if [ -n "$PREV_TAG" ]; then echo "**Full Changelog**: https://github.com/${GH_REPOSITORY}/compare/${PREV_TAG}...${GH_REF_NAME}" fi } > /tmp/release-body.md - name: Create Draft Release uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 with: tag_name: ${{ github.ref_name }} draft: true prerelease: ${{ contains(github.ref, 'RC') || contains(github.ref, 'beta') || contains(github.ref, 'alpha') }} body_path: /tmp/release-body.md 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@53b83947a5a98c8d113130e565377fae1a50d02f # 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 "dir=$(npm config get cache)" >> $GITHUB_OUTPUT - 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@53b83947a5a98c8d113130e565377fae1a50d02f # 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 "dir=$(npm config get cache)" >> $GITHUB_OUTPUT - 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@53b83947a5a98c8d113130e565377fae1a50d02f # 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 shell: bash run: | echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT - 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 args: "--publish never" # Explicit: don't auto-publish unsigned exe files # --- Code signing (SignPath failure is tolerated; post-signing verification failures will 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 alter separators in filenames, so we normalize # dots, hyphens, and spaces 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 generate latest.yml for signed executables if: steps.signpath.outcome == 'success' shell: pwsh run: | $buildDir = ".tmp/app-builds" # Only regenerate blockmaps for NSIS setup files (portables don't use them) $setupExes = Get-ChildItem -Path $buildDir -Filter "Super-Productivity-Setup*.exe" # Resolve the platform-specific app-builder binary from the lockfile-pinned package $appBuilder = node -e "console.log(require('app-builder-bin').appBuilderPath)" foreach ($exe in $setupExes) { $blockmapPath = "$($exe.FullName).blockmap" Write-Host "Regenerating blockmap for $($exe.Name)..." $output = & $appBuilder blockmap --input="$($exe.FullName)" --output="$blockmapPath" --compression=gzip Write-Host " blockmap output: $output" } # Update latest.yml with hashes from signed executables # (electron-builder generates it during build, but with hashes of unsigned files) 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 ymlPath = path.join(buildDir, 'latest.yml'); function getFileInfo(filePath) { const fileBuffer = fs.readFileSync(filePath); const info = { sha512: crypto.createHash('sha512').update(fileBuffer).digest('base64'), size: fileBuffer.length, }; const blockmapPath = filePath + '.blockmap'; if (fs.existsSync(blockmapPath)) { info.blockMapSize = fs.statSync(blockmapPath).size; } return info; } if (fs.existsSync(ymlPath)) { // Update existing latest.yml (preserves all electron-builder fields) const ymlData = yaml.load(fs.readFileSync(ymlPath, 'utf8')); if (!ymlData.files || ymlData.files.length === 0) { console.error('ERROR: latest.yml has no file entries'); process.exit(1); } for (const fileEntry of ymlData.files) { const filePath = path.join(buildDir, fileEntry.url); if (!fs.existsSync(filePath)) { console.error('ERROR: File referenced in latest.yml not found: ' + fileEntry.url); process.exit(1); } const info = getFileInfo(filePath); Object.assign(fileEntry, info); console.log(' Updated ' + fileEntry.url + ': sha512=' + info.sha512.substring(0, 20) + '... size=' + info.size); } if (ymlData.path) { const primary = ymlData.files.find(f => f.url === ymlData.path); if (primary) { ymlData.sha512 = primary.sha512; } } fs.writeFileSync(ymlPath, yaml.dump(ymlData, { lineWidth: -1 })); console.log('latest.yml updated with signed file hashes'); } else { // Fallback: generate from scratch if latest.yml was not created console.log('No existing latest.yml found, generating from scratch'); const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); const setupExes = fs.readdirSync(buildDir) .filter(f => f.startsWith('Super-Productivity-Setup') && f.endsWith('.exe')); if (setupExes.length === 0) { console.error('ERROR: No setup executables found in ' + buildDir); process.exit(1); } const files = setupExes.map(filename => { const filePath = path.join(buildDir, filename); const info = getFileInfo(filePath); console.log(' ' + filename + ': sha512=' + info.sha512.substring(0, 20) + '... size=' + info.size); return { url: filename, ...info }; }); const primary = files.find(f => f.url === 'Super-Productivity-Setup.exe') || files.find(f => f.url === 'Super-Productivity-Setup-x64.exe') || files[0]; const latestYml = { version: pkg.version, files, path: primary.url, sha512: primary.sha512, releaseDate: new Date().toISOString(), }; fs.writeFileSync(ymlPath, yaml.dump(latestYml, { lineWidth: -1 })); console.log('Generated latest.yml for ' + files.length + ' signed executables'); } " # Only publish signed Windows binaries - name: Publish signed Windows binaries to GitHub Release if: steps.signpath.outcome == 'success' uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 with: token: ${{ secrets.GITHUB_TOKEN }} files: | .tmp/app-builds/*.exe .tmp/app-builds/Super-Productivity-Setup*.blockmap .tmp/app-builds/latest*.yml draft: true prerelease: ${{ contains(github.ref, 'RC') || contains(github.ref, 'beta') || contains(github.ref, 'alpha') }}