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
57 changes: 57 additions & 0 deletions .github/labels.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
[
{
"name": "bug",
"color": "d73a4a",
"description": "Something is broken or behaving incorrectly."
},
{
"name": "enhancement",
"color": "a2eeef",
"description": "Requested improvement or new capability."
},
{
"name": "documentation",
"color": "0075ca",
"description": "Improvements or additions to documentation."
},
{
"name": "needs-info",
"color": "c5def5",
"description": "Waiting on the author for details needed to review."
},
{
"name": "DO NOT MERGE",
"color": "b60205",
"description": "Not ready to merge. State what must change before it is ready."
},
{
"name": "size:XS",
"color": "0e8a16",
"description": "0-9 effective changed lines."
},
{
"name": "size:S",
"color": "5ebd3e",
"description": "10-29 effective changed lines."
},
{
"name": "size:M",
"color": "fbca04",
"description": "30-99 effective changed lines."
},
{
"name": "size:L",
"color": "fe7d37",
"description": "100-499 effective changed lines."
},
{
"name": "size:XL",
"color": "d93f0b",
"description": "500-999 effective changed lines."
},
{
"name": "size:XXL",
"color": "b60205",
"description": "1,000+ effective changed lines."
}
]
35 changes: 35 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<!-- Keep related work together. Smaller, focused changes are easier to review;
a larger coherent change does not need artificial splitting. -->

## What changed

<!-- Start with the problem and resulting behavior. Order changes by impact. -->

## Why

<!-- Explain the reason or meaningful tradeoff without repeating the opening. -->

## UI changes

<!-- Show only changed behavior. Pair running-app captures of the same screen,
state and viewport. Add a short video for motion or interaction changes.
Remove this section when nothing visible changes. -->

## Verification

<!-- Checks actually run and results, plus material coverage limits.
Link long evidence. Distinguish suggested reproduction from completed checks. -->

## Risk

<!-- Include material remaining risks, compatibility or rollout considerations.
Remove this section when it adds no information. -->

## Checklist

- [ ] The scope is focused; related changes belong together
- [ ] I explained what changed and why
- [ ] I included before/after images for visible changes
- [ ] I included a video for motion or interaction changes

<!-- Replace inapplicable media checkboxes with a plain N/A note and reason. -->
75 changes: 75 additions & 0 deletions .github/workflows/ios.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
name: iOS

on:
push:
branches: [main]
paths:
- "ios/**"
- ".github/workflows/ios.yml"
pull_request:
paths:
- "ios/**"
- ".github/workflows/ios.yml"

permissions:
contents: read

concurrency:
group: ios-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
ios-build:
name: iOS build
# macos-26 (arm64) is the newest GA macOS image. The xcode-27 image is still a preview.
runs-on: macos-26
timeout-minutes: 30
env:
# Newest stable Xcode on macos-26 per images/macos/macos-26-arm64-Readme.md (image 20260907).
DEVELOPER_DIR: /Applications/Xcode_26.6.app/Contents/Developer
# Homebrew only installs the latest XcodeGen. The release zip gives an exact version,
# and the drift check below needs the same version the committed project was made with.
XCODEGEN_VERSION: 2.46.0
XCODEGEN_SHA256: 4d9e34b62172d645eed6457cac13fc222569974098ef4ee9c3368bedf0196806
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Show toolchain
run: |
if [ ! -d "$DEVELOPER_DIR" ]; then
echo "::error::$DEVELOPER_DIR is not on this runner image. Pick an installed Xcode from the image readme."
exit 1
fi
xcodebuild -version
swift --version

- name: Install XcodeGen
run: |
curl -fsSL -o "$RUNNER_TEMP/xcodegen.zip" \
"https://github.com/yonaskolb/XcodeGen/releases/download/${XCODEGEN_VERSION}/xcodegen.zip"
echo "${XCODEGEN_SHA256} $RUNNER_TEMP/xcodegen.zip" | shasum -a 256 -c -
unzip -q "$RUNNER_TEMP/xcodegen.zip" -d "$RUNNER_TEMP"
echo "$RUNNER_TEMP/xcodegen/bin" >> "$GITHUB_PATH"

