Skip to content

release: v0.3.9 — ship the linter in the release archives #57

release: v0.3.9 — ship the linter in the release archives

release: v0.3.9 — ship the linter in the release archives #57

Workflow file for this run

name: release
# Builds a single self-contained binary per OS and attaches the artifacts
# to a GitHub Release. The backend embeds the web/dist/ bundle via
# rust-embed, so each artifact is a true single-file deliverable.
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'release tag (e.g. v0.1.0)'
required: true
jobs:
web:
name: build web bundle
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22' }
- uses: dtolnay/rust-toolchain@stable
with: { targets: wasm32-unknown-unknown }
- name: install wasm-pack
run: curl -sSf https://rustwasm.github.io/wasm-pack/installer/init.sh | sh
- name: build wasm analyzer
run: wasm-pack build crates/analyzer-wasm --target web --out-dir ../../web/src/wasm --release
- name: install web deps
working-directory: web
run: npm ci
- name: build web
working-directory: web
run: npm run build
- uses: actions/upload-artifact@v4
with:
name: web-dist
path: web/dist/
retention-days: 1
binary:
name: ${{ matrix.label }}
needs: web
runs-on: ${{ matrix.os }}
# Job-level env so the signing gates are visible to step `if:` conditions.
# (A step's own `env:` block is NOT in scope for that step's `if:` — GitHub
# evaluates `if` before applying step env — so these MUST live at job level.)
env:
WINDOWS_CERT_BASE64: ${{ secrets.WINDOWS_CERT_BASE64 }}
APPLE_CERT_BASE64: ${{ secrets.APPLE_CERT_BASE64 }}
strategy:
fail-fast: false
matrix:
include:
# Build on 22.04 (glibc 2.35), NOT ubuntu-latest (24.04 = glibc 2.39) —
# a 2.39 binary refuses to load on most current LTS servers. 2.35 covers
# Ubuntu 22.04+, Debian 12, etc. (A static musl build for universal Linux
# reach is the planned fast-follow.)
- { label: linux-x86_64, os: ubuntu-22.04, target: x86_64-unknown-linux-gnu, archive: tar.gz, binsuffix: '' }
- { label: macos-arm64, os: macos-latest, target: aarch64-apple-darwin, archive: tar.gz, binsuffix: '' }
- { label: windows-x86_64, os: windows-latest, target: x86_64-pc-windows-msvc, archive: zip, binsuffix: '.exe' }
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
- name: fetch web bundle
uses: actions/download-artifact@v4
with: { name: web-dist, path: web/dist }
- name: build backend
# Both shipped binaries. Only the backend was built here, so the
# release archives contained no linter at all — `dbopt lint` on a
# downloaded build started the web server instead, while the README
# documents `dbopt lint` as the CI entry point.
run: cargo build --release --target ${{ matrix.target }} -p backend -p dbopt
- name: stage artifact
shell: bash
run: |
mkdir -p staging
# Names match the documentation and `cargo install dbopt`:
# dbopt -> the CLI / linter
# dbopt-backend -> the local app (API + embedded UI on :3690)
cp target/${{ matrix.target }}/release/dbopt${{ matrix.binsuffix }} staging/dbopt${{ matrix.binsuffix }}
cp target/${{ matrix.target }}/release/dbopt-backend${{ matrix.binsuffix }} staging/dbopt-backend${{ matrix.binsuffix }}
cp README.md staging/ 2>/dev/null || true
cp samples/bad.sql staging/sample.sql 2>/dev/null || true
{
echo "dbopt the linter. Analyses .sql files offline, no database needed:"
echo " dbopt lint ./db --format sarif --fail-on warning"
echo " Run 'dbopt --help' for everything else."
echo ""
echo "dbopt-backend the local app. Run it, then open http://127.0.0.1:3690 for the"
echo " UI, live server metrics and the AI assistant."
echo " Set DBOPT_NO_OPEN=1 to stop it opening a browser."
} > staging/WHICH-BINARY.txt
- name: archive
shell: bash
run: |
cd staging
if [ "${{ matrix.archive }}" = "zip" ]; then
7z a ../dbopt-${{ matrix.label }}.zip ./*
else
tar czf ../dbopt-${{ matrix.label }}.tar.gz ./*
fi
- name: macos dmg
if: startsWith(matrix.label, 'macos-')
run: |
VER="${{ github.event.inputs.tag || github.ref_name }}"
VER="${VER#v}" # strip leading v from e.g. v0.2.0
VER="${VER:-0.0.0}" # fall back if somehow empty
mkdir -p dbopt-${{ matrix.label }}-app/dbopt.app/Contents/MacOS
mkdir -p dbopt-${{ matrix.label }}-app/dbopt.app/Contents/Resources
cp staging/dbopt dbopt-${{ matrix.label }}-app/dbopt.app/Contents/MacOS/dbopt
chmod +x dbopt-${{ matrix.label }}-app/dbopt.app/Contents/MacOS/dbopt
# Brand icon for the dock / Finder (CFBundleIconFile -> Resources/dbopt.icns).
cp assets/dbopt.icns dbopt-${{ matrix.label }}-app/dbopt.app/Contents/Resources/dbopt.icns
cat > dbopt-${{ matrix.label }}-app/dbopt.app/Contents/Info.plist <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleExecutable</key><string>dbopt</string>
<key>CFBundleIconFile</key><string>dbopt</string>
<key>CFBundleIdentifier</key><string>dev.dbopt.observatory</string>
<key>CFBundleName</key><string>dbopt</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>${VER}</string>
<key>CFBundleVersion</key><string>1</string>
<key>NSHighResolutionCapable</key><true/>
</dict></plist>
EOF
# Make it a real drag-to-install image: a visible /Applications alias
# next to the app so the user can drag dbopt.app onto Applications
# (instead of running it from the read-only mounted volume).
ln -s /Applications "dbopt-${{ matrix.label }}-app/Applications"
hdiutil create -volname dbopt -srcfolder dbopt-${{ matrix.label }}-app -ov -format UDZO dbopt-${{ matrix.label }}.dmg
# Apple codesign + notarize + staple for the .dmg. Runs ONLY when the Apple
# Developer secrets are present, and is SKIPPED (job still succeeds) when
# absent — so an unconfigured repo still ships a working (unsigned) .dmg.
# Notarizing + stapling removes the Gatekeeper "unidentified developer"
# first-run wall on end-user Macs.
#
# Required repo secrets to activate (all must be set):
# APPLE_CERT_BASE64 - base64 of the "Developer ID Application" .p12
# APPLE_CERT_PASSWORD - password for that .p12
# APPLE_SIGN_IDENTITY - e.g. "Developer ID Application: Acme (TEAMID)"
# APPLE_NOTARY_APPLE_ID - the Apple ID email for notarytool
# APPLE_NOTARY_PASSWORD - an app-specific password for that Apple ID
# APPLE_TEAM_ID - the 10-char Apple Developer Team ID
- name: macos codesign + notarize + staple
if: ${{ startsWith(matrix.label, 'macos-') && env.APPLE_CERT_BASE64 != '' }}
env:
APPLE_CERT_BASE64: ${{ secrets.APPLE_CERT_BASE64 }}
APPLE_CERT_PASSWORD: ${{ secrets.APPLE_CERT_PASSWORD }}
APPLE_SIGN_IDENTITY: ${{ secrets.APPLE_SIGN_IDENTITY }}
APPLE_NOTARY_APPLE_ID: ${{ secrets.APPLE_NOTARY_APPLE_ID }}
APPLE_NOTARY_PASSWORD: ${{ secrets.APPLE_NOTARY_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
# Import the signing identity into a throwaway keychain (never logged).
KEYCHAIN="$RUNNER_TEMP/codesign.keychain-db"
KEYCHAIN_PW="$(openssl rand -base64 24)"
CERT="$RUNNER_TEMP/cert.p12"
echo "$APPLE_CERT_BASE64" | base64 --decode > "$CERT"
security create-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN"
security set-keychain-settings -lut 21600 "$KEYCHAIN"
security unlock-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN"
security import "$CERT" -k "$KEYCHAIN" -P "$APPLE_CERT_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PW" "$KEYCHAIN" >/dev/null
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"')
APP="dbopt-${{ matrix.label }}-app/dbopt.app"
# Sign the binary then the .app bundle (hardened runtime is required for notarization).
codesign --force --options runtime --timestamp \
--sign "$APPLE_SIGN_IDENTITY" "$APP/Contents/MacOS/dbopt"
codesign --force --options runtime --timestamp \
--sign "$APPLE_SIGN_IDENTITY" "$APP"
codesign --verify --strict --verbose=2 "$APP"
# Rebuild the .dmg now that the .app inside it is signed.
rm -f "dbopt-${{ matrix.label }}.dmg"
hdiutil create -volname dbopt -srcfolder "dbopt-${{ matrix.label }}-app" -ov -format UDZO "dbopt-${{ matrix.label }}.dmg"
codesign --force --timestamp --sign "$APPLE_SIGN_IDENTITY" "dbopt-${{ matrix.label }}.dmg"
# Submit to Apple's notary service and wait, then staple the ticket so
# the .dmg validates offline on the end user's machine.
xcrun notarytool submit "dbopt-${{ matrix.label }}.dmg" \
--apple-id "$APPLE_NOTARY_APPLE_ID" \
--password "$APPLE_NOTARY_PASSWORD" \
--team-id "$APPLE_TEAM_ID" \
--wait
xcrun stapler staple "dbopt-${{ matrix.label }}.dmg"
xcrun stapler validate "dbopt-${{ matrix.label }}.dmg"
# Also refresh the .tar.gz so the bare binary inside it is signed too.
rm -f "dbopt-${{ matrix.label }}.tar.gz"
cp "$APP/Contents/MacOS/dbopt" staging/dbopt-backend
(cd staging && tar czf "../dbopt-${{ matrix.label }}.tar.gz" ./*)
security delete-keychain "$KEYCHAIN" || true
echo "Notarized + stapled: dbopt-${{ matrix.label }}.dmg"
- name: windows MSI
if: matrix.label == 'windows-x86_64'
# NO continue-on-error: a broken installer must NOT ship "green". cargo-wix
# failing, or finishing without producing a .msi, fails the whole job here
# (the downstream `release` guard only catches a *missing* file — it cannot
# see a cargo-wix non-zero exit that still uploaded nothing).
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
# WiX v3 (candle/light) ships on windows-latest; ensure it's on PATH
# (install only if a future image drops it).
$wix = Get-ChildItem "C:\Program Files (x86)" -Filter "WiX Toolset v*" -Directory |
Sort-Object Name -Descending | Select-Object -First 1
if (-not $wix) {
choco install wixtoolset -y --no-progress
$wix = Get-ChildItem "C:\Program Files (x86)" -Filter "WiX Toolset v*" -Directory |
Sort-Object Name -Descending | Select-Object -First 1
}
if ($wix) { $env:PATH = "$($wix.FullName)\bin;$env:PATH"; Write-Host "WiX bin: $($wix.FullName)\bin" }
else { throw "WiX Toolset not found and could not be installed" }
cargo install cargo-wix --locked
# Build from the committed wix/ manifest (stable UpgradeCode). cargo-wix
# needs -p in a workspace, and `light` resolves the relative
# 'wix\License.rtf' from the invocation CWD — so do both: cd into the
# crate AND pass -p backend.
Push-Location crates/backend
cargo wix -p backend --no-build --target ${{ matrix.target }} --nocapture --output "$env:GITHUB_WORKSPACE\dbopt-windows-x86_64.msi"
$exit = $LASTEXITCODE
Pop-Location
if ($exit -ne 0) { throw "cargo wix exited with code $exit" }
if (Test-Path dbopt-windows-x86_64.msi) {
$size = (Get-Item dbopt-windows-x86_64.msi).Length
Write-Host "MSI built: $size bytes"
# A zero/near-zero MSI is a broken installer; treat it as a hard failure.
if ($size -lt 1024) { throw "MSI is implausibly small ($size bytes) — treating as broken" }
} else {
throw "MSI step finished but no .msi was produced"
}
# Authenticode code-signing for the .exe and .msi. Runs ONLY when the
# signing secret is present, and is SKIPPED (job still succeeds) when it is
# absent — so forks / unconfigured repos still produce working unsigned
# artifacts. This removes the unsigned first-run warning wall for end users.
#
# Required repo secrets to activate (all must be set):
# WINDOWS_CERT_BASE64 - base64 of the code-signing .pfx file
# WINDOWS_CERT_PASSWORD - password for that .pfx
# (Optional) WINDOWS_CERT_TS_URL - RFC-3161 timestamp server; defaults below.
- name: windows code-sign (.exe + .msi)
if: ${{ matrix.label == 'windows-x86_64' && env.WINDOWS_CERT_BASE64 != '' }}
env:
WINDOWS_CERT_BASE64: ${{ secrets.WINDOWS_CERT_BASE64 }}
WINDOWS_CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }}
WINDOWS_CERT_TS_URL: ${{ secrets.WINDOWS_CERT_TS_URL }}
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
# Materialize the .pfx from the secret into a temp file (never logged).
$pfx = Join-Path $env:RUNNER_TEMP "codesign.pfx"
[IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:WINDOWS_CERT_BASE64))
$ts = if ($env:WINDOWS_CERT_TS_URL) { $env:WINDOWS_CERT_TS_URL } else { "http://timestamp.digicert.com" }
# Locate signtool from the installed Windows SDK.
$signtool = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin" -Recurse -Filter signtool.exe -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -like "*x64*" } |
Sort-Object FullName -Descending | Select-Object -First 1
if (-not $signtool) { throw "signtool.exe not found in the Windows SDK" }
$targets = @()
if (Test-Path "staging\dbopt.exe") { $targets += "staging\dbopt.exe" }
if (Test-Path "dbopt-windows-x86_64.msi") { $targets += "dbopt-windows-x86_64.msi" }
foreach ($t in $targets) {
& $signtool.FullName sign /fd SHA256 /tr $ts /td SHA256 `
/f $pfx /p $env:WINDOWS_CERT_PASSWORD "$t"
if ($LASTEXITCODE -ne 0) { throw "signtool failed for $t (exit $LASTEXITCODE)" }
& $signtool.FullName verify /pa "$t"
if ($LASTEXITCODE -ne 0) { throw "signature verify failed for $t (exit $LASTEXITCODE)" }
}
Remove-Item $pfx -Force
# Re-archive the signed .exe into the .zip so the zip download is signed too.
if (Test-Path "staging\dbopt.exe") {
Remove-Item "dbopt-windows-x86_64.zip" -ErrorAction SilentlyContinue
Push-Location staging
7z a "..\dbopt-windows-x86_64.zip" .\*
Pop-Location
}
Write-Host "Signed Windows artifacts: $($targets -join ', ')"
- name: linux AppImage (best-effort)
if: matrix.label == 'linux-x86_64'
run: |
mkdir -p dbopt.AppDir/usr/bin
cp staging/dbopt dbopt.AppDir/usr/bin/dbopt
chmod +x dbopt.AppDir/usr/bin/dbopt
cat > dbopt.AppDir/dbopt.desktop <<EOF
[Desktop Entry]
Type=Application
Name=dbopt
Exec=dbopt
Icon=dbopt
Categories=Development;Database;
Terminal=true
EOF
# Real brand icon (was an empty `touch`ed file -> blank AppImage icon).
cp assets/dbopt-256.png dbopt.AppDir/dbopt.png
cat > dbopt.AppDir/AppRun <<'EOF'
#!/bin/sh
HERE="$(dirname "$(readlink -f "${0}")")"
exec "${HERE}/usr/bin/dbopt" "$@"
EOF
chmod +x dbopt.AppDir/AppRun
curl -L -o appimagetool https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage || true
chmod +x appimagetool || true
ARCH=x86_64 ./appimagetool dbopt.AppDir dbopt-linux-x86_64.AppImage || \
echo "AppImage build skipped (appimagetool unavailable)"
- uses: actions/upload-artifact@v4
with:
name: dbopt-${{ matrix.label }}
path: |
dbopt-${{ matrix.label }}.tar.gz
dbopt-${{ matrix.label }}.zip
dbopt-${{ matrix.label }}.dmg
dbopt-windows-x86_64.msi
dbopt-linux-x86_64.AppImage
if-no-files-found: ignore
# A fully-static musl binary that runs on ANY x86_64 Linux (Alpine, RHEL 8,
# ancient glibc, no glibc at all) — the universal fallback alongside the
# glibc-2.34 tar.gz. Built with cargo-zigbuild (zig cross-compiles the C deps:
# aws-lc-sys, rusqlite, ring). continue-on-error + NOT a required asset, so a
# musl/zig hiccup can never block the main release.
linux-musl:
name: linux-x86_64-musl (static)
needs: web
runs-on: ubuntu-22.04
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-musl
- uses: Swatinem/rust-cache@v2
with: { key: linux-musl }
- name: fetch web bundle
uses: actions/download-artifact@v4
with: { name: web-dist, path: web/dist }
- name: install zig + cargo-zigbuild
run: |
curl -fsSL https://ziglang.org/download/0.13.0/zig-linux-x86_64-0.13.0.tar.xz -o /tmp/zig.tar.xz
tar -xf /tmp/zig.tar.xz -C /tmp
echo "/tmp/zig-linux-x86_64-0.13.0" >> "$GITHUB_PATH"
cargo install --locked cargo-zigbuild
- name: build static musl binary
run: cargo zigbuild --release --target x86_64-unknown-linux-musl -p backend -p dbopt
- name: archive
run: |
mkdir -p staging
cp target/x86_64-unknown-linux-musl/release/dbopt staging/dbopt
cp target/x86_64-unknown-linux-musl/release/dbopt-backend staging/dbopt-backend
cp README.md staging/ 2>/dev/null || true
cp samples/bad.sql staging/sample.sql 2>/dev/null || true
(cd staging && tar czf ../dbopt-linux-x86_64-musl.tar.gz ./*)
- uses: actions/upload-artifact@v4
with:
name: dbopt-linux-x86_64-musl
path: dbopt-linux-x86_64-musl.tar.gz
if-no-files-found: ignore
release:
name: publish release
needs: [binary, linux-musl]
runs-on: ubuntu-latest
permissions:
contents: write
# Build provenance is signed with a short-lived certificate minted from
# this workflow's OIDC identity, so it needs id-token + attestations.
id-token: write
attestations: write
steps:
- uses: actions/download-artifact@v4
with: { path: artifacts }
- name: stage flat
run: |
mkdir -p out
find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.dmg' -o -name '*.msi' -o -name '*.AppImage' \) -exec cp {} out/ \;
(cd out && sha256sum * > SHA256SUMS)
ls -la out
# Guard against a SILENTLY incomplete release. The MSI step itself now
# fails the binary job hard (no continue-on-error) so a broken installer
# can't ship green — but this belt-and-suspenders check also fails the
# publish if any required asset is missing (e.g. the best-effort
# AppImage masks failures, and an upload glitch could still drop a file),
# so the website's download link can never 404. (AppImage is NOT required.)
missing=""
for f in dbopt-linux-x86_64.tar.gz dbopt-macos-arm64.dmg dbopt-macos-arm64.tar.gz dbopt-windows-x86_64.msi dbopt-windows-x86_64.zip; do
[ -f "out/$f" ] || missing="$missing $f"
done
if [ -n "$missing" ]; then
echo "::error::release is missing required assets:$missing"
exit 1
fi
echo "All required release assets present."
# Cryptographic build provenance for every published artifact.
#
# Authenticode and Apple notarization above answer "who signed this?" and
# both need a paid certificate tied to a legal identity, so they are gated
# on secrets and skipped when unconfigured. Provenance answers a different
# and, for an open-source tool, more useful question: "was this exact file
# built by this workflow, from this commit, without anyone touching it in
# between?" It is keyless — signed against the workflow's OIDC identity
# and recorded in a public transparency log — so it works with no secrets
# at all, and cannot be forged by someone who merely uploads a file to the
# release page.
#
# Anyone can check a download before running it:
# gh attestation verify dbopt-linux-x86_64.tar.gz --repo singhpratech/dbopt
- name: attest build provenance
uses: actions/attest-build-provenance@v1
with:
subject-path: 'out/*'
- name: gh release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.inputs.tag || github.ref_name }}
files: out/*
generate_release_notes: true