@invarn/cibuild 2.0.1 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,8 +28,25 @@
28
28
  *
29
29
  * protocol-result.json contains relative paths only: image paths are relative
30
30
  * to the artifacts dir, and error messages are sanitized so absolute runner
31
- * paths (resolved package_path, harness temp dir, compiler diagnostics) are
32
- * rewritten to relative paths or placeholders before they are stored.
31
+ * paths (resolved package_path, harness temp dir, extracted shipped package,
32
+ * compiler diagnostics) are rewritten to relative paths or placeholders
33
+ * before they are stored.
34
+ *
35
+ * The package_source input picks where the SwiftPM package comes from:
36
+ * - "repo" (default): the package lives in the checkout at package_path.
37
+ * Omitting the input generates a byte-identical script to the original
38
+ * step (locked by a snapshot test), and the result document keeps its
39
+ * original { renderer, screens } shape.
40
+ * - "inputs": the caller ships the package as .ci/inputs/package.tar.gz.
41
+ * The archive is extracted into a scratch directory and validated
42
+ * (single package root, Package.swift at its top, bounded extracted
43
+ * size); rendering then proceeds exactly as in repo mode. Validation
44
+ * failures are per-build structured errors (PACKAGE_ARCHIVE_MISSING,
45
+ * PACKAGE_ARCHIVE_INVALID, PACKAGE_MANIFEST_MISSING,
46
+ * PACKAGE_ARCHIVE_TOO_LARGE, PACKAGE_TARGET_UNRESOLVED) recorded as a
47
+ * top-level error object in the result document, distinct from
48
+ * per-screen render errors. With an explicit package_source the result
49
+ * document is { renderer, package_source, error, screens }.
33
50
  */
34
51
  import { BaseStepExecutor } from './base.js';
35
52
  /**
@@ -41,6 +58,21 @@ import { BaseStepExecutor } from './base.js';
41
58
  export const DEFAULT_RENDER_SIZE = '393x852';
42
59
  /** Default display scale (@2x), matching how references are typically exported. */
43
60
  export const DEFAULT_SCALE = 2;
