Skip to content

fix(runtime): improve read-aloud page navigation (#608) #165

fix(runtime): improve read-aloud page navigation (#608)

fix(runtime): improve read-aloud page navigation (#608) #165

Workflow file for this run

name: Release
on:
push:
branches: [main, develop]
workflow_dispatch:
inputs:
release_type:
description: "Version increment"
required: true
default: beta
type: choice
options:
- beta
- beta-minor
- beta-major
- patch
- minor
- major
permissions:
contents: read
concurrency:
group: release
cancel-in-progress: false
# Opt JS actions into Node 24 ahead of the June 2026 default — silences the
# Node 20 deprecation warning until upstream actions ship Node 24 majors.
# https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
prepare:
if: >-
${{
contains(fromJSON('["main", "develop"]'), github.ref_name)
&& (github.event_name == 'workflow_dispatch' || startsWith(github.event.head_commit.message, 'RELEASE:'))
}}
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
tag: ${{ steps.tag.outputs.tag }}
version: ${{ steps.tag.outputs.version }}
prerelease: ${{ steps.tag.outputs.prerelease }}
docker_tags: ${{ steps.tag.outputs.docker_tags }}
prev_tag: ${{ steps.tag.outputs.prev_tag }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Debug — list git tags visible to the runner
run: |
echo "Total tags: $(git tag --list | wc -l)"
git tag --list | sort -V
- name: Calculate release version
id: tag
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_RELEASE_TYPE: ${{ inputs.release_type }}
BRANCH: ${{ github.ref_name }}
run: |
MSG=""
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
RELEASE_TYPE="$INPUT_RELEASE_TYPE"
else
MSG=$(git log -1 --pretty=%s)
shopt -s nocasematch
if [[ "$MSG" =~ ^release:[[:space:]]*(major|minor|patch|beta-minor|beta-major|beta)[[:space:]]*$ ]]; then
RELEASE_TYPE="${BASH_REMATCH[1],,}"
fi
shopt -u nocasematch
fi
if [[ -z "$RELEASE_TYPE" ]]; then
echo "::error::Could not resolve release type. event=$EVENT_NAME input='$INPUT_RELEASE_TYPE' subject='$MSG'"
exit 1
fi
if [[ "$BRANCH" == "develop" && "$RELEASE_TYPE" != beta* ]]; then
echo "::error::develop only ships beta releases. Select beta, beta-minor, or beta-major."
exit 1
fi
if [[ "$BRANCH" == "main" && "$RELEASE_TYPE" == beta* ]]; then
echo "::error::main only ships stable releases. Select patch, minor, or major."
exit 1
fi
VERSION=$(node scripts/calculate-release-version.mjs "$RELEASE_TYPE")
TAG="v$VERSION"
if [[ "$RELEASE_TYPE" == beta* ]]; then
PRERELEASE=true
else
PRERELEASE=false
fi
if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then
echo "::error::Calculated tag $TAG already exists on origin."
exit 1
fi
DOCKER_TAGS="ghcr.io/unicef/adt-studio:$TAG"
if [[ "$PRERELEASE" == "false" ]]; then
DOCKER_TAGS="$DOCKER_TAGS"$'\n'"ghcr.io/unicef/adt-studio:latest"
fi
# Changelog baseline: pin the previous tag so release-note generation
# never falls back to the repo's first commit when GitHub can't infer
# an ancestor (stable and beta tags live on divergent branch lines).
# Stable releases diff against the previous stable so the notes cover
# the whole beta cycle; prereleases diff against the most recent tag.
if [[ "$PRERELEASE" == "false" ]]; then
PREV_TAG=$(git tag --list 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
else
PREV_TAG=$(git tag --list 'v*' | sort -V | tail -1)
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT"
echo "prev_tag=$PREV_TAG" >> "$GITHUB_OUTPUT"
{
echo "docker_tags<<EOF"
echo "$DOCKER_TAGS"
echo "EOF"
} >> "$GITHUB_OUTPUT"
echo "Resolved tag: $TAG (type: $RELEASE_TYPE, prerelease: $PRERELEASE)"
- uses: pnpm/action-setup@v4
# No `cache: pnpm` here: this job never runs `pnpm install`, so the
# pnpm store is never created and the post-run cache save fails with
# "Path Validation Error: Path(s) specified ... do(es) not exist".
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Update issue template version dropdowns
env:
TAG: ${{ steps.tag.outputs.tag }}
run: node scripts/update-issue-template-versions.mjs "$TAG"
- name: Bump apps/desktop package.json
env:
VERSION: ${{ steps.tag.outputs.version }}
run: |
cd apps/desktop
pnpm version "$VERSION" --no-git-tag-version
- name: Upload staged release metadata
uses: actions/upload-artifact@v4
with:
name: release-metadata
include-hidden-files: true # .github is a dotfile dir
retention-days: 1
path: |
.github/ISSUE_TEMPLATE
apps/desktop/package.json
desktop:
needs: prepare
strategy:
fail-fast: false
matrix:
# macos-26 (Tahoe): Xcode 26 is the native default and frameworks match.
# On macos-latest (currently macos-15), Xcode 26's actool dyld-loads
# mismatched CoreMedia/MediaToolbox symbols and crashes.
os: [macos-26, windows-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 60 # mac signing/notarization can take a while
environment: desktop
permissions:
contents: read
env:
APP_VERSION: ${{ needs.prepare.outputs.version }}
# SKIP_NOTARIZE accepts ALL, MAC, WIN, LINUX (comma/space separated).
# "true" is treated as ALL for backward compatibility.
SKIP_NOTARIZE: ${{ vars.SKIP_NOTARIZE }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build workspace (root)
run: pnpm build
- name: Build desktop
run: pnpm --filter @adt/desktop build
# electron-builder >=26 calls `actool` to compose the macOS icon asset
# catalog and requires actool >= 26.0 (Xcode 26+). The runner has it
# installed but defaults to an older Xcode; select the latest stable.
- name: Select latest Xcode (for actool)
if: matrix.os == 'macos-26'
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- name: Package MacOS
if: matrix.os == 'macos-26'
run: pnpm --filter @adt/desktop exec electron-builder --mac --publish never
env:
# macOS signing + notarization
# CSC_LINK — the base64-encoded .p12 content
# CSC_KEY_PASSWORD — the certificate password
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Resolve Windows signing skip
id: skip
if: matrix.os == 'windows-latest'
shell: bash
run: |
tokens=" $(echo "${SKIP_NOTARIZE:-}" | tr '[:lower:]' '[:upper:]' | tr ',' ' ') "
skip=false
case "$tokens" in
*" ALL "*|*" TRUE "*|*" WIN "*) skip=true ;;
esac
echo "win=$skip" >> "$GITHUB_OUTPUT"
- name: Acquire Azure code signing token
if: matrix.os == 'windows-latest' && steps.skip.outputs.win != 'true'
shell: bash
env:
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
run: |
if [ -z "${AZURE_TENANT_ID:-}" ] || [ -z "${AZURE_CLIENT_ID:-}" ] || [ -z "${AZURE_CLIENT_SECRET:-}" ]; then
echo "::error::Missing Azure credentials (AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET)."
exit 1
fi
TOKEN=$(curl -fsS -X POST \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials&client_id=${AZURE_CLIENT_ID}&client_secret=${AZURE_CLIENT_SECRET}&resource=https%3A%2F%2Fcodesigning.azure.net" \
"https://login.microsoftonline.com/${AZURE_TENANT_ID}/oauth2/token" \
| jq -r '.access_token')
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
echo "::error::Failed to obtain AZ_TOKEN from Azure OAuth endpoint."
exit 1
fi
echo "::add-mask::$TOKEN"
echo "AZ_TOKEN=$TOKEN" >> "$GITHUB_ENV"
- name: Set up Java (for jsign)
if: matrix.os == 'windows-latest'
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Cache jsign.jar
if: matrix.os == 'windows-latest'
id: jsign-cache
uses: actions/cache@v4
with:
path: apps/desktop/jsign.jar
key: jsign-7.1
- name: Download jsign.jar
if: matrix.os == 'windows-latest' && steps.jsign-cache.outputs.cache-hit != 'true'
shell: bash
run: |
curl -fsSL -o apps/desktop/jsign.jar \
https://repo1.maven.org/maven2/net/jsign/jsign/7.1/jsign-7.1.jar
- name: Package Windows
if: matrix.os == 'windows-latest'
run: pnpm --filter @adt/desktop exec electron-builder --win --publish never
- name: Package Linux
if: matrix.os == 'ubuntu-latest'
run: pnpm --filter @adt/desktop exec electron-builder --linux --publish never
- name: Collect installers
uses: actions/upload-artifact@v4
with:
name: installers-${{ matrix.os }}
retention-days: 1
if-no-files-found: error
path: |
apps/desktop/release/*.dmg
apps/desktop/release/*.zip
apps/desktop/release/*.exe
apps/desktop/release/*.deb
apps/desktop/release/*.AppImage
apps/desktop/release/*.blockmap
apps/desktop/release/latest*.yml
apps/desktop/release/beta*.yml
docker:
needs: [prepare, desktop]
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
target: app
push: true
tags: ${{ needs.prepare.outputs.docker_tags }}
finalize:
needs: [prepare, desktop, docker]
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
pull-requests: read
outputs:
tag: ${{ needs.prepare.outputs.tag }}
version: ${{ needs.prepare.outputs.version }}
sha: ${{ steps.commit.outputs.sha }}
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.RELEASE_PAT }}
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Restore staged release metadata
uses: actions/download-artifact@v4
with:
name: release-metadata
path: .
- name: Download installers
uses: actions/download-artifact@v4
with:
pattern: installers-*
path: dist
merge-multiple: true # flatten the per-OS artifacts into one dir
- name: Compose beta release notes
if: needs.prepare.outputs.prerelease == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare.outputs.tag }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.ref_name }}
TRIGGER_SHA: ${{ github.sha }}
run: |
rm -f release-notes.tmp release-notes.md
if node scripts/compose-release-notes.mjs > release-notes.tmp && [ -s release-notes.tmp ]; then
mv release-notes.tmp release-notes.md
else
rm -f release-notes.tmp release-notes.md
echo "::warning::Could not compose release notes; gh release create will generate them."
fi
- name: Commit release metadata and push tag
id: commit
env:
TAG: ${{ needs.prepare.outputs.tag }}
BRANCH: ${{ github.ref_name }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .github/ISSUE_TEMPLATE/*.yml apps/desktop/package.json
if git diff --staged --quiet; then
echo "No metadata changes to commit; pushing tag only."
git tag "$TAG"
git push origin "$TAG"
else
git commit -m "chore(release): $TAG [skip ci]"
git tag "$TAG"
git push origin --atomic HEAD:"$BRANCH" "refs/tags/$TAG"
fi
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Generate standalone docker-compose.yml
env:
TAG: ${{ needs.prepare.outputs.tag }}
run: |
sed "s/__TAG__/${TAG}/g" docker/compose-release.yml.template > docker-compose.yml
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.prepare.outputs.tag }}
REPO: ${{ github.repository }}
PRERELEASE: ${{ needs.prepare.outputs.prerelease }}
PREV_TAG: ${{ needs.prepare.outputs.prev_tag }}
run: |
PRERELEASE_FLAG=""
[[ "$PRERELEASE" == "true" ]] && PRERELEASE_FLAG="--prerelease"
START_TAG_FLAG=()
[[ -n "$PREV_TAG" ]] && START_TAG_FLAG=(--notes-start-tag "$PREV_TAG")
NOTES_ARGS=(--generate-notes "${START_TAG_FLAG[@]}")
if [[ "$PRERELEASE" == "true" && -s release-notes.md ]]; then
NOTES_ARGS=(--notes-file release-notes.md)
fi
gh release create "$TAG" --repo "$REPO" \
--title "ADT Studio $TAG" \
"${NOTES_ARGS[@]}" $PRERELEASE_FLAG \
dist/* \
docker-compose.yml \
scripts/windows-setup-and-run.bat