Skip to content

Hide and show dashboards from the native tab bar #11383

Hide and show dashboards from the native tab bar

Hide and show dashboards from the native tab bar #11383

Workflow file for this run

name: CI
permissions:
contents: read
pull-requests: write
on:
pull_request:
push:
branches:
- main
env:
DEVELOPER_DIR: /Applications/Xcode_26.4.app/Contents/Developer
FASTLANE_SKIP_UPDATE_CHECK: true
FASTLANE_XCODE_LIST_TIMEOUT: 80
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 80
HOMEBREW_NO_INSTALL_CLEANUP: TRUE
BUNDLE_PATH: vendor/bundle
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
with:
ruby-version: "3.1"
bundler-cache: true
- name: YamlLint
run: yamllint --strict --format github .
- name: RuboCop
run: bundle exec rubocop --format github
- name: SwiftLint
run: |
docker run --rm -v `pwd`:`pwd` -w `pwd` \
ghcr.io/realm/swiftlint:0.54.0 \
swiftlint lint --strict --config .swiftlint.yml --reporter github-actions-logging
- name: SwiftFormat
run: |
docker run --rm -v `pwd`:`pwd` -w `pwd` \
ghcr.io/nicklockwood/swiftformat:0.53.1 \
--lint --config .swiftformat .
check-swiftlint-disables:
needs: lint
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0 # Fetch all history for proper git diff
- name: Scan for `swiftlint:disable` in PR diff
id: scan
continue-on-error: false
run: |
git fetch origin main:refs/remotes/origin/main
DISABLE_LINES=$(git diff origin/main -- '*.swift' | grep -E '^\+.*// swiftlint:disable' || true)
echo "disable_lines<<EOF" >> $GITHUB_OUTPUT
echo "$DISABLE_LINES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Comment on PR if `swiftlint:disable` is found
if: steps.scan.outputs.disable_lines != '' && github.event.pull_request.head.repo.full_name == github.repository
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
with:
header: swiftlint-disable-check
message: |
⚠️ **SwiftLint disabled in this PR**
The following added lines contain `// swiftlint:disable`. Please verify this is necessary.
check-unused-strings:
needs: lint
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0 # Fetch all history for comparison with main branch
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v5.3.0
with:
python-version: '3.x'
- name: Detect unused L10n strings
id: detect
continue-on-error: true
run: |
OUTPUT=$(python3 Tools/detect_unused_strings.py 2>&1 || true)
echo "output<<EOF" >> $GITHUB_OUTPUT
echo "$OUTPUT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
# Last non-empty line is the comma-separated list of unused keys,
# ready to paste into the "Lokalise: Delete Keys" workflow.
KEYS=$(printf '%s\n' "$OUTPUT" | awk 'NF { last = $0 } END { print last }')
echo "keys<<EOF" >> $GITHUB_OUTPUT
echo "$KEYS" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
# Check if any unused strings were found
if echo "$OUTPUT" | grep -q "Total unused:"; then
COUNT=$(echo "$OUTPUT" | grep "Total unused:" | grep -oE '[0-9]+')
echo "has_unused=true" >> $GITHUB_OUTPUT
echo "count=$COUNT" >> $GITHUB_OUTPUT
else
echo "has_unused=false" >> $GITHUB_OUTPUT
echo "count=0" >> $GITHUB_OUTPUT
fi
- name: Comment on PR if unused strings are found
if: |
steps.detect.outputs.has_unused == 'true' &&
github.event.pull_request.head.repo.full_name == github.repository
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
with:
header: unused-strings-check
message: |
⚠️ **Unused L10n strings detected**
Found **${{ steps.detect.outputs.count }}** unused localization strings in the codebase.
<details>
<summary>Click to see details</summary>
```
${{ steps.detect.outputs.output }}
```
</details>
To remove them, run the
[Lokalise: Delete Keys](${{ github.event.repository.html_url }}/actions/workflows/delete_lokalise_keys.yml)
workflow — it deletes the keys from Lokalise and opens a PR removing them from
`Localizable.strings` and regenerating `Strings.swift`. Copy-paste these keys into the **keys** input:
```
${{ steps.detect.outputs.keys }}
```
test:
runs-on: macos-26
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
with:
ruby-version: .ruby-version
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
name: "Cache: Gems"
id: cache_gems
with:
path: vendor/bundle
key: >-
${{ format('{0}-gems-{1}-{2}-{3}',
runner.os,
env.ImageVersion,
env.DEVELOPER_DIR,
hashFiles('.ruby-version', '**/Gemfile.lock')) }}
# DerivedData is warmed on `main` (the save step below) and only restored
# on PRs, so pinned SPM dependencies are compiled once per Package.resolved
# and reused instead of recompiled from scratch on every run.
- uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
name: "Restore: DerivedData"
id: derived_data
with:
path: ~/Library/Developer/Xcode/DerivedData
key: >-
${{ format('{0}-deriveddata-{1}-{2}',
runner.os,
env.DEVELOPER_DIR,
hashFiles('Package.resolved')) }}
restore-keys: >-
${{ format('{0}-deriveddata-{1}-',
runner.os,
env.DEVELOPER_DIR) }}
- name: Install Brews
# right now, we don't need anything from brew for tests, so save some time
if: ${{ false }}
run: brew bundle
- name: Install Gems
if: steps.cache_gems.outputs.cache-hit != 'true'
run: bundle install --jobs 4 --retry 3
- name: Run tests
run: bundle exec fastlane test
# Save the warmed DerivedData only from `main`, and only on a fresh key,
# so PR runs never evict the shared base cache other PRs restore from.
- uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
name: "Save: DerivedData"
if: github.ref == 'refs/heads/main' && steps.derived_data.outputs.cache-hit != 'true'
with:
path: ~/Library/Developer/Xcode/DerivedData
key: >-
${{ format('{0}-deriveddata-{1}-{2}',
runner.os,
env.DEVELOPER_DIR,
hashFiles('Package.resolved')) }}
# `xcrun xccov` is the only source of truth for Xcode coverage: the uploader's
# own Swift plugin globs DerivedData and pairs profdata with the wrong binaries,
# which reported ~0% for the whole project. Convert the result bundle ourselves
# and hand Codecov a plain LCOV tracefile instead.
#
# Both steps below are `continue-on-error`: tests gate merges, coverage reporting
# must not. `fail_ci_if_error` covers only the upload result, not the uploader's
# own setup steps (dependency checks, CLI download, signature verification), which
# exit nonzero on their own, so the action needs the same treatment.
- name: Build coverage report
continue-on-error: true
run: >-
python3 Tools/xccov_to_lcov.py fastlane/test_output/Tests-Unit.xcresult
--lcov fastlane/test_output/coverage.lcov
--summary "$GITHUB_STEP_SUMMARY"
- uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
name: "Upload Code Coverage"
continue-on-error: true
with:
files: fastlane/test_output/coverage.lcov
disable_search: true
plugins: noop
# Handed to the `patch-coverage` job below, which runs on Linux so the mac
# runner is free the moment the tests are done.
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
name: "Upload Code Coverage Tracefile"
continue-on-error: true
with:
name: coverage-lcov
path: fastlane/test_output/coverage.lcov
if-no-files-found: ignore
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
name: "Upload Test Logs"
if: ${{ always() }}
with:
name: test-logs
path: |
~/Library/Logs/DiagnosticReports
~/Library/Developer/Xcode/DerivedData/HomeAssistant-*/Logs/Test
~/Library/Logs/scan
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
name: "Upload Simulator App"
with:
name: ios-simulator
path: ~/Library/Developer/Xcode/DerivedData/HomeAssistant-*/Build/Products/Debug-iphonesimulator/*.app
# A pull request has to test the code it brings: at least 90% of the lines it adds
# or changes, and that the unit tests are able to run at all, must actually be run
# by them. Falling short is a changes-requested review rather than only a red check,
# so the reason lands where the discussion is. The review is dismissed automatically
# as soon as a push clears the bar, and a maintainer can dismiss it to land a change
# whose new code genuinely cannot be unit tested.
patch-coverage:
name: Patch coverage
needs: test
# Runs even when the tests failed, which leaves no tracefile behind: the job has
# nothing to measure then, and its own cleanup below is what keeps a request from
# an earlier run from outliving the coverage it was about.
if: >-
!cancelled() &&
github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
pull-requests: write
env:
COVERAGE_THRESHOLD: "90"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0 # Fetch all history to diff against the merge base
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
name: "Download Code Coverage Tracefile"
id: tracefile
continue-on-error: true
with:
name: coverage-lcov
path: fastlane/test_output
# Coverage that could not be measured must not block: when the conversion or the
# artifact is missing, this step is skipped and sets no output, which leaves the
# gate reporting nothing and dismisses any request an earlier run left behind,
# rather than blocking on a number nobody can act on. The step itself is
# `continue-on-error` because the script exits nonzero to report the shortfall.
- name: Measure patch coverage
id: coverage
if: steps.tracefile.outcome == 'success'
continue-on-error: true
env:
BASE_REF: ${{ github.base_ref }}
run: |
git fetch origin "+$BASE_REF:refs/remotes/origin/$BASE_REF"
python3 Tools/diff_coverage.py fastlane/test_output/coverage.lcov \
--base "origin/$BASE_REF" \
--threshold "$COVERAGE_THRESHOLD" \
--summary "$GITHUB_STEP_SUMMARY" \
--github-output "$GITHUB_OUTPUT"
# Only same-repository pull requests: a fork's token cannot review, and the run
# would fail on the API call instead of on the coverage it is reporting.
- name: Request changes below the threshold
if: >-
steps.coverage.outputs.passed == 'false' &&
github.event.pull_request.head.repo.full_name == github.repository
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
COVERAGE: ${{ steps.coverage.outputs.coverage }}
COVERED: ${{ steps.coverage.outputs.covered }}
CHANGED: ${{ steps.coverage.outputs.changed }}
with:
script: |
const { owner, repo } = context.repo
const pull_number = context.payload.pull_request.number
const run = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`
const threshold = process.env.COVERAGE_THRESHOLD
// Marks the review as this job's, so the dismissal below leaves other bots alone.
const marker = '<!-- patch-coverage -->'
const body = [
marker,
`### Test coverage of this pull request is below ${threshold}%`,
'',
`The unit tests run **${process.env.COVERAGE}%** of the lines this pull request`,
`adds or changes (${process.env.COVERED} of ${process.env.CHANGED} coverable lines).`,
'',
'The per-file breakdown, and the changed lines no test runs, are in the',
`[job summary](${run}). Adding tests for those lines and pushing dismisses this`,
'review automatically.',
'',
'Lines that carry no executable code, and files the unit test targets do not',
'build, are not counted. If the new code genuinely cannot be unit tested, a',
'maintainer can dismiss this review.',
].join('\n')
// A push that leaves coverage exactly where it was would otherwise stack an
// identical review on every run, so repeat the request only when it changed.
const reviews = await github.paginate(github.rest.pulls.listReviews, {
owner,
repo,
pull_number,
per_page: 100,
})
const outstanding = reviews.findLast(
(review) => review.state === 'CHANGES_REQUESTED' && (review.body || '').includes(marker),
)
if (outstanding && outstanding.body === body) {
core.info('The outstanding coverage review already says this, leaving it alone')
return
}
await github.rest.pulls.createReview({ owner, repo, pull_number, event: 'REQUEST_CHANGES', body })
core.info(`Requested changes: patch coverage ${process.env.COVERAGE}% is below ${threshold}%`)
# Runs whether coverage met the bar or could not be measured at all, so a
# request from an earlier run is never left blocking a pull request whose
# coverage this run has no number for.
- name: Dismiss an outstanding coverage review
if: >-
steps.coverage.outputs.passed != 'false' &&
github.event.pull_request.head.repo.full_name == github.repository
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
COVERAGE: ${{ steps.coverage.outputs.coverage }}
with:
script: |
const { owner, repo } = context.repo
const pull_number = context.payload.pull_request.number
const reviews = await github.paginate(github.rest.pulls.listReviews, {
owner,
repo,
pull_number,
per_page: 100,
})
const marker = '<!-- patch-coverage -->'
const outstanding = reviews.filter(
(review) => review.state === 'CHANGES_REQUESTED' && (review.body || '').includes(marker),
)
const coverage = process.env.COVERAGE
const message = coverage
? `Patch coverage is now ${coverage}%, at or above the ` +
`${process.env.COVERAGE_THRESHOLD}% threshold.`
: 'Patch coverage could not be measured on this run, so the gate no longer applies.'
for (const review of outstanding) {
try {
await github.rest.pulls.dismissReview({ owner, repo, pull_number, review_id: review.id, message })
core.info(`Dismissed review ${review.id}`)
} catch (error) {
// A request nobody can dismiss blocks the pull request for good, so say
// so loudly enough that a maintainer can dismiss it by hand.
core.warning(`Could not dismiss review ${review.id}, dismiss it by hand: ${error.message}`)
}
}
- name: Fail below the threshold
if: steps.coverage.outputs.passed == 'false'
env:
COVERAGE: ${{ steps.coverage.outputs.coverage }}
run: |
echo "::error::Patch coverage $COVERAGE% is below the required $COVERAGE_THRESHOLD%"
exit 1