From 36724faf7b86d5646995772143f07f9bbc381074 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Fri, 30 Oct 2026 13:17:01 +0000 Subject: [PATCH 1/5] Keep graphical checks bounded and retain native control evidence --- .github/workflows/native117.yml | 51 ++++++++++++++++++ .../lootable/buildtest/ClientChecks.java | 29 ++++++++-- .../buildtest/VisibilityClientChecks.java | 2 +- tools/native37_control_evidence.py | 19 ++++--- tools/test_graphical_split.py | 54 +++++++++++++++++++ tools/test_native37_control_evidence.py | 21 ++++++++ tools/verify_packaged_client.py | 43 +++++++++++++-- 7 files changed, 205 insertions(+), 14 deletions(-) create mode 100644 tools/test_graphical_split.py diff --git a/.github/workflows/native117.yml b/.github/workflows/native117.yml index b496c6a..89d016c 100644 --- a/.github/workflows/native117.yml +++ b/.github/workflows/native117.yml @@ -5,6 +5,7 @@ on: branches: [master-1.17.1] pull_request: branches: [master-1.17.1] + types: [opened, synchronize, reopened, labeled] permissions: contents: read @@ -58,6 +59,52 @@ jobs: build/reports/** build/release/bundle/* ${{ runner.temp }}/native117-python-tools.log + # A labelled PR can investigate the Linux display/packet seam without + # repeating preparation, historical worlds or unrelated graphical modes. + - name: Prepare a labelled Linux visibility investigation + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'native117-visibility-diagnostic') + env: + LOOTABLEBODIES_JAVA16_HOME: ${{ env.JAVA_HOME_16_X64 }} + LOOTABLEBODIES_MAVENIZER_JAVA_HOME: ${{ env.JAVA_HOME_25_X64 }} + run: | + sudo apt-get update -o Acquire::Retries=2 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 + sudo apt-get install --yes --no-install-recommends xvfb x11-xserver-utils libgl1-mesa-dri libglu1-mesa libopenal1 pulseaudio pulseaudio-utils + pulseaudio --start --exit-idle-time=-1 + pactl load-module module-null-sink sink_name=minecraft + echo "PULSE_SINK=minecraft" >> "$GITHUB_ENV" + echo "ALSOFT_DRIVERS=pulse" >> "$GITHUB_ENV" + echo "LIBGL_ALWAYS_SOFTWARE=true" >> "$GITHUB_ENV" + python3 -B tools/native_runtime.py --java "$JAVA_HOME_16_X64/bin/java" --output build/native-server-distribution + python3 -B tools/native_runtime.py --java "$JAVA_HOME_16_X64/bin/java" --output build/native-client-distribution --client + ./gradlew prepareMinecraftAssets --no-daemon --offline --console=plain + - name: Run only the labelled Linux visibility investigation + if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'native117-visibility-diagnostic') + run: xvfb-run -a -s '-screen 0 1280x720x24 +extension GLX +render -noreset' python3 -B tools/native117_ci.py scenario --group visibility + - name: Retain the labelled investigation on every outcome + if: always() && github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'native117-visibility-diagnostic') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: native117-diagnostic-visibility-${{ github.sha }} + if-no-files-found: error + include-hidden-files: true + path: | + build/ci117-commands/** + build/ci117-scenarios/**/*.log + build/ci117-scenarios/**/*.log.gz + build/ci117-scenarios/**/*.txt + build/ci117-scenarios/**/*.json + build/ci117-scenarios/**/*.pass + build/ci117-scenarios/**/*.properties + build/ci117-scenarios/**/*.diagnostics/** + build/ci117-scenarios/**/sync/** + build/ci117-scenarios/**/world/** + build/ci117-scenarios/**/saves/** + build/ci117-scenarios/**/mods/*.jar + build/ci117-scenarios/**/config/** + build/ci117-scenarios/**/crash-reports/** + build/ci117-scenarios/**/screenshots/*.png + build/native-server-distribution/*.log + build/native-client-distribution/*.log prepare: name: Prepare verified migration inputs @@ -280,13 +327,17 @@ jobs: build/ci117-scenarios/**/*.log.gz build/ci117-scenarios/**/*.json build/ci117-scenarios/**/*.txt + build/ci117-scenarios/**/*.pass + build/ci117-scenarios/**/*.properties build/ci117-scenarios/**/*.diagnostics/** + build/ci117-scenarios/**/sync/** build/ci117-scenarios/**/world/** build/ci117-scenarios/**/saves/** build/ci117-scenarios/**/mods/*.jar build/ci117-scenarios/**/config/** build/ci117-scenarios/**/crash-reports/** build/ci117-scenarios/**/screenshots/*.png + build/ci117-scenarios/**/connected-screenshots/*.png build/ci117-platform-inputs/inputs.json aggregate: diff --git a/src/integrationTest/java/cyano/lootable/buildtest/ClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/ClientChecks.java index 57deb6b..a0a94cd 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/ClientChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/ClientChecks.java @@ -69,8 +69,22 @@ public void register() { mc.options.pauseOnLostFocus=false; mc.options.framerateLimit=30; mc.options.renderDistance=4; - stage=1; - FixtureApi.ClientWorlds.create(mc,"build-fixture","Build fixture",net.minecraft.world.level.GameType.CREATIVE,false); + if(Boolean.getBoolean("lootablebodies.clientResumeDrops")) { + RuntimeChecks.require(!Boolean.getBoolean("lootablebodies.clientStopBeforeDrops") && + new File(mc.gameDirectory,"client-connected.pass").isFile(),"Missing connected phase for graphical continuation"); + RuntimeChecks.require(cyano.lootable.LootableBodies.allowCorpseDecay && + cyano.lootable.LootableBodies.ticksPerItemDecay==6000,"Continuation did not start with default timers"); + // Restore the same verifier-only clock freeze applied by + // RuntimeChecks.run in the original continuous process. + cyano.lootable.LootableBodies.allowCorpseDecay=false; + cyano.lootable.LootableBodies.ticksPerItemDecay=-1; + stage=40; mc.loadLevel("build-fixture"); + } else { + stage=1; + FixtureApi.ClientWorlds.create(mc,"build-fixture","Build fixture",net.minecraft.world.level.GameType.CREATIVE,false); + } + } else if(stage==40 && mc.level!=null && mc.player!=null && mc.getSingleplayerServer()!=null) { + drops.start(mc);stage=21; } else if(stage==1 && mc.level!=null && mc.player!=null && mc.getSingleplayerServer()!=null) { stage=2; pending=mc.getSingleplayerServer().submitAsync(() -> { @@ -109,7 +123,16 @@ public void register() { Screenshot.grab(mc.gameDirectory,"corpse-gui-reload.png",mc.getMainRenderTarget(),message->System.out.println(message.getString())); mc.player.closeContainer(); stage=20; connected.start(mc); } else if(stage==20 && connected.tick(mc)) { - mc.player.closeContainer();drops.start(mc);stage=21; + mc.player.closeContainer(); + if(Boolean.getBoolean("lootablebodies.clientStopBeforeDrops")) { + RuntimeChecks.require(!cyano.lootable.LootableBodies.allowCorpseDecay && + cyano.lootable.LootableBodies.ticksPerItemDecay==-1,"Connected inventory fixture timer context changed"); + try(FileWriter out=new FileWriter(new File(mc.gameDirectory,"client-connected.pass"))) { + out.write("fresh/reload GUI and all connected inventory checks complete\n"); + } + System.out.println("LOOTABLE_BODIES_CONNECTED_STAGE_PASS"); + stage=8;FixtureConnections.disconnect(mc); + } else {drops.start(mc);stage=21;} } else if(stage==21 && drops.tick(mc)) { mc.player.closeContainer();nbt.start(mc);stage=22; } else if(stage==22 && nbt.tick(mc)) { diff --git a/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java index 1ee4783..13b93ff 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java @@ -52,7 +52,7 @@ final class VisibilityClientChecks { FixtureConnections.connect(mc); } if(mc.level==null || mc.player==null)return; - if(frames%200==0)System.out.println("VISIBILITY_CLIENT_STATE "+role+" phase="+phase+" position="+mc.player.blockPosition()+" rendered="+rendered+" screen="+(mc.screen==null?null:mc.screen.getClass().getSimpleName())+" container="+mc.player.containerMenu.getClass().getSimpleName()); + if(frames%200==0)System.out.println("VISIBILITY_CLIENT_STATE "+role+" phase="+phase+" position="+mc.player.blockPosition()+" rendered="+rendered+" screen="+(mc.screen==null?null:mc.screen.getClass().getName())+" deathScreen="+(mc.screen instanceof DeathScreen)+" done="+done+" firstBody="+body(mc,"first")+" secondBody="+body(mc,"second")+" container="+mc.player.containerMenu.getClass().getSimpleName()); if(phase==12 && frames%200==0) { EntityLootableBody b=body(mc,"first");RuntimeChecks.require(b!=null&&!b.isRemoved()&&!b.isInvisible(),"Corpse disappeared during five-minute wait"); } diff --git a/tools/native37_control_evidence.py b/tools/native37_control_evidence.py index 2fc2515..a3a41f9 100644 --- a/tools/native37_control_evidence.py +++ b/tools/native37_control_evidence.py @@ -11,17 +11,24 @@ def normalized_stack(text): def residual(text): expected=(Path(__file__).parent/'fixtures/native37-delayed-movement-stack.txt').read_text() - lines=text.splitlines();out=[];count=0;i=0 + # The retained Linux control additionally emitted one initial PosRot packet. + # Classify only that exact observed sequence, outside the clean-log auditor. + rotation=expected.replace('ServerboundMovePlayerPacket$Pos.m_5797_(SourceFile:42)', + 'ServerboundMovePlayerPacket$PosRot.m_5797_(SourceFile:16)') + lines=text.splitlines();out=[];sequence=[];i=0 while i Date: Fri, 30 Oct 2026 13:27:46 +0000 Subject: [PATCH 2/5] Prepare the exact runtime inputs for the Linux visibility check --- .github/workflows/native117.yml | 2 +- tools/test_native117_ci.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/native117.yml b/.github/workflows/native117.yml index 89d016c..f20da47 100644 --- a/.github/workflows/native117.yml +++ b/.github/workflows/native117.yml @@ -76,7 +76,7 @@ jobs: echo "LIBGL_ALWAYS_SOFTWARE=true" >> "$GITHUB_ENV" python3 -B tools/native_runtime.py --java "$JAVA_HOME_16_X64/bin/java" --output build/native-server-distribution python3 -B tools/native_runtime.py --java "$JAVA_HOME_16_X64/bin/java" --output build/native-client-distribution --client - ./gradlew prepareMinecraftAssets --no-daemon --offline --console=plain + ./gradlew preparePackagedIntegrationProbe prepareMinecraftAssets -PminecraftAssetsDirectory=build/native-assets --no-daemon --offline --console=plain - name: Run only the labelled Linux visibility investigation if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'native117-visibility-diagnostic') run: xvfb-run -a -s '-screen 0 1280x720x24 +extension GLX +render -noreset' python3 -B tools/native117_ci.py scenario --group visibility diff --git a/tools/test_native117_ci.py b/tools/test_native117_ci.py index 4b24feb..5d3bb6a 100644 --- a/tools/test_native117_ci.py +++ b/tools/test_native117_ci.py @@ -4,6 +4,17 @@ import native117_ci as ci class Native117CITests(unittest.TestCase): + def test_optional_diagnostic_prepares_the_exact_assets_directory_used_by_scenarios(self): + workflow=(Path(ci.__file__).resolve().parents[1]/'.github/workflows/native117.yml').read_text() + step=workflow.split('- name: Prepare a labelled Linux visibility investigation\n',1)[1].split('\n - name:',1)[0] + self.assertIn('-PminecraftAssetsDirectory=build/native-assets',step) + self.assertIn("'native117-visibility-diagnostic'",step) + + def test_optional_diagnostic_builds_the_packaged_probe_before_native_launch(self): + workflow=(Path(ci.__file__).resolve().parents[1]/'.github/workflows/native117.yml').read_text() + step=workflow.split('- name: Prepare a labelled Linux visibility investigation\n',1)[1].split('\n - name:',1)[0] + self.assertIn('preparePackagedIntegrationProbe',step) + def test_eclipse_gate_receives_the_real_prepared_client_natives_after_sdk_install(self): with tempfile.TemporaryDirectory() as d,patch.object(ci,'PROJECT',Path(d)): events=[];natives=Path(d)/'build/native-client-distribution/prepared-inputs/natives' From 9efe58f556ff2f7beccc4619debe0fc32e57c91c Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Fri, 30 Oct 2026 13:42:20 +0000 Subject: [PATCH 3/5] Run historical origins with two isolated workers --- tools/historical_workers.py | 22 +++++++++++++++++ tools/migration_ci.py | 2 +- tools/test_historical_workers.py | 41 ++++++++++++++++++++++++++++++++ tools/verify_world_upgrades.py | 17 ++++++++----- 4 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 tools/historical_workers.py create mode 100644 tools/test_historical_workers.py diff --git a/tools/historical_workers.py b/tools/historical_workers.py new file mode 100644 index 0000000..6d73cfe --- /dev/null +++ b/tools/historical_workers.py @@ -0,0 +1,22 @@ +"""Run at most two independent whole-origin histories, without retries.""" +from concurrent.futures import ThreadPoolExecutor + + +def run_origins(origins, execute, *, workers=1): + origins=list(origins) + if not origins or type(workers) is not int or workers not in (1,2) or len(origins)!=len(set(origins)): + raise ValueError('Historical origins must be unique and use one or two isolated workers') + if workers==1: + for origin in origins:execute(origin) + return + with ThreadPoolExecutor(max_workers=workers) as pool: + # Finish the current bounded batch, retaining both actual outcomes. + # A failure prevents any later batch from starting; nothing is retried. + for offset in range(0,len(origins),workers): + pending=[(origin,pool.submit(execute,origin)) for origin in origins[offset:offset+workers]] + failed=[] + for origin,future in pending: + try:future.result() + except Exception as error:failed.append((origin,error)) + if failed: + raise RuntimeError('Historical origin workers failed: '+ '; '.join(name+': '+repr(error) for name,error in failed)) from failed[0][1] diff --git a/tools/migration_ci.py b/tools/migration_ci.py index 464cc22..5b00a04 100644 --- a/tools/migration_ci.py +++ b/tools/migration_ci.py @@ -143,7 +143,7 @@ def run_shard(project, java, shard, commit, total=12): if digest(project / name) != manifest['files'][name]['sha256']: raise ValueError('Prepared candidate/probe changed') args = SimpleNamespace(java=java, origins=origins, variants=['normal','stamps'], seeds=[seeds], - forge117_home=project/'build/native-server-distribution', prepared_sources=None, output=project/'build/migration-results') + forge117_home=project/'build/native-server-distribution', prepared_sources=None, output=project/'build/migration-results', workers=2) verify(args, project) abort_root = project / 'build/migration-abort';abort_root.mkdir(exist_ok=False) aborts = {} diff --git a/tools/test_historical_workers.py b/tools/test_historical_workers.py new file mode 100644 index 0000000..9fe0335 --- /dev/null +++ b/tools/test_historical_workers.py @@ -0,0 +1,41 @@ +import threading +import unittest +from historical_workers import run_origins + + +class HistoricalWorkersTests(unittest.TestCase): + def test_two_isolated_workers_keep_each_origin_once_and_never_exceed_two(self): + lock=threading.Lock();barrier=threading.Barrier(2,timeout=3) + active=0;peak=0;seen=[] + def execute(origin): + nonlocal active,peak + with lock:active+=1;peak=max(peak,active);seen.append(origin) + barrier.wait() + with lock:active-=1 + run_origins(['one','two','three','four'],execute,workers=2) + self.assertEqual(2,peak);self.assertCountEqual(['one','two','three','four'],seen) + + def test_one_worker_keeps_the_original_serial_order(self): + seen=[];run_origins(['one','two'],seen.append,workers=1) + self.assertEqual(['one','two'],seen) + + def test_a_real_failure_is_propagated_and_no_later_batch_starts(self): + seen=[] + def execute(origin): + seen.append(origin) + if origin=='one':raise ValueError('actual origin failed') + with self.assertRaisesRegex(RuntimeError,'one'): + run_origins(['one','two','three','four'],execute,workers=2) + self.assertNotIn('three',seen);self.assertNotIn('four',seen) + + def test_duplicate_origins_and_unsafe_worker_counts_fail_before_launch(self): + for origins,workers in [([],2),(['one','one'],2),(['one'],0),(['one'],3),(['one'],True)]: + with self.subTest(origins=origins,workers=workers),self.assertRaises(ValueError): + run_origins(origins,lambda origin:self.fail('Worker launched'),workers=workers) + + def test_both_failed_workers_keep_their_distinct_errors(self): + def execute(origin):raise ValueError(origin+' actual failure') + with self.assertRaises(RuntimeError) as result: + run_origins(['one','two'],execute,workers=2) + self.assertIn('one actual failure',str(result.exception)) + self.assertIn('two actual failure',str(result.exception)) diff --git a/tools/verify_world_upgrades.py b/tools/verify_world_upgrades.py index 10280df..b26fce6 100644 --- a/tools/verify_world_upgrades.py +++ b/tools/verify_world_upgrades.py @@ -562,7 +562,10 @@ def verify(args, project): if set(sources) != set(args.origins): raise RuntimeError('Missing requested historical fixture') report = {'candidate_sha256': candidate_hash, 'probe_sha256':probe_hash, 'prepared_sources':prepared_proof, 'cases': {}} - for origin in args.origins: + from historical_workers import run_origins + from threading import Lock + report_lock=Lock() + def run_origin(origin): source,details=sources[origin] for variant in args.variants: name = origin + '-' + variant @@ -594,14 +597,16 @@ def verify(args, project): relative.endswith('r.16.16.mca') and phase in PHASES[:6]) if unopened and digest(world / relative) != original: raise RuntimeError('Unopened distant region was modified: ' + relative) - phases.append(phase) if fingerprint(source) != details['world'] or digest(candidate) != candidate_hash or digest(probe) != probe_hash: raise RuntimeError('Sealed source/candidate changed') - report['cases'][name] = {'phases': phases, 'distant_regions': distant, 'stamp_mutations': changes, - 'source_unchanged': True, 'origin_jar_sha256': details['jar_sha256'], - 'preserved_historical_markers':historical_markers} - (root / 'result.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + with report_lock: + phases.append(phase) + report['cases'][name] = {'phases': list(phases), 'distant_regions': distant, 'stamp_mutations': changes, + 'source_unchanged': True, 'origin_jar_sha256': details['jar_sha256'], + 'preserved_historical_markers':historical_markers} + (root / 'result.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') print('WORLD_UPGRADE_PASS', name, phase, flush=True) + run_origins(args.origins,run_origin,workers=getattr(args,'workers',1)) print('WORLD_UPGRADE_MATRIX_PASS cases=' + str(len(report['cases'])), flush=True) From aa221bdd0ea8ec12fe02174aa634f392eafe8a11 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Fri, 30 Oct 2026 14:13:14 +0000 Subject: [PATCH 4/5] Keep gameplay qualification local before publication --- .github/scripts/test_release_workflow.py | 29 +- .github/workflows/deploy-release.yml | 30 ++- .github/workflows/native117.yml | 327 ++--------------------- tools/test_native117_ci.py | 15 +- 4 files changed, 70 insertions(+), 331 deletions(-) diff --git a/.github/scripts/test_release_workflow.py b/.github/scripts/test_release_workflow.py index 4873c2f..e5faaa2 100644 --- a/.github/scripts/test_release_workflow.py +++ b/.github/scripts/test_release_workflow.py @@ -25,8 +25,8 @@ ['released-113', 'recovery-113', 'via113-110', 'via113-111', 'via113-112'] for stamps in ['normal', 'stamps']] MATRIX115 = ['World upgrade matrix', 'Prepare verified migration inputs', 'Validate release workflow'] + ['Historical world shard ' + str(i) for i in range(6)] -CORE117 = ['Build, test, and audit', 'Analyze Java', 'Validation'] -MATRIX117 = ['Prepare verified migration inputs', 'Validate release workflow'] + ['Historical world shard ' + str(i) for i in range(12)] + ['Native117 ' + x for x in ['servers','default','config','skin','issues','names','rejected','multiplayer','visibility','respawn','control']] +CORE117 = ['Build, test, and audit', 'Analyze Java', 'Validation', 'Validate release workflow'] +MATRIX117 = [] MATRIX116 = ['World upgrade matrix', 'Prepare verified migration inputs', 'Validate release workflow'] + ['Historical world shard ' + str(i) for i in range(12)] MATRIX = [origin + ' / ' + stamps for origin in ['original-110', 'released-110', 'original-111', 'recovery-110'] @@ -56,7 +56,7 @@ def select(self, version, repository=REPOSITORY, ref='refs/heads/master-1.12'): values = dict(line.split('=', 1) for line in output.read_text().splitlines()) if output.exists() else {} return result.returncode, values - def validate(self, minecraft, names=None, mutation=None, release_exists=False, wrong_tag=False, audit_success=True, qualification=None): + def validate(self, minecraft, names=None, mutation=None, release_exists=False, wrong_tag=False, audit_success=True, qualification=None, local_receipt=None): suffix, branch, forge = { '1.10.2': ('110021', '1.10.2', '12.18.3.2511'), '1.11.2': ('111021', '1.11.2-dev', '13.20.1.2588'), @@ -67,6 +67,9 @@ def validate(self, minecraft, names=None, mutation=None, release_exists=False, w '1.16.5': ('116051', 'master-1.16.5', '36.2.42'), '1.17.1': ('117011', 'master-1.17.1', '37.1.1')}[minecraft] version = '2.4.2.' + suffix + files={name:'C'*64 for name in ['main.jar','sources.jar','javadoc.jar','release.pom','CHANGELOG.txt','SHA256SUMS']} + receipt={'schema':1,'source':SHA,'version':version,'files':files,'evidence_sha256':'F'*64,'qualified_locally':True,'manual_accepted':True} + if local_receipt is not None:receipt=local_receipt values = dict(minecraft_version=minecraft, forge_version=forge, loader_name='forge', loader_code='1', java_version='16' if minecraft=='1.17.1' else '8', java_toolchain_version='16.0.2+7' if minecraft=='1.17.1' else '8.0.502+7', gradle_java_version='17', mod_id='lootablebodies', mod_artifact='LootableBodies', mod_group='zone.moddev.mc.lootablebodies', mod_license='LGPL-2.1-only', @@ -100,8 +103,9 @@ def existing(args, **options): with tempfile.TemporaryDirectory() as directory, patch.dict(os.environ, { 'RELEASE_VERSION': version, 'GITHUB_REPOSITORY': REPOSITORY, 'MINECRAFT_VERSION': minecraft, 'SOURCE_BRANCH': branch, - 'GITHUB_OUTPUT': str(Path(directory) / 'output')}), \ - patch('pathlib.Path.read_text', return_value='\n'.join(k+'='+v for k,v in values.items())), \ + 'GITHUB_OUTPUT': str(Path(directory) / 'output'), + 'LOCAL_QUALIFICATION':json.dumps(receipt) if receipt else ''}), \ + patch('pathlib.Path.read_text', side_effect=['\n'.join(k+'='+v for k,v in values.items()),json.dumps({'files':files})]), \ patch('subprocess.check_output', side_effect=command), patch('subprocess.run', side_effect=existing): exec(compile(code, 'release-preflight', 'exec'), {}) @@ -231,12 +235,25 @@ def test_117_routes_to_the_accepted_target(self): code,values=self.select('2.4.8.117011') self.assertEqual(0,code);self.assertEqual('master-1.17.1',values['source_branch']) - def test_117_requires_all_native_scenarios_and_twelve_historical_shards(self): + def test_117_requires_build_security_and_archive_checks_without_hosted_games(self): self.validate('1.17.1') for missing in CORE117+MATRIX117: with self.subTest(missing=missing),self.assertRaises(SystemExit): self.validate('1.17.1',names=[x for x in CORE117+MATRIX117 if x!=missing]) + def test_117_requires_a_complete_hash_bound_local_qualification_receipt(self): + self.validate('1.17.1') + files={name:'C'*64 for name in ['main.jar','sources.jar','javadoc.jar','release.pom','CHANGELOG.txt','SHA256SUMS']} + good={'schema':1,'source':SHA,'version':'2.4.2.117011','files':files,'evidence_sha256':'F'*64,'qualified_locally':True,'manual_accepted':True} + for mutation in [{'source':'b'*40},{'version':'2.4.8.117011'},{'files':{}},{'evidence_sha256':''},{'evidence_sha256':'0'*64},{'qualified_locally':False},{'manual_accepted':False},{'schema':True}]: + with self.subTest(mutation=mutation),self.assertRaises(SystemExit): + self.validate('1.17.1',local_receipt=dict(good,**mutation)) + with self.assertRaises(SystemExit):self.validate('1.17.1',local_receipt={}) + + def test_local_receipt_is_passed_to_the_actual_preflight_step(self): + step=WORKFLOW.read_text().split('- name: Validate identity, exact commit and successful checks',1)[1].split(' run: |',1)[0] + self.assertIn('LOCAL_QUALIFICATION: ${{ inputs.local_qualification }}',step) + def test_117_requires_an_exact_successful_merged_native_run(self): for mutation in [{'head_sha':'b'*40},{'head_branch':'master-1.16.5'},{'event':'pull_request'},{'path':'.github/workflows/build.yml'},{'conclusion':'failure'}]: with self.subTest(mutation=mutation),self.assertRaises(SystemExit): diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml index 746e8de..a87ebd1 100644 --- a/.github/workflows/deploy-release.yml +++ b/.github/workflows/deploy-release.yml @@ -19,6 +19,11 @@ on: required: true default: false type: boolean + local_qualification: + description: Hash-bound local qualification receipt, required for the 1.17.1 release. + required: false + default: '' + type: string permissions: actions: read @@ -75,6 +80,7 @@ jobs: SOURCE_BRANCH: ${{ steps.target.outputs.source_branch }} GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ inputs.release_version }} + LOCAL_QUALIFICATION: ${{ inputs.local_qualification }} run: | set -euo pipefail python3 - <<'PY' @@ -126,18 +132,28 @@ jobs: ['Historical world shard ' + str(i) for i in range(6 if minecraft == '1.15.2' else 12)]) qualification_run = '' if native17: - required = {'Prepare verified migration inputs', 'Build, test, and audit', - 'Analyze Java', 'Validation', 'Validate release workflow'} - required.update('Historical world shard ' + str(i) for i in range(12)) - required.update('Native117 ' + group for group in - ['servers', 'default', 'config', 'skin', 'issues', 'names', - 'rejected', 'multiplayer', 'visibility', 'respawn', 'control']) + required = {'Build, test, and audit', 'Analyze Java', 'Validation', 'Validate release workflow'} + # The authorised local controller rehashes actual gameplay proof + # before dispatch. Hosted CI verifies its exact source/artifact + # binding; it does not rerun games or claim a fresh gameplay pass. + try: + local=json.loads(os.environ.get('LOCAL_QUALIFICATION','')) + except (ValueError,TypeError): + raise SystemExit('A local qualification receipt is required') + keys={'schema','source','version','files','evidence_sha256','qualified_locally','manual_accepted'} + accepted=json.loads(Path('.github/release117.json').read_text())['files'] + if (not isinstance(local,dict) or set(local)!=keys or type(local['schema']) is not int or local['schema']!=1 + or local['source']!=sha or local['version']!=version or local['files']!=accepted + or local['qualified_locally'] is not True or local['manual_accepted'] is not True + or not isinstance(local['evidence_sha256'],str) or not re.fullmatch('[A-F0-9]{64}',local['evidence_sha256']) + or local['evidence_sha256']=='0'*64): + raise SystemExit('Local qualification is missing or belongs to another source/artifact bundle') runs = json.loads(subprocess.check_output(['gh', 'api', f'repos/{repo}/actions/workflows/native117.yml/runs?event=push&branch=master-1.17.1&head_sha={sha}&per_page=100'], text=True))['workflow_runs'] exact = [r for r in runs if r['head_sha'] == sha and r['head_branch'] == 'master-1.17.1' and r['event'] == 'push' and r['path'] == '.github/workflows/native117.yml'] if not exact or max(exact, key=lambda r:r['id'])['conclusion'] != 'success': - raise SystemExit('No successful exact-merge native117 qualification') + raise SystemExit('No successful exact-merge native117 build and unit checks') qualification_run = str(max(exact, key=lambda r:r['id'])['id']) for name in required: matching = [check for check in checks if check['name'] == name] diff --git a/.github/workflows/native117.yml b/.github/workflows/native117.yml index f20da47..cca37cb 100644 --- a/.github/workflows/native117.yml +++ b/.github/workflows/native117.yml @@ -5,7 +5,6 @@ on: branches: [master-1.17.1] pull_request: branches: [master-1.17.1] - types: [opened, synchronize, reopened, labeled] permissions: contents: read @@ -19,11 +18,8 @@ env: PYTHONDONTWRITEBYTECODE: '1' jobs: - quick: - name: Fast build and contracts - # Full Linux qualification runs once on the merged MMD source, before - # publication. PRs retain deterministic build, test and archive checks. - if: github.repository != 'MinecraftModDevelopmentMods/LootableBodies' || github.event_name == 'pull_request' + build: + name: Build, test, and audit runs-on: ubuntu-latest timeout-minutes: 40 steps: @@ -40,7 +36,9 @@ jobs: with: distribution: temurin java-version: '17.0.1+12' - - name: Test tools and audit the native117 archives + # Gameplay, graphical clients and world migrations are qualified locally + # before the guarded release request. Hosted CI checks build inputs only. + - name: Test tools, compile and audit the release archives env: LOOTABLEBODIES_JAVA16_HOME: ${{ env.JAVA_HOME_16_X64 }} LOOTABLEBODIES_MAVENIZER_JAVA_HOME: ${{ env.JAVA_HOME_25_X64 }} @@ -48,320 +46,27 @@ jobs: chmod +x gradlew python3 -B -m unittest discover -s tools -p 'test_*.py' -v 2>&1 | tee "$RUNNER_TEMP/native117-python-tools.log" ./gradlew test assemble javadoc verifyProductionRuntimeClasspath prepareReleaseBundle --no-daemon --no-build-cache --stacktrace - - name: Retain fast-check evidence - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: native117-fast-${{ github.sha }} - if-no-files-found: error - path: | - build/test-results/** - build/reports/** - build/release/bundle/* - ${{ runner.temp }}/native117-python-tools.log - # A labelled PR can investigate the Linux display/packet seam without - # repeating preparation, historical worlds or unrelated graphical modes. - - name: Prepare a labelled Linux visibility investigation - if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'native117-visibility-diagnostic') - env: - LOOTABLEBODIES_JAVA16_HOME: ${{ env.JAVA_HOME_16_X64 }} - LOOTABLEBODIES_MAVENIZER_JAVA_HOME: ${{ env.JAVA_HOME_25_X64 }} - run: | - sudo apt-get update -o Acquire::Retries=2 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 - sudo apt-get install --yes --no-install-recommends xvfb x11-xserver-utils libgl1-mesa-dri libglu1-mesa libopenal1 pulseaudio pulseaudio-utils - pulseaudio --start --exit-idle-time=-1 - pactl load-module module-null-sink sink_name=minecraft - echo "PULSE_SINK=minecraft" >> "$GITHUB_ENV" - echo "ALSOFT_DRIVERS=pulse" >> "$GITHUB_ENV" - echo "LIBGL_ALWAYS_SOFTWARE=true" >> "$GITHUB_ENV" - python3 -B tools/native_runtime.py --java "$JAVA_HOME_16_X64/bin/java" --output build/native-server-distribution - python3 -B tools/native_runtime.py --java "$JAVA_HOME_16_X64/bin/java" --output build/native-client-distribution --client - ./gradlew preparePackagedIntegrationProbe prepareMinecraftAssets -PminecraftAssetsDirectory=build/native-assets --no-daemon --offline --console=plain - - name: Run only the labelled Linux visibility investigation - if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'native117-visibility-diagnostic') - run: xvfb-run -a -s '-screen 0 1280x720x24 +extension GLX +render -noreset' python3 -B tools/native117_ci.py scenario --group visibility - - name: Retain the labelled investigation on every outcome - if: always() && github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'native117-visibility-diagnostic') - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: native117-diagnostic-visibility-${{ github.sha }} - if-no-files-found: error - include-hidden-files: true - path: | - build/ci117-commands/** - build/ci117-scenarios/**/*.log - build/ci117-scenarios/**/*.log.gz - build/ci117-scenarios/**/*.txt - build/ci117-scenarios/**/*.json - build/ci117-scenarios/**/*.pass - build/ci117-scenarios/**/*.properties - build/ci117-scenarios/**/*.diagnostics/** - build/ci117-scenarios/**/sync/** - build/ci117-scenarios/**/world/** - build/ci117-scenarios/**/saves/** - build/ci117-scenarios/**/mods/*.jar - build/ci117-scenarios/**/config/** - build/ci117-scenarios/**/crash-reports/** - build/ci117-scenarios/**/screenshots/*.png - build/native-server-distribution/*.log - build/native-client-distribution/*.log - - prepare: - name: Prepare verified migration inputs - if: github.repository == 'MinecraftModDevelopmentMods/LootableBodies' && github.event_name == 'push' - runs-on: ubuntu-latest - timeout-minutes: 90 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 - with: - distribution: temurin - java-version: '25.0.3+9.0.LTS' - - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 - with: - distribution: temurin - java-version: '8.0.502+7' - - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 - with: - distribution: temurin - java-version: '16.0.2+7' - - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 - with: - distribution: temurin - java-version: '17.0.1+12' - - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6 - with: - cache-disabled: true - - name: Pin separate runtimes and test tools - run: | - chmod +x gradlew - echo "GRADLE_USER_HOME=$RUNNER_TEMP/lootablebodies117-gradle" >> "$GITHUB_ENV" - echo "LOOTABLEBODIES_JAVA16_HOME=$JAVA_HOME_16_X64" >> "$GITHUB_ENV" - echo "LOOTABLEBODIES_JAVA8_HOME=$JAVA_HOME_8_X64" >> "$GITHUB_ENV" - echo "LOOTABLEBODIES_MAVENIZER_JAVA_HOME=$JAVA_HOME_25_X64" >> "$GITHUB_ENV" - python3 -B -m unittest discover -s tools -p 'test_*.py' -v 2>&1 | tee "$RUNNER_TEMP/native117-python-tools.log" - - name: Two clean uncached builds with offline replay - run: python3 -B tools/native117_ci.py build - - name: Install graphical runtime support - run: | - sudo apt-get update -o Acquire::Retries=2 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 - sudo apt-get install --yes --no-install-recommends xvfb x11-xserver-utils libgl1-mesa-dri libglu1-mesa libopenal1 pulseaudio pulseaudio-utils - pulseaudio --start --exit-idle-time=-1 - pactl load-module module-null-sink sink_name=minecraft - echo "PULSE_SINK=minecraft" >> "$GITHUB_ENV" - echo "ALSOFT_DRIVERS=pulse" >> "$GITHUB_ENV" - echo "LIBGL_ALWAYS_SOFTWARE=true" >> "$GITHUB_ENV" - - name: Download the original sealed thirty-world bundle - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: LootableBodies-1.16.5-migration-inputs-9edcc246bc156d93dabd51970b5a832d7d53a511 - run-id: 40909860288 - github-token: ${{ github.token }} - path: build/prior116-download - - name: Qualify mapped launches and prepare immutable native inputs once - run: xvfb-run -a -s '-screen 0 1280x720x24 +extension GLX +render -noreset' python3 -B tools/native117_ci.py prepare - - name: Upload the exact six qualified files + python3 - <<'PY' + import hashlib,json + from pathlib import Path + expected=json.loads(Path('.github/release117.json').read_text())['files'] + actual={p.name:hashlib.sha256(p.read_bytes()).hexdigest().upper() for p in Path('build/release/bundle').iterdir() if p.is_file()} + if actual != expected:raise SystemExit('Release files differ from the locally qualified six-file bundle') + PY + - name: Retain the exact six release files uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: LootableBodies-1.17.1-${{ github.sha }} if-no-files-found: error path: build/release/bundle/* - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: native117-history-inputs-${{ github.sha }} - if-no-files-found: error - path: build/migration-ci-inputs/* - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: native117-platform-inputs-${{ github.sha }} - if-no-files-found: error - path: build/ci117-platform-inputs/* - - name: Retain preparation and mapped-runtime evidence on every outcome + - name: Retain build and unit-test evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: native117-preparation-evidence-${{ github.sha }}-${{ github.run_attempt }} + name: native117-fast-${{ github.sha }} if-no-files-found: error - include-hidden-files: true path: | - build/ci117-commands/** - build/ci117-build-proof/** build/test-results/** build/reports/** - build/integration-run/** - build/upgrade-contract-run/** - build/client-integration-run/** - build/ordinary-development/** - build/native37-resource-budget/**/*.log - build/native37-resource-budget/**/*.json - build/native37-resource-budget/**/*.diagnostics/** - build/native37-resource-budget/**/mods/*.jar - ${{ runner.temp }}/lootablebodies117-first-build/** + build/release/bundle/* ${{ runner.temp }}/native117-python-tools.log - build/migration-seeds/**/*.log - build/migration-seeds/**/*.diagnostics/** - build/migration-seeds/**/crash-reports/** - build/migration-seeds/**/world/** - build/migration-seeds/**/mods/*.jar - build/migration-seeds/**/config/** - build/migration-seeds/**/*.properties - build/migration-seeds/*report.json - build/migration-seeds/provenance.json - build/native-server-distribution/**/*.log - build/native-server-distribution/runtime-inputs.json - build/native-client-distribution/**/*.log - build/native-client-distribution/runtime-inputs.json - .gradle/legacy-tools/released-116/build*.log - .gradle/legacy-tools/released-116/source.json - .gradle/legacy-tools/released-116/tools.json - - name: Preserve incomplete SDK inputs after preparation failure - if: failure() && hashFiles('build/native-server-distribution/**', 'build/native-client-distribution/**', 'build/ci117-platform-inputs/**', 'build/migration-ci-inputs/**') != '' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: native117-preparation-failure-inputs-${{ github.sha }}-${{ github.run_attempt }} - if-no-files-found: error - include-hidden-files: true - path: | - build/native-server-distribution/** - build/native-client-distribution/** - build/ci117-platform-inputs/** - build/migration-ci-inputs/** - - upgrade: - name: Historical world shard ${{ matrix.shard }} - needs: prepare - runs-on: ubuntu-latest - timeout-minutes: 45 - strategy: - fail-fast: false - matrix: - shard: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 - with: - distribution: temurin - java-version: '16.0.2+7' - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: native117-history-inputs-${{ github.sha }} - path: build/migration-ci-inputs - - name: Restore and qualify assigned original worlds - run: | - python3 -B tools/migration_ci.py restore --commit "$GITHUB_SHA" - python3 -B tools/migration_ci.py shard --commit "$GITHUB_SHA" --shards 12 --shard ${{ matrix.shard }} --java "$JAVA_HOME/bin/java" - - name: Retain full successful and failed world evidence - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: native117-upgrade-shard-${{ matrix.shard }}-${{ github.sha }} - if-no-files-found: error - include-hidden-files: true - path: | - build/migration-ci/report.json - build/migration-ci-inputs/inputs.json - build/migration-results/**/*.log - build/migration-results/**/*.log.gz - build/migration-results/**/*.json - build/migration-results/**/*.properties - build/migration-results/**/*.diagnostics/** - build/migration-results/**/world/** - build/migration-results/**/mods/*.jar - build/migration-results/**/config/** - build/migration-results/**/crash-reports/** - build/migration-abort/**/*.log - build/migration-abort/**/*.log.gz - build/migration-abort/**/*.json - build/migration-abort/**/*.properties - build/migration-abort/**/*.diagnostics/** - build/migration-abort/**/world/** - build/migration-abort/**/mods/*.jar - build/migration-abort/**/config/** - build/migration-abort/**/original-journal/** - build/migration-abort/**/crash-reports/** - - scenarios: - name: Native117 ${{ matrix.group }} - needs: prepare - runs-on: ubuntu-latest - timeout-minutes: 35 - strategy: - fail-fast: false - matrix: - group: [servers, default, config, skin, issues, names, rejected, multiplayer, visibility, respawn, control] - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 - with: - distribution: temurin - java-version: '16.0.2+7' - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - name: native117-platform-inputs-${{ github.sha }} - path: build/ci117-platform-inputs - - name: Restore exact qualified runtime inputs - run: python3 -B tools/migration_ci.py restore --commit "$GITHUB_SHA" --folder build/ci117-platform-inputs - - name: Install graphical runtime support - run: | - sudo apt-get update -o Acquire::Retries=2 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 - sudo apt-get install --yes --no-install-recommends xvfb x11-xserver-utils libgl1-mesa-dri libglu1-mesa libopenal1 pulseaudio pulseaudio-utils - pulseaudio --start --exit-idle-time=-1 - pactl load-module module-null-sink sink_name=minecraft - echo "PULSE_SINK=minecraft" >> "$GITHUB_ENV" - echo "ALSOFT_DRIVERS=pulse" >> "$GITHUB_ENV" - echo "LIBGL_ALWAYS_SOFTWARE=true" >> "$GITHUB_ENV" - - name: Run the assigned native scenario once - run: xvfb-run -a -s '-screen 0 1280x720x24 +extension GLX +render -noreset' python3 -B tools/native117_ci.py scenario --group ${{ matrix.group }} - - name: Retain complete successful and failed scenario evidence - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: native117-scenario-${{ matrix.group }}-${{ github.sha }} - if-no-files-found: error - include-hidden-files: true - path: | - build/ci117-commands/** - build/ci117-scenarios/**/*.log - build/ci117-scenarios/**/*.log.gz - build/ci117-scenarios/**/*.json - build/ci117-scenarios/**/*.txt - build/ci117-scenarios/**/*.pass - build/ci117-scenarios/**/*.properties - build/ci117-scenarios/**/*.diagnostics/** - build/ci117-scenarios/**/sync/** - build/ci117-scenarios/**/world/** - build/ci117-scenarios/**/saves/** - build/ci117-scenarios/**/mods/*.jar - build/ci117-scenarios/**/config/** - build/ci117-scenarios/**/crash-reports/** - build/ci117-scenarios/**/screenshots/*.png - build/ci117-scenarios/**/connected-screenshots/*.png - build/ci117-platform-inputs/inputs.json - - aggregate: - name: Build, test, and audit - needs: [prepare, upgrade, scenarios] - if: always() && github.repository == 'MinecraftModDevelopmentMods/LootableBodies' && github.event_name == 'push' - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Every required execution must succeed - env: - PREPARE: ${{ needs.prepare.result }} - UPGRADE: ${{ needs.upgrade.result }} - SCENARIOS: ${{ needs.scenarios.result }} - run: test "$PREPARE" = success && test "$UPGRADE" = success && test "$SCENARIOS" = success - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - pattern: native117-upgrade-shard-*-${{ github.sha }} - path: build/migration-shards - - name: Independently audit all 76 cases and 912 native receipts - run: python3 -B tools/migration_ci.py aggregate --commit "$GITHUB_SHA" --shards 12 --folder build/migration-shards - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: native117-world-upgrades-${{ github.sha }} - if-no-files-found: error - path: build/migration-summary.json diff --git a/tools/test_native117_ci.py b/tools/test_native117_ci.py index 5d3bb6a..757d33c 100644 --- a/tools/test_native117_ci.py +++ b/tools/test_native117_ci.py @@ -4,16 +4,17 @@ import native117_ci as ci class Native117CITests(unittest.TestCase): - def test_optional_diagnostic_prepares_the_exact_assets_directory_used_by_scenarios(self): + def test_hosted_workflow_keeps_gameplay_and_sdk_installation_local(self): workflow=(Path(ci.__file__).resolve().parents[1]/'.github/workflows/native117.yml').read_text() - step=workflow.split('- name: Prepare a labelled Linux visibility investigation\n',1)[1].split('\n - name:',1)[0] - self.assertIn('-PminecraftAssetsDirectory=build/native-assets',step) - self.assertIn("'native117-visibility-diagnostic'",step) + for token in ['native_runtime.py','scenario --group','migration_ci.py','xvfb-run','pulseaudio','prepareMinecraftAssets']: + with self.subTest(token=token):self.assertNotIn(token,workflow) + self.assertIn('test assemble javadoc verifyProductionRuntimeClasspath prepareReleaseBundle',workflow) - def test_optional_diagnostic_builds_the_packaged_probe_before_native_launch(self): + def test_hosted_workflow_uploads_the_exact_six_files_for_publication(self): workflow=(Path(ci.__file__).resolve().parents[1]/'.github/workflows/native117.yml').read_text() - step=workflow.split('- name: Prepare a labelled Linux visibility investigation\n',1)[1].split('\n - name:',1)[0] - self.assertIn('preparePackagedIntegrationProbe',step) + self.assertIn('name: LootableBodies-1.17.1-${{ github.sha }}',workflow) + self.assertIn('actual != expected',workflow) + self.assertIn('if-no-files-found: error',workflow) def test_eclipse_gate_receives_the_real_prepared_client_natives_after_sdk_install(self): with tempfile.TemporaryDirectory() as d,patch.object(ci,'PROJECT',Path(d)): From 0c8ae83b03bc6e11e5cfdd7f133f21008709ff07 Mon Sep 17 00:00:00 2001 From: JohnBraham Date: Fri, 30 Oct 2026 14:43:16 +0000 Subject: [PATCH 5/5] Record corpse visibility when the death screen is drawn --- RELEASING.md | 25 ++++++++ .../buildtest/VisibilityClientChecks.java | 59 +++++++++++++++++-- tools/test_runtime_diagnostics.py | 16 +++-- tools/test_visibility_death_proof.py | 41 +++++++++++++ tools/verify_visibility.py | 20 +++++++ 5 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 RELEASING.md create mode 100644 tools/test_visibility_death_proof.py diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..05a5a66 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,25 @@ +# Releasing + +Gameplay, graphical clients and world migration checks run locally before +publishing. GitHub Actions runs the unit tests, build, archive checks and security +checks. It does not install a separate game runtime or run the gameplay suite. + +1. Qualify the packaged release locally, including the supported historical + worlds, refusal cases, client/server packets, graphics, restart and IDE checks. + Keep complete logs, diagnostics, source-world checksums and reviewed images. + Record manual acceptance separately. Use two clean builds with the build cache + disabled and compare all six release files. +2. Freeze the release files and evidence. Existing proof can be reused when its + actual artifacts, runtime, fixtures and relevant code are unchanged. Record + that reuse explicitly, and test changed inputs before accepting them. Keep + failed runs; do not replace them with later results. +3. Review the diff and merge after the unit, build, archive and security checks + pass. Recheck the exact merged source and all six file hashes. The independent + artifact audit checks the uploaded files without rebuilding them. +4. Rehash the retained local evidence and supply its receipt to the guarded + release workflow. For 1.17.1 the receipt binds the exact source commit, version, + six file hashes, evidence hash and manual acceptance. Publish the files already + checked by CI, then independently download and compare the public GitHub files + and CurseForge jar. An accepted upload alone does not complete the release. + +Local evidence and test output stay outside the repository and release assets. diff --git a/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java b/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java index 13b93ff..6f8a5cc 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java @@ -33,25 +33,73 @@ final class VisibilityClientChecks { private boolean connecting,respawnSent,finished; private final Set done=new HashSet<>(); private final Set rendered=new HashSet<>(); + private final Set deathRendered=new HashSet<>(); + private net.minecraft.network.Connection traced; + private net.minecraft.client.multiplayer.ClientLevel profileWorld; void register(){if(Boolean.getBoolean("lootablebodies.visibility")&&role!=null){net.minecraftforge.common.MinecraftForge.EVENT_BUS.register(this);MinecraftForge.EVENT_BUS.register(this);}} @SubscribeEvent public void mouse(net.minecraftforge.client.event.GuiScreenEvent.MouseClickedEvent.Pre e){e.setCanceled(true);} @SubscribeEvent public void key(net.minecraftforge.client.event.GuiScreenEvent.KeyboardKeyPressedEvent.Pre e){e.setCanceled(true);} - @SubscribeEvent public void rendered(RenderLivingEvent.Post e){if(e.getEntity() instanceof EntityLootableBody)rendered.add(e.getEntity().getUUID());} + @SubscribeEvent public void rendered(RenderLivingEvent.Post e){ + if(e.getEntity() instanceof EntityLootableBody) { + rendered.add(e.getEntity().getUUID()); + if(Minecraft.getInstance().screen instanceof DeathScreen && !e.getEntity().isRemoved() && !e.getEntity().isInvisible())deathRendered.add(e.getEntity().getUUID()); + } + } + @SubscribeEvent public void deathScreenDrawn(net.minecraftforge.client.event.GuiScreenEvent.DrawScreenEvent.Post e) { + if(!role.equals("A") || !(e.getGui() instanceof DeathScreen) || (phase!=2&&phase!=14) || done.contains(phase))return; + Minecraft mc=Minecraft.getInstance(); + try { + EntityLootableBody b=body(mc,phase==2?"first":"second"); + if(b==null || !deathRendered.contains(b.getUUID()))return; + RuntimeChecks.require(!b.isRemoved()&&!b.isInvisible(),"Rendered death-screen corpse is dead/invisible"); + shot(mc,"death-"+phase+".png"); + String receipt="{\"phase\":"+phase+",\"uuid\":\""+b.getUUID()+"\",\"death_screen_drawn\":true,\"live_corpse_rendered\":true}"; + Files.write(VisibilityChecks.file(role+".death-render-"+phase+".json").toPath(),receipt.getBytes(StandardCharsets.UTF_8)); + System.out.println("VISIBILITY_DEATH_RENDER_PROOF "+role+" "+receipt);ack(); + } catch(Throwable error){error.printStackTrace();finished=true;mc.stop();} + } + @SubscribeEvent public void removed(net.minecraftforge.event.entity.EntityLeaveWorldEvent e) { + Entity entity=e.getEntity(); + if(entity instanceof EntityLootableBody && entity.level.isClientSide) { + System.out.println("VISIBILITY_CLIENT_REMOVAL "+role+" phase="+phase+" uuid="+entity.getUUID()+" reason="+entity.getRemovalReason()+" rendered="+rendered.contains(entity.getUUID())); + StackWalker.getInstance().forEach(frame->System.out.println("VISIBILITY_REMOVAL_FRAME "+frame)); + } + } @SubscribeEvent public void tick(TickEvent.ClientTickEvent e) { - if(e.phase!=TickEvent.Phase.END)return;Minecraft mc=Minecraft.getInstance(); + Minecraft mc=Minecraft.getInstance(); + if(e.phase==TickEvent.Phase.START) { + if(mc.level!=null&&mc.level!=profileWorld) { + profileWorld=mc.level; + // These two offline fixture accounts use explicit empty native + // texture properties. Visibility does not depend on Mojang HTTP. + net.minecraft.server.players.GameProfileCache cache=new net.minecraft.server.players.GameProfileCache(null,new File(mc.gameDirectory,"visibility-usercache.json")); + cache.setExecutor(mc::execute); + for(String name:new String[]{"ContainerA","ContainerB"}) { + UUID id=UUID.nameUUIDFromBytes(("OfflinePlayer:"+name).getBytes(StandardCharsets.UTF_8)); + com.mojang.authlib.GameProfile profile=new com.mojang.authlib.GameProfile(id,name); + String texture="{\"timestamp\":0,\"profileId\":\""+id.toString().replace("-","")+"\",\"profileName\":\""+name+"\",\"textures\":{}}"; + profile.getProperties().put("textures",new com.mojang.authlib.properties.Property("textures",Base64.getEncoder().encodeToString(texture.getBytes(StandardCharsets.UTF_8)))); + cache.add(profile); + } + net.minecraft.world.level.block.entity.SkullBlockEntity.setProfileCache(cache); + } + return; + } if(mc.isWindowActive())mc.mouseHandler.releaseMouse();net.minecraft.client.KeyMapping.releaseAll(); try { if(finished){mc.stop();return;} RuntimeChecks.require(++ticks<15000,"Visibility client timeout role="+role+" phase="+phase); RuntimeChecks.require(!VisibilityChecks.file("abort").exists(),"Visibility runner aborted"); int n=Integer.parseInt(new String(Files.readAllBytes(VisibilityChecks.file("phase").toPath()),StandardCharsets.UTF_8).trim()); - if(n!=phase){phase=n;frames=0;guiFrames=0;respawnSent=false;rendered.clear();System.out.println("VISIBILITY_CLIENT_PHASE "+role+" "+phase);}frames++; + if(n!=phase){phase=n;frames=0;guiFrames=0;respawnSent=false;rendered.clear();deathRendered.clear();System.out.println("VISIBILITY_CLIENT_PHASE "+role+" "+phase);}frames++; if(FixtureConnections.retry(mc,role))connecting=false; if(!connecting && FixtureConnections.ready(role) && mc.level==null && mc.screen instanceof TitleScreen && (phase==0 || phase==9 || phase==11)) { connecting=true;mc.options.pauseOnLostFocus=false;mc.options.framerateLimit=30;mc.options.renderDistance=4; FixtureConnections.connect(mc); } if(mc.level==null || mc.player==null)return; + net.minecraft.network.Connection connection=mc.getConnection().getConnection(); + if(connection!=traced){RespawnPacketTrace.install(connection,new File(mc.gameDirectory,"client-packets.txt"),null);traced=connection;} if(frames%200==0)System.out.println("VISIBILITY_CLIENT_STATE "+role+" phase="+phase+" position="+mc.player.blockPosition()+" rendered="+rendered+" screen="+(mc.screen==null?null:mc.screen.getClass().getName())+" deathScreen="+(mc.screen instanceof DeathScreen)+" done="+done+" firstBody="+body(mc,"first")+" secondBody="+body(mc,"second")+" container="+mc.player.containerMenu.getClass().getSimpleName()); if(phase==12 && frames%200==0) { EntityLootableBody b=body(mc,"first");RuntimeChecks.require(b!=null&&!b.isRemoved()&&!b.isInvisible(),"Corpse disappeared during five-minute wait"); @@ -59,8 +107,11 @@ final class VisibilityClientChecks { if(done.contains(phase) || frames<20)return; if(phase==0 || phase==1 || phase==13){ack();return;} if(phase==2 || phase==14) { + // The owner proves the live corpse at the actual rendered death + // screen. Native tracking may remove it before a later tick. + if(role.equals("A"))return; EntityLootableBody b=body(mc,phase==2?"first":"second");if(b==null)return; - if(role.equals("A") && !(mc.screen instanceof DeathScreen))return; + if(!rendered.contains(b.getUUID()))return; RuntimeChecks.require(!b.isRemoved()&&!b.isInvisible(),"Received corpse is dead/invisible");shot(mc,"death-"+phase+".png");ack(); } else if(phase==3 || phase==15) { if(role.equals("A")) { diff --git a/tools/test_runtime_diagnostics.py b/tools/test_runtime_diagnostics.py index 018f7de..fc4e9aa 100644 --- a/tools/test_runtime_diagnostics.py +++ b/tools/test_runtime_diagnostics.py @@ -57,13 +57,21 @@ def test_heap_metrics_are_captured_even_if_a_gc_log_has_no_events(self): home = Path(directory) with self.assertRaises(subprocess.TimeoutExpired): self.run_child(home, 'import time; time.sleep(30)', metric=[ - sys.executable, '-c', 'import sys; print("HEAP_TARGET="+sys.argv[1])']) + sys.executable, '-c', 'from pathlib import Path; import sys,time; ' + 'seen=Path("heap-seen"); time.sleep(30) if seen.exists() else None; ' + 'seen.touch(); print("HEAP_TARGET="+sys.argv[1],flush=True)']) report = json.loads((home/'phase.diagnostics/result.json').read_text()) self.assertTrue(report['metrics']) + self.assertTrue(any(m['returncode']==0 for m in report['metrics'])) + self.assertTrue(any(m['timed_out'] for m in report['metrics'])) for metric in report['metrics']: - self.assertEqual(0, metric['returncode']) - self.assertIn('HEAP_TARGET='+str(report['pid']), - (home/'phase.diagnostics'/metric['file']).read_text()) + # A later collector must stop at the existing runtime deadline. + # Require actual heap output and retain every bounded stop. + self.assertTrue(metric['returncode']==0 or + (metric['timed_out'] and metric['returncode'] is not None)) + if metric['returncode']==0: + self.assertIn('HEAP_TARGET='+str(report['pid']), + (home/'phase.diagnostics'/metric['file']).read_text()) def test_a_stuck_heap_collector_is_killed_without_extending_the_deadline(self): with tempfile.TemporaryDirectory() as directory: diff --git a/tools/test_visibility_death_proof.py b/tools/test_visibility_death_proof.py new file mode 100644 index 0000000..c340bb3 --- /dev/null +++ b/tools/test_visibility_death_proof.py @@ -0,0 +1,41 @@ +"""Death-screen proof requires both the real render receipt and receiving wire.""" +import json,tempfile,unittest +from pathlib import Path +import verify_visibility as verifier + +class VisibilityDeathProofTest(unittest.TestCase): + def fixture(self,root): + (root/'sync').mkdir();(root/'clientA').mkdir() + packets=[] + for phase,name,identity in [(2,'first','11111111-1111-4111-8111-111111111111'),(14,'second','22222222-2222-4222-8222-222222222222')]: + (root/'sync'/(name+'.uuid')).write_text(identity) + receipt=dict(phase=phase,uuid=identity,death_screen_drawn=True,live_corpse_rendered=True) + (root/'sync'/('A.death-render-'+str(phase)+'.json')).write_text(json.dumps(receipt)) + packets.append('1 500 IN MOD_SPAWN id=4 mod=lootablebodies type=1 uuid='+identity) + (root/'clientA/client-packets.txt').write_text('\n'.join(packets)+'\n') + def test_both_actual_deaths_require_render_and_tcp_proof(self): + with tempfile.TemporaryDirectory() as d: + root=Path(d);self.fixture(root) + self.assertEqual([2,14],[x['phase'] for x in verifier.death_render_proof(root)]) + def test_missing_second_death_is_fatal(self): + with tempfile.TemporaryDirectory() as d: + root=Path(d);self.fixture(root);(root/'sync/A.death-render-14.json').unlink() + with self.assertRaises(FileNotFoundError):verifier.death_render_proof(root) + def test_wrong_identity_phase_or_render_event_is_fatal(self): + for key,value in [('uuid','wrong'),('phase',3),('death_screen_drawn',False),('live_corpse_rendered',False),('death_screen_drawn',1)]: + with self.subTest(key=key,value=value),tempfile.TemporaryDirectory() as d: + root=Path(d);self.fixture(root);file=root/'sync/A.death-render-2.json' + receipt=json.loads(file.read_text());receipt[key]=value;file.write_text(json.dumps(receipt)) + with self.assertRaises(RuntimeError):verifier.death_render_proof(root) + def test_an_outgoing_or_wrong_mod_packet_is_not_a_received_corpse(self): + for before,after in [(' IN ',' OUT '),('mod=lootablebodies ','mod=other ')]: + with self.subTest(after=after),tempfile.TemporaryDirectory() as d: + root=Path(d);self.fixture(root);file=root/'clientA/client-packets.txt';file.write_text(file.read_text().replace(before,after)) + with self.assertRaises(RuntimeError):verifier.death_render_proof(root) + def test_dropped_packet_cannot_qualify_a_render_receipt(self): + with tempfile.TemporaryDirectory() as d: + root=Path(d);self.fixture(root);file=root/'clientA/client-packets.txt' + file.write_text(file.read_text()+'3 501 FAULT_DROPPED MOD_SPAWN uuid=11111111-1111-4111-8111-111111111111\n') + with self.assertRaises(RuntimeError):verifier.death_render_proof(root) + +if __name__=='__main__':unittest.main() diff --git a/tools/verify_visibility.py b/tools/verify_visibility.py index 1344b8d..7e6a4c7 100644 --- a/tools/verify_visibility.py +++ b/tools/verify_visibility.py @@ -15,6 +15,22 @@ from verify_packaged import prepare, FORGE, MAIN, digest, run +def death_render_proof(home): + shared=home/'sync';packets=(home/'clientA/client-packets.txt').read_text();proof=[] + for phase,name in [(2,'first'),(14,'second')]: + receipt=json.loads((shared/('A.death-render-'+str(phase)+'.json')).read_text()) + identity=(shared/(name+'.uuid')).read_text().strip() + if (receipt!={'phase':phase,'uuid':identity,'death_screen_drawn':True,'live_corpse_rendered':True} + or type(receipt.get('death_screen_drawn')) is not bool or type(receipt.get('live_corpse_rendered')) is not bool): + raise RuntimeError('Missing actual rendered death-screen corpse proof') + if not any(' IN MOD_SPAWN ' in line and 'mod=lootablebodies ' in line and 'uuid='+identity in line for line in packets.splitlines()): + raise RuntimeError('Rendered corpse lacks its actual receiving TCP spawn packet') + if any('FAULT_DROPPED ' in line and 'uuid='+identity in line for line in packets.splitlines()): + raise RuntimeError('Dropped corpse spawn cannot qualify a render receipt') + proof.append(receipt) + return proof + + def main(): parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('--java',type=Path,required=True) @@ -99,6 +115,9 @@ def start(name): if list((home/name).rglob('crash-reports/*.txt')): raise RuntimeError('Crash report') for role in ['A','B']: if not (home/('client'+role)/(args.mode+'-client.pass')).is_file(): raise RuntimeError('Client did not finish '+role) + death_proof=[] + if args.mode=='visibility' and not args.source_server: + death_proof=death_render_proof(home) if args.mode=='visibility' and not args.source_server: (home/'server/server.properties').write_text(props.replace('server-port='+str(port),'server-port=0')) archive_live_logs(home) @@ -111,6 +130,7 @@ def start(name): (home/'result.json').write_text(json.dumps({'candidate_sha256':before,'mode':args.mode, 'checks':checks,'two_tcp_clients':True,'simultaneous':args.simultaneous,'drop_spawn':args.drop_spawn, 'counter_collision':args.counter_collision,'connected_restart':bool(args.source_server), + 'rendered_death_screen_proof':death_proof, 'computer_control':'none'},indent=2)+'\n') print('VISIBILITY_RESPAWN_PASS',args.mode,flush=True)