Skip to content

Build and Release

Build and Release #34

Workflow file for this run

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 }}