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
63 changes: 63 additions & 0 deletions .github/workflows/binary_interface.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: Binary Interface

on:
workflow_dispatch:
workflow_call:
inputs:
ref:
required: true
type: string
outputs:
verified-sha:
value: ${{ jobs.verify.outputs.sha }}

permissions:
contents: read
id-token: write

jobs:
verify:
name: Build the binary package Example
runs-on: [self-hosted, macos-15, xcode-26.6]
outputs:
sha: ${{ steps.checkout.outputs.commit }}
env:
GH_TOKEN: ${{ github.token }}
OPENSWIFTUI_USE_LOCAL_DEPS: 1
OPENSWIFTUI_OPENATTRIBUTESHIMS_ATTRIBUTEGRAPH: 0
OPENSWIFTUI_OPENATTRIBUTESHIMS_COMPUTE: 1
OPENSWIFTUI_OPENATTRIBUTESHIMS_COMPUTE_BINARY: 0
steps:
- uses: actions/checkout@v4
id: checkout
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
- uses: OpenSwiftUIProject/setup-xcode@v2
with:
xcode-version: '26.6'
- uses: jdx/mise-action@v2
with:
install: false
cache: false
- name: Set up dependencies
run: Scripts/CI/darwin_setup_build.sh
- name: Set up Tuist
run: |
source Scripts/Tuist/common.sh
tuist_use_mise_environment compute
tuist_ci_setup
- name: Build and verify local binary interfaces
env:
PROJECT_BUILD_DIR: ${{ runner.temp }}/binary-interface
OPENSWIFTUI_BINARY_PACKAGE_PATH: ${{ runner.temp }}/OpenSwiftUI-spm
run: Scripts/CI/check_binary_interface.sh
- name: Upload verification results
if: failure()
uses: actions/upload-artifact@v7
with:
name: binary-interface-results
path: |
${{ runner.temp }}/binary-interface/interface-check.*/setup.log
${{ runner.temp }}/binary-interface/interface-check.*/build.log
${{ runner.temp }}/binary-interface/interface-check.*/Example.xcresult
8 changes: 7 additions & 1 deletion .github/workflows/release_checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,16 @@ jobs:
ref: ${{ needs.prepare.outputs.sha }}
backend: all

binary-interface:
needs: prepare
uses: ./.github/workflows/binary_interface.yml
with:
ref: ${{ needs.prepare.outputs.sha }}

verify:
name: Verify all required checks
if: always()
needs: [prepare, macos, ios, visionos, ubuntu, ui, interaction, compatibility, stdout]
needs: [prepare, macos, ios, visionos, ubuntu, ui, interaction, compatibility, stdout, binary-interface]
runs-on: ubuntu-latest
outputs:
sha: ${{ steps.verify.outputs.sha }}
Expand Down
29 changes: 29 additions & 0 deletions Docs/Release.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ By default, the release gate requires all of these checks:
| interaction tests | iOS and macOS; SwiftUI, then OpenSwiftUI with the OpenSwiftUI renderer and Compute (IAG) |
| Compatibility tests | iOS and macOS |
| Stdout Renderer | AttributeGraph and Compute on macOS |
| Binary Interface | Build iOS Simulator arm64 XCFrameworks and compile the binary package Example from textual interfaces |

Each workflow returns its checked-out commit SHA. In the default mode, a
failed, cancelled, skipped, or missing check cannot pass the gate. Snapshot checks
Expand Down Expand Up @@ -192,6 +193,34 @@ Binary Integration section. A manual theme still applies; otherwise the current
title stays unchanged, which can leave a new release with only its version.
Invalid manual themes fail validation instead of silently falling back.

## Check Binary Interfaces Locally

Run the same consumer check used by the release gate:

```shell
Scripts/CI/check_binary_interface.sh
```

This builds all seven release XCFrameworks for iOS Simulator arm64 with Compute,
then builds the `OpenSwiftUI-spm/Example` app. It does not boot a simulator.
The consumer prefers textual interfaces, even with the same compiler as the
producer, and uses fresh DerivedData to avoid cached modules hiding errors.