- name: Generate project
run: xcodegen generate --spec ios/project.yml

- name: Check committed project matches project.yml
run: |
if ! git diff --exit-code -- ios; then
echo "::error::HouseScan.xcodeproj differs from project.yml. Run 'make ios-project' (XcodeGen ${XCODEGEN_VERSION}) and commit the result."
exit 1
fi
untracked="$(git ls-files --others --exclude-standard -- ios)"
if [ -n "$untracked" ]; then
echo "::error::xcodegen generate wrote files that are not committed. Run 'make ios-project' and commit them."
echo "$untracked"
exit 1
fi

- name: Build
run: >-
xcodebuild -project ios/HouseScan.xcodeproj -scheme HouseScan -configuration Debug
-destination "generic/platform=iOS" CODE_SIGNING_ALLOWED=NO build
74 changes: 74 additions & 0 deletions .github/workflows/labels.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
name: Labels

on:
push:
branches: [main]
paths:
- .github/labels.json
- .github/workflows/labels.yml
workflow_dispatch:

permissions:
contents: read
issues: write

concurrency:
group: labels

jobs:
sync:
name: Sync label definitions
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Create or update every label in the manifest
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const manifest = require(`${process.env.GITHUB_WORKSPACE}/.github/labels.json`);
const { owner, repo } = context.repo;
const tooLong = manifest.filter((label) => (label.description ?? "").length > 100);
const badColor = manifest.filter((label) => !/^[0-9a-fA-F]{6}$/.test(label.color ?? ""));
const names = manifest.map((label) => label.name);
const duplicates = names.filter((name, index) => names.indexOf(name) !== index);
const nameSet = new Set(names);
const badRename = manifest.filter((label) => label.renamedFrom && nameSet.has(label.renamedFrom));
if (tooLong.length || badColor.length || duplicates.length || badRename.length) {
core.setFailed(
[
tooLong.length ? `Description over 100 chars: ${tooLong.map((label) => label.name).join(", ")}` : "",
badColor.length ? `Color is not 6 hex digits: ${badColor.map((label) => label.name).join(", ")}` : "",
duplicates.length ? `Duplicate name: ${[...new Set(duplicates)].join(", ")}` : "",
badRename.length ? `renamedFrom points at a live name: ${badRename.map((label) => label.name).join(", ")}` : "",
].filter(Boolean).join("; "),
);
return;
}

const getLabel = async (name) => {
try {
return (await github.rest.issues.getLabel({ owner, repo, name })).data;
} catch (error) {
if (error.status !== 404) throw error;
return null;
}
};

for (const label of manifest) {
const existing = await getLabel(label.name);
if (!existing && label.renamedFrom) {
const previous = await getLabel(label.renamedFrom);
if (previous) {
await github.rest.issues.updateLabel({
owner, repo, name: label.renamedFrom, new_name: label.name,
color: label.color, description: label.description,
});
continue;
}
}
if (!existing) {
await github.rest.issues.createLabel({ owner, repo, name: label.name, color: label.color, description: label.description });
} else if (existing.color.toLowerCase() !== label.color.toLowerCase() || (existing.description ?? "") !== label.description) {
await github.rest.issues.updateLabel({ owner, repo, name: label.name, color: label.color, description: label.description });
}
}
143 changes: 143 additions & 0 deletions .github/workflows/pr-size.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
name: PR Size

on:
pull_request_target:
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]

permissions:
contents: read

jobs:
label:
name: Label PR size
runs-on: ubuntu-24.04
permissions:
contents: read
issues: read
pull-requests: write
concurrency:
group: pr-size-${{ github.event.pull_request.number }}
cancel-in-progress: true
steps:
# This workflow runs with a write token. It reads the base branch and fetches
# the PR head only as Git data. Do not run PR scripts or install dependencies here.
- name: Checkout base repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Sync PR size label
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { execFileSync } = require("node:child_process");

