Files
rustfs/scripts/test_release_build_workflow.py
T
Hauser c7848df9a0 fix(build): enforce GNU ABI floor and canonical target metadata (#8524)
* fix(build): pin gnu glibc baseline to 2.31 and report compile target in version metadata

gnu release binaries were built natively on Ubuntu 24.04, embedding
GLIBC_2.38/2.39 symbol references while the RPM/DEB metadata promised
glibc >= 2.31, so Rocky/RHEL 9 (glibc 2.34) could not run them (#8062).
The two linux-gnu legs now build through cargo-zigbuild pinned to the
2.31 baseline (same-arch; zig acts as the linker), with post-build
guards asserting both the embedded version metadata and the highest
referenced GLIBC symbol version, keeping the package declarations
honest.

Version metadata itself now comes from cargo's TARGET cfg instead of
shadow-rs' host stamping, so builds whose host differs from their
target (cross compilation, musl targets on a glibc-hosted toolchain)
report the platform they were compiled for (#8048 follow-up).

The cross toolchain (zig 0.14.x, cargo-zigbuild 0.23.4) is pinned in
the setup action now that it is load-bearing for releases, the build
job gets an explicit matrix-independent name so the connect-*
acceptance workflows keep matching it when matrix fields change, and
the preview-release workflow contract is updated for the TARGET_ARG
build commands.

Fixes #8062
Follow-up on #8048

* fix(build): enforce GNU ABI floor and canonical target metadata

Pin Zig 0.17.0 and cargo-zigbuild, isolate cross-toolchain caches, and
build GNU releases against the declared glibc 2.31 floor. Validate both
release ELF binaries using their loader version requirements.

Generate target metadata once through shadow-rs and check the actual
release version output without adding a parallel metadata facade.

Refs: rustfs/backlog#2824

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* perf(build): reuse GNU release profile across binaries

Switching the CLI back to the default strip profile restarted 419
compilation units after the server build. Keep the profiling build
profile for both binaries, then strip only the CLI artifact.

Retain serial links, server profiling symbols, and post-strip ABI gates.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* perf(build): limit release artifacts to the rustfs server

Build only rustfs in the release workflow and remove the CLI build,
strip, and packaging paths. Preserve GNU profiling symbols and catalog
sidecars, and align strict acceptance archive readers with the new set.

Exercise all six build targets, Linux package checks, and all four
strict acceptance extractors using their actual workflow scripts.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: majinghe <42570491+majinghe@users.noreply.github.com>
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-13 12:00:40 -07:00

138 lines
7.1 KiB
Python

#!/usr/bin/env python3
"""Exercise server-only release commands and strict acceptance archive readers."""
from __future__ import annotations
import io
import json
import os
import re
import shlex
import subprocess
import sys
import tempfile
import unittest
import zipfile
from pathlib import Path
from test_security_workflow import named_steps, shell_body, yaml_block
ROOT = Path(__file__).resolve().parents[1]
SOURCE = (ROOT / '.github/workflows/build.yml').read_text()
STEPS = named_steps(yaml_block(SOURCE.splitlines(), 'build-rustfs', 2))
MATRIX = json.loads(re.search(r"all='(\{.*?\})'", SOURCE, re.S)[1])['include']
CATALOG = ('rustfs.cpu-symbol-catalog.json', 'rustfs.cpu-symbol-catalog.sha256')
EXTRACTORS = (
('connect-diagnostic-schedule-acceptance.yml', 'diagnostic-schedule', 'Extract the verified official binary'),
('connect-health-service-acceptance.yml', 'health-service', 'Extract only the verified official service binary'),
('connect-profile-memory-acceptance.yml', 'profile-memory', 'Extract only the verified official service binary'),
('connect-top-disk-acceptance.yml', 'top-disk', 'Extract only the verified official service binary'),
)
def render(script, target, package='package.zip'):
context = {f'matrix.{key}': str(value).lower() if isinstance(value, bool) else str(value) for key, value in target.items()}
context['steps.package.outputs.package_file'] = package
return re.sub(r'\$\{\{\s*(.*?)\s*\}\}', lambda match: context[match[1]], script)
def archive(path, members, binary):
with zipfile.ZipFile(path, 'w') as package:
for name in members:
entry = zipfile.ZipInfo(name)
entry.external_attr = (0o100755 if name == 'rustfs' else 0o100644) << 16
package.writestr(entry, binary if name == 'rustfs' else b'fixture')
class ReleaseBuildTests(unittest.TestCase):
def test_every_matrix_target_builds_only_server(self):
mock_cargo = r'''
cargo() {
printf '%q ' "$@" >> cargo-call
printf '\n' >> cargo-call
local file="target/$BUILD_TARGET/release/rustfs"
if [[ "$BUILD_TARGET" == *-windows-msvc ]]; then file+='.exe'; fi
mkdir -p "$(dirname "$file")"
cat > "$file" <<'BIN'
#!/usr/bin/env bash
printf 'build target : %s\nbuild os : %s\nrust channel : stable-%s\n' "$BUILD_TARGET" "$BUILD_OS" "$BUILD_TARGET"
BIN
chmod +x "$file"
}
'''
for target in MATRIX:
with self.subTest(target=target['target_id']), tempfile.TemporaryDirectory() as directory:
path = Path(directory)
(path / 'rustfs').mkdir()
(path / 'rustfs/build.rs').touch()
(path / 'scripts/release').mkdir(parents=True)
(path / 'scripts/release/check_glibc.py').write_bytes((ROOT / 'scripts/release/check_glibc.py').read_bytes())
(path / 'tools').mkdir()
readelf = path / 'tools/readelf'
readelf.write_text("#!/bin/sh\nprintf '%s\\n' 'Version needs section:' 'Name: GLIBC_2.30'\n")
readelf.chmod(0o755)
script = mock_cargo + render(shell_body(STEPS['Build RustFS']), target)
environment = {**os.environ, 'BUILD_TARGET': target['target'],
'BUILD_OS': target['platform'] + '-' + target['target'].split('-')[0],
'PATH': str(path / 'tools') + os.pathsep + os.environ['PATH']}
result = subprocess.run(['bash', '-e', '-o', 'pipefail', '-c', script], cwd=path,
env=environment, capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
calls = (path / 'cargo-call').read_text().splitlines()
self.assertEqual(len(calls), 1)
args = shlex.split(calls[0])
self.assertEqual(args[0], 'zigbuild' if target['cross'] else 'build')
self.assertEqual(args[args.index('--bin') + 1], 'rustfs')
self.assertNotIn('rustfs-cli', args)
self.assertIn('--locked', args)
expected_target = target['target'] + ('.' + target['glibc'] if target['glibc'] else '')
self.assertEqual(args[args.index('--target') + 1], expected_target)
if target['features']:
self.assertEqual(args[args.index('--features') + 1], target['features'])
if target['glibc']:
self.assertEqual(args[args.index('--config') + 1], 'profile.release.strip="none"')
def test_packaged_linux_server_and_catalog_members(self):
binary = b'#!/bin/sh\necho rustfs-fixture\n'
for libc in ('gnu', 'musl'):
target = next(item for item in MATRIX if item['target_id'] == f'linux-x86_64-{libc}')
expected = ['rustfs', *CATALOG] if libc == 'gnu' else ['rustfs']
for members, valid in ((expected, True), (expected + ['rustfs-cli'], False)):
with self.subTest(libc=libc, members=members), tempfile.TemporaryDirectory() as directory:
path = Path(directory)
archive(path / 'package.zip', members, binary)
script = render(shell_body(STEPS['Verify packaged Linux binary']), target)
result = subprocess.run(['bash', '-e', '-o', 'pipefail', '-c', script], cwd=path,
capture_output=True, text=True)
self.assertEqual(result.returncode == 0, valid, result.stderr)
def test_actual_acceptance_extractors_keep_strict_members(self):
source_sha = '0123456789abcdef' * 2 + '01234567'
package_name = f'rustfs-linux-x86_64-gnu-dev-{source_sha[:7]}.zip'
header = bytearray(20)
header[:7] = b'\x7fELF\x02\x01\x01'
header[18:20] = b'\x3e\x00'
valid_members = ['rustfs', *CATALOG]
cases = ((valid_members, True), (['rustfs'], False), (valid_members + ['rustfs-cli'], False),
(valid_members + ['../extra'], False))
for workflow, job_name, step in EXTRACTORS:
source = (ROOT / '.github/workflows' / workflow).read_text()
job = yaml_block(source.splitlines(), job_name, 2)
body = shell_body(named_steps(job)[step])
python = body.split("python3 - <<'PY'\n", 1)[1].split('\nPY', 1)[0]
for members, valid in cases:
with self.subTest(workflow=workflow, members=members), tempfile.TemporaryDirectory() as directory:
path = Path(directory)
archive(path / package_name, members, bytes(header))
with zipfile.ZipFile(path / 'official-artifact.zip', 'w') as outer:
outer.write(path / package_name, package_name)
result = subprocess.run([sys.executable, '-c', python], cwd=path,
env={**os.environ, 'SOURCE_SHA': source_sha}, capture_output=True, text=True)
self.assertEqual(result.returncode == 0, valid, result.stderr)
if valid:
self.assertEqual((path / 'binary/rustfs').read_bytes(), bytes(header))
if __name__ == '__main__':
unittest.main()