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
77 changes: 77 additions & 0 deletions .github/scripts/ci-native-swift.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/**
* @file Routing contract for the native Swift coverage in the broad CI lane.
*
* The Swift packages of @astryxdesign/native build only on macOS. They join
* the `test` owner (AST-030 FR11), but a macOS runner is requested only when
* a Linux job finds a Swift change under packages/native, so no other pull
* request waits for one.
*/

import fs from 'node:fs';
import path from 'node:path';

import {describe, expect, it} from 'vitest';
import yaml from 'yaml';

const root = path.resolve(import.meta.dirname, '../..');
const ci = yaml.parse(
fs.readFileSync(path.join(root, '.github/workflows/ci.yml'), 'utf8'),
);
const scope = ci.jobs['native-swift-scope'];
const swift = ci.jobs['test-native-swift'];
const step = (job, name) =>
job.steps.find(candidate => candidate.name === name);

describe('native Swift routing', () => {
it('is joined by test', () => {
expect(ci.jobs.test.needs).toContain('test-native-swift');
expect(
step(ci.jobs.test, 'Require expected test-owner outcomes').env,
).toMatchObject({
TEST_NATIVE_SWIFT_RESULT: '${{ needs.test-native-swift.result }}',
});
});

it('decides scope on Linux and asks for macOS only when Swift changed', () => {
expect(scope['runs-on']).toBe('ubuntu-slim');
expect(swift.needs).toEqual(['check-scope', 'native-swift-scope']);
expect(swift['runs-on']).toBe(
"${{ needs.native-swift-scope.outputs.applies == 'true' && 'macos-15' || 'ubuntu-slim' }}",
);
for (const candidate of swift.steps) {
if (candidate.uses || candidate.name?.startsWith('Build and test')) {
expect(candidate.if, candidate.name ?? candidate.uses).toBe(
"needs.native-swift-scope.outputs.applies == 'true'",
);
}
}
});

it('fails closed when the scope cannot be decided', () => {
expect(step(scope, 'Require successful scope classification').if).toBe(
"needs.check-scope.result != 'success'",
);
expect(step(swift, 'Require a decided Swift scope').if).toBe(
"needs.native-swift-scope.result != 'success'",
);
const decide = step(scope, 'Decide whether native Swift changed').run;
expect(decide).toContain(
'git show "$BASE_SHA:.github/scripts/pr-scope-touches.sh"',
);
// Only "out of scope" (exit 1) skips; unknown (2) runs the Swift work.
expect(decide).toContain('if [ "$scope" = 1 ]; then');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This pins the comparison, but not the default scope=2 (ci.yml:769) or the pattern (ci.yml:775): changing the default to scope=1, or the pattern to one that cannot match a Swift path, leaves the whole .github/scripts suite green while native Swift changes would skip the build. Asserting both, or running the step against a few fixture paths (Swift source, Package.swift, a new swift-ios-<n>/ package, a moved-out .swift), would make the fail-closed promise mutation-sensitive.

});

it('skips the Swift work on docsite-only and tooling-only changes', () => {
const decide = step(scope, 'Decide whether native Swift changed');
expect(decide.env).toMatchObject({
DOCSITE_ONLY: '${{ needs.check-scope.outputs.docsite_only }}',
TOOLING_ONLY: '${{ needs.check-scope.outputs.tooling_only }}',
});
expect(decide.run).toContain(
'if [ "$DOCSITE_ONLY" = true ] || [ "$TOOLING_ONLY" = true ]; then',
);
});
});
1 change: 1 addition & 0 deletions .github/scripts/ci-test-join.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const requiredSuccess = [
['test-ui', process.env.TEST_UI_RESULT],
['test-node', process.env.TEST_NODE_RESULT],
['registry-contract', process.env.REGISTRY_CONTRACT_RESULT],
['test-native-swift', process.env.TEST_NATIVE_SWIFT_RESULT],
];

