diff --git a/.github/workflows/qualify-browser-update-release.yml b/.github/workflows/qualify-browser-update-release.yml index a78363a122..7ebbbfbde4 100644 --- a/.github/workflows/qualify-browser-update-release.yml +++ b/.github/workflows/qualify-browser-update-release.yml @@ -69,9 +69,9 @@ jobs: --from-commit "$from_commit" --to-commit "$to_commit" --tag "$TAG" \ --output "$RUNNER_TEMP/browser-release/packet.json" python3 -c 'import json,os,pathlib; keys=("GITHUB_WORKFLOW_SHA","GITHUB_SHA","GITHUB_RUN_ID","GITHUB_RUN_ATTEMPT"); pathlib.Path(os.environ["RUNNER_TEMP"]+"/browser-release/control.json").write_text(json.dumps({k:os.environ[k] for k in keys},indent=2)+"\n")' - echo "from=$from" >> "$GITHUB_OUTPUT" - echo "from_commit=$from_commit" >> "$GITHUB_OUTPUT" - echo "to_commit=$to_commit" >> "$GITHUB_OUTPUT" + python3 scripts/write_github_output.py from "$from" + python3 scripts/write_github_output.py from_commit "$from_commit" + python3 scripts/write_github_output.py to_commit "$to_commit" - name: Authenticate both release archives before execution env: FROM: ${{ steps.pair.outputs.from }} @@ -88,7 +88,7 @@ jobs: mkdir -m 0700 "$root/$tag" name="pulse-$tag-linux-amd64.tar.gz" for file in "$name" "$name.sshsig"; do - curl --fail --silent --show-error --location --retry 0 --max-time 120 \ + curl --fail --silent --show-error --location --retry 0 --max-time 120 --max-filesize 536870912 \ "https://github.com/rcourtman/Pulse/releases/download/$tag/$file" --output "$root/$tag/$file" done ssh-keygen -Y verify -f "$root/allowed_signers" -I pulse-installer -n pulse-install \ @@ -101,6 +101,8 @@ jobs: uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24.20.0' + cache: '' + package-manager-cache: false - name: Prepare Chromium and native tools env: PLAYWRIGHT_BROWSERS_PATH: ${{ runner.temp }}/browser-release/browsers diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index dc291872a9..fa4ee93db3 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -6431,7 +6431,13 @@ Control is reviewed `main`, bound to `expected_workflow_sha`, its checkout and `GITHUB_WORKFLOW_SHA`. Product inputs are only the latest published immutable stable and the requested published immutable RC. Peeled commit, release/asset IDs, sizes and SHA-256 digests are checked by `release_browser_packet.py`; SSH -signatures verify before execution. Each installed binary hash must match its +signatures verify before execution. Archives are bounded to512MiB (current +stable/RC archives are about265MiB), signatures to16KiB, the fixed regular +server binary to1GiB and expanded inventory to2GiB/4096 entries. Streaming +hashes avoid allocating an entire archive; anonymous downloads have the same +archive-size ceiling. Setup-action caches are explicitly disabled in this +CI-credential-bearing job, and derived command-file outputs use the reviewed +collision-safe writer. Each installed binary hash must match its signed archive, rather than just a reported version. No rebuilt main binary, frozen-product insertion, candidate re-cut or stable promotion is performed. diff --git a/scripts/release_browser_packet.py b/scripts/release_browser_packet.py index 0916cbe2a3..3f1cffd8a9 100644 --- a/scripts/release_browser_packet.py +++ b/scripts/release_browser_packet.py @@ -11,6 +11,11 @@ from pathlib import Path import re import tarfile +MAX_ARCHIVE = 512 * 1024 * 1024 +MAX_SIGNATURE = 16 * 1024 +MAX_BINARY = 1024 * 1024 * 1024 +MAX_EXPANDED = 2 * MAX_BINARY + def release_identity(release, *, tag, commit, prerelease): pattern = r'v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)' @@ -31,7 +36,7 @@ def release_identity(release, *, tag, commit, prerelease): asset = matches[0] if (asset.get('browser_download_url') != f'https://github.com/rcourtman/Pulse/releases/download/{tag}/{name}' or type(asset.get('id')) is not int or asset['id'] <= 0 - or type(asset.get('size')) is not int or not 0 < asset['size'] <= 256 * 1024 * 1024 + or type(asset.get('size')) is not int or not 0 < asset['size'] <= (MAX_SIGNATURE if name.endswith('.sshsig') else MAX_ARCHIVE) or not re.fullmatch(r'sha256:[0-9a-f]{64}', str(asset.get('digest')))): raise ValueError('Unsafe or unbound release asset') assets.append({k: asset[k] for k in ['name', 'id', 'size', 'digest']}) @@ -50,13 +55,13 @@ def archive_identity(path): total_size = 0 for i, member in enumerate(archive): total_size += member.size - if total_size > 1024 * 1024 * 1024: + if total_size > MAX_EXPANDED: raise ValueError('Oversized expanded release archive') if i >= 4096: raise ValueError('Oversized release archive inventory') if member.name in ('./bin/pulse', 'bin/pulse'): matches.append(member) - if len(matches) != 1 or not matches[0].isfile() or not 0 < matches[0].size <= 256 * 1024 * 1024: + if len(matches) != 1 or not matches[0].isfile() or not 0 < matches[0].size <= MAX_BINARY: raise ValueError('Missing or ambiguous regular server binary') stream = archive.extractfile(matches[0]) return hashlib.file_digest(stream, 'sha256').hexdigest() diff --git a/scripts/tests/test_release_browser_packet.py b/scripts/tests/test_release_browser_packet.py index 8a802b70cb..c3763d6b6c 100644 --- a/scripts/tests/test_release_browser_packet.py +++ b/scripts/tests/test_release_browser_packet.py @@ -48,9 +48,19 @@ class BrowserPacketTest(unittest.TestCase): with self.assertRaises(ValueError): packet.packet(self.baseline, release('v6.4.5-rc.9', 'c' * 40), 'a' * 40, 'c' * 40, 'v6.4.5-rc.9') + def test_current_published_archives_fit_bounded_admission(self): + # Actual GitHub metadata readback: stable276780330 and RC277779060 + # bytes both exceed the original256MiB limit. No asset bytes executed. + self.baseline['assets'][0]['size'] = 276780330 + self.candidate['assets'][0]['size'] = 277779060 + self.assertEqual(2, len(self.bind()['releases'])) + self.candidate['assets'][1]['size'] = packet.MAX_SIGNATURE + 1 + with self.assertRaises(ValueError): + self.bind() + def test_rejects_redirect_asset_duplicate_oversize_or_unbound_digest(self): for field, value in [('browser_download_url', 'https://example.com/archive'), ('size', 0), - ('size', 257 * 1024 * 1024), ('digest', None), ('id', True)]: + ('size', 513 * 1024 * 1024), ('digest', None), ('id', True)]: with self.subTest(field=field), self.assertRaises(ValueError): candidate = copy.deepcopy(self.candidate) candidate['assets'][0][field] = value diff --git a/scripts/tests/test_release_lifecycle_rehearsal.py b/scripts/tests/test_release_lifecycle_rehearsal.py index f36a90acca..aa8a143b39 100644 --- a/scripts/tests/test_release_lifecycle_rehearsal.py +++ b/scripts/tests/test_release_lifecycle_rehearsal.py @@ -177,6 +177,16 @@ class HarnessContractTest(unittest.TestCase): self.assertEqual(result.returncode, 2, result.stdout + result.stderr) +class HostedWorkflowCommandFilesTest(unittest.TestCase): + def test_bound_pair_uses_the_reviewed_collision_safe_output_writer(self) -> None: + source = (REPO_ROOT / '.github/workflows/qualify-browser-update-release.yml').read_text() + for field in ('from', 'from_commit', 'to_commit'): + self.assertIn(f'python3 scripts/write_github_output.py {field} "${field}"', source) + self.assertNotIn('echo "to_commit=', source) + self.assertIn("cache: ''", source) + self.assertIn('package-manager-cache: false', source) + + class BrowserRecoveryAccessStopTest(unittest.TestCase): def test_refused_upgrade_does_not_launch_another_login(self) -> None: source = HARNESS_PATH.read_text(encoding="utf-8")