Build and Release #34
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
| name: Build and Release | |
| on: | |
| workflow_dispatch: {} | |
| permissions: | |
| contents: write | |
| jobs: | |
| preflight: | |
| name: Preflight - suggest secrets | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Print recommended secrets and guidance | |
| run: | | |
| echo "Recommended repository secrets for this workflow:" | |
| echo " - GPG_SIGNING_KEY: (optional) ASCII-armored GPG private key for signing release artifacts" | |
| echo " - CODE_SIGN_CERT: (optional) Windows code-signing certificate (base64)" | |
| echo " - STORAGE_TOKEN: (optional) token for private artifact stores" | |
| echo | |
| echo "To add a secret: repository Settings → Secrets and variables → Actions → New repository secret" | |
| build: | |
| name: Build (matrix) | |
| runs-on: ${{ matrix.os }} | |
| needs: preflight | |
| env: | |
| CODE_SIGN_CERT: ${{ secrets.CODE_SIGN_CERT }} | |
| CODE_SIGN_CERT_PASSWORD: ${{ secrets.CODE_SIGN_CERT_PASSWORD }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - os: windows-latest | |
| platform: Windows | |
| arch: amd64 | |
| ext: .exe | |
| package: zip | |
| - os: ubuntu-latest | |
| platform: Linux | |
| arch: amd64 | |
| ext: "" | |
| package: tar.gz | |
| - os: ubuntu-latest | |
| platform: Linux | |
| arch: arm64 | |
| ext: "" | |
| package: tar.gz | |
| - os: macos-latest | |
| platform: MacOS | |
| arch: arm64 | |
| ext: "" | |
| package: tar.gz | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| - name: Set up Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.11" | |
| cache: "pip" | |
| - name: Install dependencies | |
| run: | | |
| pip install --upgrade pip | |
| pip install pyinstaller | |
| pip install -r requirements.txt || true | |
| - name: Prepare build directory (Windows) | |
| if: ${{ matrix.os == 'windows-latest' }} | |
| shell: powershell | |
| run: | | |
| Remove-Item -LiteralPath dist -Recurse -Force -ErrorAction SilentlyContinue | |
| New-Item -ItemType Directory -Path dist -Force | Out-Null | |
| - name: Prepare build directory (non-Windows) | |
| if: ${{ matrix.os != 'windows-latest' }} | |
| run: | | |
| rm -rf dist || true | |
| mkdir -p dist | |
| - name: Build client | |
| run: | | |
| pyinstaller --onefile --name "MasterDnsVPN_Client_${{ matrix.platform }}_${{ matrix.arch }}" client.py | |
| - name: Build server | |
| run: | | |
| pyinstaller --onefile --name "MasterDnsVPN_Server_${{ matrix.platform }}_${{ matrix.arch }}" server.py | |
| - name: Bundle config templates with executables | |
| shell: bash | |
| run: | | |
| cp client_config.toml.simple dist/client_config.toml || true | |
| cp server_config.toml.simple dist/server_config.toml || true | |
| - name: Package artifacts (Windows) | |
| if: ${{ matrix.os == 'windows-latest' }} | |
| shell: powershell | |
| run: | | |
| Set-StrictMode -Version Latest | |
| Set-Location dist | |
| $platform = '${{ matrix.platform }}' | |
| $arch = '${{ matrix.arch }}' | |
| $ext = '${{ matrix.ext }}' | |
| $archUpper = $arch.ToUpper() | |
| $clientDesiredBase = "MasterDnsVPN_Client_${platform}_$archUpper" | |
| $serverDesiredBase = "MasterDnsVPN_Server_${platform}_$archUpper" | |
| $clientOrig = "MasterDnsVPN_Client_${{ matrix.platform }}_${{ matrix.arch }}${{ matrix.ext }}" | |
| $serverOrig = "MasterDnsVPN_Server_${{ matrix.platform }}_${{ matrix.arch }}${{ matrix.ext }}" | |
| if (Test-Path $clientOrig) { | |
| Rename-Item -Path $clientOrig -NewName "${clientDesiredBase}${ext}" -Force | |
| } | |
| if (Test-Path $serverOrig) { | |
| Rename-Item -Path $serverOrig -NewName "${serverDesiredBase}${ext}" -Force | |
| } | |
| Compress-Archive -Path "${clientDesiredBase}${ext}","client_config.toml" -DestinationPath "${clientDesiredBase}.zip" -Force | |
| Compress-Archive -Path "${serverDesiredBase}${ext}","server_config.toml" -DestinationPath "${serverDesiredBase}.zip" -Force | |
| - name: Package artifacts (non-Windows) | |
| if: ${{ matrix.os != 'windows-latest' }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| cd dist | |
| ARCH_UPPER=$(echo "${{ matrix.arch }}" | tr '[:lower:]' '[:upper:]') | |
| PLATFORM="${{ matrix.platform }}" | |
| CLIENT_DESIRED_BASE="MasterDnsVPN_Client_${PLATFORM}_${ARCH_UPPER}" | |
| SERVER_DESIRED_BASE="MasterDnsVPN_Server_${PLATFORM}_${ARCH_UPPER}" | |
| CLIENT_ORIG="MasterDnsVPN_Client_${{ matrix.platform }}_${{ matrix.arch }}${{ matrix.ext }}" | |
| SERVER_ORIG="MasterDnsVPN_Server_${{ matrix.platform }}_${{ matrix.arch }}${{ matrix.ext }}" | |
| if [ -f "${CLIENT_ORIG}" ]; then | |
| mv "${CLIENT_ORIG}" "${CLIENT_DESIRED_BASE}${{ matrix.ext }}" || true | |
| fi | |
| if [ -f "${SERVER_ORIG}" ]; then | |
| mv "${SERVER_ORIG}" "${SERVER_DESIRED_BASE}${{ matrix.ext }}" || true | |
| fi | |
| CLIENT_ZIP="${CLIENT_DESIRED_BASE}.zip" | |
| SERVER_ZIP="${SERVER_DESIRED_BASE}.zip" | |
| CLIENT_TAR="${CLIENT_DESIRED_BASE}.tar.gz" | |
| SERVER_TAR="${SERVER_DESIRED_BASE}.tar.gz" | |
| CLIENT_TOML="client_config.toml" | |
| SERVER_TOML="server_config.toml" | |
| zip -j "${CLIENT_ZIP}" "${CLIENT_DESIRED_BASE}${{ matrix.ext }}" "${CLIENT_TOML}" || true | |
| tar -czf "${CLIENT_TAR}" "${CLIENT_DESIRED_BASE}${{ matrix.ext }}" "${CLIENT_TOML}" || true | |
| zip -j "${SERVER_ZIP}" "${SERVER_DESIRED_BASE}${{ matrix.ext }}" "${SERVER_TOML}" || true | |
| tar -czf "${SERVER_TAR}" "${SERVER_DESIRED_BASE}${{ matrix.ext }}" "${SERVER_TOML}" || true | |
| - name: Code sign Windows executables (optional) | |
| if: ${{ env.CODE_SIGN_CERT != '' && matrix.os == 'windows-latest' }} | |
| shell: powershell | |
| run: | | |
| Write-Host "Decoding CODE_SIGN_CERT to cert.pfx" | |
| [System.IO.File]::WriteAllBytes('cert.pfx', [Convert]::FromBase64String('${{ env.CODE_SIGN_CERT }}')) | |
| if (Test-Path 'cert.pfx') { | |
| $signtool = Get-Command signtool.exe -ErrorAction SilentlyContinue | |
| if ($null -ne $signtool) { | |
| Write-Host "signtool found at $($signtool.Path). Signing files..." | |
| $files = Get-ChildItem -Path .\ -Filter "*Windows*amd64*${{ matrix.ext }}*" -Recurse -File | |
| foreach ($f in $files) { | |
| Write-Host "Signing $($f.FullName)" | |
| & $signtool.Path sign /f cert.pfx /p "${{ env.CODE_SIGN_CERT_PASSWORD }}" /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 $f.FullName | |
| } | |
| } else { Write-Host "signtool not found on runner; skipping Windows signing." } | |
| } else { Write-Host "cert.pfx not found after decode; skipping signing." } | |
| - name: Code sign macOS executables (optional) | |
| if: ${{ env.CODE_SIGN_CERT != '' && matrix.os == 'macos-latest' }} | |
| shell: bash | |
| run: | | |
| echo "Decoding CODE_SIGN_CERT to cert.pfx" | |
| echo "${{ env.CODE_SIGN_CERT }}" | base64 -d > cert.pfx | |
| if [ -f cert.pfx ]; then | |
| echo "Importing cert.pfx into login keychain (may require interactive access)" | |
| security import cert.pfx -k ~/Library/Keychains/login.keychain -P "${{ env.CODE_SIGN_CERT_PASSWORD }}" -T /usr/bin/codesign || true | |
| # attempt to find an identity and codesign | |
| IDENTITY=$(security find-identity -v -p codesigning | head -n1 | awk -F '"' '{print $2}') || true | |
| if [ -n "$IDENTITY" ]; then | |
| echo "Found identity: $IDENTITY. Signing macOS binaries." | |
| for f in ./dist/*${{ matrix.platform }}*${{ matrix.arch }}*; do | |
| if [ -f "$f" ]; then | |
| codesign --sign "$IDENTITY" --options runtime --timestamp --force "$f" || true | |
| fi | |
| done | |
| else | |
| echo "No signing identity found; skipping codesign." | |
| fi | |
| else | |
| echo "cert.pfx not present; skipping macOS signing." | |
| fi | |
| - name: Upload artifacts | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: build-artifacts-${{ matrix.platform }}-${{ matrix.arch }} | |
| path: | | |
| dist/*.zip | |
| dist/*.tar.gz | |
| release: | |
| name: Create Release and attach artifacts | |
| runs-on: ubuntu-latest | |
| needs: build | |
| steps: | |
| - name: Checkout repository (fetch tags) | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Download all build artifacts | |
| uses: actions/download-artifact@v4 | |
| with: | |
| path: release_assets | |
| - name: List downloaded artifacts | |
| run: | | |
| echo "Contents of release_assets:" | |
| ls -la release_assets || true | |
| echo "Recursive listing:" | |
| find release_assets -maxdepth 3 -print || true | |
| - name: Get short commit SHA | |
| id: get_short_sha | |
| run: echo "short_sha=$(echo ${GITHUB_SHA} | cut -c1-7)" >> $GITHUB_OUTPUT | |
| - name: Build release notes body | |
| id: create_release | |
| uses: actions/github-script@v6 | |
| with: | |
| script: | | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const head = process.env.GITHUB_SHA; | |
| let prevTag = null; | |
| try { | |
| const latest = await github.rest.repos.getLatestRelease({ owner, repo }); | |
| prevTag = latest.data.tag_name; | |
| } catch (e) { | |
| prevTag = null; | |
| } | |
| let commits = []; | |
| if (prevTag) { | |
| const comp = await github.rest.repos.compareCommits({ owner, repo, base: prevTag, head }); | |
| commits = comp.data.commits || []; | |
| } else { | |
| const list = await github.rest.repos.listCommits({ owner, repo, sha: head, per_page: 250 }); | |
| commits = list.data || []; | |
| } | |
| const prSet = new Map(); | |
| const contributors = new Map(); | |
| for (const c of commits) { | |
| const sha = c.sha; | |
| const author = (c.author && c.author.login) ? `@${c.author.login}` : (c.commit && c.commit.author && c.commit.author.name) || 'unknown'; | |
| contributors.set(author, (contributors.get(author) || 0) + 1); | |
| try { | |
| const prs = await github.rest.repos.listPullRequestsAssociatedWithCommit({ owner, repo, commit_sha: sha }); | |
| for (const pr of prs.data) { | |
| if (pr.merged_at) { | |
| prSet.set(pr.number, { number: pr.number, title: pr.title, url: pr.html_url, user: pr.user ? `@${pr.user.login}` : '' }); | |
| } | |
| } | |
| } catch (e) {} | |
| } | |
| let body = `Automated release created by workflow run ${context.runId} for commit ${head}\n\n`; | |
| if (prevTag) body = `Changes since ${prevTag}:\n\n` + body; | |
| body += '### Commits\n'; | |
| for (const c of commits.slice(-50)) { | |
| const msg = c.commit.message.split('\n')[0]; | |
| body += `- ${msg} ([${c.sha.substring(0,7)}](https://github.com/${owner}/${repo}/commit/${c.sha}))\n`; | |
| } | |
| if (prSet.size > 0) { | |
| body += '\n### Merged PRS\n'; | |
| for (const pr of prSet.values()) { | |
| body += `- [#${pr.number}](${pr.url}) ${pr.title} ${pr.user}\n`; | |
| } | |
| } | |
| if (contributors.size > 0) { | |
| body += '\n### Contributors\n'; | |
| for (const [name, count] of contributors.entries()) { | |
| body += `- ${name} — ${count} commit(s)\n`; | |
| } | |
| } | |
| return { body }; | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Get timestamp for tag | |
| id: get_timestamp | |
| run: | | |
| echo "ts=$(date -u +%Y.%m.%d.%H%M%S)" >> $GITHUB_OUTPUT | |
| - name: Debug release assets and planned tag | |
| run: | | |
| echo "Planned release tag: ${{ steps.get_short_sha.outputs.short_sha }}-${{ steps.get_timestamp.outputs.ts }}" | |
| echo "Contents of release_assets:" | |
| find release_assets -maxdepth 5 -type f -print || true | |
| - name: Fail if no artifacts downloaded | |
| run: | | |
| set -euo pipefail | |
| cnt=$(find release_assets -type f | wc -l || true) | |
| echo "Found $cnt artifact file(s) in release_assets" | |
| if [ "$cnt" -eq 0 ]; then | |
| echo "No artifacts found to upload. Aborting release." >&2 | |
| exit 1 | |
| fi | |
| - name: Create GitHub Release and upload assets | |
| uses: softprops/action-gh-release@v1 | |
| with: | |
| files: | | |
| release_assets/**/*.zip | |
| release_assets/**/*.tar.gz | |
| tag_name: v${{ steps.get_timestamp.outputs.ts }}-${{ steps.get_short_sha.outputs.short_sha }} | |
| name: Release v${{ steps.get_timestamp.outputs.ts }} (${{ steps.get_short_sha.outputs.short_sha }}) | |
| body: ${{ fromJson(steps.create_release.outputs.result).body }} | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |