@invarn/cibuild 2.0.0 → 2.0.2

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,388 @@ 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
+ * Replaces exactly one occurrence of `anchor` in `source`. Throws when the
870
+ * anchor is missing or ambiguous, so any drift between the v1 runtime text
871
+ * and the package-source variant fails loudly at module load.
872
+ */
873
+ function replaceOnce(source, anchor, replacement) {
874
+ const first = source.indexOf(anchor);
875
+ if (first === -1 || source.indexOf(anchor, first + anchor.length) !== -1) {
876
+ throw new Error('ui-fidelity-render: expected exactly one occurrence of anchor: ' + anchor);
877
+ }
878
+ return source.slice(0, first) + replacement + source.slice(first + anchor.length);
879
+ }
880
+ /**
881
+ * Builds the runtime main for explicitly-configured package_source ("repo"
882
+ * or "inputs") by patching the v1 runtime text. RENDER_SCRIPT_MAIN itself is
883
+ * never modified: omitting package_source keeps the generated script
884
+ * byte-identical to v1 (locked by a snapshot test), and the patch points
885
+ * below are the complete, reviewable diff between the two modes.
886
+ */
887
+ function buildPackageSourceMain() {
888
+ let main = RENDER_SCRIPT_MAIN;
889
+ // The result document gains the configured package source and a top-level
890
+ // per-build error slot, distinct from per-screen errors.
891
+ main = replaceOnce(main, 'var doc = { renderer: RENDERER, screens: currentEntries };', 'var doc = {\n' +
892
+ ' renderer: RENDERER,\n' +
893
+ ' package_source: CONFIG.packageSource,\n' +
894
+ ' error: buildError,\n' +
895
+ ' screens: currentEntries,\n' +
896
+ ' };');
897
+ // packagePath is null with package_source "inputs": the package to build
898
+ // from is only known after extraction.
899
+ main = replaceOnce(main, "var resolvedPackagePath = path.resolve(CONFIG.packagePath);\n" +
900
+ 'registerPathReplacement(\n' +
901
+ ' resolvedPackagePath,\n' +
902
+ " path.isAbsolute(CONFIG.packagePath) ? '<package_path>' : CONFIG.packagePath\n" +
903
+ ');', 'var resolvedPackagePath =\n' +
904
+ ' CONFIG.packagePath === null ? null : path.resolve(CONFIG.packagePath);\n' +
905
+ 'if (resolvedPackagePath !== null) {\n' +
906
+ ' registerPathReplacement(\n' +
907
+ ' resolvedPackagePath,\n' +
908
+ " path.isAbsolute(CONFIG.packagePath) ? '<package_path>' : CONFIG.packagePath\n" +
909
+ ' );\n' +
910
+ '}');
911
+ // The harness target may be discovered at runtime from the shipped
912
+ // manifest, so renderWithHarness reads the resolved activeTarget.
913
+ main = replaceOnce(main, 'target: CONFIG.target,', 'target: activeTarget,');
914
+ main = replaceOnce(main, "' a public View with a parameterless init in ' + CONFIG.target + '?):\\n' +", "' a public View with a parameterless init in ' + activeTarget + '?):\\n' +");
915
+ // The shipped-package stage runs before any harness build. It is reached
916
+ // even when no screen is renderable, so packaging mistakes are always
917
+ // reported; its scratch directory is cleaned up afterwards.
918
+ main = replaceOnce(main, ' // 3. Render the remaining screens through the synthesized harness.\n' +
919
+ ' var renderable = currentEntries.filter(function (entry) { return entry.error === null; });\n' +
920
+ ' if (renderable.length > 0) {\n' +
921
+ ' if (!fs.existsSync(resolvedPackagePath)) {\n' +
922
+ ' currentEntries.forEach(function (entry) {\n' +
923
+ " setError(entry, 'RENDER_UNSUPPORTED', 'package_path does not exist: ' + resolvedPackagePath);\n" +
924
+ ' });\n' +
925
+ ' } else {\n' +
926
+ ' renderWithHarness(renderable, resolvedPackagePath);\n' +
927
+ ' }\n' +
928
+ ' } else if (currentEntries.length > 0) {\n' +
929
+ " log('no renderable screens, skipping harness build');\n" +
930
+ ' }', ' // 3. Resolve the package to build from, then render the remaining\n' +
931
+ ' // screens through the synthesized harness.\n' +
932
+ ' var renderable = currentEntries.filter(function (entry) { return entry.error === null; });\n' +
933
+ " if (CONFIG.packageSource === 'inputs') {\n" +
934
+ ' try {\n' +
935
+ ' var shippedRoot = prepareShippedPackage();\n' +
936
+ ' if (shippedRoot !== null) {\n' +
937
+ ' if (renderable.length > 0) {\n' +
938
+ ' renderWithHarness(renderable, shippedRoot);\n' +
939
+ ' } else if (currentEntries.length > 0) {\n' +
940
+ " log('no renderable screens, skipping harness build');\n" +
941
+ ' }\n' +
942
+ ' }\n' +
943
+ ' } finally {\n' +
944
+ ' cleanupShippedPackage();\n' +
945
+ ' }\n' +
946
+ ' } else if (renderable.length > 0) {\n' +
947
+ ' if (!fs.existsSync(resolvedPackagePath)) {\n' +
948
+ ' currentEntries.forEach(function (entry) {\n' +
949
+ " setError(entry, 'RENDER_UNSUPPORTED', 'package_path does not exist: ' + resolvedPackagePath);\n" +
950
+ ' });\n' +
951
+ ' } else {\n' +
952
+ ' renderWithHarness(renderable, resolvedPackagePath);\n' +
953
+ ' }\n' +
954
+ ' } else if (currentEntries.length > 0) {\n' +
955
+ " log('no renderable screens, skipping harness build');\n" +
956
+ ' }');
957
+ // A per-build package error fails the step even when no screen reached
958
+ // the harness (including the zero-screens case).
959
+ main = replaceOnce(main, ' // 4. Exit code: zero only when every screen rendered.\n' +
960
+ ' writeResult();\n', ' // 4. Exit code: zero only when the shipped package (if any) was valid\n' +
961
+ ' // and every screen rendered.\n' +
962
+ ' writeResult();\n' +
963
+ ' if (buildError !== null) {\n' +
964
+ " logError('package validation failed: ' + buildError.code);\n" +
965
+ ' return 1;\n' +
966
+ ' }\n');
967
+ // The shipped-package stage is defined ahead of main().
968
+ main = replaceOnce(main, 'function main() {', RENDER_SCRIPT_PACKAGE_STAGE + '\nfunction main() {');
969
+ return main;
970
+ }
971
+ const RENDER_SCRIPT_MAIN_WITH_PACKAGE_SOURCE = buildPackageSourceMain();
558
972
  /**
559
973
  * Evaluates the embedded generator source and returns the real functions, so
560
974
  * unit tests exercise exactly the code that ships inside the runtime script.
@@ -570,6 +984,10 @@ export function getRenderScriptInternals() {
570
984
  /**
571
985
  * Generates the self-contained runtime node script for the step.
572
986
  * Pure function of its config — exported so tests can execute the script.
987
+ *
988
+ * When config.packageSource is absent the output is byte-identical to the
989
+ * pre-package_source script (JSON.stringify drops undefined keys, and the
990
+ * unpatched v1 runtime is used).
573
991
  */
