Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
323 changes: 323 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,323 @@
name: CI

on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
push:
branches: [main, develop]
workflow_dispatch:

concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

permissions:
contents: read
pull-requests: write
issues: write

env:
DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer

jobs:
# ============================================
# Quick validation checks (run first)
# ============================================

lint:
name: SwiftLint
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: SwiftLint
id: swiftlint
uses: norio-nomura/[email protected]
with:
args: --strict
continue-on-error: true

- name: Comment PR with SwiftLint results
uses: actions/github-script@v7
if: github.event_name == 'pull_request'
with:
script: |
const output = '${{ steps.swiftlint.outputs.stdout }}';
if (output) {
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '## SwiftLint Results\n\n```\n' + output + '\n```'
});
}

swiftformat:
name: SwiftFormat Check
runs-on: macos-26

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Install SwiftFormat
run: brew install swiftformat

- name: Check code formatting
run: |
swiftformat --version
swiftformat . --lint --verbose
continue-on-error: true

- name: Generate format diff
if: failure()
run: |
swiftformat . --dryrun > format-diff.txt
cat format-diff.txt

- name: Upload format diff
if: failure()
uses: actions/upload-artifact@v4
with:
name: format-diff
path: format-diff.txt

pr-size:
name: PR Size Check
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Check PR size
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const additions = pr.additions;
const deletions = pr.deletions;
const total = additions + deletions;

let label = '';
if (total < 10) label = 'size/XS';
else if (total < 50) label = 'size/S';
else if (total < 200) label = 'size/M';
else if (total < 500) label = 'size/L';
else if (total < 1000) label = 'size/XL';
else label = 'size/XXL';

// Remove all size labels
const labels = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number
});

for (const label of labels.data) {
if (label.name.startsWith('size/')) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
name: label.name
Comment on lines +125 to +131
Copy link

Copilot AI Nov 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable name collision: the loop variable label on line 125 shadows the label variable defined on line 110. This will cause the size label calculation to be overwritten during the loop. Consider renaming the loop variable to something like existingLabel to avoid this conflict.

for (const existingLabel of labels.data) {
  if (existingLabel.name.startsWith('size/')) {
    await github.rest.issues.removeLabel({
      owner: context.repo.owner,
      repo: context.repo.repo,
      issue_number: pr.number,
      name: existingLabel.name
    });
  }
}
Suggested change
for (const label of labels.data) {
if (label.name.startsWith('size/')) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
name: label.name
for (const existingLabel of labels.data) {
if (existingLabel.name.startsWith('size/')) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
name: existingLabel.name

Copilot uses AI. Check for mistakes.
});
}
}

// Add new size label
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: [label]
});

check-commits:
name: Check Commit Messages
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Check commits
uses: actions/github-script@v7
with:
script: |
const commits = await github.rest.pulls.listCommits({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number
});

const conventionalCommitRegex = /^(feat|fix|docs|style|refactor|test|chore|perf|ci|build|revert)(\(.+\))?: .+/;
const invalidCommits = [];

for (const commit of commits.data) {
const message = commit.commit.message.split('\n')[0];
if (!conventionalCommitRegex.test(message)) {
invalidCommits.push(`- ${commit.sha.substring(0, 7)}: ${message}`);
}
}

if (invalidCommits.length > 0) {
core.warning(`Found ${invalidCommits.length} commits without conventional format:\n${invalidCommits.join('\n')}`);
}

# ============================================
# Build and Test
# ============================================

build-and-test:
name: Build and Test
runs-on: macos-26

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive

- name: Select Xcode version
run: sudo xcode-select -s /Applications/Xcode_26.0.1.app/Contents/Developer

- name: Show Xcode version
run: xcodebuild -version

- name: Install xcpretty
run: gem install xcpretty

- name: Cache SPM packages
uses: actions/cache@v4
with:
path: ~/Library/Developer/Xcode/DerivedData/**/SourcePackages
key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }}
restore-keys: |
${{ runner.os }}-spm-