61
+ /** Valid values for the package_source input. */
62
+ export const PACKAGE_SOURCES = ['repo', 'inputs'];
63
+ /** Default package source: the package lives in the repo checkout. */
64
+ export const DEFAULT_PACKAGE_SOURCE = 'repo';
65
+ /** Basename of the shipped package archive inside the run-inputs directory. */
66
+ export const PACKAGE_ARCHIVE_BASENAME = 'package.tar.gz';
67
+ /**
68
+ * Default cap on the extracted size of a shipped package archive (32 MiB),
69
+ * overridable at runtime via UI_FIDELITY_MAX_PACKAGE_BYTES. The archive
70
+ * arrives through a size-limited dispatch channel (256 KiB), and gzip tops
71
+ * out around a 1000:1 ratio, so this bound is a decompression-bomb guard
72
+ * with enormous headroom for legitimate source slices, which are typically
73
+ * well under 1 MiB extracted.
74
+ */
75
+ export const DEFAULT_MAX_EXTRACTED_BYTES = 32 * 1024 * 1024;
44
76
  /**
45
77
  * Parses a "<width>x<height>" render size string (device points).
46
78
  * @throws Error when the value is malformed or non-positive
@@ -555,6 +587,485 @@ try {
555
587
  }
556
588
  process.exit(exitCode);
557
589
  `;
590
+ /**
591
+ * Runtime support for package_source "inputs": locating, extracting, and
592
+ * validating the shipped archive, plus target discovery from its manifest.
593
+ * Inserted into the runtime script only when package_source is explicitly
594
+ * configured. Plain dependency-free CJS; must not contain backticks, "${",
595
+ * or bare $UPPERCASE tokens (the pre-execution validator would parse those
596
+ * as variable references).
597
+ */
598
+ const RENDER_SCRIPT_PACKAGE_STAGE = String.raw `
599
+ // ---- shipped-package stage (package_source: "inputs") ----
600
+ //
601
+ // The caller ships a SwiftPM package as .ci/inputs/package.tar.gz. Before
602
+ // any rendering, the archive is located, extracted into a scratch
603
+ // directory, and validated. Failures here are PER-BUILD structured errors,
604
+ // recorded as the result document's top-level error object -- distinct from
605
+ // per-screen render errors, which keep their v1 semantics. The extracted
606
+ // size is bounded as a decompression-bomb guard.
607
+
608
+ var PACKAGE_ARCHIVE_NAME = 'package.tar.gz';
609
+ var DEFAULT_MAX_EXTRACTED_BYTES = 32 * 1024 * 1024;
610
+ var maxExtractedBytes =
611
+ Number(process.env.UI_FIDELITY_MAX_PACKAGE_BYTES || '') || DEFAULT_MAX_EXTRACTED_BYTES;
612
+
613
+ var buildError = null;
614
+ var activeTarget = CONFIG.target;
615
+ var shippedExtractDir = null;
616
+
617
+ function setBuildError(code, message) {
618
+ buildError = { code: code, message: sanitizeMessage(message) };
619
+ logError(code + ': ' + buildError.message);
620
+ }
621
+
622
+ function runTar(args) {
623
+ var result = cp.spawnSync('tar', args, {
624
+ encoding: 'utf-8',
625
+ maxBuffer: 64 * 1024 * 1024,
626
+ });
627
+ if (result.error) {
628
+ result.status = result.status == null ? 127 : result.status;
629
+ result.stderr =
630
+ (result.stderr || '') + String(result.error.message || result.error);
631
+ }
632
+ return result;
633
+ }
634
+
635
+ function isUnsafeArchiveMember(name) {
636
+ if (name.charAt(0) === '/') return true;
637
+ var segments = name.split('/');
638
+ for (var i = 0; i < segments.length; i++) {
639
+ if (segments[i] === '..') return true;
640
+ }
641
+ return false;
642
+ }
643
+
644
+ function extractedSize(dir) {
645
+ var total = 0;
646
+ var stack = [dir];
647
+ while (stack.length > 0) {
648
+ var current = stack.pop();
649
+ var names = fs.readdirSync(current);
650
+ for (var i = 0; i < names.length; i++) {
651
+ var entryPath = path.join(current, names[i]);
652
+ var stat = fs.lstatSync(entryPath);
653
+ if (stat.isDirectory()) stack.push(entryPath);
654
+ else total += stat.size;
655
+ }
656
+ }
657
+ return total;
658
+ }
659
+
660
+ // Entries that macOS archiving tools add but that say nothing about the
661
+ // package layout are ignored when locating the package root.
662
+ function isJunkEntry(name) {
663
+ return name === '.DS_Store' || name === '__MACOSX' || name.indexOf('._') === 0;
664
+ }
665
+
666
+ function hasManifest(dir) {
667
+ try {
668
+ return fs.statSync(path.join(dir, 'Package.swift')).isFile();
669
+ } catch (statError) {
670
+ return false;
671
+ }
672
+ }
673
+
674
+ // Package-root rule: Package.swift at the extraction root wins; otherwise
675
+ // exactly one top-level directory with Package.swift at its top is the
676
+ // root; anything else is a structured per-build error.
677
+ function findPackageRoot(extractDir) {
678
+ if (hasManifest(extractDir)) return extractDir;
679
+ var entries = fs.readdirSync(extractDir).filter(function (name) {
680
+ return !isJunkEntry(name);
681
+ });
682
+ if (entries.length === 0) {
683
+ setBuildError('PACKAGE_ARCHIVE_INVALID', PACKAGE_ARCHIVE_NAME + ' is empty');
684
+ return null;
685
+ }
686
+ if (entries.length > 1) {
687
+ setBuildError(
688
+ 'PACKAGE_ARCHIVE_INVALID',
689
+ PACKAGE_ARCHIVE_NAME + ' must contain a single package root; found ' +
690
+ entries.length + ' top-level entries (' + entries.sort().join(', ') +
691
+ ') and no Package.swift at the archive root'
692
+ );
693
+ return null;
694
+ }
695
+ var rootPath = path.join(extractDir, entries[0]);
696
+ if (!fs.statSync(rootPath).isDirectory()) {
697
+ setBuildError(
698
+ 'PACKAGE_ARCHIVE_INVALID',
699
+ PACKAGE_ARCHIVE_NAME + ' must contain a package directory; its only ' +
700
+ 'top-level entry (' + entries[0] + ') is not a directory'
701
+ );
702
+ return null;
703
+ }
704
+ if (!hasManifest(rootPath)) {
705
+ setBuildError(
706
+ 'PACKAGE_MANIFEST_MISSING',
707
+ 'no Package.swift at the top of the package root (' + entries[0] + ') in ' +
708
+ PACKAGE_ARCHIVE_NAME
709
+ );
710
+ return null;
711
+ }
712
+ return rootPath;
713
+ }
714
+
715
+ // When no target input is configured, the target is discovered from the
716
+ // shipped package's manifest: the package must declare exactly one library
717
+ // product. The harness depends on the package via .product(name:package:),
718
+ // so library products are the unit of discovery.
719
+ function resolveTargetFromManifest(packageRoot) {
720
+ var dump = runSwift(['package', '--package-path', packageRoot, 'dump-package']);
721
+ if (dump.status !== 0) {
722
+ setBuildError(
723
+ 'PACKAGE_TARGET_UNRESOLVED',
724
+ 'could not read the shipped package manifest (swift package dump-package failed):\n' +
725
+ outputTail(dump)
726
+ );
727
+ return null;
728
+ }
729
+ var manifest = null;
730
+ try {
731
+ manifest = JSON.parse(dump.stdout);
732
+ } catch (parseError) {
733
+ setBuildError(
734
+ 'PACKAGE_TARGET_UNRESOLVED',
735
+ 'swift package dump-package produced unparseable output: ' +
736
+ (parseError && parseError.message ? parseError.message : String(parseError))
737
+ );
738
+ return null;
739
+ }
740
+ var libraries = ((manifest && manifest.products) || [])
741
+ .filter(function (product) {
742
+ return product && product.type && typeof product.type === 'object' &&
743
+ 'library' in product.type;
744
+ })
745
+ .map(function (product) { return String(product.name); });
746
+ if (libraries.length === 0) {
747
+ setBuildError(
748
+ 'PACKAGE_TARGET_UNRESOLVED',
749
+ 'the shipped package declares no library products; declare exactly one ' +
750
+ 'library product, or set the target input'
751
+ );
752
+ return null;
753
+ }
754
+ if (libraries.length > 1) {
755
+ setBuildError(
756
+ 'PACKAGE_TARGET_UNRESOLVED',
757
+ 'the shipped package declares ' + libraries.length + ' library products (' +
758
+ libraries.sort().join(', ') + '); set the target input to pick one'
759
+ );
760
+ return null;
761
+ }
762
+ if (!SWIFT_IDENTIFIER.test(libraries[0])) {
763
+ setBuildError(
764
+ 'PACKAGE_TARGET_UNRESOLVED',
765
+ 'discovered library product ' + JSON.stringify(libraries[0]) +
766
+ ' is not a valid Swift module identifier'
767
+ );
768
+ return null;
769
+ }
770
+ return libraries[0];
771
+ }
772
+
773
+ // Locates, extracts, and validates the shipped archive. Returns the package
774
+ // root to build from, or null after recording a structured build error.
775
+ function prepareShippedPackage() {
776
+ var archivePath = path.join(inputsDir, PACKAGE_ARCHIVE_NAME);
777
+ var archiveStat = null;
778
+ try {
779
+ archiveStat = fs.statSync(archivePath);
780
+ } catch (statError) {
781
+ archiveStat = null;
782
+ }
783
+ if (!archiveStat || !archiveStat.isFile()) {
784
+ setBuildError(
785
+ 'PACKAGE_ARCHIVE_MISSING',
786
+ 'no ' + PACKAGE_ARCHIVE_NAME + ' in ' + inputsDir + '; ship the SwiftPM ' +
787
+ 'package as a run input named ' + PACKAGE_ARCHIVE_NAME
788
+ );
789
+ return null;
790
+ }
791
+
792
+ // bsdtar and GNU tar both refuse absolute and dot-dot member paths by
793
+ // default; the listing is checked explicitly anyway so the guard does not
794
+ // depend on the host tar implementation. A failed listing also catches
795
+ // unreadable or corrupt archives before anything touches the disk.
796
+ var listing = runTar(['-tzf', archivePath]);
797
+ if (listing.status !== 0) {
798
+ setBuildError(
799
+ 'PACKAGE_ARCHIVE_INVALID',
800
+ PACKAGE_ARCHIVE_NAME + ' is not a readable gzipped tar archive:\n' +
801
+ outputTail(listing)
802
+ );
803
+ return null;
804
+ }
805
+ var members = (listing.stdout || '').split('\n').filter(function (name) {
806
+ return name !== '';
807
+ });
808
+ var unsafe = members.filter(isUnsafeArchiveMember);
809
+ if (unsafe.length > 0) {
810
+ setBuildError(
811
+ 'PACKAGE_ARCHIVE_INVALID',
812
+ PACKAGE_ARCHIVE_NAME + ' contains unsafe member paths (absolute or dot-dot): ' +
813
+ unsafe.slice(0, 5).join(', ')
814
+ );
815
+ return null;
816
+ }
817
+
818
+ shippedExtractDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-fidelity-package-'));
819
+ registerPathReplacement(shippedExtractDir, '<package>');
820
+ log('extracting ' + PACKAGE_ARCHIVE_NAME + ' (' + archiveStat.size + ' bytes)');
821
+ var extraction = runTar(['-xzf', archivePath, '-C', shippedExtractDir]);
822
+ if (extraction.status !== 0) {
823
+ setBuildError(
824
+ 'PACKAGE_ARCHIVE_INVALID',
825
+ PACKAGE_ARCHIVE_NAME + ' could not be extracted:\n' + outputTail(extraction)
826
+ );
827
+ return null;
828
+ }
829
+
830
+ var totalBytes = extractedSize(shippedExtractDir);
831
+ if (totalBytes > maxExtractedBytes) {
832
+ setBuildError(
833
+ 'PACKAGE_ARCHIVE_TOO_LARGE',
834
+ PACKAGE_ARCHIVE_NAME + ' extracts to ' + totalBytes + ' bytes, over the ' +
835
+ maxExtractedBytes + '-byte limit; ship a leaner source slice'
836
+ );
837
+ return null;
838
+ }
839
+
840
+ var packageRoot = findPackageRoot(shippedExtractDir);
841
+ if (packageRoot === null) return null;
842
+ var rootLabel = path.relative(shippedExtractDir, packageRoot);
843
+ log('package root: ' + (rootLabel === '' ? '.' : rootLabel));
844
+
845
+ if (activeTarget === null) {
846
+ var discovered = resolveTargetFromManifest(packageRoot);
847
+ if (discovered === null) return null;
848
+ log('discovered library target: ' + discovered);
849
+ activeTarget = discovered;
850
+ }
851
+ return packageRoot;
852
+ }
853
+
854
+ function cleanupShippedPackage() {
855
+ if (shippedExtractDir === null) return;
856
+ if (process.env.UI_FIDELITY_KEEP_HARNESS) {
857
+ log('keeping extracted package (UI_FIDELITY_KEEP_HARNESS set)');
858
+ return;
859
+ }
860
+ try {
861
+ fs.rmSync(shippedExtractDir, { recursive: true, force: true });
862
+ } catch (cleanupError) {
863
+ // best effort
864
+ }
865
+ shippedExtractDir = null;
866
+ }
867
+ `;
868
+ /**
869
+ * Runtime support for asset catalogs (package_source variants only).
870
+ *
871
+ * `swift build` copies a target's .xcassets into its resource bundle but never
872
+ * runs actool, so SwiftUI's Image("name") finds no compiled catalog and the
873
+ * image does not resolve. After the harness builds, compile every .xcassets in
874
+ * the package with actool into the harness bin directory (= the render
875
+ * executable's Bundle.main), so unmodified Image("name") references resolve.
876
+ * One actool invocation over all catalogs (it merges them). Best-effort:
877
+ * a compile failure is logged and rendering continues -- the affected images
878
+ * simply will not appear, which the human comparing the screens will see.
879
+ */
880
+ const RENDER_SCRIPT_ASSET_CATALOG_STAGE = String.raw `
881
+ // ---- asset-catalog stage (package_source variants) ----
882
+
883
+ var ASSET_CATALOG_MIN_MACOS = '13.0';
884
+
885
+ // Recursively collect *.xcassets directories under root, skipping .build and
886
+ // the catalogs' own contents (a catalog is a leaf for this scan).
887
+ function findAssetCatalogs(root) {
888
+ var found = [];
889
+ var stack = [root];
890
+ while (stack.length > 0) {
891
+ var dir = stack.pop();
892
+ var entries;
893
+ try {
894
+ entries = fs.readdirSync(dir, { withFileTypes: true });
895
+ } catch (readError) {
896
+ continue;
897
+ }
898
+ for (var i = 0; i < entries.length; i++) {
899
+ var entry = entries[i];
900
+ if (!entry.isDirectory()) continue;
901
+ if (entry.name === '.build') continue;
902
+ var full = path.join(dir, entry.name);
903
+ if (entry.name.slice(-9) === '.xcassets') {
904
+ found.push(full);
905
+ } else {
906
+ stack.push(full);
907
+ }
908
+ }
909
+ }
910
+ return found.sort();
911
+ }
912
+
913
+ function compileAssetCatalogs(packageDir, harnessDir) {
914
+ var catalogs = findAssetCatalogs(packageDir);
915
+ if (catalogs.length === 0) return;
916
+
917
+ var binResult = runSwift(['build', '--package-path', harnessDir, '--show-bin-path']);
918
+ if (binResult.status !== 0) {
919
+ logError('asset-catalog compile skipped: could not resolve the build bin path');
920
+ return;
921
+ }
922
+ var binPath = String(binResult.stdout || '').trim().split('\n').pop().trim();
923
+ if (!binPath) {
924
+ logError('asset-catalog compile skipped: empty build bin path');
925
+ return;
926
+ }
927
+ try {
928
+ fs.mkdirSync(binPath, { recursive: true });
929
+ } catch (mkdirError) {
930
+ // best effort -- actool reports a missing output dir itself
931
+ }
932
+
933
+ var args = ['actool'].concat(catalogs);
934
+ args.push(
935
+ '--compile', binPath,
936
+ '--platform', 'macosx',
937
+ '--minimum-deployment-target', ASSET_CATALOG_MIN_MACOS,
938
+ '--output-format', 'human-readable-text'
939
+ );
940
+ log('xcrun ' + args.join(' '));
941
+ var result = cp.spawnSync('xcrun', args, {
942
+ encoding: 'utf-8',
943
+ maxBuffer: 16 * 1024 * 1024,
944
+ });
945
+ if (result.stdout) process.stdout.write(result.stdout);
946
+ if (result.stderr) process.stderr.write(result.stderr);
947
+ if (result.error || result.status !== 0) {
948
+ var detail = (result.stderr || '') +
949
+ (result.error ? String(result.error.message || result.error) : '');
950
+ logError(
951
+ 'asset-catalog compile failed (continuing; catalog images may not ' +
952
+ 'resolve): ' + sanitizeMessage(detail.trim())
953
+ );
954
+ return;
955
+ }
956
+ log('compiled ' + catalogs.length + ' asset catalog(s) for macOS');
957
+ }
958
+ `;
959
+ /**
960
+ * Replaces exactly one occurrence of `anchor` in `source`. Throws when the
961
+ * anchor is missing or ambiguous, so any drift between the v1 runtime text
962
+ * and the package-source variant fails loudly at module load.
963
+ */
964
+ function replaceOnce(source, anchor, replacement) {
965
+ const first = source.indexOf(anchor);
966
+ if (first === -1 || source.indexOf(anchor, first + anchor.length) !== -1) {
967
+ throw new Error('ui-fidelity-render: expected exactly one occurrence of anchor: ' + anchor);
968
+ }
969
+ return source.slice(0, first) + replacement + source.slice(first + anchor.length);
970
+ }
971
+ /**
972
+ * Builds the runtime main for explicitly-configured package_source ("repo"
973
+ * or "inputs") by patching the v1 runtime text. RENDER_SCRIPT_MAIN itself is
974
+ * never modified: omitting package_source keeps the generated script
975
+ * byte-identical to v1 (locked by a snapshot test), and the patch points
976
+ * below are the complete, reviewable diff between the two modes.
977
+ */
978
+ function buildPackageSourceMain() {
979
+ let main = RENDER_SCRIPT_MAIN;
980
+ // The result document gains the configured package source and a top-level
981
+ // per-build error slot, distinct from per-screen errors.
982
+ main = replaceOnce(main, 'var doc = { renderer: RENDERER, screens: currentEntries };', 'var doc = {\n' +
983
+ ' renderer: RENDERER,\n' +
984
+ ' package_source: CONFIG.packageSource,\n' +
985
+ ' error: buildError,\n' +
986
+ ' screens: currentEntries,\n' +
987
+ ' };');
988
+ // packagePath is null with package_source "inputs": the package to build
989
+ // from is only known after extraction.
990
+ main = replaceOnce(main, "var resolvedPackagePath = path.resolve(CONFIG.packagePath);\n" +
991
+ 'registerPathReplacement(\n' +
992
+ ' resolvedPackagePath,\n' +
993
+ " path.isAbsolute(CONFIG.packagePath) ? '<package_path>' : CONFIG.packagePath\n" +
994
+ ');', 'var resolvedPackagePath =\n' +
995
+ ' CONFIG.packagePath === null ? null : path.resolve(CONFIG.packagePath);\n' +
996
+ 'if (resolvedPackagePath !== null) {\n' +
997
+ ' registerPathReplacement(\n' +
998
+ ' resolvedPackagePath,\n' +
999
+ " path.isAbsolute(CONFIG.packagePath) ? '<package_path>' : CONFIG.packagePath\n" +
1000
+ ' );\n' +
1001
+ '}');
1002
+ // The harness target may be discovered at runtime from the shipped
1003
+ // manifest, so renderWithHarness reads the resolved activeTarget.
1004
+ main = replaceOnce(main, 'target: CONFIG.target,', 'target: activeTarget,');
1005
+ main = replaceOnce(main, "' a public View with a parameterless init in ' + CONFIG.target + '?):\\n' +", "' a public View with a parameterless init in ' + activeTarget + '?):\\n' +");
1006
+ // The shipped-package stage runs before any harness build. It is reached
1007
+ // even when no screen is renderable, so packaging mistakes are always
1008
+ // reported; its scratch directory is cleaned up afterwards.
1009
+ main = replaceOnce(main, ' // 3. Render the remaining screens through the synthesized harness.\n' +
1010
+ ' var renderable = currentEntries.filter(function (entry) { return entry.error === null; });\n' +
1011
+ ' if (renderable.length > 0) {\n' +
1012
+ ' if (!fs.existsSync(resolvedPackagePath)) {\n' +
1013
+ ' currentEntries.forEach(function (entry) {\n' +
1014
+ " setError(entry, 'RENDER_UNSUPPORTED', 'package_path does not exist: ' + resolvedPackagePath);\n" +
1015
+ ' });\n' +
1016
+ ' } else {\n' +
1017
+ ' renderWithHarness(renderable, resolvedPackagePath);\n' +
1018
+ ' }\n' +
1019
+ ' } else if (currentEntries.length > 0) {\n' +
1020
+ " log('no renderable screens, skipping harness build');\n" +
1021
+ ' }', ' // 3. Resolve the package to build from, then render the remaining\n' +
1022
+ ' // screens through the synthesized harness.\n' +
1023
+ ' var renderable = currentEntries.filter(function (entry) { return entry.error === null; });\n' +
1024
+ " if (CONFIG.packageSource === 'inputs') {\n" +
1025
+ ' try {\n' +
1026
+ ' var shippedRoot = prepareShippedPackage();\n' +
1027
+ ' if (shippedRoot !== null) {\n' +
1028
+ ' if (renderable.length > 0) {\n' +
1029
+ ' renderWithHarness(renderable, shippedRoot);\n' +
1030
+ ' } else if (currentEntries.length > 0) {\n' +
1031
+ " log('no renderable screens, skipping harness build');\n" +
1032
+ ' }\n' +
1033
+ ' }\n' +
1034
+ ' } finally {\n' +
1035
+ ' cleanupShippedPackage();\n' +
1036
+ ' }\n' +
1037
+ ' } else if (renderable.length > 0) {\n' +
1038
+ ' if (!fs.existsSync(resolvedPackagePath)) {\n' +
1039
+ ' currentEntries.forEach(function (entry) {\n' +
1040
+ " setError(entry, 'RENDER_UNSUPPORTED', 'package_path does not exist: ' + resolvedPackagePath);\n" +
1041
+ ' });\n' +
1042
+ ' } else {\n' +
1043
+ ' renderWithHarness(renderable, resolvedPackagePath);\n' +
1044
+ ' }\n' +
1045
+ ' } else if (currentEntries.length > 0) {\n' +
1046
+ " log('no renderable screens, skipping harness build');\n" +
1047
+ ' }');
1048
+ // A per-build package error fails the step even when no screen reached
1049
+ // the harness (including the zero-screens case).
1050
+ main = replaceOnce(main, ' // 4. Exit code: zero only when every screen rendered.\n' +
1051
+ ' writeResult();\n', ' // 4. Exit code: zero only when the shipped package (if any) was valid\n' +
1052
+ ' // and every screen rendered.\n' +
1053
+ ' writeResult();\n' +
1054
+ ' if (buildError !== null) {\n' +
1055
+ " logError('package validation failed: ' + buildError.code);\n" +
1056
+ ' return 1;\n' +
1057
+ ' }\n');
1058
+ // The shipped-package stage is defined ahead of main().
1059
+ main = replaceOnce(main, 'function main() {', RENDER_SCRIPT_PACKAGE_STAGE + '\nfunction main() {');
1060
+ // Asset-catalog stage: define the helpers ahead of renderWithHarness, then
1061
+ // compile any catalogs once the package has proven it builds (after the
1062
+ // probe) and before the per-screen renders that depend on the images.
1063
+ main = replaceOnce(main, 'function renderWithHarness(renderable, packagePath) {', RENDER_SCRIPT_ASSET_CATALOG_STAGE + '\nfunction renderWithHarness(renderable, packagePath) {');
1064
+ main = replaceOnce(main, ' renderable.forEach(function (entry) {', ' compileAssetCatalogs(packagePath, harnessDir);\n\n' +
1065
+ ' renderable.forEach(function (entry) {');
1066
+ return main;
1067
+ }
1068
+ const RENDER_SCRIPT_MAIN_WITH_PACKAGE_SOURCE = buildPackageSourceMain();
558
1069
  /**
559
1070
  * Evaluates the embedded generator source and returns the real functions, so
560
1071
  * unit tests exercise exactly the code that ships inside the runtime script.
@@ -570,6 +1081,10 @@ export function getRenderScriptInternals() {
570
1081
  /**
571
1082
  * Generates the self-contained runtime node script for the step.
572
1083
  * Pure function of its config — exported so tests can execute the script.
1084
+ *
1085
+ * When config.packageSource is absent the output is byte-identical to the
1086
+ * pre-package_source script (JSON.stringify drops undefined keys, and the
1087
+ * unpatched v1 runtime is used).
573
1088
  */
