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 b496c6a..cca37cb 100644 --- a/.github/workflows/native117.yml +++ b/.github/workflows/native117.yml @@ -18,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: @@ -39,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 }} @@ -47,270 +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 - - 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/**/*.diagnostics/** - 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-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/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/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..6f8a5cc 100644 --- a/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java +++ b/src/integrationTest/java/cyano/lootable/buildtest/VisibilityClientChecks.java @@ -33,34 +33,85 @@ 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; - 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()); + 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"); } 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/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/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