Before packaging and final signing, the build normalizes duplicate platform
introductions in all public, private, and package Swift interfaces. For each
platform, it keeps the highest version in a short-form `@available` attribute.
Long-form attributes, including `unavailable` and `deprecated`, remain unchanged.
This preserves source availability macros while working around
[swiftlang/swift#93415](https://github.com/swiftlang/swift/issues/93415).

The default output directory is `../build`. Set `PROJECT_BUILD_DIR` to change it.
The script uses `../OpenSwiftUI-spm`, or clones it with `gh` when absent. Set
`OPENSWIFTUI_BINARY_PACKAGE_PATH` to use another checkout. The script passes
`OPENSWIFTUI_LOCAL_XCFRAMEWORKS_PATH` to the package without editing its manifest,
and rejects missing local frameworks instead of downloading published binaries.
Use `--skip-build` to verify existing output. Logs and the Example result bundle
are retained in a new `interface-check.*` directory under the build directory.

## Skip Pre-release Checks

Set `skip_checks=true` to bypass all pre-release test workflows for one
Expand Down
124 changes: 124 additions & 0 deletions Scripts/CI/Tests/availability-interface-tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
const assert = require("node:assert/strict");
const { spawnSync } = require("node:child_process");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { normalizeText, normalizeDirectory } = require("../../normalize_swiftinterface.js");

const root = path.resolve(__dirname, "../../..");

test("duplicate platform versions use the strictest numeric version in either order", () => {
for (const versions of ["macOS 10.9, macOS 10.15.1", "macOS 10.15.1, macOS 10.9"]) {
const source = ` @available(iOS 13.0, ${versions}, iOS 15.0, visionOS 1.0, *)\n`;
const expected = " @available(iOS 15.0, macOS 10.15.1, visionOS 1.0, *)\n";
assert.equal(normalizeText(source), expected);
assert.equal(normalizeText(expected), expected);
}
assert.equal(normalizeText("@available(iOS 13, iOS 13.0, *)"), "@available(iOS 13, *)");
});

test("complex attributes, comments, and strings remain unchanged", () => {
const source = [
'@available(iOS, introduced: 13.0, deprecated: 15.0, message: "Use another API")',
'@available(macOS, unavailable)',
'@available(iOS 13.0, iOS 15.0, *, deprecated)',
'@available(*, deprecated, renamed: "replacement()")',
'@available(swift 6.0)',
'@available(iOS 13.0, macOS 10.15, *)',
'// @available(iOS 13.0, iOS 15.0, *)',
'/* Outer /* Nested */ @available(iOS 13.0, iOS 15.0, *) */',
'public let text = #"""',
'@available(iOS 13.0, iOS 15.0, *)',
'"""#',
'public let escaped = "\\\"@available(iOS 13.0, iOS 15.0, *)"',
'public let interpolated = "\\(value("@available(iOS 13.0, iOS 15.0, *)"))"',
].join("\n");
assert.equal(normalizeText(source), source);
assert.equal(normalizeText(source + '\n@available(iOS 13.0, iOS 15.0, *)'),
source + '\n@available(iOS 15.0, *)');
});

test("normalizes all interface variants without touching binary modules", async t => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "interface files "));
t.after(() => fs.rm(directory, { recursive: true, force: true }));
const modules = path.join(directory, "Probe.swiftmodule");
await fs.mkdir(modules);
const source = "@available(iOS 13.0, iOS 15.0, *)\n";
for (const suffix of ["swiftinterface", "private.swiftinterface", "package.swiftinterface", "swiftmodule"]) {
await fs.writeFile(path.join(modules, `arm64.${suffix}`), source);
}
// macOS frameworks expose Modules through a Versions/Current symlink.
const current = path.join(directory, "Current");
await fs.symlink(modules, current);
assert.equal(normalizeDirectory(current), 3);
for (const suffix of ["swiftinterface", "private.swiftinterface", "package.swiftinterface"]) {
assert.equal(await fs.readFile(path.join(modules, `arm64.${suffix}`), "utf8"), "@available(iOS 15.0, *)\n");
}
assert.equal(await fs.readFile(path.join(modules, "arm64.swiftmodule"), "utf8"), source);
assert.equal(normalizeDirectory(directory), 0);
assert.throws(() => normalizeDirectory(path.join(directory, "missing")), /ENOENT/);
});