574
992
  export function generateRenderScript(config) {
575
993
  return [
@@ -577,7 +995,9 @@ export function generateRenderScript(config) {
577
995
  '// ui-fidelity-render runtime script (generated by cibuild at YAML conversion time)',
578
996
  'var CONFIG = ' + JSON.stringify(config) + ';',
579
997
  RENDER_SCRIPT_GENERATORS,
580
- RENDER_SCRIPT_MAIN,
998
+ config.packageSource === undefined
999
+ ? RENDER_SCRIPT_MAIN
1000
+ : RENDER_SCRIPT_MAIN_WITH_PACKAGE_SOURCE,
581
1001
  ].join('\n');
582
1002
  }
583
1003
  /**
@@ -587,6 +1007,14 @@ export function generateRenderScript(config) {
587
1007
  */
588
1008
  export class UiFidelityRenderStepExecutor extends BaseStepExecutor {
589
1009
  getValidationRequirements(inputs, _env, _config) {
1010
+ if (inputs && inputs.package_source === 'inputs') {
1011
+ // The package ships as .ci/inputs/package.tar.gz at run time, so
1012
+ // neither package_path nor target is required up front.
1013
+ return [
1014
+ this.requireCommand('swift', 'Swift toolchain used to build and run the render harness', 'Install Xcode (or the Swift toolchain) on the runner'),
1015
+ this.requireCommand('tar', 'Archive tool used to extract the shipped package', 'Install tar on the runner'),
1016
+ ];
1017
+ }
590
1018
  return [
591
1019
  this.requireInput('package_path', inputs, 'Path to the SwiftPM package containing the screens'),
592
1020
  this.requireInput('target', inputs, 'SPM library product to import in the render harness'),
@@ -595,15 +1023,43 @@ export class UiFidelityRenderStepExecutor extends BaseStepExecutor {
595
1023
  }
596
1024
  async execute(inputs, _env, _config) {
597
1025
  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)) {
1026
+ // Omitted package_source means v1 repo behavior with a byte-identical
1027
+ // generated script; an explicit value (repo or inputs) is recorded in
1028
+ // the script config and the result document.
1029
+ const rawPackageSource = this.getInput(inputs, 'package_source', undefined);
1030
+ let packageSource;
1031
+ if (rawPackageSource !== undefined) {
1032
+ const value = String(rawPackageSource);
1033
+ if (!PACKAGE_SOURCES.includes(value)) {
1034
+ throw new Error(`Invalid package_source '${value}' for step '${stepName}': ` +
1035
+ `expected one of: ${PACKAGE_SOURCES.join(', ')}`);
1036
+ }
1037
+ packageSource = value;
1038
+ }
1039
+ let packagePath = null;
1040
+ let target = null;
1041
+ if (packageSource === 'inputs') {
1042
+ // package_path is ignored: the package arrives as a run input.
1043
+ // target is optional and discovered from the shipped manifest when
1044
+ // the package declares exactly one library product.
1045
+ const configuredTarget = this.getInput(inputs, 'target', undefined);
1046
+ target = configuredTarget === undefined ? null : String(configuredTarget);
1047
+ }
1048
+ else {
1049
+ packagePath = String(this.getRequiredInput(inputs, 'package_path', stepName));
1050
+ target = String(this.getRequiredInput(inputs, 'target', stepName));
1051
+ }
1052
+ if (target !== null && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(target)) {
601
1053
  throw new Error(`Invalid target '${target}' for step '${stepName}': ` +
602
1054
  'must be a Swift module identifier (letters, digits, underscores)');
603
1055
  }
604
1056
  const { width, height } = parseRenderSize(String(this.getInput(inputs, 'render_size', DEFAULT_RENDER_SIZE)));
605
1057
  const scale = parseScale(this.getInput(inputs, 'scale', DEFAULT_SCALE));
606
- const script = generateRenderScript({ packagePath, target, width, height, scale });
1058
+ const config = { packagePath, target, width, height, scale };
1059
+ if (packageSource !== undefined) {
1060
+ config.packageSource = packageSource;
1061
+ }
1062
+ const script = generateRenderScript(config);
607
1063
  return this.createNodeStep(script, stepName);
608
1064
  }
609
1065
  }