- name: Resolve Swift packages
run: |
xcodebuild -resolvePackageDependencies \
-project V2er.xcodeproj \
-scheme V2er

- name: Build for testing
run: |
set -o pipefail && xcodebuild build-for-testing \
-project V2er.xcodeproj \
-scheme V2er \
-sdk iphonesimulator \
-destination 'platform=iOS Simulator,name=iPhone 17' \
ONLY_ACTIVE_ARCH=YES \
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO | xcpretty --color

- name: Run tests
run: |
set -o pipefail && xcodebuild test-without-building \
-project V2er.xcodeproj \
-scheme V2er \
-sdk iphonesimulator \
-destination 'platform=iOS Simulator,name=iPhone 17' \
ONLY_ACTIVE_ARCH=YES \
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO | xcpretty --color --test

- name: Upload test results
uses: actions/upload-artifact@v4
if: failure()
with:
name: test-results
path: |
~/Library/Logs/DiagnosticReports/
~/Library/Developer/Xcode/DerivedData/**/Logs/Test/

# ============================================
# Code Coverage
# ============================================

code-coverage:
name: Code Coverage
runs-on: macos-26

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive

- name: Select Xcode version
run: sudo xcode-select -s /Applications/Xcode_26.0.1.app/Contents/Developer

- name: Install xcpretty
run: gem install xcpretty

- name: Build and test with coverage
run: |
xcodebuild test \
-project V2er.xcodeproj \
-scheme V2er \
-sdk iphonesimulator \
-destination 'platform=iOS Simulator,name=iPhone 17' \
-enableCodeCoverage YES \
-derivedDataPath build/DerivedData \
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO | xcpretty

- name: Generate coverage report
run: |
cd build/DerivedData
# Find the xcresult bundle
RESULT_BUNDLE=$(find . -name '*.xcresult' -type d | head -n 1)

if [ -z "$RESULT_BUNDLE" ]; then
echo "No test results found, setting coverage to 0%"
echo "coverage=0.00" >> $GITHUB_ENV
else
xcrun xccov view --report --json "$RESULT_BUNDLE" > coverage.json || echo '{}' > coverage.json

# Extract coverage percentage with fallback
COVERAGE=$(cat coverage.json | jq -r '.lineCoverage // 0' | awk '{printf "%.2f", $1 * 100}')
Copy link

Copilot AI Nov 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Useless use of cat: The command cat coverage.json | jq can be simplified to jq '...' coverage.json which is more efficient and follows best practices by avoiding an unnecessary pipe.

COVERAGE=$(jq -r '.lineCoverage // 0' coverage.json | awk '{printf "%.2f", $1 * 100}')
Suggested change
COVERAGE=$(cat coverage.json | jq -r '.lineCoverage // 0' | awk '{printf "%.2f", $1 * 100}')
COVERAGE=$(jq -r '.lineCoverage // 0' coverage.json | awk '{printf "%.2f", $1 * 100}')

Copilot uses AI. Check for mistakes.
echo "Code coverage: ${COVERAGE}%"
echo "coverage=${COVERAGE}" >> $GITHUB_ENV
fi

- name: Create coverage badge
if: env.GIST_SECRET != ''
uses: schneegans/[email protected]
with:
auth: ${{ secrets.GIST_SECRET }}
gistID: ${{ secrets.GIST_ID }}
filename: v2er-ios-coverage.json
label: Coverage
message: ${{ env.coverage }}%
color: ${{ env.coverage > 80 && 'success' || env.coverage > 60 && 'yellow' || 'critical' }}
env:
GIST_SECRET: ${{ secrets.GIST_SECRET }}

- name: Comment PR with coverage
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const coverage = parseFloat('${{ env.coverage }}');
const emoji = coverage > 80 ? '✅' : coverage > 60 ? '⚠️' : '❌';

github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `## Code Coverage Report ${emoji}\n\nCurrent coverage: **${coverage}%**`
});
Loading
Loading