Phase B: Context-Aware Ranking (tracking) #87
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: CI | |
on: | |
push: | |
branches: [ dev, feature/**, bugfix/**, chore/** ] | |
pull_request: | |
branches: [ dev ] | |
permissions: | |
contents: read | |
pull-requests: write | |
jobs: | |
build: | |
runs-on: macos-14 | |
# Optionally pin Xcode explicitly to reduce toolchain drift | |
# - Uses default Xcode on macOS-13 (15.2) if not set up below | |
env: | |
CACHE_VERSION: v2 # bumped to invalidate old caches | |
SCHEME: Avro Keyboard # Default equals target name in this repo | |
TARGET: Avro Keyboard # Fallback target if no shared scheme | |
CONFIG: Debug | |
LOG_DIR: ci-logs # Directory for CI logs and summaries | |
DESTINATION: platform=macOS | |
XCODE_VERSION: '16.2' | |
NSUnbufferedIO: "YES" | |
COCOAPODS_VERSION: '1.16.2' | |
steps: | |
- name: Checkout | |
uses: actions/checkout@v4 | |
- name: Xcode version | |
run: | | |
xcodebuild -version | |
sw_vers | |
- name: Setup Xcode (optional pin) | |
if: ${{ always() }} | |
uses: maxim-lobanov/setup-xcode@v1 | |
with: | |
xcode-version: '${{ env.XCODE_VERSION }}' | |
- name: Prepare logs directory | |
run: | | |
rm -rf "$LOG_DIR" && mkdir -p "$LOG_DIR" | |
- name: Enforce Podfile.lock presence | |
shell: bash | |
run: | | |
if [[ -f Podfile.lock ]]; then | |
echo "Found Podfile.lock (enforced)." | |
else | |
echo "ERROR: Podfile.lock is missing. Run 'pod install' locally and commit the lockfile for reproducible builds." >&2 | |
exit 1 | |
fi | |
- name: Enable Xcode problem matcher | |
run: echo "::add-matcher::.github/problem-matchers/xcodebuild.json" | |
- name: Enable Linker problem matcher | |
run: echo "::add-matcher::.github/problem-matchers/linker.json" | |
- name: Install xcpretty | |
run: | | |
gem install xcpretty --no-document | |
- name: Install CocoaPods (pinned) | |
run: | | |
echo "Installing CocoaPods ${COCOAPODS_VERSION} via RubyGems" | |
gem install cocoapods -v "${COCOAPODS_VERSION}" --no-document | |
echo "CocoaPods version: $(pod --version)" | |
- name: Install Subversion (for RegexKitLite) | |
shell: bash | |
run: | | |
set -euo pipefail | |
if command -v svn >/dev/null 2>&1; then | |
echo "svn already installed: $(svn --version --quiet)" | |
else | |
echo "Installing Subversion via Homebrew (no auto-update)" | |
export HOMEBREW_NO_AUTO_UPDATE=1 | |
brew install subversion | |
fi | |
- name: Cache CocoaPods | |
id: pods_cache | |
uses: actions/cache@v4 | |
with: | |
path: | | |
Pods | |
~/.cocoapods | |
~/Library/Caches/CocoaPods | |
key: ${{ runner.os }}-${{ env.CACHE_VERSION }}-pods-${{ hashFiles('Podfile.lock') }} | |
restore-keys: | | |
${{ runner.os }}-${{ env.CACHE_VERSION }}-pods- | |
${{ runner.os }}-pods- | |
- name: Cache Xcode DerivedData | |
id: derived_cache | |
uses: actions/cache@v4 | |
with: | |
path: | | |
~/Library/Developer/Xcode/DerivedData | |
key: ${{ runner.os }}-${{ env.CACHE_VERSION }}-derived-${{ hashFiles('AvroKeyboard.xcodeproj/project.pbxproj', 'Podfile.lock') }}-${{ env.SCHEME }} | |
restore-keys: | | |
${{ runner.os }}-${{ env.CACHE_VERSION }}-derived- | |
${{ runner.os }}-derived- | |
- name: Pod install (deployment mode) | |
shell: bash | |
env: | |
COCOAPODS_DISABLE_STATS: "true" | |
run: | | |
set -euo pipefail | |
echo "Using CocoaPods $(pod --version)" | |
mkdir -p "$LOG_DIR" | |
# Enforce lockfile resolution | |
if ! pod install --deployment 2>&1 | tee "$LOG_DIR/pod_install.log"; then | |
echo "pod install --deployment failed; retrying after pod repo update..." | |
pod repo update || true | |
pod install --deployment --verbose 2>&1 | tee -a "$LOG_DIR/pod_install.log" | |
fi | |
- name: Pod env (on failure) | |
if: failure() | |
run: pod env || true | |
- name: Detect project or workspace | |
shell: bash | |
run: | | |
set -euo pipefail | |
ROOT=$(pwd -P) | |
WS="AvroKeyboard.xcworkspace" | |
PRJ="AvroKeyboard.xcodeproj" | |
if [[ -d "$WS" && -d "Pods" && -f "Pods/Pods.xcodeproj/project.pbxproj" ]]; then | |
echo "KIND=workspace" >> "$GITHUB_ENV" | |
echo "WORKSPACE=$ROOT/$WS" >> "$GITHUB_ENV" | |
elif [[ -d "$PRJ" ]]; then | |
echo "KIND=project" >> "$GITHUB_ENV" | |
echo "PROJECT=$ROOT/$PRJ" >> "$GITHUB_ENV" | |
else | |
echo "No Xcode workspace or project found"; ls -la; exit 1 | |
fi | |
- name: Resolve scheme | |
shell: bash | |
run: | | |
set -euo pipefail | |
export LC_ALL=C | |
WANT_SCHEME="${SCHEME:-}" | |
list_cmd() { | |
if [[ "${KIND:-}" == "workspace" ]]; then | |
xcodebuild -list -workspace "${WORKSPACE}" 2>/dev/null | |
else | |
xcodebuild -list -project "${PROJECT}" 2>/dev/null | |
fi | |
} | |
LIST_TRIMMED=$(list_cmd \ | |
| sed -n '/Schemes:/,$p' \ | |
| tail -n +2 \ | |
| sed 's/\r$//' \ | |
| awk '{gsub(/^[ \t]+|[ \t]+$/,""); if (length) print}') | |
echo "Available schemes:"; printf '%s\n' "$LIST_TRIMMED" | |
SEL="" | |
WANT_SCHEME_TRIM=$(printf '%s' "$WANT_SCHEME" | awk '{gsub(/^[ \t]+|[ \t]+$/,""); print}') | |
if [[ -n "$WANT_SCHEME_TRIM" ]] && printf '%s\n' "$LIST_TRIMMED" | grep -Fxq "$WANT_SCHEME_TRIM"; then | |
SEL="$WANT_SCHEME_TRIM" | |
else | |
SEL=$(printf '%s\n' "$LIST_TRIMMED" | head -n1) | |
fi | |
if [[ -z "$SEL" ]]; then | |
echo "No shared schemes found"; exit 1 | |
fi | |
echo "Resolved scheme: '$SEL'" | |
echo "SCHEME_RESOLVED=$SEL" >> "$GITHUB_ENV" | |
- name: Record build start time | |
run: echo "BUILD_START=$(date +%s)" >> "$GITHUB_ENV" | |
- name: Build | |
shell: bash | |
run: | | |
set -euo pipefail | |
if [[ "${KIND:-}" == "workspace" ]]; then | |
xcodebuild \ | |
-workspace "${WORKSPACE}" \ | |
-scheme "${SCHEME_RESOLVED}" \ | |
-configuration "$CONFIG" \ | |
-destination "$DESTINATION" \ | |
CODE_SIGNING_ALLOWED=NO \ | |
-resultBundlePath "$LOG_DIR/build.xcresult" \ | |
build 2>&1 | tee "$LOG_DIR/build.log" | xcpretty | |
else | |
xcodebuild \ | |
-project "${PROJECT}" \ | |
-scheme "${SCHEME_RESOLVED}" \ | |
-configuration "$CONFIG" \ | |
-destination "$DESTINATION" \ | |
CODE_SIGNING_ALLOWED=NO \ | |
-resultBundlePath "$LOG_DIR/build.xcresult" \ | |
build 2>&1 | tee "$LOG_DIR/build.log" | xcpretty || { | |
echo "Scheme build failed; retrying with target '$TARGET'"; | |
xcodebuild \ | |
-project "${PROJECT}" \ | |
-target "$TARGET" \ | |
-configuration "$CONFIG" \ | |
-destination "$DESTINATION" \ | |
CODE_SIGNING_ALLOWED=NO \ | |
-resultBundlePath "$LOG_DIR/build.xcresult" \ | |
build 2>&1 | tee -a "$LOG_DIR/build.log" | xcpretty; | |
} | |
fi | |
continue-on-error: false | |
- name: Record build end time | |
if: always() | |
run: echo "BUILD_END=$(date +%s)" >> "$GITHUB_ENV" | |
- name: Run unit tests (if any) | |
shell: bash | |
run: | | |
set -euo pipefail | |
HAS_TESTS=$( { grep -R --include='*.m' --include='*.swift' -n "XCTestCase" . || true; } | wc -l | tr -d '[:space:]') | |
if [[ "$HAS_TESTS" -gt "0" ]]; then | |
if [[ "${KIND:-}" == "workspace" ]]; then | |
# Only run tests with a scheme; if no shared scheme, skip. | |
if xcodebuild -list -workspace "${WORKSPACE}" | grep -q "Schemes:"; then | |
echo "TEST_START=$(date +%s)" >> "$GITHUB_ENV" | |
xcodebuild \ | |
-workspace "${WORKSPACE}" \ | |
-scheme "${SCHEME_RESOLVED}" \ | |
-configuration "$CONFIG" \ | |
-destination "$DESTINATION" \ | |
CODE_SIGNING_ALLOWED=NO \ | |
-resultBundlePath "$LOG_DIR/test.xcresult" \ | |
test 2>&1 | tee "$LOG_DIR/test.log" | xcpretty | |
echo "TEST_END=$(date +%s)" >> "$GITHUB_ENV" | |
else | |
echo "No shared scheme detected; skipping XCTest run." | |
fi | |
else | |
if xcodebuild -list -project "${PROJECT}" | grep -q "Schemes:"; then | |
echo "TEST_START=$(date +%s)" >> "$GITHUB_ENV" | |
xcodebuild \ | |
-project "${PROJECT}" \ | |
-scheme "${SCHEME_RESOLVED}" \ | |
-configuration "$CONFIG" \ | |
-destination "$DESTINATION" \ | |
CODE_SIGNING_ALLOWED=NO \ | |
-resultBundlePath "$LOG_DIR/test.xcresult" \ | |
test 2>&1 | tee "$LOG_DIR/test.log" | xcpretty | |
echo "TEST_END=$(date +%s)" >> "$GITHUB_ENV" | |
else | |
echo "No shared scheme detected; skipping XCTest run." | |
fi | |
fi | |
else | |
echo "No XCTest targets detected; skipping test step." | |
fi | |
- name: Write CI Summary | |
if: always() | |
env: | |
JOB_STATUS: ${{ job.status }} | |
shell: bash | |
run: | | |
set -euo pipefail | |
mkdir -p "$LOG_DIR" | |
BUILD_START_TS=${BUILD_START:-} | |
BUILD_END_TS=${BUILD_END:-} | |
TEST_START_TS=${TEST_START:-} | |
TEST_END_TS=${TEST_END:-} | |
dur() { perl -e '($s,$e)=@ARGV; if($s && $e){ printf("%ds", $e-$s) } else { print "n/a" }' "$1" "$2" 2>/dev/null || echo n/a; } | |
BUILD_DUR=$(dur "$BUILD_START_TS" "$BUILD_END_TS") | |
TEST_DUR=$(dur "$TEST_START_TS" "$TEST_END_TS") | |
{ | |
echo "CI Summary" | |
echo "==========" | |
echo "Status: $JOB_STATUS" | |
echo "Kind: ${KIND:-unknown}" | |
echo "Scheme: ${SCHEME_RESOLVED:-${SCHEME:-unset}}" | |
echo "Config: ${CONFIG}" | |
echo "Destination: ${DESTINATION}" | |
echo "Xcode: ${XCODE_VERSION}" | |
echo "Build duration: ${BUILD_DUR} (start: ${BUILD_START_TS:-n/a}, end: ${BUILD_END_TS:-n/a})" | |
echo "Test duration: ${TEST_DUR} (start: ${TEST_START_TS:-n/a}, end: ${TEST_END_TS:-n/a})" | |
echo | |
echo "Logs:" | |
echo "- build: ${LOG_DIR}/build.log" | |
echo "- test: ${LOG_DIR}/test.log (if any)" | |
echo "- xcresult: ${LOG_DIR}/build.xcresult, ${LOG_DIR}/test.xcresult (if any)" | |
} | tee "${LOG_DIR}/ci_summary.txt" | |
{ | |
echo "## CI Summary" | |
echo "- Status: $JOB_STATUS" | |
echo "- Kind: ${KIND:-unknown}" | |
echo "- Scheme: ${SCHEME_RESOLVED:-${SCHEME:-unset}}" | |
echo "- Config: ${CONFIG}" | |
echo "- Destination: ${DESTINATION}" | |
echo "- Xcode: ${XCODE_VERSION}" | |
echo "- Build duration: ${BUILD_DUR}" | |
echo "- Test duration: ${TEST_DUR}" | |
} >> "$GITHUB_STEP_SUMMARY" | |
- name: Upload CI logs and result bundles | |
if: always() | |
uses: actions/upload-artifact@v4 | |
with: | |
name: ci-logs-${{ github.run_number }} | |
path: | | |
${{ env.LOG_DIR }}/ | |
${{ env.LOG_DIR }}/build.xcresult | |
${{ env.LOG_DIR }}/test.xcresult | |
if-no-files-found: warn | |
retention-days: 7 | |
- name: Summarize failure (Option B) | |
if: failure() | |
shell: bash | |
run: | | |
set -euo pipefail | |
SUMMARY="$LOG_DIR/ci_summary.txt" | |
{ | |
echo "CI Failure Summary" | |
echo "Kind: ${KIND:-unknown}" | |
echo "Workspace: ${WORKSPACE:-n/a}" | |
echo "Project: ${PROJECT:-n/a}" | |
echo "Scheme: ${SCHEME_RESOLVED:-n/a}" | |
echo | |
if [[ -f "$LOG_DIR/build.log" ]]; then | |
echo "=== Build Errors (top hits) ===" | |
grep -nE "xcodebuild: error:|\\berror:|Undefined symbols|Command PhaseScriptExecution failed|exit code 65|ld: error" "$LOG_DIR/build.log" | head -n 100 || true | |
echo | |
echo "=== Build Log Tail (200 lines) ===" | |
tail -n 200 "$LOG_DIR/build.log" || true | |
echo | |
else | |
echo "No build.log captured." | |
fi | |
if [[ -f "$LOG_DIR/test.log" ]]; then | |
echo "=== Test Errors (top hits) ===" | |
grep -nE "\\berror:|failing|Assertion failed|Test Failed|Failures:" "$LOG_DIR/test.log" | head -n 100 || true | |
echo | |
echo "=== Test Log Tail (200 lines) ===" | |
tail -n 200 "$LOG_DIR/test.log" || true | |
echo | |
fi | |
} > "$SUMMARY" | |
echo "## CI Failure Summary" >> "$GITHUB_STEP_SUMMARY" | |
echo "" >> "$GITHUB_STEP_SUMMARY" | |
echo '\`\`\`' >> "$GITHUB_STEP_SUMMARY" | |
tail -n 200 "$SUMMARY" >> "$GITHUB_STEP_SUMMARY" || true | |
echo '\`\`\`' >> "$GITHUB_STEP_SUMMARY" | |
- name: Compress logs (failure) | |
if: failure() | |
shell: bash | |
run: | | |
set -euo pipefail | |
for f in "$LOG_DIR"/*.log; do | |
[[ -f "$f" ]] || continue | |
gzip -9f "$f" || true | |
done | |
- name: Upload logs (compressed) | |
if: always() | |
uses: actions/upload-artifact@v4 | |
with: | |
name: ci-logs | |
retention-days: 30 | |
path: | | |
ci-logs/** | |
- name: Comment success summary on PR | |
if: success() && github.event_name == 'pull_request' | |
uses: actions/github-script@v7 | |
with: | |
script: | | |
function fmtSeconds(sec) { | |
sec = Number(sec || 0); | |
const m = Math.floor(sec / 60); | |
const s = sec % 60; | |
return (m > 0 ? `${m}m ` : '') + `${s}s`; | |
} | |
const start = Number(process.env.BUILD_START || 0); | |
const end = Number(process.env.BUILD_END || 0); | |
const duration = (end && start) ? (end - start) : 0; | |
const podsCache = core.getInput('podsHit') || (process.env.PODS_HIT || ''); | |
const derivedCache = core.getInput('derivedHit') || (process.env.DERIVED_HIT || ''); | |
const podsHit = '${{ steps.pods_cache.outputs.cache-hit }}' === 'true'; | |
const derivedHit = '${{ steps.derived_cache.outputs.cache-hit }}' === 'true'; | |
const pr = context.payload.pull_request; | |
const body = [ | |
`CI Success Summary (workflow: ${process.env.GITHUB_WORKFLOW})`, | |
'', | |
`• Build time: ${fmtSeconds(duration)}`, | |
`• CocoaPods cache hit: ${podsHit}`, | |
`• DerivedData cache hit: ${derivedHit}`, | |
].join('\n'); | |
await github.rest.issues.createComment({ | |
owner: context.repo.owner, | |
repo: context.repo.repo, | |
issue_number: pr.number, | |
body | |
}); | |
- name: Comment failure summary on PR | |
if: failure() && github.event_name == 'pull_request' | |
uses: actions/github-script@v7 | |
with: | |
script: | | |
const fs = require('fs'); | |
const path = 'ci-logs/ci_summary.txt'; | |
let body = 'CI failure, but no summary was generated.'; | |
try { body = fs.readFileSync(path, 'utf8'); } catch (e) {} | |
const header = `CI Failure Summary (workflow: ${process.env.GITHUB_WORKFLOW})\n\n`; | |
const pr = context.payload.pull_request; | |
await github.rest.issues.createComment({ | |
owner: context.repo.owner, | |
repo: context.repo.repo, | |
issue_number: pr.number, | |
body: '```\n' + header + body + '\n```' | |
}); | |
- name: Regression tests (optional; skip if file missing) | |
shell: bash | |
run: | | |
if [[ -f "Tests/Regression/phrases.tsv" && -x "Tools/run_regression.sh" ]]; then | |
Tools/run_regression.sh Tests/Regression/phrases.tsv | |
else | |
echo "Regression harness not present yet; skipping." | |
fi |