const issueNumber = context.payload.pull_request.number;
const baseSha = context.payload.pull_request.base.sha;
const headSha = context.payload.pull_request.head.sha;
const headTrackingRef = `refs/remotes/pr-size/${issueNumber}`;
const manifest = require(`${process.env.GITHUB_WORKSPACE}/.github/labels.json`);
const managedLabelNames = new Set(
manifest.map((label) => label.name).filter((name) => name.startsWith("size:")),
);
// Test layouts here: Swift (Tests/, *Tests.swift), TypeScript (*.test.*), Python (tests/, test_*.py).
const testExcludePathspecs = [
":(glob,exclude)**/Tests/**",
":(glob,exclude)**/*Tests.swift",
":(glob,exclude)**/__tests__/**",
":(glob,exclude)**/test/**",
":(glob,exclude)**/tests/**",
":(glob,exclude)**/*.test.*",
":(glob,exclude)**/*.spec.*",
":(glob,exclude)**/test_*.py",
":(glob,exclude)**/*_test.py",
];

const sumNumstat = (text) =>
text
.split("\n")
.filter(Boolean)
.reduce((total, line) => {
const [insertionsRaw = "0", deletionsRaw = "0"] = line.split("\t");
const additions = insertionsRaw === "-" ? 0 : Number.parseInt(insertionsRaw, 10) || 0;
const deletions = deletionsRaw === "-" ? 0 : Number.parseInt(deletionsRaw, 10) || 0;
return total + additions + deletions;
}, 0);

const resolveSizeLabel = (totalChangedLines) => {
if (totalChangedLines < 10) return "size:XS";
if (totalChangedLines < 30) return "size:S";
if (totalChangedLines < 100) return "size:M";
if (totalChangedLines < 500) return "size:L";
if (totalChangedLines < 1000) return "size:XL";
return "size:XXL";
};

execFileSync("git", ["fetch", "--no-tags", "origin", baseSha], { stdio: "inherit" });
execFileSync(
"git",
["fetch", "--no-tags", "origin", `+refs/pull/${issueNumber}/head:${headTrackingRef}`],
{ stdio: "inherit" },
);

const resolvedHeadSha = execFileSync("git", ["rev-parse", headTrackingRef], {
encoding: "utf8",
}).trim();
if (resolvedHeadSha !== headSha) {
core.warning(`Fetched head SHA ${resolvedHeadSha} does not match pull request head SHA ${headSha}.`);
}

execFileSync("git", ["cat-file", "-e", `${baseSha}^{commit}`], { stdio: "inherit" });
const diffArgs = ["diff", "--numstat", "--ignore-all-space", "--ignore-blank-lines", `${baseSha}...${resolvedHeadSha}`];
const totalChangedLines = sumNumstat(execFileSync("git", diffArgs, { encoding: "utf8" }));
const nonTestChangedLines = sumNumstat(
execFileSync("git", [...diffArgs, "--", ".", ...testExcludePathspecs], { encoding: "utf8" }),
);
const testChangedLines = Math.max(0, totalChangedLines - nonTestChangedLines);
const changedLines = nonTestChangedLines === 0 ? testChangedLines : nonTestChangedLines;
const nextLabelName = resolveSizeLabel(changedLines);

const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
per_page: 100,
});
const definedLabels = await github.paginate(github.rest.issues.listLabelsForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
if (!definedLabels.some((label) => label.name === nextLabelName)) {
core.setFailed(`${nextLabelName} is not defined. Run the Labels workflow to sync .github/labels.json.`);
return;
}

for (const label of currentLabels) {
if (!managedLabelNames.has(label.name) || label.name === nextLabelName) continue;
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
name: label.name,
});
} catch (error) {
if (error.status !== 404) throw error;
}
}
if (!currentLabels.some((label) => label.name === nextLabelName)) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: [nextLabelName],
});
}

const classification =
nonTestChangedLines === 0
? testChangedLines > 0 ? "test-only PR" : "no line changes"
: testChangedLines > 0 ? "test lines excluded" : "all non-test changes";
core.info(
`PR #${issueNumber}: ${nonTestChangedLines} non-test lines, ${testChangedLines} test lines, ${changedLines} effective lines -> ${nextLabelName} (${classification})`,
);
Loading