Add a fastlane pipeline that releases HealthChat to TestFlight (#2)

element-x-ios had no release machinery at all: no fastlane, no Gemfile, and none of
its workflows built or uploaded anything. Upstream ships via Xcode Cloud, which
needs Element's private Enterprise submodule and so is unavailable here, while the
legacy element-ios fork already proves a fastlane pipeline against the same Apple
team, certificate and App Store Connect key.

Tagging healthchat-v<version> now builds a signed App Store IPA on the org's Mac
Studio and uploads it to TestFlight. Release notes come from a new
HEALTHCHAT_CHANGES.md, because upstream's own tooling owns CHANGES.md.

The lane deliberately does not pin Xcode. The runner carries a single globally
selected version, and naming an absent one does not fail fast — it sends xcodes
into a multi-GB authenticated download that no CI secret can satisfy. Each run logs
xcodebuild -version instead, which is the only record of what compiled a build.

Everything here is fork-owned, since upstream has no fastlane directory, so none of
it can conflict on rebase.
This commit is contained in:
2026-07-17 17:12:22 +02:00
committed by Sedat Qaja
parent 1da4c0302e
commit 8e81ebbd77
9 changed files with 731 additions and 3 deletions
+133
View File
@@ -0,0 +1,133 @@
name: Release HealthChat
on:
push:
tags:
- 'healthchat-v*'
workflow_dispatch:
inputs:
version:
description: 'Marketing version (e.g. 26.7.4). Must be 1-3 dot-separated integers — Apple rejects anything else.'
required: true
type: string
release_notes_override:
description: 'Optional release notes. If empty, falls back to the HEALTHCHAT_CHANGES.md section for the given version.'
required: false
type: string
permissions:
# Minimum needed for actions/checkout to clone an internal repo. The workflow does not push,
# comment, or create releases — everything else authenticates with the ASC key and .p12.
contents: read
concurrency:
group: release-healthchat-${{ github.event.inputs.version || github.ref_name }}
cancel-in-progress: false
jobs:
release:
name: Build & upload to TestFlight
runs-on: [self-hosted, macOS]
timeout-minutes: 90
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# This repo's only submodule is element-hq's private Enterprise repo, which Novaloop
# cannot clone. Checking out submodules fails the job on its very first step.
submodules: false
persist-credentials: false
- name: Record toolchain
# HealthChat: nothing pins Xcode (the runner carries a single globally-selected one), so this
# log line is the only record of which compiler produced a given TestFlight build.
run: xcodebuild -version
- name: Resolve marketing version
id: version
env:
# Read via env, never `${{ }}` inside the script: the input is attacker-controlled text
# that GitHub would splice into the shell before the regex below could reject it.
INPUT_VERSION: ${{ github.event.inputs.version }}
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
VERSION="$INPUT_VERSION"
else
VERSION="${GITHUB_REF_NAME#healthchat-v}"
fi
# Apple caps CFBundleShortVersionString at three integers. The Fastfile guards this too,
# but failing here costs seconds instead of a signing run.
if ! [[ "$VERSION" =~ ^[0-9]+(\.[0-9]+){0,2}$ ]]; then
echo "::error::Invalid marketing version '$VERSION' — expected 1-3 dot-separated integers, e.g. 26.7.4"
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Resolved marketing version: $VERSION"
- name: Extract release notes
id: notes
env:
VERSION: ${{ steps.version.outputs.version }}
OVERRIDE: ${{ github.event.inputs.release_notes_override }}
run: |
set -euo pipefail
if [[ -n "$OVERRIDE" ]]; then
NOTES="$OVERRIDE"
echo "Using release notes from the workflow input"
elif [[ ! -f HEALTHCHAT_CHANGES.md ]]; then
echo "::error::HEALTHCHAT_CHANGES.md is missing — it is the release-notes source"
exit 1
else
NOTES=$(awk -v ver="$VERSION" '
/^## Changes in / {
if (found) { exit }
if ($0 ~ "## Changes in " ver "( |$|\\()") { found = 1; next }
}
found { print }
' HEALTHCHAT_CHANGES.md | sed '/^[[:space:]]*$/{N;/^\n$/D;}')
if [[ -z "$NOTES" ]]; then
NOTES="HealthChat ${VERSION} — automated build from ${GITHUB_REF_NAME}."
echo "::warning::No HEALTHCHAT_CHANGES.md section found for $VERSION — using fallback"
fi
fi
{
echo 'notes<<NOTES_EOF'
echo "$NOTES"
echo 'NOTES_EOF'
} >> "$GITHUB_OUTPUT"
- name: Bundle install
run: |
bundle config path vendor/bundle
bundle install --jobs 4 --retry 3
- name: Import signing certificate
uses: apple-actions/import-codesign-certs@b610f78488812c1e56b20e6df63ec42d833f2d14 # v6.0.0
with:
p12-file-base64: ${{ secrets.HEALTHCHAT_CERTIFICATES_P12 }}
p12-password: ${{ secrets.HEALTHCHAT_CERTIFICATES_P12_PASSWORD }}
- name: Build & upload to TestFlight
run: bundle exec fastlane release_testflight
env:
# Passed via env rather than lane options so multi-line notes containing quotes,
# $, or backticks survive without shell escaping.
HEALTHCHAT_MARKETING_VERSION: ${{ steps.version.outputs.version }}
HEALTHCHAT_RELEASE_NOTES: ${{ steps.notes.outputs.notes }}
APPSTORECONNECT_KEY_ID: ${{ secrets.HEALTHCHAT_ASC_KEY_ID }}
APPSTORECONNECT_KEY_ISSUER_ID: ${{ secrets.HEALTHCHAT_ASC_KEY_ISSUER_ID }}
APPSTORECONNECT_KEY_CONTENT: ${{ secrets.HEALTHCHAT_ASC_KEY_CONTENT }}
TEAM_ID: '6G37K72W82'
- name: Upload artifacts
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: healthchat-${{ steps.version.outputs.version }}
path: |
out/ElementX.ipa
out/ElementX.app.dSYM.zip
build/Logs/Gym/*.log
retention-days: 90
if-no-files-found: warn