diff --git a/tests/composition/fable-game/external-reference-template-package.py b/tests/composition/fable-game/external-reference-template-package.py
new file mode 100644
index 00000000..7fbf0060
--- /dev/null
+++ b/tests/composition/fable-game/external-reference-template-package.py
@@ -0,0 +1,178 @@
+#!/usr/bin/env python3
+"""Inspect an immutable external Templates input; never pack, install or fetch."""
+from pathlib import Path, PurePosixPath
+import hashlib
+import io
+import importlib.util
+import json
+import re
+import stat
+import sys
+from xml.etree import ElementTree as ET
+from zipfile import ZipFile
+
+PACKAGE_ID = 'FS.GG.Workspace.Template'
+PACKAGE_VERSION = '0.18.3'
+
+
+def inspect_package(path, expected_sha):
+ path = Path(path)
+ if not re.fullmatch('[0-9a-f]{64}', expected_sha) or path.suffix != '.nupkg' or path.is_symlink():
+ raise ValueError('exact nupkg and SHA256 required')
+ info = path.lstat()
+ if not stat.S_ISREG(info.st_mode) or not 0 < info.st_size <= 64 * 1024**2:
+ raise ValueError('regular bounded template archive required')
+ with path.open('rb') as stream:
+ raw = stream.read(64 * 1024**2 + 1)
+ if len(raw) != info.st_size or hashlib.sha256(raw).hexdigest() != expected_sha:
+ raise ValueError('external template archive identity differs')
+ entries = {}; expanded = 0; seen = set()
+ with ZipFile(io.BytesIO(raw)) as archive:
+ infos = archive.infolist()
+ if len(infos) > 20000:
+ raise ValueError('template archive member bound exceeded')
+ for member in infos:
+ name = member.filename
+ if (not name or name.startswith('/') or '\\' in name or ':' in name or '\0' in name
+ or '..' in PurePosixPath(name).parts or PurePosixPath(name).as_posix() != name.rstrip('/')
+ or name in seen or (member.external_attr >> 16) & 0o170000 == 0o120000
+ or member.file_size > 16 * 1024**2):
+ raise ValueError('unsafe or duplicate template archive member')
+ seen.add(name)
+ expanded += member.file_size
+ if expanded > 256 * 1024**2:
+ raise ValueError('template archive expansion bound exceeded')
+ if not member.is_dir():
+ entries[name] = archive.read(member)
+ nuspecs = [name for name in entries if name.endswith('.nuspec')]
+ if len(nuspecs) != 1 or len(entries[nuspecs[0]]) > 1024**2 or b' 64 * 1024**2:
+ raise ValueError('retained Rendering archive absent or unbounded')
+ with path.open('rb') as stream:
+ raw = stream.read(64 * 1024**2 + 1)
+ if len(raw) > 64 * 1024**2 or hashlib.sha256(raw).hexdigest() != expected_sha:
+ raise ValueError('Rendering archive differs from exact original candidate')
+ rows.append({'name': path.name, 'sha256': expected_sha})
+ if identity == 'FS.GG.UI.Scene.SvgBrowser':
+ browser_raw = raw
+ with ZipFile(io.BytesIO(browser_raw)) as archive:
+ for name, expected_sha in RENDERING_FABLE_ENTRIES.items():
+ if hashlib.sha256(archive.read(name)).hexdigest() != expected_sha:
+ raise ValueError('original packaged Fable entry differs')
+ return {'schema': 'fsgg.external-reference-retained-rendering-package-input/1',
+ 'producerRevision': source, 'producerTree': None, 'version': version, 'archives': rows,
+ 'fableEntryHashes': RENDERING_FABLE_ENTRIES,
+ 'identitySource': 'exact-original-archive-and-Fable-entry-hashes',
+ 'originalOuterArtifact': 11725317255,
+ 'originalOuterSha256': '7b0532caf431cfa41acaf35555016cf126bc495c5a6e13fe7db6b8fbe7478cca',
+ 'custodyManifestSha256': '85bb1dcdf0378d05ca536d5878b88dedc301ff1524d39d98966e23888aae0120',
+ 'feedQualification': 'not-established', 'installedAcceptance': False}
+
+
+def validate_generated(workspace):
+ workspace = Path(workspace).resolve()
+ spec = importlib.util.spec_from_file_location('maintained_public_references', Path(__file__).with_name('external-reference-public.py'))
+ references = importlib.util.module_from_spec(spec); spec.loader.exec_module(references)
+ projects = sorted([*workspace.rglob('*.fsproj'), *workspace.rglob('*.csproj'), *workspace.rglob('*.vbproj')])
+ build_inputs = sorted([*projects, *workspace.rglob('*.props'), *workspace.rglob('*.targets')])
+ if not projects or len(build_inputs) > 1000:
+ raise ValueError('bounded generated project closure required')
+ for project in build_inputs:
+ if project.is_symlink() or not project.resolve().is_relative_to(workspace) or project.stat().st_size > 1024**2:
+ raise ValueError('generated project escaped workspace')
+ tree = ET.parse(project)
+ # MSBuild's standard XML namespace has the same literal reference rules.
+ for node in tree.iter():
+ if node.tag.startswith('{'):
+ namespace, local = node.tag[1:].split('}', 1)
+ if namespace != 'http://schemas.microsoft.com/developer/msbuild/2003':
+ raise ValueError('unsupported generated build XML namespace')
+ node.tag = local
+ relative = project.relative_to(workspace).as_posix()
+ if relative in references.LOCAL_PROJECT_REFERENCES:
+ references.validate_project_references(tree, relative, workspace)
+ else:
+ for node in tree.findall('.//ProjectReference'):
+ value = node.attrib.get('Include', '')
+ if set(node.attrib) != {'Include'} or list(node) or not value or any(v in value for v in ['$', '*', '?']):
+ raise ValueError('unresolved generated project reference')
+ target = project.parent / value.replace('\\', '/')
+ if target.is_symlink() or not target.resolve().is_relative_to(workspace) or not target.is_file():
+ raise ValueError('generated project reference outside installed workspace')
+ # Producer source must not be imported or compiled through a second path either.
+ for node in tree.iter():
+ if node.tag not in ['Compile', 'Import']:
+ continue
+ value = node.attrib.get('Include' if node.tag == 'Compile' else 'Project', '')
+ if not value:
+ continue
+ if '$(' in value or '*' in value or '?' in value:
+ raise ValueError('unresolved generated source/import path')
+ target = (project.parent / value.replace('\\', '/')).resolve()
+ if not target.is_relative_to(workspace):
+ raise ValueError('generated source/import outside installed workspace')
+ return {'schema': 'fsgg.external-reference-generated-package-workspace/1', 'projects': len(projects),
+ 'buildInputs': len(build_inputs), 'scope': 'literal project/source/import references in generated projects, props and targets',
+ 'producerSourceReferenced': False, 'installedAcceptance': False}
+
+
+if __name__ == '__main__':
+ if len(sys.argv) == 5 and sys.argv[1] == 'inspect':
+ result = inspect_package(Path(sys.argv[2]), sys.argv[3]); output = Path(sys.argv[4])
+ elif len(sys.argv) == 6 and sys.argv[1] == 'rendering':
+ result = verify_rendering_feed(Path(sys.argv[2]), sys.argv[3], sys.argv[4]); output = Path(sys.argv[5])
+ elif len(sys.argv) == 4 and sys.argv[1] == 'generated':
+ result = validate_generated(Path(sys.argv[2])); output = Path(sys.argv[3])
+ else:
+ raise SystemExit('usage: external-reference-template-package.py inspect NUPKG SHA FRESH_JSON | generated WORKSPACE FRESH_JSON')
+ with output.open('x', encoding='utf8') as stream:
+ json.dump(result, stream, indent=2); stream.write('\n')
diff --git a/tests/composition/fable-game/test-external-reference-template-package.py b/tests/composition/fable-game/test-external-reference-template-package.py
new file mode 100644
index 00000000..1172b06f
--- /dev/null
+++ b/tests/composition/fable-game/test-external-reference-template-package.py
@@ -0,0 +1,157 @@
+#!/usr/bin/env python3
+"""Offline package/preflight controls; fake dotnet stops before any generated workload."""
+from pathlib import Path
+import hashlib
+import importlib.util
+import json
+import os
+import shutil
+import subprocess
+import tempfile
+import unittest
+from unittest.mock import patch
+from zipfile import ZipFile
+
+ROOT = Path(__file__).resolve().parent
+spec = importlib.util.spec_from_file_location('package_input', ROOT/'external-reference-template-package.py')
+module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
+SHA = '9b73395658d4683a067d150ca9441b04e802c4af'
+
+
+def package(path, version='0.18.3', identity=module.PACKAGE_ID, repository='https://github.com/FS-GG/FS.GG.Templates', extra=None):
+ nuspec=f'{identity}{version}'
+ with ZipFile(path, 'w') as archive:
+ archive.writestr('template.nuspec', nuspec)
+ archive.writestr('content/templates/fs-gg-fable-game/.template.config/template.json', json.dumps({'identity':'FS.GG.Workspace.Template.FableGame','groupIdentity':'FS.GG.Workspace.Template.FableGame','shortName':'fs-gg-fable-game'}))
+ if extra: archive.writestr(extra, 'unexpected')
+ return hashlib.sha256(path.read_bytes()).hexdigest()
+
+
+class PackageControls(unittest.TestCase):
+ def test_closed_identity_and_truthful_scope(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path=Path(tmp)/'input.nupkg'; sha=package(path); result=module.inspect_package(path,sha)
+ self.assertEqual(result['producerRevision'], SHA)
+ self.assertEqual(result['version'],'0.18.3')
+ self.assertEqual(result['feedQualification'],'not-established')
+ self.assertIs(result['installedAcceptance'],False)
+
+ def test_bad_identity_and_archive_blocked(self):
+ for values in [{'version':'0.18.2'}, {'identity':'FS.GG.Other'}, {'repository':'https://wrong.example/repo'},
+ {'extra':'../escaped'}, {'extra':'/absolute'}, {'extra':'content//alias'}]:
+ with self.subTest(values=values), tempfile.TemporaryDirectory() as tmp:
+ path=Path(tmp)/'input.nupkg'; sha=package(path,**values)
+ with self.assertRaises(ValueError): module.inspect_package(path,sha)
+
+ def test_hash_and_link_blocked(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path=Path(tmp)/'input.nupkg'; sha=package(path)
+ for bad in ['0'*64,sha.upper(),'']:
+ with self.subTest(bad=bad),self.assertRaises(ValueError):module.inspect_package(path,bad)
+ link=Path(tmp)/'link.nupkg';link.symlink_to(path)
+ with self.assertRaises(ValueError):module.inspect_package(link,sha)
+
+ def test_generated_closure_without_producer_source(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root=Path(tmp);(root/'A').mkdir();(root/'B').mkdir();(root/'B/B.fsproj').write_text('')
+ project=root/'A/A.fsproj'; project.write_text('')
+ (root/'A/Main.fs').write_text('module Main')
+ result=module.validate_generated(root)
+ self.assertIs(result['producerSourceReferenced'],False)
+ for bad in ['../../producer.fs','$(ProducerRoot)/source.fs']:
+ project.write_text(f'')
+ with self.subTest(bad=bad),self.assertRaises(ValueError):module.validate_generated(root)
+ project.write_text('')
+ with self.assertRaises(ValueError):module.validate_generated(root)
+ project.write_text('')
+ for name in ['Directory.Build.props', 'Generated.targets']:
+ imported = root / name
+ imported.write_text('')
+ with self.subTest(build_input=name), self.assertRaises(ValueError):module.validate_generated(root)
+ imported.unlink()
+ namespace='http://schemas.microsoft.com/developer/msbuild/2003'
+ for element in ['',
+ '',
+ '']:
+ project.write_text(f'{element}')
+ with self.subTest(namespace_element=element),self.assertRaises(ValueError):module.validate_generated(root)
+ project.write_text(f'')
+ self.assertFalse(module.validate_generated(root)['producerSourceReferenced'])
+ project.write_text('')
+ with self.assertRaises(ValueError):module.validate_generated(root)
+
+ def test_rendering_hash_correspondence_and_refusals(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ feed=Path(tmp)
+ files={name:('fixture '+name).encode() for name in module.RENDERING_FABLE_ENTRIES}
+ pins={}
+ for identity in module.RENDERING_ARCHIVES:
+ archive=feed/(identity+'.0.32.2.nupkg')
+ with ZipFile(archive,'w') as z:
+ if identity.endswith('SvgBrowser'):
+ for name,raw in files.items():z.writestr(name,raw)
+ else:z.writestr('fixture.txt','not original native bytes')
+ pins[identity]=hashlib.sha256(archive.read_bytes()).hexdigest()
+ entries={name:hashlib.sha256(raw).hexdigest()for name,raw in files.items()}
+ with patch.object(module,'RENDERING_ARCHIVES',pins),patch.object(module,'RENDERING_FABLE_ENTRIES',entries):
+ sizes=[];real_open=Path.open
+ class BoundedReader:
+ def __init__(self,stream):self.stream=stream
+ def __enter__(self):self.stream.__enter__();return self
+ def __exit__(self,*args):return self.stream.__exit__(*args)
+ def read(self,size=-1):sizes.append(size);return self.stream.read(size)
+ with patch.object(Path,'open',lambda path,*args,**kwargs:BoundedReader(real_open(path,*args,**kwargs))):
+ value=module.verify_rendering_feed(feed,'0.32.2',SHA)
+ self.assertEqual(sizes,[64*1024**2+1]*3)
+ self.assertIs(value['installedAcceptance'],False)
+ self.assertEqual(value['feedQualification'],'not-established')
+ self.assertIsNone(value['producerTree'])
+ for version,source in [('0.32.1',SHA),('0.32.2','0'*40)]:
+ with self.subTest(version=version,source=source),self.assertRaises(ValueError):
+ module.verify_rendering_feed(feed,version,source)
+ with patch.object(module,'RENDERING_FABLE_ENTRIES',{**entries,next(iter(entries)):'0'*64}):
+ with self.assertRaises(ValueError):module.verify_rendering_feed(feed,'0.32.2',SHA)
+ path=feed/'FS.GG.UI.KeyboardInput.0.32.2.nupkg';path.write_bytes(path.read_bytes()+b'changed')
+ with self.assertRaises(ValueError):module.verify_rendering_feed(feed,'0.32.2',SHA)
+
+ def test_existing_runner_external_branch_has_no_producer_source(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ base=Path(tmp);controller=base/'controller/tests/composition/fable-game';controller.mkdir(parents=True)
+ for name in ['verify-external-reference-candidate.sh','external-reference-template-package.py']:
+ shutil.copyfile(ROOT/name,controller/name)
+ producer=base/'absent-rendering'
+ feed=base/'feed';feed.mkdir();files=['SvgExternalSessionHost.fs','SvgExternalSessionHost.fsi','SvgInputHost.fs','SvgInputHost.fsi']
+ for identity in ['FS.GG.UI.Scene','FS.GG.UI.KeyboardInput','FS.GG.UI.Scene.SvgBrowser']:
+ with ZipFile(feed/(identity+'.0.32.2.nupkg'),'w') as archive:
+ if identity.endswith('SvgBrowser'):
+ for name in files:archive.writestr('fable/'+name,name)
+ archive.writestr('fable-compatibility/compatibility-profile.v1.json','{}')
+ else:archive.writestr('fixture.txt','not native evidence')
+ # The actual shell route uses a private inert feed-pin seam, never production pins.
+ fixture_archives={identity:hashlib.sha256((feed/(identity+'.0.32.2.nupkg')).read_bytes()).hexdigest()
+ for identity in module.RENDERING_ARCHIVES}
+ fixture_entries={'fable/'+name:hashlib.sha256(name.encode()).hexdigest() for name in files}
+ fixture_entries['fable-compatibility/compatibility-profile.v1.json']=hashlib.sha256(b'{}').hexdigest()
+ copied=controller/'external-reference-template-package.py'
+ copied.write_text(copied.read_text().replace(repr(module.RENDERING_ARCHIVES),repr(fixture_archives))
+ .replace(repr(module.RENDERING_FABLE_ENTRIES),repr(fixture_entries)))
+ archive=base/'external.nupkg';sha=package(archive);tools=base/'tools';tools.mkdir();calls=base/'dotnet-calls.jsonl'
+ (tools/'git').write_text('#!/usr/bin/env python3\nraise SystemExit("producer source read forbidden in external package fixture")\n')
+ (tools/'dotnet').write_text('#!/usr/bin/env python3\nimport sys,os,json\nwith open(os.environ["CALLS"],"a")as f:f.write(json.dumps({"args":sys.argv[1:],"home":os.environ["DOTNET_CLI_HOME"],"cache":os.environ["NUGET_PACKAGES"]})+"\\n")\nassert sys.argv[1:3] in [["new","install"],["new","fs-gg-fable-game"]]\nsys.exit(0 if sys.argv[2]=="install" else 9)\n')
+ for path in tools.iterdir():path.chmod(0o700)
+ env={**os.environ,'PATH':str(tools)+os.pathsep+os.environ['PATH'],'CALLS':str(calls)}
+ out=base/'qualification';argv=['bash',str(controller/'verify-external-reference-candidate.sh'),str(producer),str(feed),'0.32.2',str(out),SHA,'candidate',str(archive),sha]
+ result=subprocess.run(argv,env=env,capture_output=True,text=True,timeout=10)
+ self.assertEqual(result.returncode,9,result.stderr)
+ rows=[json.loads(line)for line in calls.read_text().splitlines()];self.assertEqual(len(rows),2)
+ self.assertIn('--debug:custom-hive',rows[0]['args']);self.assertIn(str(out/'template-hive'),rows[0]['args'])
+ self.assertIn('--package',rows[1]['args']);self.assertEqual(rows[0]['home'],str(out/'home'));self.assertEqual(rows[0]['cache'],str(out/'packages'))
+ self.assertFalse((base/'controller/FS.GG.Templates.csproj').exists());self.assertFalse((base/'controller/templates').exists())
+ self.assertFalse(producer.exists())
+ bad_out=base/'bad'
+ bad=['bash',str(controller/'verify-external-reference-candidate.sh'),str(producer),str(feed),'0.32.2',str(bad_out),SHA,'candidate',str(archive),'0'*64]
+ result=subprocess.run(bad,env=env,capture_output=True,text=True,timeout=10)
+ self.assertNotEqual(result.returncode,0);self.assertEqual(len(calls.read_text().splitlines()),2)
+
+
+if __name__=='__main__':unittest.main()
diff --git a/tests/composition/fable-game/verify-external-reference-candidate.sh b/tests/composition/fable-game/verify-external-reference-candidate.sh
index f07d2aab..36040338 100755
--- a/tests/composition/fable-game/verify-external-reference-candidate.sh
+++ b/tests/composition/fable-game/verify-external-reference-candidate.sh
@@ -1,20 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
-producer="${1:?exact Rendering source checkout required}"
+producer="${1:?Rendering source checkout or absent external-package input root required}"
producer_feed="${2:?packed Rendering source feed required}"
version="${3:?isolated Rendering candidate version required}"
out="${4:?empty qualification directory required}"
expected_source="${5:-097e228388375bf27a03ac008627e4a038686c53}"
input_mode="${6:-candidate}"
+templates_package="${7:-}"
+templates_package_sha="${8:-}"
+[[ "$#" -le 8 ]]
+[[ -z "$templates_package" && -z "$templates_package_sha" || -n "$templates_package" && "$templates_package_sha" =~ ^[0-9a-f]{64}$ ]] || { echo "external template package requires exact path and SHA256" >&2; exit 1; }
[[ "$input_mode" == candidate || "$input_mode" == public ]] || { echo "unknown input mode" >&2; exit 1; }
if [[ "$input_mode" == public ]]; then
[[ "$version" == 0.32.1 && "$expected_source" == 6c9f766fdd91483c2de6f061e75589e94852a265 ]]
[[ -z "${FsGgSvgInputVersion:-}" && -z "${FsGgSvgAuthoringVersion:-}" ]] || { echo "public defaults cannot be overridden" >&2; exit 1; }
fi
[[ "$expected_source" =~ ^[0-9a-f]{40}$ && "$version" =~ ^[0-9A-Za-z][0-9A-Za-z.+-]*$ && "$version" != 0.31.0 ]]
-[[ ! -e "$out" && "$(git -C "$producer" rev-parse HEAD)" == "$expected_source" ]]
-[[ -z "$(git -C "$producer" status --porcelain --untracked-files=no)" ]]
+[[ ! -e "$out" ]]
+if [[ -z "$templates_package" ]]; then
+ [[ "$(git -C "$producer" rev-parse HEAD)" == "$expected_source" ]]
+ [[ -z "$(git -C "$producer" status --porcelain --untracked-files=no)" ]]
+fi
mkdir -p "$out"/{feed,home,packages,http}
out="$(realpath "$out")"
export DOTNET_CLI_HOME="$out/home" NUGET_PACKAGES="$out/packages" NUGET_HTTP_CACHE_PATH="$out/http"
@@ -28,11 +35,14 @@ else
cp "$producer_feed/$id.$version.nupkg" "$out/feed/"
done
fi
-python3 - "$producer" "$out/feed" "$version" <<'PY'
+if [[ -n "$templates_package" ]]; then
+ python3 "$root/tests/composition/fable-game/external-reference-template-package.py" rendering "$out/feed" "$version" "$expected_source" "$out/producer-package-input.json"
+fi
+python3 - "$producer" "$out/feed" "$version" "$templates_package" <<'PY'
from pathlib import Path
from zipfile import ZipFile
import hashlib,json,sys
-source,feed=map(Path,sys.argv[1:3]); version=sys.argv[3]
+source,feed=map(Path,sys.argv[1:3]); version=sys.argv[3]; external=bool(sys.argv[4])
rows=[]
source_entries=[]
for package in sorted(feed.glob('*.nupkg')):
@@ -40,10 +50,10 @@ for package in sorted(feed.glob('*.nupkg')):
with ZipFile(feed/f'FS.GG.UI.Scene.SvgBrowser.{version}.nupkg') as archive:
for name in ('SvgExternalSessionHost.fs','SvgExternalSessionHost.fsi','SvgInputHost.fs','SvgInputHost.fsi'):
raw=archive.read('fable/'+name)
- assert raw==(source/'src/Scene.SvgBrowser'/name).read_bytes(), name
+ if not external: assert raw==(source/'src/Scene.SvgBrowser'/name).read_bytes(), name
source_entries.append({'path':'fable/'+name,'sha256':hashlib.sha256(raw).hexdigest()})
profile='fable-compatibility/compatibility-profile.v1.json'
- assert archive.read(profile)==(source/'src/Scene.SvgBrowser/Fable/compatibility-profile.v1.json').read_bytes()
+ if not external: assert archive.read(profile)==(source/'src/Scene.SvgBrowser/Fable/compatibility-profile.v1.json').read_bytes()
source_entries.append({'path':profile,'sha256':hashlib.sha256(archive.read(profile)).hexdigest()})
(feed.parent/'producer-archives.json').write_text(json.dumps(rows,indent=2)+'\n')
(feed.parent/'producer-source-entries.json').write_text(json.dumps(source_entries,indent=2)+'\n')
@@ -55,15 +65,40 @@ cat > "$out/NuGet.Config" <
CONFIG
fi
-# Reuse the canonical checked Python projection for a fresh complete Templates archive.
-. "$root/tests/composition/fable-game/svg-source-python-fixture.sh"
-prepare_svg_source_python_fixture "$root" "$out"
-dotnet pack "$root/FS.GG.Templates.csproj" -c Release -o "$out/template" -m:1 -p:UseSharedCompilation=false >"$out/templates-pack.log" 2>&1
-mapfile -t archives < <(find "$out/template" -maxdepth 1 -name '*.nupkg' -type f)
-[[ "${#archives[@]}" == 1 ]]
-dotnet new install "${archives[0]}" --force >"$out/template-install.log" 2>&1
-dotnet new fs-gg-fable-game -n ExternalReference -o "$out/complete" --bundle complete --lifecycle none >"$out/complete-generate.log" 2>&1
-dotnet new fs-gg-fable-game -n DefaultPlayer -o "$out/player" >"$out/player-generate.log" 2>&1
+template_scope_args=()
+if [[ -n "$templates_package" ]]; then
+ # An external archive is a distinct candidate input; no producer pack/projection/source lookup.
+ # Keep generated builds outside either producer's automatic MSBuild source hierarchy.
+ python3 - "$out" "$root" "$producer" <<'PYBOUNDARY'
+from pathlib import Path
+import sys
+output,controller,rendering=map(lambda value:Path(value).resolve(),sys.argv[1:])
+assert not output.is_relative_to(controller) and not output.is_relative_to(rendering), 'external package output must be outside producer roots'
+PYBOUNDARY
+ python3 "$root/tests/composition/fable-game/external-reference-template-package.py" inspect \
+ "$templates_package" "$templates_package_sha" "$out/template-package-input.json"
+ mkdir "$out/template" "$out/template-hive"
+ cp "$templates_package" "$out/template/FS.GG.Workspace.Template.0.18.3.nupkg"
+ archive="$out/template/FS.GG.Workspace.Template.0.18.3.nupkg"
+ printf '%s %s\n' "$templates_package_sha" "$archive" | sha256sum --check --strict
+ dotnet new install "$archive" --force --debug:custom-hive "$out/template-hive" >"$out/template-install.log" 2>&1
+ template_scope_args=(--debug:custom-hive "$out/template-hive" --debug:disable-sdk-templates --no-update-check --package FS.GG.Workspace.Template)
+else
+ # Preserve the existing source-built candidate and its canonical checked Python projection.
+ . "$root/tests/composition/fable-game/svg-source-python-fixture.sh"
+ prepare_svg_source_python_fixture "$root" "$out"
+ dotnet pack "$root/FS.GG.Templates.csproj" -c Release -o "$out/template" -m:1 -p:UseSharedCompilation=false >"$out/templates-pack.log" 2>&1
+ mapfile -t archives < <(find "$out/template" -maxdepth 1 -name '*.nupkg' -type f)
+ [[ "${#archives[@]}" == 1 ]]
+ dotnet new install "${archives[0]}" --force >"$out/template-install.log" 2>&1
+fi
+dotnet new fs-gg-fable-game -n ExternalReference -o "$out/complete" --bundle complete --lifecycle none "${template_scope_args[@]}" >"$out/complete-generate.log" 2>&1
+dotnet new fs-gg-fable-game -n DefaultPlayer -o "$out/player" "${template_scope_args[@]}" >"$out/player-generate.log" 2>&1
+if [[ -n "$templates_package" ]]; then
+ python3 "$root/tests/composition/fable-game/external-reference-template-package.py" generated \
+ "$out/complete" "$out/template-generated-independent.json"
+ printf '%s %s\n' "$templates_package_sha" "$archive" | sha256sum --check --strict
+fi
[[ -f "$out/complete/SvgFoundation/Examples/ExternalAuthority/reference.json" ]]
[[ -f "$out/complete/SvgFoundation/Examples/FourD/reference.json" ]]
[[ ! -e "$out/player/SvgFoundation/Examples/ExternalAuthority" && ! -e "$out/player/SvgFoundation/Examples/FourD" ]]
@@ -148,16 +183,31 @@ report=json.load(open(sys.argv[1])); stats=report['stats']
assert stats['expected']==8 and stats['unexpected']==0 and stats['skipped']==0 and stats['flaky']==0, stats
PY
done
-python3 - "$root" "$producer" "$out" "$version" "$producer_feed" "$input_mode" <<'PY'
+python3 - "$root" "$producer" "$out" "$version" "$producer_feed" "$input_mode" "$templates_package" <<'PY'
from pathlib import Path
import hashlib,json,subprocess,sys
root,producer,out=map(Path,sys.argv[1:4]); version=sys.argv[4]
def git(path,field): return subprocess.check_output(['git','-C',str(path),'rev-parse',field],text=True).strip()
def hashes(path): return [{'path':p.relative_to(path).as_posix(),'sha256':hashlib.sha256(p.read_bytes()).hexdigest()} for p in sorted(path.rglob('*')) if p.is_file() and 'node_modules' not in p.parts and 'obj' not in p.parts and 'bin' not in p.parts]
-packet={'schema':'fsgg.external-reference-source-qualification/1','disposition':'passed','templates':{'revision':git(root,'HEAD'),'tree':git(root,'HEAD^{tree}')},'producer':{'inputCustody':json.loads((Path(sys.argv[5])/'ROOT-CUSTODY.json').read_text()) if (Path(sys.argv[5])/'ROOT-CUSTODY.json').exists() else None,'custodyManifestSha256':hashlib.sha256((Path(sys.argv[5])/'release-custody.json').read_bytes()).hexdigest() if (Path(sys.argv[5])/'release-custody.json').exists() else None,'revision':git(producer,'HEAD'),'tree':git(producer,'HEAD^{tree}'),'candidateVersion':version,'archives':json.loads((out/'producer-archives.json').read_text()),'sourceEntries':json.loads((out/'producer-source-entries.json').read_text())},'environment':{'dotnet':subprocess.check_output(['dotnet','--info'],text=True),'node':subprocess.check_output(['node','--version'],text=True).strip(),'npm':subprocess.check_output(['npm','--version'],text=True).strip(),'fableLoadedVersion':(out/'fable-version.log').read_text().strip(),'fableLogSha256':hashlib.sha256((out/'fable-build.log').read_bytes()).hexdigest()},'receiver':hashes(out/'complete/SvgFoundation'),'browserFamilies':['chromium','firefox','webkit'],'browserReports':hashes(out/'complete/Browser.Tests/test-results'),'browserReportSha256':{family:hashlib.sha256((out/(family+'-browser.json')).read_bytes()).hexdigest() for family in ['chromium','firefox','webkit']},'passedPerFamily':8,'skipped':0,'domAutomation':True,'actualScreenReader':'not-observed','publication':False,'installedAcceptance':False,'renderingInputSource':'nuget.org' if sys.argv[6]=='public' else 'candidate','renderingPublicInputsQualified':sys.argv[6]=='public','templatesInputSource':'source-built-candidate','publicReadback':json.loads((out/'feed/public-readback.json').read_text()) if sys.argv[6]=='public' else None}
+template_input=json.loads((out/'template-package-input.json').read_text()) if sys.argv[7] else None
+rendering_input=json.loads((out/'producer-package-input.json').read_text()) if template_input else None
+producer_identity={'revision':rendering_input['producerRevision'],'tree':None} if rendering_input else {'revision':git(producer,'HEAD'),'tree':git(producer,'HEAD^{tree}')}
+template_identity={'revision':template_input['producerRevision'],'tree':None} if template_input else {'revision':git(root,'HEAD'),'tree':git(root,'HEAD^{tree}')}
+packet={'schema':'fsgg.external-reference-source-qualification/1','disposition':'passed','templates':template_identity,'producer':{'inputCustody':json.loads((Path(sys.argv[5])/'ROOT-CUSTODY.json').read_text()) if (Path(sys.argv[5])/'ROOT-CUSTODY.json').exists() else None,'custodyManifestSha256':hashlib.sha256((Path(sys.argv[5])/'release-custody.json').read_bytes()).hexdigest() if (Path(sys.argv[5])/'release-custody.json').exists() else None,**producer_identity,'candidateVersion':version,'archives':json.loads((out/'producer-archives.json').read_text()),'sourceEntries':json.loads((out/'producer-source-entries.json').read_text())},'environment':{'dotnet':subprocess.check_output(['dotnet','--info'],text=True),'node':subprocess.check_output(['node','--version'],text=True).strip(),'npm':subprocess.check_output(['npm','--version'],text=True).strip(),'fableLoadedVersion':(out/'fable-version.log').read_text().strip(),'fableLogSha256':hashlib.sha256((out/'fable-build.log').read_bytes()).hexdigest()},'receiver':hashes(out/'complete/SvgFoundation'),'browserFamilies':['chromium','firefox','webkit'],'browserReports':hashes(out/'complete/Browser.Tests/test-results'),'browserReportSha256':{family:hashlib.sha256((out/(family+'-browser.json')).read_bytes()).hexdigest() for family in ['chromium','firefox','webkit']},'passedPerFamily':8,'skipped':0,'domAutomation':True,'actualScreenReader':'not-observed','publication':False,'installedAcceptance':False,'renderingInputSource':'nuget.org' if sys.argv[6]=='public' else 'candidate','renderingPublicInputsQualified':sys.argv[6]=='public','templatesInputSource':'source-built-candidate','publicReadback':json.loads((out/'feed/public-readback.json').read_text()) if sys.argv[6]=='public' else None}
if sys.argv[6]=='public':
packet['schema']='fsgg.external-reference-public-input-qualification/1'
packet['producer']['publishedVersion']=packet['producer'].pop('candidateVersion')
+if template_input:
+ packet['schema']='fsgg.external-reference-template-package-qualification/1'
+ packet['templatesInputSource']='external-package-candidate'
+ packet['templatePackageInput']=template_input
+ packet['renderingPackageInput']=rendering_input
+ packet['renderingProducerSourceUnavailable']=not (producer/'.git').exists() and not (producer/'src').exists()
+ packet['renderingSourceCorrespondence']='reused exact original packaged-entry hashes; no producer tree read in this mode'
+ packet['templateGeneratedIndependent']=json.loads((out/'template-generated-independent.json').read_text())
+ packet['templateProducerSourceUnavailable']=not (root/'FS.GG.Templates.csproj').exists() and not (root/'templates').exists()
+ # Local immutable package installation does not establish released-feed installed adoption.
+ assert packet['installedAcceptance'] is False and template_input['installedAcceptance'] is False
(out/'qualification.json').write_text(json.dumps(packet,indent=2)+'\n')
PY
printf 'external-reference-candidate: passed; evidence=%s/qualification.json\n' "$out"