CD #5
Workflow file for this run
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: 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: | | |
| **/* |