test("normalized source availability configurations can be loaded as interfaces", {
skip: process.platform !== "darwin",
}, async t => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "availability-interface-"));
t.after(() => fs.rm(directory, { recursive: true, force: true }));
const blocks = new Set();
for (const file of [
"Sources/OpenSwiftUICore/Data/EnvironmentKeys/ControlSize.swift",
"Sources/OpenSwiftUI/Event/Focus/DefaultFocus.swift",
]) {
const source = await fs.readFile(path.join(root, file), "utf8");
for (const match of source.matchAll(/(?:^[ \t]*@available\([^\n]+\)\n)+/gm)) blocks.add(match[0].trim());
}
assert.ok(blocks.size > 0);
const fixture = path.join(directory, "Probe.swift");
await fs.writeFile(fixture, [...blocks].map((block, index) =>
`${block}\npublic struct Probe${index} {}\n`).join("\n") +
"\n@available(macOS 11.0, *)\n@available(macOS 15.0, *)\npublic func api() {}\n");
const manifest = await fs.readFile(path.join(root, "Package.swift"), "utf8");
const minimum = manifest.match(/let minimumVersion = "([^"]+)"/)[1];
const definitions = [...manifest.matchAll(/AvailabilityMacro=(\w+):\\\(ignoreAvailability \? minimumVersion : "([^"]+)"\)/g)];
assert.ok(definitions.length > 0);
const configurations = {
package: definitions.map(([, name]) => `${name}:${minimum}`),
compatibility: definitions.map(([, name, versions]) => `${name}:${versions}`),
};
for (const name of ["OpenSwiftUI", "SwiftUI"]) {
const config = await fs.readFile(path.join(root, `Configurations/Shared/basic/${name}.xcconfig`), "utf8");
configurations[name] = [...config.matchAll(/"AvailabilityMacro=([^"]+)"/g)].map(match => match[1]);
}
for (const [name, macros] of Object.entries(configurations)) {
await t.test(name, async () => {
const output = path.join(directory, name);
await fs.mkdir(output);
const base = ["swiftc", "-swift-version", "6", "-target", "arm64-apple-macosx11.0",
"-module-cache-path", path.join(output, "cache")];
const result = spawnSync("xcrun", [...base, fixture, "-emit-module", "-enable-library-evolution",
"-no-verify-emitted-module-interface", "-module-name", "Probe", "-package-name", "ProbePackage",
"-emit-module-path", path.join(output, "Probe.swiftmodule"),
"-emit-module-interface-path", path.join(output, "Probe.swiftinterface"),
"-emit-package-module-interface-path", path.join(output, "Probe.package.swiftinterface"),
...macros.flatMap(macro => ["-enable-experimental-feature", `AvailabilityMacro=${macro}`]),
], { encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
normalizeDirectory(output);
// Import each variant on its own so serialized modules and other variants cannot hide errors.
for (const variant of ["swiftinterface", "private.swiftinterface", "package.swiftinterface"]) {
const load = path.join(output, variant);
await fs.mkdir(load);
await fs.copyFile(path.join(output, `Probe.${variant}`), path.join(load, "Probe.swiftinterface"));
const client = path.join(load, "Client.swift");
for (const version of ["13.0", "15.0"]) {
await fs.writeFile(client, `import Probe\n@available(macOS ${version}, *)\nfunc caller() { api() }\n`);
const check = spawnSync("xcrun", [...base, "-typecheck", client, "-I", load,
"-Xfrontend", "-module-load-mode", "-Xfrontend", "prefer-interface"], { encoding: "utf8" });
assert.equal(check.status, version === "13.0" ? 1 : 0, check.stderr);
if (version === "13.0") assert.match(check.stderr, /'api\(\)' is only available in macOS 15.0 or newer/);
}
}
});
}
});
71 changes: 71 additions & 0 deletions Scripts/CI/Tests/binary-interface-tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
const assert = require("node:assert/strict");
const { spawnSync } = require("node:child_process");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");

const frameworks = require("../release.js").frameworks;

async function fixture(t, { missing, buildStatus = 0, consumerStatus = 0 } = {}) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "binary interface "));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const source = path.join(root, "OpenSwiftUI");
const output = path.join(root, "build");
const binary = path.join(root, "OpenSwiftUI-spm");
await fs.mkdir(path.join(source, "Scripts/CI"), { recursive: true });
await fs.mkdir(path.join(binary, "Example"), { recursive: true });
await fs.mkdir(path.join(root, "bin"));
await fs.copyFile(path.join(__dirname, "../check_binary_interface.sh"), path.join(source, "Scripts/CI/check_binary_interface.sh"));
await fs.writeFile(path.join(source, "Scripts/build_xcframework.sh"), `#!/bin/bash
printf '%s\\n' "$@" > "$TEST_ROOT/build-args"
exit ${buildStatus}
`, { mode: 0o755 });
await fs.writeFile(path.join(binary, "Example/setup.sh"), "#!/bin/bash\nexit 0\n", { mode: 0o755 });
await fs.writeFile(path.join(root, "bin/xcodebuild"), `#!/bin/bash
printf '%s\\n' "$@" > "$TEST_ROOT/consumer-args"
printf '%s' "$OPENSWIFTUI_LOCAL_XCFRAMEWORKS_PATH" > "$TEST_ROOT/local-path"
exit ${consumerStatus}
`, { mode: 0o755 });
for (const name of frameworks.filter(name => name !== missing)) {
await fs.mkdir(path.join(output, `${name}.xcframework`), { recursive: true });
}
return {
root, output,
run: (...args) => spawnSync("bash", [path.join(source, "Scripts/CI/check_binary_interface.sh"), ...args], {
encoding: "utf8", env: { ...process.env, PATH: `${root}/bin:${process.env.PATH}`, TEST_ROOT: root,
PROJECT_BUILD_DIR: output, OPENSWIFTUI_BINARY_PACKAGE_PATH: binary },
}),
};
}

