Skip to content

CD

CD #5

Workflow file for this run

name: CD
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
publish_release:
description: "Publish a GitHub Release (manual runs only)"
required: false
default: false
type: boolean
sign_artifacts:
description: "Enable code-signing steps if signing secrets are present"
required: false
default: false
type: boolean
permissions:
contents: write
jobs:
# ---------------------------------------------------------------------------
# Pre-flight gates. These run on every invocation and block all build jobs
# from starting if the release artifact would be inconsistent with the
# sources. Both gates are skipped for `workflow_dispatch` runs where
# `publish_release=false`, so maintainers can still dry-run the build matrix
# without a tag.
# ---------------------------------------------------------------------------
changelog-gate:
name: CHANGELOG.md has section for tag
runs-on: ubuntu-latest
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release) }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Verify CHANGELOG.md contains the tagged version
shell: bash
run: |
set -euo pipefail
ref="${GITHUB_REF_NAME}"
# Strip leading `v` (tags are `vX.Y.Z`; CHANGELOG uses `[X.Y.Z]`).
version="${ref#v}"
if ! grep -qE "^## \[${version}\]( - [0-9]{4}-[0-9]{2}-[0-9]{2})?$" CHANGELOG.md; then
echo "::error file=CHANGELOG.md::Missing '## [${version}]' section in CHANGELOG.md" >&2
echo "Expected a heading like '## [${version}] - YYYY-MM-DD' (Keep a Changelog)." >&2
exit 1
fi
echo "CHANGELOG.md contains a section for ${version}."
- name: Ensure no unreleased Towncrier fragments remain
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
fragments=(newsfragments/*.added.md newsfragments/*.changed.md newsfragments/*.deprecated.md newsfragments/*.removed.md newsfragments/*.fixed.md newsfragments/*.security.md)
if [ "${#fragments[@]}" -gt 0 ]; then
echo "::error::Unreleased Towncrier fragments remain. Build CHANGELOG.md before tagging:" >&2
printf ' - %s\n' "${fragments[@]}" >&2
exit 1
fi
version-consistency:
name: Version is consistent across sources
runs-on: ubuntu-latest
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release) }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up uv with Python
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
with:
python-version: "3.12"
enable-cache: true
- name: Verify __version__, [tool.briefcase].version, and git tag agree
shell: bash
run: |
set -euo pipefail
ref="${GITHUB_REF_NAME}"
tag_version="${ref#v}"
pkg_version=$(uv run --no-sync --no-project python -c "import re, pathlib; \
src = pathlib.Path('src/dbs_annotator/__init__.py').read_text(); \
m = re.search(r'^__version__\\s*=\\s*[\"\\'](?P<v>[^\"\\']+)[\"\\']', src, re.MULTILINE); \
print(m.group('v') if m else '')")
briefcase_version=$(uv run --no-sync --no-project python -c "import tomllib, pathlib; \
data = tomllib.loads(pathlib.Path('pyproject.toml').read_text()); \
print(data['tool']['briefcase'].get('version', ''))")
echo "git tag : ${tag_version}"
echo "__version__ : ${pkg_version}"
echo "[tool.briefcase].ver : ${briefcase_version}"
fail=0
if [ "${pkg_version}" != "${tag_version}" ]; then
echo "::error file=src/dbs_annotator/__init__.py::__version__ (${pkg_version}) != tag (${tag_version})" >&2
fail=1
fi
if [ "${briefcase_version}" != "${tag_version}" ]; then
echo "::error file=pyproject.toml::[tool.briefcase].version (${briefcase_version}) != tag (${tag_version})" >&2
fail=1
fi
exit "${fail}"
build-python-dist:
name: Build Python distribution
runs-on: ubuntu-latest
needs:
- changelog-gate
- version-consistency
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up uv with Python
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
with:
python-version: "3.12"
enable-cache: true
- name: Build distribution
run: |
uv build
- name: Upload Python dist artifacts
uses: actions/upload-artifact@v7
with:
name: python-dist
path: dist/*
if-no-files-found: error
publish-docs:
name: Trigger Read the Docs tag build
runs-on: ubuntu-latest
needs:
- changelog-gate
- version-consistency
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
steps:
- name: Trigger RTD build for this tag version
shell: bash
env:
READTHEDOCS_TOKEN: ${{ secrets.READTHEDOCS_TOKEN }}
# Update if your RTD slug differs.
READTHEDOCS_PROJECT_SLUG: app-clinicaldbsannot
run: |
set -euo pipefail
if [ -z "${READTHEDOCS_TOKEN:-}" ]; then
echo "READTHEDOCS_TOKEN is not configured; skipping RTD trigger."
exit 0
fi
version="${GITHUB_REF_NAME}"
echo "Triggering RTD build for project=${READTHEDOCS_PROJECT_SLUG}, version=${version}"
curl -fsS -X POST \
-H "Authorization: Token ${READTHEDOCS_TOKEN}" \
"https://readthedocs.org/api/v3/projects/${READTHEDOCS_PROJECT_SLUG}/versions/${version}/builds/"
build-briefcase-msi:
name: Build Briefcase MSI (Windows)
runs-on: windows-latest
needs:
- changelog-gate
- version-consistency
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
with:
python-version: "3.12"
enable-cache: true
- name: Set up .NET SDK
uses: actions/setup-dotnet@v5
with:
dotnet-version: "8.0.x"
- name: Install WiX CLI (prefer modern toolchain)
shell: pwsh
run: |
dotnet tool install --global wix --version "5.*"
"$env:USERPROFILE\.dotnet\tools" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
wix --version
# Briefcase can use WIX env var; point it at the known-good CLI install.
$wixExe = Join-Path $env:USERPROFILE ".dotnet\tools\wix.exe"
if (-not (Test-Path $wixExe)) {
throw "WiX executable not found at expected path: $wixExe"
}
"WIX=$wixExe" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- name: Sync dependencies (incl. build / Briefcase)
run: |
uv sync --locked --dev --group build
- name: Export lock-derived constraints for Briefcase (Windows)
run: |
uv export --locked --format requirements.txt --no-dev --no-hashes --no-emit-project --no-emit-workspace --output-file constraints-briefcase.txt
- name: Briefcase create / build / package (msi + zip raw)
env:
QT_QPA_PLATFORM: offscreen
PIP_INDEX_URL: https://pypi.org/simple
PIP_EXTRA_INDEX_URL: ""
PIP_NO_CACHE_DIR: "1"
run: |
uv run briefcase create --no-input
uv run briefcase build --no-input
uv run briefcase package --no-input -p msi
uv run briefcase package --no-input -p zip
- name: Name Windows raw portable archive
shell: pwsh
run: |
$v = $env:GITHUB_REF_NAME -replace '^v',''
$z = Get-ChildItem -Path dist -Filter *.zip -File -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $z) { throw "No .zip in dist/ after briefcase package -p zip" }
$raw = "DBSAnnotator-$v-windows-x86_64-raw.zip"
if ($z.Name -ne $raw) {
Move-Item -LiteralPath $z.FullName -Destination (Join-Path "dist" $raw) -Force
}
Write-Host "Windows raw: dist/$raw"
# step.if cannot use secrets.* (actionlint / GHA); probe via env then gate signing.
- name: Detect Windows signing secrets
id: win_signing
shell: bash
env:
WINDOWS_SIGN_PFX_BASE64: ${{ secrets.WINDOWS_SIGN_PFX_BASE64 }}
WINDOWS_SIGN_PFX_PASSWORD: ${{ secrets.WINDOWS_SIGN_PFX_PASSWORD }}
run: |
if [ -n "${WINDOWS_SIGN_PFX_BASE64}" ] && [ -n "${WINDOWS_SIGN_PFX_PASSWORD}" ]; then
echo "ready=true" >> "${GITHUB_OUTPUT}"
else
echo "ready=false" >> "${GITHUB_OUTPUT}"
fi
- name: Validate Windows signing secrets (optional gate)
if: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_artifacts }}
shell: pwsh
env:
WINDOWS_SIGN_PFX_BASE64: ${{ secrets.WINDOWS_SIGN_PFX_BASE64 }}
WINDOWS_SIGN_PFX_PASSWORD: ${{ secrets.WINDOWS_SIGN_PFX_PASSWORD }}
run: |
if ([string]::IsNullOrWhiteSpace($env:WINDOWS_SIGN_PFX_BASE64) -or [string]::IsNullOrWhiteSpace($env:WINDOWS_SIGN_PFX_PASSWORD)) {
throw "sign_artifacts=true but Windows signing secrets are missing."
}
- name: Sign MSI (optional)
if: >-
${{
(github.event_name == 'workflow_dispatch' && inputs.sign_artifacts) ||
(github.event_name == 'push' && steps.win_signing.outputs.ready == 'true')
}}
shell: pwsh
env:
WINDOWS_SIGN_PFX_BASE64: ${{ secrets.WINDOWS_SIGN_PFX_BASE64 }}
WINDOWS_SIGN_PFX_PASSWORD: ${{ secrets.WINDOWS_SIGN_PFX_PASSWORD }}
run: |
$pfxPath = Join-Path $env:RUNNER_TEMP "codesign.pfx"
[IO.File]::WriteAllBytes($pfxPath, [Convert]::FromBase64String($env:WINDOWS_SIGN_PFX_BASE64))
$secure = ConvertTo-SecureString $env:WINDOWS_SIGN_PFX_PASSWORD -AsPlainText -Force
Import-PfxCertificate -FilePath $pfxPath -Password $secure -CertStoreLocation Cert:\CurrentUser\My | Out-Null
$msis = Get-ChildItem -Path dist -Filter *.msi -Recurse
if (-not $msis) { throw "No MSI found under dist/ to sign." }
foreach ($msi in $msis) {
signtool sign /fd SHA256 /td SHA256 /tr http://timestamp.digicert.com /a "$($msi.FullName)"
}
- name: Upload Briefcase MSI artifacts
uses: actions/upload-artifact@v7
with:
name: briefcase-msi
path: dist/*
if-no-files-found: error
build-briefcase-macos-arm:
name: Build Briefcase macOS DMG (arm64)
runs-on: macos-14
needs:
- changelog-gate
- version-consistency
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
with:
python-version: "3.12"
enable-cache: true
- name: Sync dependencies (incl. build / Briefcase)
run: |
uv sync --locked --dev --group build
- name: Export lock-derived constraints for Briefcase (macOS)
run: |
uv export --locked --format requirements.txt --no-dev --no-hashes --no-emit-project --no-emit-workspace --output-file constraints-briefcase.txt
- name: Briefcase create / build / package (dmg)
env:
QT_QPA_PLATFORM: offscreen
PIP_INDEX_URL: https://pypi.org/simple
PIP_EXTRA_INDEX_URL: ""
PIP_NO_CACHE_DIR: "1"
run: |
set -euo pipefail
uv run briefcase create --no-input
uv run briefcase build --no-input
# --adhoc-sign keeps `briefcase package` non-interactive when no Apple Developer
# ID is configured. When signing secrets ARE provided, the dedicated
# "Sign and notarize DMG" step below re-signs and notarizes the artifact.
uv run briefcase package --no-input --adhoc-sign -p dmg
- name: Detect macOS signing secrets
id: mac_signing
shell: bash
env:
APPLE_IDENTITY: ${{ secrets.APPLE_IDENTITY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
run: |
if [ -n "${APPLE_IDENTITY}" ] && [ -n "${APPLE_API_ISSUER}" ] && \
[ -n "${APPLE_API_KEY_ID}" ] && [ -n "${APPLE_API_KEY}" ]; then
echo "ready=true" >> "${GITHUB_OUTPUT}"
else
echo "ready=false" >> "${GITHUB_OUTPUT}"
fi
- name: Validate macOS signing secrets (optional gate)
if: ${{ github.event_name == 'workflow_dispatch' && inputs.sign_artifacts }}
shell: bash
env:
APPLE_IDENTITY: ${{ secrets.APPLE_IDENTITY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
run: |
for var in APPLE_IDENTITY APPLE_API_ISSUER APPLE_API_KEY_ID APPLE_API_KEY; do
if [ -z "${!var}" ]; then
echo "sign_artifacts=true but missing secret: $var" >&2
exit 1
fi
done
- name: Sign and notarize DMG (optional)
if: >-
${{
(github.event_name == 'workflow_dispatch' && inputs.sign_artifacts) ||
(github.event_name == 'push' && steps.mac_signing.outputs.ready == 'true')
}}
shell: bash
env:
APPLE_IDENTITY: ${{ secrets.APPLE_IDENTITY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
run: |
KEY_PATH="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8"
printf '%s' "$APPLE_API_KEY" > "$KEY_PATH"
mapfile -t DMGS < <(find dist -name '*.dmg' -type f)
if [ "${#DMGS[@]}" -eq 0 ]; then
echo "No DMG found under dist/ to sign." >&2
exit 1
fi
for dmg in "${DMGS[@]}"; do
codesign --force --sign "$APPLE_IDENTITY" "$dmg"
xcrun notarytool submit "$dmg" --key "$KEY_PATH" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER" --wait
xcrun stapler staple "$dmg"
done
- name: Create macOS raw .app bundle archive (no DMG)
shell: bash
run: |
set -euo pipefail
v="${GITHUB_REF_NAME#v}"
app_path=$(find build/dbs_annotator/macos -name "DBSAnnotator.app" -type d 2>/dev/null | head -1)
if [ -z "$app_path" ]; then
echo "::error::DBSAnnotator.app not found under build/dbs_annotator/macos" >&2
exit 1
fi
parent=$(dirname "$app_path")
out="${GITHUB_WORKSPACE}/dist/DBSAnnotator-${v}-macos-arm64-raw.tar.gz"
(cd "$parent" && tar -czf "$out" "DBSAnnotator.app")
echo "macOS raw: dist/DBSAnnotator-${v}-macos-arm64-raw.tar.gz"
- name: Upload Briefcase macOS artifacts
uses: actions/upload-artifact@v7
with:
name: briefcase-macos-arm64
path: dist/*
if-no-files-found: error
build-briefcase-linux-x86:
name: Build Briefcase Linux package (x86_64)
runs-on: ubuntu-latest
needs:
- changelog-gate
- version-consistency
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b
with:
python-version: "3.12"
enable-cache: true
# PySide6 links libEGL even for offscreen; ubuntu-latest images omit it by default.
- name: Install Qt EGL/XCB dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libegl1 \
libgl1 \
libxkbcommon0 \
libdbus-1-3
- name: Sync dependencies (incl. build / Briefcase)
run: |
uv sync --locked --dev --group build
- name: Export lock-derived constraints for Briefcase (Linux)
run: |
uv export --locked --format requirements.txt --no-dev --no-hashes --no-emit-project --no-emit-workspace --output-file constraints-briefcase.txt
- name: Briefcase create / build / package (linux system)
env:
QT_QPA_PLATFORM: offscreen
PIP_INDEX_URL: https://pypi.org/simple
PIP_EXTRA_INDEX_URL: ""
PIP_NO_CACHE_DIR: "1"
run: |
uv run briefcase create linux system --no-input
uv run briefcase build linux system --no-input
uv run briefcase package linux system --no-input
- name: Create Linux raw app tree archive (no .deb)
shell: bash
run: |
set -euo pipefail
v="${GITHUB_REF_NAME#v}"
# Briefcase linux system: build/<app>/<vendor>/<codename>/... (not .../linux/...).
# App key dbs_annotator; .deb may use dbs-annotator. No bare "find -name app".
root="build/dbs_annotator"
if [ ! -d "${root}" ]; then
echo "::error::Missing Briefcase build tree at ${root}" >&2
find build -maxdepth 3 -type d 2>/dev/null | head -40 >&2 || true
exit 1
fi
app_dir=$(find "${root}" -type d \( -path "*/dbs_annotator/app" -o -path "*/dbs-annotator/app" \) 2>/dev/null | head -1) || true
if [ -z "${app_dir}" ]; then
stub=$(find "${root}" -type f \( -name dbs-annotator -o -name dbs_annotator \) 2>/dev/null | head -1) || true
if [ -n "${stub}" ]; then
d=$(dirname "${stub}")
while [ "$(basename "${d}")" != "app" ] && [ "${d}" != "/" ] && [ -n "${d}" ]; do
d=$(dirname "${d}")
done
if [ "$(basename "${d}")" = "app" ] && [ -d "${d}/src" ]; then
app_dir=${d}
fi
fi
fi
if [ -z "${app_dir}" ]; then
echo "::error::Could not find Briefcase 'app' tree under ${root}" >&2
find "${root}" -type d 2>/dev/null | head -80 >&2 || true
exit 1
fi
parent=$(dirname "${app_dir}")
out="${GITHUB_WORKSPACE}/dist/dbs-annotator_${v}_linux_x86_64-raw.tar.gz"
(cd "${parent}" && tar -czf "${out}" "$(basename "${app_dir}")")
echo "Linux raw: dist/dbs-annotator_${v}_linux_x86_64-raw.tar.gz"
- name: Upload Briefcase Linux artifacts
uses: actions/upload-artifact@v7
with:
name: briefcase-linux-x86_64
path: dist/*
if-no-files-found: error
release:
name: Create GitHub Release and upload artifacts
runs-on: ubuntu-latest
needs:
- build-python-dist
- build-briefcase-msi
- build-briefcase-macos-arm
- build-briefcase-linux-x86
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release) }}
steps:
- name: Download build artifacts
uses: actions/download-artifact@v8
with:
path: release-assets
merge-multiple: true
- name: Create GitHub Release and upload artifacts
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ github.ref_name }}
token: ${{ secrets.GITHUB_TOKEN }}
fail_on_unmatched_files: false
generate_release_notes: true
working_directory: release-assets
files: |
**/*