function fail(message) {
Expand Down
3 changes: 3 additions & 0 deletions .github/scripts/ci-test-join.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const broad = {
TEST_NODE_RESULT: 'success',
TEST_BUILD_RESULT: 'success',
REGISTRY_CONTRACT_RESULT: 'success',
TEST_NATIVE_SWIFT_RESULT: 'success',
};

function run(overrides = {}) {
Expand All @@ -43,6 +44,8 @@ describe('pull-request test join', () => {
['TEST_NODE_RESULT', 'cancelled'],
['TEST_BUILD_RESULT', 'skipped'],
['REGISTRY_CONTRACT_RESULT', 'failure'],
['TEST_NATIVE_SWIFT_RESULT', 'failure'],
['TEST_NATIVE_SWIFT_RESULT', 'skipped'],
]) {
expect(run({[name]: value}), name).toMatchObject({status: 1});
}
Expand Down
2 changes: 2 additions & 0 deletions .github/scripts/ci-tooling-routing.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ describe('Node-tooling CI routing', () => {
'test-node',
'test-build',
'registry-contract',
'test-native-swift',
]);
expect(ci.jobs.build.needs).toEqual([
'build-storybook',
Expand All @@ -193,6 +194,7 @@ describe('Node-tooling CI routing', () => {
TEST_NODE_RESULT: '${{ needs.test-node.result }}',
TEST_BUILD_RESULT: '${{ needs.test-build.result }}',
REGISTRY_CONTRACT_RESULT: '${{ needs.registry-contract.result }}',
TEST_NATIVE_SWIFT_RESULT: '${{ needs.test-native-swift.result }}',
});
const buildJoin = step(
ci.jobs.build,
Expand Down
124 changes: 123 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,119 @@ jobs:
if: matrix.shard == 1 && needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.spec_only != 'true' && needs.check-scope.outputs.tooling_only != 'true'
run: node .github/scripts/theme-family-cascade.js

# Native Swift: the Swift packages of the experimental, canary-only
# @astryxdesign/native (AST-082) build and test only on macOS. They are
# coverage inside the shared surface (AST-030 FR11), so `test` joins
# test-native-swift. A Linux job first decides whether the change touches
# Swift under packages/native; only then does test-native-swift take a macOS
# runner, and otherwise it passes on Linux without work.
native-swift-scope:
needs: [check-scope]
if: ${{ github.event_name != 'workflow_dispatch' && always() && !cancelled() && needs.check-scope.outputs.spec_only != 'true' }}
runs-on: ubuntu-slim
permissions:
contents: read
outputs:
applies: ${{ steps.scope.outputs.applies }}
steps:
- name: Require successful scope classification
if: needs.check-scope.result != 'success'
run: |
echo "Change scope could not be classified; refusing to skip required work."
exit 1

- uses: actions/checkout@v7
if: needs.check-scope.outputs.docsite_only != 'true' && needs.check-scope.outputs.tooling_only != 'true'

# The helper comes from the base, since an older head lacks it. Exit 1
# means out of scope; 0 in scope; 2 unknown, so the Swift work runs.
- name: Decide whether native Swift changed
id: scope
env:
DOCSITE_ONLY: ${{ needs.check-scope.outputs.docsite_only }}
TOOLING_ONLY: ${{ needs.check-scope.outputs.tooling_only }}
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
run: |
if [ "$DOCSITE_ONLY" = true ] || [ "$TOOLING_ONLY" = true ]; then
echo 'Specialized lane; no native Swift work.'
echo 'applies=false' >> "$GITHUB_OUTPUT"
exit 0
fi
scope=2
helper="$(mktemp)"
if [ -n "$BASE_SHA" ] \
&& git fetch --quiet --no-tags --depth=1 origin "$BASE_SHA" \
&& git show "$BASE_SHA:.github/scripts/pr-scope-touches.sh" > "$helper"; then
scope=0
bash "$helper" "$BASE_SHA" '^packages/native/.*(\.swift|/Package\.swift)$' || scope=$?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

With ci.yml out of this pattern (the earlier head had it), an edit to the build step below never runs it, so a break first fails the next native Swift pull request. Moving the script into its own file under .github/scripts/ and adding that path here would cover lane edits without putting macos-15 in front of every workflow change.

fi
if [ "$scope" = 1 ]; then
echo 'No native Swift change; nothing to build.'
echo 'applies=false' >> "$GITHUB_OUTPUT"
else
echo 'applies=true' >> "$GITHUB_OUTPUT"
fi

test-native-swift:
needs: [check-scope, native-swift-scope]
if: ${{ github.event_name != 'workflow_dispatch' && always() && !cancelled() && needs.check-scope.outputs.spec_only != 'true' }}
runs-on: ${{ needs.native-swift-scope.outputs.applies == 'true' && 'macos-15' || 'ubuntu-slim' }}
timeout-minutes: 30
permissions:
contents: read
steps:
- name: Require a decided Swift scope
if: needs.native-swift-scope.result != 'success'
run: |
echo "Native Swift scope could not be decided; refusing to skip required work."
exit 1

- name: Skip Swift work outside its scope
if: needs.native-swift-scope.outputs.applies != 'true'
run: echo "No native Swift change in this scope."

- uses: actions/checkout@v7
if: needs.native-swift-scope.outputs.applies == 'true'

# One package per directory that holds a Package.swift, such as apple/
# (generated contracts) and each versioned swift-ios-<n>/. A package that
# supports macOS builds and tests there; every package builds for the iOS
# Simulator, and an iOS-only package runs its tests there. A package
# without a test target only builds.
- name: Build and test each native Swift package
if: needs.native-swift-scope.outputs.applies == 'true'
run: |
set -euo pipefail
xcodebuild -version
packages=$(find packages/native -name Package.swift -not -path '*/.build/*' -not -path '*/node_modules/*' -exec dirname {} \; | sort)
if [ -z "$packages" ]; then
echo 'No Swift package found under packages/native.'
exit 1
fi
for package in $packages; do
echo "::group::$package"
(
cd "$package"
swift package dump-package > "$RUNNER_TEMP/package.json"
read -r macos tests < <(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(any(p["platformName"]=="macos" for p in d.get("platforms",[])) or not d.get("platforms"), any(t["type"]=="test" for t in d["targets"]))' "$RUNNER_TEMP/package.json")
# Xcode names the whole-package scheme <name>-Package when a
# package has several products, and after its product otherwise.
xcodebuild -list -json > "$RUNNER_TEMP/schemes.json"
scheme=$(python3 -c 'import json,sys; s=json.load(open(sys.argv[1]))["workspace"]["schemes"]; print(next((x for x in s if x.endswith("-Package")), s[0]))' "$RUNNER_TEMP/schemes.json")
if [ "$macos" = True ] && [ "$tests" = True ]; then
swift test
elif [ "$macos" = True ]; then
swift build
fi
if [ "$macos" != True ] && [ "$tests" = True ]; then
xcodebuild test -scheme "$scheme" -destination 'platform=iOS Simulator,name=iPhone 16' -quiet
else
xcodebuild build -scheme "$scheme" -destination 'generic/platform=iOS Simulator' -quiet
fi
)
echo '::endgroup::'
done

# Public registry contract. Generation proves schema, dependency, route-lock,
# alias, and release-gating invariants; the final step drives every canonical
# item through the exact pinned ShadCN client and compiles every written source.
Expand Down Expand Up @@ -804,7 +917,15 @@ jobs:
# public registry contract always report or explicitly skip, so this join
# projects their combined result without creating another surface lane.
test:
needs: [check-scope, test-ui, test-node, test-build, registry-contract]
needs:
[
check-scope,
test-ui,
test-node,
test-build,
registry-contract,
test-native-swift,
]
if: ${{ github.event_name != 'workflow_dispatch' && always() && !cancelled() && needs.check-scope.outputs.spec_only != 'true' }}
runs-on: ubuntu-slim
permissions:
Expand All @@ -822,6 +943,7 @@ jobs:
TEST_NODE_RESULT: ${{ needs.test-node.result }}
TEST_BUILD_RESULT: ${{ needs.test-build.result }}
REGISTRY_CONTRACT_RESULT: ${{ needs.registry-contract.result }}
TEST_NATIVE_SWIFT_RESULT: ${{ needs.test-native-swift.result }}
run: node .github/scripts/ci-test-join.mjs

# Build storybook + typecheck + analyze components (parallel with test and
Expand Down
Loading