test("builds one simulator architecture and loads the local textual interfaces", async t => {
const f = await fixture(t);
const result = f.run();
assert.equal(result.status, 0, result.stderr);
const build = await fs.readFile(path.join(f.root, "build-args"), "utf8");
assert.match(build, /--sdk\niphonesimulator\n--archs\narm64/);
const consumer = await fs.readFile(path.join(f.root, "consumer-args"), "utf8");
assert.match(consumer, /generic\/platform=iOS Simulator/);
assert.match(consumer, /-module-load-mode.*prefer-interface/);
assert.equal(await fs.readFile(path.join(f.root, "local-path"), "utf8"), await fs.realpath(f.output));
});

test("a missing local framework fails before the package can download a release", async t => {
const f = await fixture(t, { missing: "OpenSwiftUICore" });
const result = f.run("--skip-build");
assert.notEqual(result.status, 0);
assert.match(result.stderr, /OpenSwiftUICore.xcframework/);
await assert.rejects(fs.stat(path.join(f.root, "consumer-args")), { code: "ENOENT" });
});

test("a packaging failure cannot pass the check", async t => {
const f = await fixture(t, { buildStatus: 42 });
assert.equal(f.run().status, 42);
await assert.rejects(fs.stat(path.join(f.root, "consumer-args")), { code: "ENOENT" });
});

test("a consumer compile failure cannot pass the check", async t => {
const f = await fixture(t, { consumerStatus: 65 });
assert.equal(f.run("--skip-build").status, 65);
});
2 changes: 1 addition & 1 deletion Scripts/CI/Tests/workflow-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ async function script(step, env, context = {}, github = {}, observations = {}) {

test("pre-release covers every check family at one SHA and all optional configurations", () => {
const { jobs } = workflows.release_checks;
assert.deepEqual(release.requiredChecks, ["macos", "ios", "visionos", "ubuntu", "ui", "interaction", "compatibility", "stdout"]);
assert.deepEqual(release.requiredChecks, ["macos", "ios", "visionos", "ubuntu", "ui", "interaction", "compatibility", "stdout", "binary-interface"]);
assert.deepEqual(jobs.verify.needs, ["prepare", ...release.requiredChecks]);
assert.equal(jobs.verify.if, "always()");
for (const name of release.requiredChecks) {
Expand Down
Loading
Loading