574
1089
  export function generateRenderScript(config) {
575
1090
  return [
@@ -577,7 +1092,9 @@ export function generateRenderScript(config) {
577
1092
  '// ui-fidelity-render runtime script (generated by cibuild at YAML conversion time)',
578
1093
  'var CONFIG = ' + JSON.stringify(config) + ';',
579
1094
  RENDER_SCRIPT_GENERATORS,
580
- RENDER_SCRIPT_MAIN,
1095
+ config.packageSource === undefined
1096
+ ? RENDER_SCRIPT_MAIN
1097
+ : RENDER_SCRIPT_MAIN_WITH_PACKAGE_SOURCE,
581
1098
  ].join('\n');
582
1099
  }
583
1100
  /**
@@ -587,6 +1104,14 @@ export function generateRenderScript(config) {
587
1104
  */
588
1105
  export class UiFidelityRenderStepExecutor extends BaseStepExecutor {
589
1106
  getValidationRequirements(inputs, _env, _config) {
1107
+ if (inputs && inputs.package_source === 'inputs') {
1108
+ // The package ships as .ci/inputs/package.tar.gz at run time, so
1109
+ // neither package_path nor target is required up front.
1110
+ return [
1111
+ this.requireCommand('swift', 'Swift toolchain used to build and run the render harness', 'Install Xcode (or the Swift toolchain) on the runner'),
1112
+ this.requireCommand('tar', 'Archive tool used to extract the shipped package', 'Install tar on the runner'),
1113
+ ];
1114
+ }
590
1115
  return [
591
1116
  this.requireInput('package_path', inputs, 'Path to the SwiftPM package containing the screens'),
592
1117
  this.requireInput('target', inputs, 'SPM library product to import in the render harness'),
@@ -595,15 +1120,43 @@ export class UiFidelityRenderStepExecutor extends BaseStepExecutor {
595
1120
  }
596
1121
  async execute(inputs, _env, _config) {
597
1122
  const stepName = 'ui-fidelity-render';
598
- const packagePath = String(this.getRequiredInput(inputs, 'package_path', stepName));
599
- const target = String(this.getRequiredInput(inputs, 'target', stepName));
600
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(target)) {
1123
+ // Omitted package_source means v1 repo behavior with a byte-identical
1124
+ // generated script; an explicit value (repo or inputs) is recorded in
1125
+ // the script config and the result document.
1126
+ const rawPackageSource = this.getInput(inputs, 'package_source', undefined);
1127
+ let packageSource;
1128
+ if (rawPackageSource !== undefined) {
1129
+ const value = String(rawPackageSource);
1130
+ if (!PACKAGE_SOURCES.includes(value)) {
1131
+ throw new Error(`Invalid package_source '${value}' for step '${stepName}': ` +
1132
+ `expected one of: ${PACKAGE_SOURCES.join(', ')}`);
1133
+ }
1134
+ packageSource = value;
1135
+ }
1136
+ let packagePath = null;
1137
+ let target = null;
1138
+ if (packageSource === 'inputs') {
1139
+ // package_path is ignored: the package arrives as a run input.
1140
+ // target is optional and discovered from the shipped manifest when
1141
+ // the package declares exactly one library product.
1142
+ const configuredTarget = this.getInput(inputs, 'target', undefined);
1143
+ target = configuredTarget === undefined ? null : String(configuredTarget);
1144
+ }
1145
+ else {
1146
+ packagePath = String(this.getRequiredInput(inputs, 'package_path', stepName));
1147
+ target = String(this.getRequiredInput(inputs, 'target', stepName));
1148
+ }
1149
+ if (target !== null && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(target)) {
601
1150
  throw new Error(`Invalid target '${target}' for step '${stepName}': ` +
602
1151
  'must be a Swift module identifier (letters, digits, underscores)');
603
1152
  }
604
1153
  const { width, height } = parseRenderSize(String(this.getInput(inputs, 'render_size', DEFAULT_RENDER_SIZE)));
605
1154
  const scale = parseScale(this.getInput(inputs, 'scale', DEFAULT_SCALE));
606
- const script = generateRenderScript({ packagePath, target, width, height, scale });
1155
+ const config = { packagePath, target, width, height, scale };
1156
+ if (packageSource !== undefined) {
1157
+ config.packageSource = packageSource;
1158
+ }
1159
+ const script = generateRenderScript(config);
607
1160
  return this.createNodeStep(script, stepName);
608
1161
  }
609
1162
  }