From 61fb8dc4ebab0012099abd6e3cbfc0a444f354b9 Mon Sep 17 00:00:00 2001 From: cgnl <120372881+cgnl@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:17:54 +0200 Subject: [PATCH 1/4] Avoid repeated image builds and excessive CI logging PostgreSQL fixture versions do not change the Plex image. Share four immutable native builds across eight test lanes, retain byte binding and certification requirements, and bound normal container logs while keeping explicit diagnostics available. Constraint: Preserve the running frozen v1.3.23 certification source Confidence: high Scope-risk: moderate Tested: 103 Python regressions; actionlint 1.7.12; bash syntax; whitespace; independent workflow review Not-tested: Revised workflow execution on GitHub runners Directive: Merge after v1.3.23 certification and release, preserving its evidence --- .github/workflows/docker-publish.yml | 170 ++++++++++++++++------ docs/ci-efficiency.md | 31 ++++ scripts/plex-container-e2e.sh | 11 +- scripts/tests/test_native_image_matrix.py | 93 ++++++++++++ scripts/tests/test_native_log_limits.py | 84 +++++++++++ 5 files changed, 342 insertions(+), 47 deletions(-) create mode 100644 docs/ci-efficiency.md create mode 100644 scripts/tests/test_native_image_matrix.py create mode 100644 scripts/tests/test_native_log_limits.py diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 58057e93..b67ea20c 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -207,59 +207,36 @@ jobs: docker rm -fv "$container" rm "$RUNTIME_E2E_CONTAINER_FILE" - candidate: - name: Probe ${{ matrix.variant }} / ${{ matrix.arch }} / PG${{ matrix.postgres }} - needs: [resolve, media-fixtures, linux-bundle] + candidate-image: + name: Build ${{ matrix.variant }} / ${{ matrix.arch }} + needs: [resolve, linux-bundle] + if: ${{ always() && needs.resolve.result == 'success' && needs.linux-bundle.result == 'success' }} permissions: contents: read packages: write runs-on: ${{ matrix.runner }} timeout-minutes: 60 - strategy: &native_strategy + strategy: fail-fast: false - max-parallel: 8 + max-parallel: 4 matrix: include: - variant: linuxserver dockerfile: Dockerfile arch: amd64 runner: ubuntu-24.04 - postgres: 15 - - variant: linuxserver - dockerfile: Dockerfile - arch: amd64 - runner: ubuntu-24.04 - postgres: 18 - variant: linuxserver dockerfile: Dockerfile arch: arm64 runner: ubuntu-24.04-arm - postgres: 15 - - variant: linuxserver - dockerfile: Dockerfile - arch: arm64 - runner: ubuntu-24.04-arm - postgres: 18 - variant: plexinc dockerfile: Dockerfile.standalone arch: amd64 runner: ubuntu-24.04 - postgres: 15 - - variant: plexinc - dockerfile: Dockerfile.standalone - arch: amd64 - runner: ubuntu-24.04 - postgres: 18 - - variant: plexinc - dockerfile: Dockerfile.standalone - arch: arm64 - runner: ubuntu-24.04-arm - postgres: 15 - variant: plexinc dockerfile: Dockerfile.standalone arch: arm64 runner: ubuntu-24.04-arm - postgres: 18 steps: - name: Use an isolated Docker client configuration run: | @@ -273,10 +250,6 @@ jobs: with: ref: ${{ needs.resolve.outputs.source_sha }} persist-credentials: false - - uses: actions/download-artifact@v4 - with: - name: ${{ inputs.profile_id && format('{0}-pinned-real-media', inputs.profile_id) || 'pinned-real-media' }} - path: media-fixture-cache - uses: actions/download-artifact@v4 with: name: ${{ inputs.profile_id && format('{0}-upstream-candidate', inputs.profile_id) || 'upstream-candidate' }} @@ -293,13 +266,12 @@ jobs: REPOSITORY_OWNER: ${{ github.repository_owner }} VARIANT: ${{ matrix.variant }} ARCH: ${{ matrix.arch }} - POSTGRES_MAJOR: ${{ matrix.postgres }} PROFILE_ID: ${{ inputs.profile_id }} run: | set -eu IMAGE="ghcr.io/${REPOSITORY_OWNER,,}/plex-postgresql-${VARIANT}" echo "repository=$IMAGE" >> "$GITHUB_OUTPUT" - echo "tag=$IMAGE:candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${PROFILE_ID:-legacy}-${ARCH}-pg${POSTGRES_MAJOR}" >> "$GITHUB_OUTPUT" + echo "tag=$IMAGE:candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${PROFILE_ID:-legacy}-${ARCH}" >> "$GITHUB_OUTPUT" - uses: actions/download-artifact@v4 with: name: ${{ (inputs.payload_profile_id || inputs.profile_id) && format('{0}-', inputs.payload_profile_id || inputs.profile_id) || '' }}runtime-payload-${{ matrix.arch }} @@ -320,13 +292,128 @@ jobs: RUNTIME_PAYLOAD=certified_payload build-contexts: | certified_payload=./certified-payload + - name: Preserve immutable build identity + env: + CANDIDATE_REPOSITORY: ${{ steps.image.outputs.repository }} + CANDIDATE_DIGEST: ${{ steps.build.outputs.digest }} + BUILD_METADATA: ${{ steps.build.outputs.metadata }} + SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }} + run: | + printf '%s\n' "$CANDIDATE_REPOSITORY@$CANDIDATE_DIGEST" > evidence/candidate-ref.txt + printf '%s\n' "$BUILD_METADATA" > evidence/build-metadata.json + printf '%s\n' "$SOURCE_SHA" > evidence/source-sha.txt + - uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.profile_id && format('{0}-', inputs.profile_id) || '' }}built-image-${{ matrix.variant }}-${{ matrix.arch }} + path: evidence + if-no-files-found: error + + candidate: + name: Probe ${{ matrix.variant }} / ${{ matrix.arch }} / PG${{ matrix.postgres }} + needs: [resolve, media-fixtures, candidate-image] + if: ${{ always() && needs.resolve.result == 'success' && needs.media-fixtures.result == 'success' && needs.candidate-image.result == 'success' }} + permissions: + contents: read + packages: read + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + strategy: &native_strategy + fail-fast: false + max-parallel: 8 + matrix: + include: + - variant: linuxserver + dockerfile: Dockerfile + arch: amd64 + runner: ubuntu-24.04 + postgres: 15 + - variant: linuxserver + dockerfile: Dockerfile + arch: amd64 + runner: ubuntu-24.04 + postgres: 18 + - variant: linuxserver + dockerfile: Dockerfile + arch: arm64 + runner: ubuntu-24.04-arm + postgres: 15 + - variant: linuxserver + dockerfile: Dockerfile + arch: arm64 + runner: ubuntu-24.04-arm + postgres: 18 + - variant: plexinc + dockerfile: Dockerfile.standalone + arch: amd64 + runner: ubuntu-24.04 + postgres: 15 + - variant: plexinc + dockerfile: Dockerfile.standalone + arch: amd64 + runner: ubuntu-24.04 + postgres: 18 + - variant: plexinc + dockerfile: Dockerfile.standalone + arch: arm64 + runner: ubuntu-24.04-arm + postgres: 15 + - variant: plexinc + dockerfile: Dockerfile.standalone + arch: arm64 + runner: ubuntu-24.04-arm + postgres: 18 + steps: + - name: Use an isolated Docker client configuration + run: | + set -eu + docker_config=$(mktemp -d "$RUNNER_TEMP/plex-docker-config.XXXXXX") + printf '{}\n' > "$docker_config/config.json" + chmod 700 "$docker_config" + chmod 600 "$docker_config/config.json" + echo "DOCKER_CONFIG=$docker_config" >> "$GITHUB_ENV" + - uses: actions/checkout@v4 + with: + ref: ${{ needs.resolve.outputs.source_sha }} + persist-credentials: false + - uses: actions/download-artifact@v4 + with: + name: ${{ inputs.profile_id && format('{0}-pinned-real-media', inputs.profile_id) || 'pinned-real-media' }} + path: media-fixture-cache + - uses: actions/download-artifact@v4 + with: + name: ${{ inputs.profile_id && format('{0}-', inputs.profile_id) || '' }}built-image-${{ matrix.variant }}-${{ matrix.arch }} + path: evidence + - name: Require the shared immutable image on the resolved source + env: + SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }} + REPOSITORY_OWNER: ${{ github.repository_owner }} + VARIANT: ${{ matrix.variant }} + run: | + python3 - <<'PYCODE' + import os, re + from pathlib import Path + root = Path('evidence') + image = (root / 'candidate-ref.txt').read_text().strip() + prefix = 'ghcr.io/' + os.environ['REPOSITORY_OWNER'].lower() + '/plex-postgresql-' + os.environ['VARIANT'] + '@' + if not image.startswith(prefix) or not re.fullmatch(r'sha256:[0-9a-f]{64}', image[len(prefix):]): + raise SystemExit('Shared image must be an immutable candidate digest') + if (root / 'source-sha.txt').read_text().strip() != os.environ['SOURCE_SHA']: + raise SystemExit('Shared image source mismatch') + with open(os.environ['GITHUB_ENV'], 'a') as output: + output.write('CANDIDATE_IMAGE=' + image + '\n') + PYCODE + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - uses: actions/download-artifact@v4 with: name: ${{ inputs.profile_id && format('{0}-linux-release-zip', inputs.profile_id) || 'linux-release-zip' }} path: release-bundle - name: Bind the container payload to exact certified bundle bytes env: - CANDIDATE: ${{ steps.image.outputs.repository }}@${{ steps.build.outputs.digest }} + CANDIDATE: ${{ env.CANDIDATE_IMAGE }} ARCH: ${{ matrix.arch }} run: | docker pull "$CANDIDATE" @@ -348,7 +435,6 @@ jobs: coredumpctl --version - name: Verify the exact candidate digest env: - CANDIDATE_IMAGE: ${{ steps.image.outputs.repository }}@${{ steps.build.outputs.digest }} BASE_PLEX_IMAGE: ${{ matrix.variant == 'linuxserver' && needs.resolve.outputs.linuxserver_image || needs.resolve.outputs.plexinc_image }} POSTGRES_IMAGE: ${{ needs.resolve.outputs[format('postgres{0}_image', matrix.postgres)] }} EXPECTED_PLEX_VERSION: ${{ needs.resolve.outputs.plex_version }} @@ -358,20 +444,13 @@ jobs: PLEX_E2E_MEDIA_CACHE_DIR: ${{ github.workspace }}/media-fixture-cache PLEX_E2E_RESTART_CYCLES: ${{ inputs.mode == 'probe' && '1' || '100' }} run: bash scripts/plex-container-e2e.sh - - name: Preserve candidate digest and build metadata + - name: Preserve PostgreSQL lane identity if: always() env: - CANDIDATE_REPOSITORY: ${{ steps.image.outputs.repository }} - CANDIDATE_DIGEST: ${{ steps.build.outputs.digest }} - BUILD_METADATA: ${{ steps.build.outputs.metadata }} - SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }} POSTGRES_MAJOR: ${{ matrix.postgres }} POSTGRES_IMAGE: ${{ needs.resolve.outputs[format('postgres{0}_image', matrix.postgres)] }} run: | mkdir -p evidence - printf '%s\n' "$CANDIDATE_REPOSITORY@$CANDIDATE_DIGEST" > evidence/candidate-ref.txt - printf '%s\n' "$BUILD_METADATA" > evidence/build-metadata.json - printf '%s\n' "$SOURCE_SHA" > evidence/source-sha.txt printf '%s\n' "$POSTGRES_MAJOR" > evidence/postgres-major.txt printf '%s\n' "$POSTGRES_IMAGE" > evidence/postgres-ref.txt - uses: actions/upload-artifact@v4 @@ -535,6 +614,7 @@ jobs: native-bundle-install: name: Native ZIP / ${{ matrix.variant }} / ${{ matrix.arch }} needs: [resolve, linux-bundle] + if: ${{ always() && needs.resolve.result == 'success' && needs.linux-bundle.result == 'success' }} runs-on: ${{ matrix.runner }} timeout-minutes: 45 strategy: diff --git a/docs/ci-efficiency.md b/docs/ci-efficiency.md new file mode 100644 index 00000000..3d83b7b1 --- /dev/null +++ b/docs/ci-efficiency.md @@ -0,0 +1,31 @@ +# Native CI cost and timing + +The native matrix covers LinuxServer and PlexInc, AMD64 and ARM64, and +PostgreSQL 15 and 18. PostgreSQL runs in a separate test container; its version +does not change the Plex image. + +Build each Plex image once, then test its immutable digest with both PostgreSQL +versions. This reduces image builds from eight to four while retaining all eight +test combinations. Each test still checks that the installed libraries match the +release ZIP. Tests can read packages; only build jobs can push candidates. + +Normal runs use INFO shim logs and PostgreSQL error logging. Docker keeps three +20 MB log files per test container. Recent errors are collected with the test +results; older rotated output is discarded. For a focused diagnosis, set: + +```sh +PLEX_E2E_SHIM_LOG_LEVEL=DEBUG +PLEX_E2E_POSTGRES_LOG_STATEMENT=all +``` + +A short probe checks changed Plex inputs. The full release run requires 100 +restarts and 350 minutes of sustained work per combination, plus four native +ZIP installation tests. Reducing duplicate builds saves runner time and download +traffic, but the full load phase still takes at least 5h50. + +Retry a lost runner separately after the other jobs finish. Assertion failures +need investigation. A retry must still provide valid source, digest and artifact +provenance before publication; a green badge alone is insufficient. + +These changes should be merged after the currently frozen release has finished +testing. They do not alter that running workflow or its evidence. diff --git a/scripts/plex-container-e2e.sh b/scripts/plex-container-e2e.sh index b3e49f73..533c2a4b 100644 --- a/scripts/plex-container-e2e.sh +++ b/scripts/plex-container-e2e.sh @@ -7,6 +7,11 @@ set -euo pipefail : "${EXPECTED_ARCH:?native architecture required}" : "${VARIANT:?linuxserver or plexinc required}" EVIDENCE_DIR=${EVIDENCE_DIR:-container-evidence} +# Verbose diagnostics are opt-in; full-duration runs retain bounded error context. +shim_log_level=${PLEX_E2E_SHIM_LOG_LEVEL:-INFO} +postgres_log_statement=${PLEX_E2E_POSTGRES_LOG_STATEMENT:-none} +case "$shim_log_level" in INFO|DEBUG) ;; *) echo "Shim log level must be INFO or DEBUG" >&2; exit 2 ;; esac +case "$postgres_log_statement" in none|all) ;; *) echo "PostgreSQL statement logging must be none or all" >&2; exit 2 ;; esac restart_cycles=${PLEX_E2E_RESTART_CYCLES:-1} [[ "$restart_cycles" =~ ^[0-9]{1,5}$ ]] || { echo "Invalid restart cycle count" >&2; exit 2; } restart_cycles=$((10#$restart_cycles)) @@ -479,8 +484,9 @@ docker network create --internal --label "plex-pg-canary=$fixture" "$network" >/ docker volume create --label "plex-pg-canary=$fixture" "$config" >/dev/null docker volume create --label "plex-pg-canary=$fixture" "$negative_config" >/dev/null docker run -d --name "$postgres" --label "plex-pg-canary=$fixture" --network "$network" --network-alias postgres \ + --log-driver json-file --log-opt max-size=20m --log-opt max-file=3 \ -e POSTGRES_USER=plex -e "POSTGRES_PASSWORD=$password" -e POSTGRES_DB=plex \ - "$POSTGRES_IMAGE" -c log_statement=all >/dev/null + "$POSTGRES_IMAGE" -c "log_statement=$postgres_log_statement" -c log_min_error_statement=error >/dev/null postgres_ready=false for ((attempt=1; attempt<=60; attempt++)); do if docker exec "$postgres" pg_isready -U plex -d plex >/dev/null 2>&1; then postgres_ready=true; break; fi @@ -490,10 +496,11 @@ done start_plex() { docker run -d --name "$1" --label "plex-pg-canary=$fixture" --network "$network" \ + --log-driver json-file --log-opt max-size=20m --log-opt max-file=3 \ -v "$2:/config" \ -e VERSION=docker -e PLEX_PG_HOST=postgres -e PLEX_PG_PORT=5432 \ -e PLEX_PG_DATABASE=plex -e PLEX_PG_USER=plex -e "PLEX_PG_PASSWORD=$password" \ - -e PLEX_PG_SCHEMA=plex -e PLEX_PG_LOG_LEVEL=DEBUG -e MIGRATION_INTERACTIVE=0 \ + -e PLEX_PG_SCHEMA=plex -e "PLEX_PG_LOG_LEVEL=$shim_log_level" -e MIGRATION_INTERACTIVE=0 \ -e PLEX_PG_REAPER_DIAGNOSTICS=1 \ "$candidate_id" >/dev/null } diff --git a/scripts/tests/test_native_image_matrix.py b/scripts/tests/test_native_image_matrix.py new file mode 100644 index 00000000..2608f661 --- /dev/null +++ b/scripts/tests/test_native_image_matrix.py @@ -0,0 +1,93 @@ +"""Guard build sharing without reducing native acceptance coverage.""" +import os +from pathlib import Path +import re +import subprocess +import tempfile +import textwrap +import unittest + + +WORKFLOW = Path(__file__).resolve().parents[2] / '.github/workflows/docker-publish.yml' + + +class NativeImageMatrix(unittest.TestCase): + def setUp(self): + self.workflow = WORKFLOW.read_text() + self.build = self.workflow.split('\n candidate-image:\n')[1].split('\n candidate:\n')[0] + self.candidate = self.workflow.split('\n candidate:\n')[1].split('\n soak:\n')[0] + + def test_four_native_builds_feed_eight_postgres_test_lanes(self): + rows = re.findall(r'- variant: (\w+)\n\s+dockerfile: [^\n]+\n\s+arch: (\w+)\n\s+runner: ([^\n]+)', self.build) + self.assertEqual(len(rows), 4) + self.assertEqual({(v, a) for v, a, _ in rows}, + {(v, a) for v in ('linuxserver', 'plexinc') for a in ('amd64', 'arm64')}) + for _, arch, runner in rows: + self.assertEqual(runner, 'ubuntu-24.04-arm' if arch == 'arm64' else 'ubuntu-24.04') + self.assertNotIn('postgres:', self.build) + self.assertEqual(self.candidate.count('postgres: 15'), 4) + self.assertEqual(self.candidate.count('postgres: 18'), 4) + self.assertIn('strategy: *native_strategy', self.workflow.split('\n soak:\n')[1]) + + def test_test_jobs_cannot_build_or_push_images(self): + self.assertIn('packages: write', self.build) + self.assertIn('packages: read', self.candidate) + self.assertNotIn('packages: write', self.candidate) + self.assertNotIn('docker/build-push-action', self.candidate) + self.assertNotIn('docker/setup-buildx-action', self.candidate) + self.assertIn('needs: [resolve, linux-bundle]', self.build) + self.assertIn('needs: [resolve, media-fixtures, candidate-image]', self.candidate) + self.assertEqual(self.build.count('docker/build-push-action'), 1) + self.assertNotIn('docker pull', self.build) + + def test_historical_payload_skip_does_not_skip_successful_consumers(self): + # Historical profiles reuse the latest payload, so linux-payload is + # deliberately skipped while linux-bundle completes. Every consumer + # must override Actions' implicit ancestor success() with always(), + # while still requiring every direct dependency to have succeeded. + dependencies = { + 'candidate-image': ('resolve', 'linux-bundle'), + 'candidate': ('resolve', 'media-fixtures', 'candidate-image'), + 'native-bundle-install': ('resolve', 'linux-bundle'), + } + for job, required in dependencies.items(): + with self.subTest(job=job): + block = self.workflow.split('\n ' + job + ':\n')[1].split(' steps:', 1)[0] + condition = re.search(r'^ if: \$\{\{ (.+) \}\}$', block, re.MULTILINE).group(1) + self.assertEqual(condition, 'always() && ' + ' && '.join( + "needs." + dependency + ".result == 'success'" for dependency in required)) + self.assertNotIn('linux-payload.result', condition) + + def test_existing_certificate_lane_shape_and_byte_binding_remain(self): + self.assertIn('built-image-${{ matrix.variant }}-${{ matrix.arch }}', self.build) + self.assertIn('built-image-${{ matrix.variant }}-${{ matrix.arch }}', self.candidate) + self.assertIn("inputs.payload_profile_id || inputs.profile_id", self.build) + for field in ('candidate-ref.txt', 'build-metadata.json', 'source-sha.txt'): + self.assertIn(field, self.build) + self.assertIn('verify_candidate_payload.py --bundle', self.candidate) + self.assertIn('--output evidence/payload-binding.json', self.candidate) + self.assertIn('candidate-${{ matrix.variant }}-${{ matrix.arch }}-pg${{ matrix.postgres }}', self.candidate) + self.assertIn("inputs.mode == 'probe' && '1' || '100'", self.candidate) + self.assertIn('21000', self.workflow) + self.assertNotIn('steps.build.outputs', self.candidate) + + def test_consumer_rejects_tags_other_repository_and_wrong_source(self): + block = self.candidate.split('- name: Require the shared immutable image on the resolved source\n')[1] + script = textwrap.dedent(block.split(' run: |\n')[1].split(' - uses:')[0]) + image = 'ghcr.io/cgnl/plex-postgresql-plexinc@sha256:' + 'a' * 64 + sha = 'b' * 40 + for ref, source, ok in [(image, sha, True), (image.replace('@sha256:' + 'a' * 64, ':latest'), sha, False), + (image.replace('/cgnl/', '/other/'), sha, False), (image, 'c' * 40, False)]: + with self.subTest(ref=ref, source=source), tempfile.TemporaryDirectory() as tmp: + root = Path(tmp); (root / 'evidence').mkdir() + (root / 'evidence/candidate-ref.txt').write_text(ref) + (root / 'evidence/source-sha.txt').write_text(source) + env = dict(os.environ, SOURCE_SHA=sha, REPOSITORY_OWNER='cgnl', VARIANT='plexinc', GITHUB_ENV=str(root / 'env')) + result = subprocess.run(['bash', '-e', '-c', script], cwd=tmp, env=env, capture_output=True) + self.assertEqual(result.returncode == 0, ok, result.stderr) + if ok: + self.assertEqual((root / 'env').read_text(), 'CANDIDATE_IMAGE=' + image + '\n') + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/tests/test_native_log_limits.py b/scripts/tests/test_native_log_limits.py new file mode 100644 index 00000000..9c1738ad --- /dev/null +++ b/scripts/tests/test_native_log_limits.py @@ -0,0 +1,84 @@ +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +class NativeLogLimits(unittest.TestCase): + def run_driver(self, **overrides): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + driver = root / 'plex-container-e2e.sh' + shutil.copyfile(Path(__file__).resolve().parents[1] / driver.name, driver) + (root / 'prepare-media-fixtures.py').write_text( + 'import pathlib,sys\n' + 'p=pathlib.Path(sys.argv[1]); p.mkdir(exist_ok=True)\n' + '(p/"media-fixtures.json").write_text("{}")\n') + (root / 'plex-host-core-diagnostic.py').write_text('pass\n') + docker = root / 'docker' + docker.write_text('''#!/usr/bin/env python3 +import json,os,sys +a=sys.argv[1:] +with open(os.environ['COMMAND_LOG'],'a') as f: f.write(json.dumps(a)+'\\n') +if a[:2]==['image','inspect']: + if a[-2]=='{{.Id}}': print('sha256:'+'a'*64) + elif a[-2]=='{{.Architecture}}': print('amd64') + else: print('{}') +elif a[:1]==['info']: print('amd64') +elif a[:1]==['run'] and '--rm' in a: + print('1.2.3' if 'bash' in a else '000 T sqlite3_open') +elif a[:1]==['run'] and '--name' in a and a[a.index('--name')+1].endswith('-plex'): + sys.exit(47) # Stop before any actual workload or readiness loop. +''') + docker.chmod(0o755) + command_log = root / 'commands.jsonl' + env = dict(os.environ, PATH=str(root) + os.pathsep + os.environ['PATH'], + COMMAND_LOG=str(command_log), CANDIDATE_IMAGE='sha256:' + 'a' * 64, + POSTGRES_IMAGE='postgres@sha256:' + 'b' * 64, + EXPECTED_PLEX_VERSION='1.2.3', EXPECTED_ARCH='amd64', + VARIANT='linuxserver', EVIDENCE_DIR=str(root / 'evidence'), + PLEX_E2E_MEDIA_CACHE_DIR=str(root / 'media')) + for key in ('PLEX_E2E_SHIM_LOG_LEVEL', 'PLEX_E2E_POSTGRES_LOG_STATEMENT'): + env.pop(key, None) + env.update(overrides) + result = subprocess.run(['bash', str(driver)], env=env, + capture_output=True, text=True, timeout=15) + commands = [json.loads(line) for line in command_log.read_text().splitlines()] if command_log.exists() else [] + return result, commands + + def assert_container_limits(self, commands, shim_level, sql_level): + runs = [a for a in commands if a[:2] == ['run', '-d']] + self.assertEqual(len(runs), 2) + for run in runs: + self.assertEqual(run[run.index('--log-driver') + 1], 'json-file') + options = [run[i + 1] for i, arg in enumerate(run) if arg == '--log-opt'] + self.assertEqual(options, ['max-size=20m', 'max-file=3']) + self.assertIn('log_statement=' + sql_level, runs[0]) + self.assertIn('log_min_error_statement=error', runs[0]) + self.assertIn('PLEX_PG_LOG_LEVEL=' + shim_level, runs[1]) + + def test_default_execution_bounds_logs_and_retains_error_statements(self): + result, commands = self.run_driver() + self.assertEqual(result.returncode, 47, result.stderr) + self.assert_container_limits(commands, 'INFO', 'none') + + def test_explicit_diagnostics_preserve_rotation(self): + result, commands = self.run_driver(PLEX_E2E_SHIM_LOG_LEVEL='DEBUG', + PLEX_E2E_POSTGRES_LOG_STATEMENT='all') + self.assertEqual(result.returncode, 47, result.stderr) + self.assert_container_limits(commands, 'DEBUG', 'all') + + def test_invalid_options_fail_before_docker(self): + for override in ({'PLEX_E2E_SHIM_LOG_LEVEL': 'TRACE'}, + {'PLEX_E2E_POSTGRES_LOG_STATEMENT': 'error'}): + with self.subTest(override=override): + result, commands = self.run_driver(**override) + self.assertEqual(result.returncode, 2) + self.assertEqual(commands, []) + + +if __name__ == '__main__': + unittest.main() From b2b9277b39cd3dcc42987a4e5fc52840e00ff36c Mon Sep 17 00:00:00 2001 From: cgnl <120372881+cgnl@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:16:29 +0200 Subject: [PATCH 2/4] Keep non-ZIP build records out of certificate downloads Docker build records use gzip storage and cannot be extracted by the ZIP artifact action. Exclude only those records while retaining the workload evidence and release bundle. Confidence: high Scope-risk: narrow Tested: Actual build-record gzip signature; actionlint; whitespace checks Not-tested: Updated filter on GitHub runners --- .github/workflows/release-certification.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release-certification.yml b/.github/workflows/release-certification.yml index 1e52dd4f..cd07a76b 100644 --- a/.github/workflows/release-certification.yml +++ b/.github/workflows/release-certification.yml @@ -104,6 +104,8 @@ jobs: persist-credentials: false - uses: actions/download-artifact@v4 with: + # Docker build records are gzip data, not ZIP evidence archives. + pattern: '!*.dockerbuild' path: certification-evidence - name: Validate every cell and bind final artifact bytes env: From d2d7a5944f47aed2f536c24b119972d7600dd9e6 Mon Sep 17 00:00:00 2001 From: cgnl <120372881+cgnl@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:42:16 +0200 Subject: [PATCH 3/4] Allow completed native tests to produce valid release evidence Pair the duration evidence with the full checks already completed by its identical smoke candidate. Upload the validated release bytes again for the current issuer attempt, retaining the independent publication timestamp and provenance checks. Constraint: Partial infrastructure retries must not authenticate stale publication artifacts Confidence: high Scope-risk: moderate Directive: Keep final provenance and timestamp guards strict Tested: 108 Python tests, actionlint, shell syntax, diff checks Not-tested: Revised workflow on native GitHub runners --- .github/workflows/docker-publish.yml | 14 +++++ .../workflows/publish-certified-release.yml | 19 +++--- .github/workflows/release-certification.yml | 15 ++++- scripts/issue_release_certificate.py | 58 +++++++++++++++++-- scripts/load_release_trust.py | 42 ++++++++++++++ scripts/plex-container-e2e.sh | 4 ++ scripts/release_certificate.py | 24 +++++++- scripts/tests/test_release_certificate.py | 48 ++++++++++++++- scripts/tests/test_release_trust_config.py | 45 ++++++++++++++ 9 files changed, 251 insertions(+), 18 deletions(-) create mode 100644 scripts/load_release_trust.py create mode 100644 scripts/tests/test_release_trust_config.py diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b67ea20c..d1fbfd10 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -422,6 +422,13 @@ jobs: docker cp "$container:/usr/local/lib/plex-postgresql" container-payload bundle=$(find release-bundle -maxdepth 1 -name '*.zip' -type f) python3 scripts/verify_candidate_payload.py --bundle "$bundle" --directory container-payload --arch "$ARCH" --output evidence/payload-binding.json + python3 - <<'PY' + import json, os, re + checksum = json.load(open('evidence/payload-binding.json'))['bundle_sha256'] + assert re.fullmatch('[0-9a-f]{64}', checksum) + with open(os.environ['GITHUB_ENV'], 'a') as output: + output.write('PLEX_E2E_BUNDLE_SHA256=' + checksum + '\n') + PY - name: Prepare offline crash diagnostics run: | if ! command -v gdb >/dev/null; then @@ -435,6 +442,7 @@ jobs: coredumpctl --version - name: Verify the exact candidate digest env: + SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }} BASE_PLEX_IMAGE: ${{ matrix.variant == 'linuxserver' && needs.resolve.outputs.linuxserver_image || needs.resolve.outputs.plexinc_image }} POSTGRES_IMAGE: ${{ needs.resolve.outputs[format('postgres{0}_image', matrix.postgres)] }} EXPECTED_PLEX_VERSION: ${{ needs.resolve.outputs.plex_version }} @@ -519,8 +527,13 @@ jobs: expected['restart_cycles_completed'] = int(os.environ['REQUIRED_CYCLES']) if any(result.get(key) != value for key, value in expected.items()): raise SystemExit('Prior native smoke failed or is incomplete') + binding = json.loads((root / 'payload-binding.json').read_text()) + checksum = binding['bundle_sha256'] + if not re.fullmatch('[0-9a-f]{64}', checksum) or result.get('bundle_sha256') != checksum: + raise SystemExit('Prior native bundle binding mismatch') with open(os.environ['GITHUB_ENV'], 'a') as output: output.write('CANDIDATE_IMAGE=' + image + '\n') + output.write('PLEX_E2E_BUNDLE_SHA256=' + checksum + '\n') PY - uses: docker/login-action@v3 with: @@ -540,6 +553,7 @@ jobs: coredumpctl --version - name: Continuous native mixed fixture workload for 21000 seconds env: + SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }} POSTGRES_IMAGE: ${{ needs.resolve.outputs[format('postgres{0}_image', matrix.postgres)] }} EXPECTED_PLEX_VERSION: ${{ needs.resolve.outputs.plex_version }} EXPECTED_ARCH: ${{ matrix.arch }} diff --git a/.github/workflows/publish-certified-release.yml b/.github/workflows/publish-certified-release.yml index 8ee7b813..f2409666 100644 --- a/.github/workflows/publish-certified-release.yml +++ b/.github/workflows/publish-certified-release.yml @@ -20,20 +20,17 @@ jobs: runs-on: ubuntu-24.04 outputs: enabled: ${{ steps.trust.outputs.enabled }} + trust_hash: ${{ steps.trust.outputs.hash }} steps: - uses: actions/checkout@v4 with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false - id: trust - run: | - if [[ ! -f .github/release-trust.json ]]; then - echo 'enabled=false' >> "$GITHUB_OUTPUT" - echo 'release-publication-unconfigured: genuine certificate bootstrap and reviewed release-trust.json are required.' >> "$GITHUB_STEP_SUMMARY" - exit 1 - fi - python3 -c 'import json; assert json.load(open(".github/release-trust.json")).get("enabled") is True' - echo 'enabled=true' >> "$GITHUB_OUTPUT" + env: + RELEASE_TRUST_JSON: ${{ vars.RELEASE_TRUST_JSON }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: python3 scripts/load_release_trust.py publish: needs: configuration if: needs.configuration.outputs.enabled == 'true' @@ -56,6 +53,12 @@ jobs: [[ "$RUN_ID" =~ ^[0-9]+$ ]] gh run download "$RUN_ID" --repo "$GITHUB_REPOSITORY" --name release-certificate --dir certificate echo "CERTIFICATE_RUN_ID=$RUN_ID" >> "$GITHUB_ENV" + - name: Recheck operator-reviewed release configuration + env: + RELEASE_TRUST_JSON: ${{ vars.RELEASE_TRUST_JSON }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + EXPECTED_TRUST_HASH: ${{ needs.configuration.outputs.trust_hash }} + run: python3 scripts/load_release_trust.py --expected-hash "$EXPECTED_TRUST_HASH" - name: Independently authenticate provenance and publish existing bytes env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/release-certification.yml b/.github/workflows/release-certification.yml index cd07a76b..1982e1dc 100644 --- a/.github/workflows/release-certification.yml +++ b/.github/workflows/release-certification.yml @@ -112,7 +112,20 @@ jobs: GH_TOKEN: ${{ github.token }} PROFILE_JSON: ${{ inputs.upstream_profile_json }} SOURCE_SHA: ${{ inputs.source_sha }} - run: python3 scripts/issue_release_certificate.py --evidence certification-evidence --profile-json "$PROFILE_JSON" --source-sha "$SOURCE_SHA" --policy .github/plex-compatibility-policy.json --output certificate/certificate.json + run: python3 scripts/issue_release_certificate.py --evidence certification-evidence --profile-json "$PROFILE_JSON" --source-sha "$SOURCE_SHA" --policy .github/plex-compatibility-policy.json --assets certified-assets --output certificate/draft.json + - uses: actions/upload-artifact@v4 + id: certified-assets + with: + name: certified-linux-release-zip-${{ github.run_attempt }} + path: certified-assets/* + if-no-files-found: error + retention-days: 90 + - name: Bind fresh publication artifact + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_ID: ${{ steps.certified-assets.outputs.artifact-id }} + ARTIFACT_DIGEST: ${{ steps.certified-assets.outputs.artifact-digest }} + run: python3 scripts/issue_release_certificate.py --draft certificate/draft.json --artifact-id "$ARTIFACT_ID" --artifact-digest "$ARTIFACT_DIGEST" --assets certified-assets --output certificate/certificate.json - uses: actions/upload-artifact@v4 with: name: release-certificate diff --git a/scripts/issue_release_certificate.py b/scripts/issue_release_certificate.py index 10c541c0..cd3b9dea 100644 --- a/scripts/issue_release_certificate.py +++ b/scripts/issue_release_certificate.py @@ -4,6 +4,8 @@ import hashlib import json import os +import shutil +from datetime import datetime, timezone from pathlib import Path from publish_certified_release import GitHub @@ -74,10 +76,18 @@ def issue(root, profile, candidate_sha, client, run_id, attempt, workflow_revisi require(json.loads((lane / 'payload-binding.json').read_text())['bundle_sha256'] == bundle_hash, 'Container is not bound to exact ZIP') smoke = json.loads((lane / 'container/result.json').read_text()) + for key, expected in (('source_sha', candidate_sha), + ('postgres_image', profile['images'][f'postgres{postgres}']['ref']), + ('bundle_sha256', bundle_hash)): + require(smoke.get(key) == expected, 'Smoke provenance mismatch: ' + key) validate_workload(smoke, source=candidate_sha, version=profile['plex_version'], variant=variant, arch=arch, image=image) require((lane / 'container/installed-version.txt').read_text().strip() == profile['plex_version'], 'Unmeasured Plex version') soak = json.loads((root / f'{prefix}soak-{variant}-{arch}-pg{postgres}/soak/result.json').read_text()) - validate_workload(soak, source=candidate_sha, version=profile['plex_version'], variant=variant, arch=arch, image=image, soak=True) + for key, expected in (('source_sha', candidate_sha), + ('postgres_image', profile['images'][f'postgres{postgres}']['ref']), + ('bundle_sha256', bundle_hash)): + require(soak.get(key) == expected, 'Soak provenance mismatch: ' + key) + validate_workload(soak, source=candidate_sha, version=profile['plex_version'], variant=variant, arch=arch, image=image, soak=True, smoke_result=smoke) cells.extend([f'{profile["id"]}/smoke/{variant}/{arch}/pg{postgres}', f'{profile["id"]}/soak/{variant}/{arch}/pg{postgres}']) images.append(image) if postgres == 15: @@ -111,15 +121,49 @@ def issue(root, profile, candidate_sha, client, run_id, attempt, workflow_revisi 'run_attempt': attempt, 'head_sha': run['head_sha'], 'conclusion': 'success'}} +def bind_publication_artifact(certificate, client, artifact_id, expected_digest, asset_root): + """Bind freshly uploaded, byte-identical assets without accepting an older attempt.""" + auth = certificate['authorization'] + run = client.get(f"actions/runs/{auth['run_id']}/attempts/{auth['run_attempt']}") + remote = client.get(f'actions/artifacts/{artifact_id}') + require(remote.get('id') == artifact_id, 'Publication artifact ID mismatch') + require(run['head_sha'] == auth['workflow_revision'] and run['event'] == 'workflow_dispatch', 'Untrusted binding run') + require(remote.get('workflow_run', {}).get('id') == auth['run_id'] and + remote['workflow_run'].get('head_sha') == auth['workflow_revision'], 'Foreign publication artifact') + require(remote.get('name') == f"certified-linux-release-zip-{auth['run_attempt']}" and not remote.get('expired'), 'Wrong publication artifact') + require(remote.get('digest') == 'sha256:' + expected_digest.removeprefix('sha256:'), 'Publication digest mismatch') + created = datetime.fromisoformat(remote['created_at'].replace('Z', '+00:00')) + started = datetime.fromisoformat(run['run_started_at'].replace('Z', '+00:00')) + require(started <= created <= datetime.now(timezone.utc), 'Publication artifact belongs to old attempt') + files = {path.name: hashlib.sha256(path.read_bytes()).hexdigest() for path in Path(asset_root).iterdir() if path.is_file()} + require(files and all(item['files'] == files for item in certificate['artifacts']), 'Publication bytes differ from tested bytes') + result = dict(certificate) + result['artifacts'] = [{'name': remote['name'], 'artifact_id': remote['id'], + 'sha256': expected_digest.removeprefix('sha256:'), + 'kind': 'linux-certified-bundle', 'files': files}] + return result + + if __name__ == '__main__': parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--evidence', required=True) - parser.add_argument('--profile-json', required=True) - parser.add_argument('--source-sha', required=True) - parser.add_argument('--policy', required=True) + parser.add_argument('--evidence') + parser.add_argument('--profile-json') + parser.add_argument('--source-sha') + parser.add_argument('--policy') + parser.add_argument('--draft') + parser.add_argument('--artifact-id', type=int) + parser.add_argument('--artifact-digest') + parser.add_argument('--assets', required=True) parser.add_argument('--ledger', default='.github/plex-compatibility-profiles.json') parser.add_argument('--output', required=True) args = parser.parse_args() + if args.draft: + result = bind_publication_artifact(json.loads(Path(args.draft).read_text()), + GitHub(os.environ['GITHUB_REPOSITORY'], os.environ['GH_TOKEN']), + args.artifact_id, args.artifact_digest, args.assets) + Path(args.output).parent.mkdir(parents=True, exist_ok=True) + Path(args.output).write_text(json.dumps(result, indent=2) + '\n') + raise SystemExit(0) profiles = certification_profiles(json.loads(args.profile_json), json.loads(Path(args.ledger).read_text()), json.loads(Path(args.policy).read_text())['maxHistoricalProfiles']) result = aggregate([issue(args.evidence, profile, args.source_sha, @@ -127,5 +171,9 @@ def issue(root, profile, candidate_sha, client, run_id, attempt, workflow_revisi int(os.environ['GITHUB_RUN_ID']), int(os.environ['GITHUB_RUN_ATTEMPT']), os.environ['GITHUB_SHA'], hashlib.sha256(Path(args.policy).read_bytes()).hexdigest()) for profile in profiles]) + assets = Path(args.assets) + assets.mkdir(parents=True, exist_ok=False) + for name in result['artifacts'][0]['files']: + shutil.copyfile(Path(args.evidence) / (profiles[0]['id'] + '-linux-release-zip') / name, assets / name) Path(args.output).parent.mkdir(parents=True, exist_ok=True) Path(args.output).write_text(json.dumps(result, indent=2) + '\n') diff --git a/scripts/load_release_trust.py b/scripts/load_release_trust.py new file mode 100644 index 00000000..c14b60e2 --- /dev/null +++ b/scripts/load_release_trust.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Load operator-reviewed release metadata without changing the tested source.""" +import argparse +import hashlib +import json +import os +import re +from pathlib import Path + + +def load(path, raw, repository, default_branch, expected_hash=None): + # Configured but malformed values must not fall back to another authority. + trust = json.loads(raw) if raw else json.loads(Path(path).read_text()) + if not isinstance(trust, dict) or trust.get('enabled') is not True: + raise ValueError('Release publication is not enabled') + if trust.get('repository') != repository or trust.get('default_branch') != default_branch: + raise ValueError('Release configuration repository or branch mismatch') + if type(trust.get('workflow_id')) is not int or trust['workflow_id'] <= 0: + raise ValueError('Missing trusted workflow identity') + if not re.fullmatch('[0-9a-f]{64}', trust.get('policy_hash', '')): + raise ValueError('Missing policy hash') + digest = hashlib.sha256(json.dumps(trust, sort_keys=True, separators=(',', ':')).encode()).hexdigest() + if expected_hash is not None and digest != expected_hash: + raise ValueError('Release configuration changed between jobs') + return trust, digest + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--path', default='.github/release-trust.json') + parser.add_argument('--expected-hash') + args = parser.parse_args() + raw = os.environ.get('RELEASE_TRUST_JSON', '') + if not raw and not Path(args.path).exists(): + raise SystemExit('Release publication is unconfigured; genuine bootstrap evidence is required') + trust, digest = load(args.path, raw, os.environ['GITHUB_REPOSITORY'], + os.environ['DEFAULT_BRANCH'], args.expected_hash) + Path(args.path).write_text(json.dumps(trust, indent=2) + '\n') + if 'GITHUB_OUTPUT' in os.environ: + with open(os.environ['GITHUB_OUTPUT'], 'a') as output: + output.write('enabled=true\nhash=' + digest + '\n') + print('Release configuration verified:', digest) diff --git a/scripts/plex-container-e2e.sh b/scripts/plex-container-e2e.sh index 533c2a4b..c0df42aa 100644 --- a/scripts/plex-container-e2e.sh +++ b/scripts/plex-container-e2e.sh @@ -427,10 +427,14 @@ cleanup() { fi python3 - "$EVIDENCE_DIR/result.json" "$phase" "$status" "$CANDIDATE_IMAGE" "$EXPECTED_PLEX_VERSION" "$VARIANT" "$EXPECTED_ARCH" "$restart_cycles" "$restart_cycles_completed" "$soak_seconds" "$soak_seconds_completed" "$soak_iterations" "$live_recovery_verified" "$decoded_fixture_verified" "$real_media_verified" "$real_media_full_decode_verified" "$native_roundtrip_verified" "$concurrent_real_media_verified" <<'PY' import json +import os import sys from pathlib import Path path, phase, status, image, version, variant, arch, cycles, completed, soak, elapsed, iterations, live_recovery, decoded, real_media, full_decode, roundtrip, concurrent = sys.argv[1:] Path(path).write_text(json.dumps({ + "source_sha": os.environ.get("SOURCE_SHA", ""), + "postgres_image": os.environ.get("POSTGRES_IMAGE", ""), + "bundle_sha256": os.environ.get("PLEX_E2E_BUNDLE_SHA256", ""), "phase": phase, "exit_code": int(status), "candidate": image, "plex_version": version, "variant": variant, "arch": arch, "restart_cycles_requested": int(cycles), "restart_cycles_completed": int(completed), diff --git a/scripts/release_certificate.py b/scripts/release_certificate.py index 1025f4a9..535f3978 100644 --- a/scripts/release_certificate.py +++ b/scripts/release_certificate.py @@ -15,7 +15,7 @@ 'scripts/publish_certified_release.py', 'scripts/promote_certified_images.py', 'scripts/stage_compatibility_release.py', 'scripts/validate_certification_source.py', 'scripts/verify_candidate_payload.py', 'scripts/native_compatibility_observation.py', - 'scripts/bootstrap_release_trust.py', + 'scripts/bootstrap_release_trust.py', 'scripts/load_release_trust.py', 'scripts/plex_compatibility.py', 'scripts/check-upstream-updates.py', 'Dockerfile', 'Dockerfile.standalone', '.github/plex-compatibility-policy.json', '.github/plex-compatibility-profiles.json', @@ -51,15 +51,33 @@ def profile_inputs(profile, profile_id): return profile -def validate_workload(result, *, source, version, variant, arch, image, soak=False): +def validate_workload(result, *, source, version, variant, arch, image, soak=False, smoke_result=None): expected = {'plex_version': version, 'variant': variant, 'arch': arch, 'candidate': image, 'phase': 'native-workload-complete', 'exit_code': 0, 'workload_passed': True, 'promotion_allowed': False, 'restart_cycles_requested': 1 if soak else 100, 'restart_cycles_completed': 1 if soak else 100} + full_checks = ('real_media_full_decode_verified', 'native_import_and_rollback_verified') + paired = soak and any(result.get(key) is not True for key in full_checks) + if paired: + require(smoke_result is not None, 'Skipped soak checks require matching smoke evidence') + require(all(result.get(key) is True or result.get(key) is False for key in full_checks), + 'Soak must explicitly record skipped checks') + validate_workload(smoke_result, source=source, version=version, variant=variant, arch=arch, image=image) + require(result.get('source_sha') == smoke_result.get('source_sha') == source, + 'Paired workloads require exact source evidence') + pg = result.get('postgres_image', '') + require(re.fullmatch(r'[^\s@]+@sha256:[0-9a-f]{64}', pg) is not None and + smoke_result.get('postgres_image') == pg, 'Paired PostgreSQL input mismatch') + bundle = result.get('bundle_sha256', '') + require(re.fullmatch(r'[0-9a-f]{64}', bundle) is not None and + smoke_result.get('bundle_sha256') == bundle, 'Paired release bundle mismatch') + for evidence in (result, smoke_result) if paired else (result,): + require(evidence.get('source_sha', source) == source, 'Workload source mismatch') for key in ('live_postgres_recovery_verified', 'decoded_fixture_verified', 'real_movie_and_tv_playback_verified', 'real_media_full_decode_verified', 'native_import_and_rollback_verified', 'four_concurrent_real_media_clients_verified'): - expected[key] = True + if not paired or key not in full_checks: + expected[key] = True require(all(result.get(key) == value for key, value in expected.items()), 'Incomplete native workload') require(re.fullmatch(r'[0-9a-f]{40}', source) is not None, 'Invalid source SHA') if soak: diff --git a/scripts/tests/test_release_certificate.py b/scripts/tests/test_release_certificate.py index aa6a0bc2..e3a4e9d4 100644 --- a/scripts/tests/test_release_certificate.py +++ b/scripts/tests/test_release_certificate.py @@ -15,7 +15,7 @@ from verify_candidate_payload import verify from release_certificate import trusted_file_paths from validate_certification_source import validate_candidate -from issue_release_certificate import aggregate +from issue_release_certificate import aggregate, bind_publication_artifact from promote_certified_images import promotion_commands, promote, latest_command @@ -80,6 +80,28 @@ def test_previous_attempt_artifact_rejected(self): with self.assertRaises(ValueError): self.check() + def test_fresh_publication_binding_checks_owner_digest_attempt_and_bytes(self): + import hashlib + with tempfile.TemporaryDirectory() as directory: + asset = Path(directory) / 'bundle.zip' + asset.write_bytes(b'tested bytes') + self.certificate['artifacts'][0]['files'] = {'bundle.zip': hashlib.sha256(asset.read_bytes()).hexdigest()} + remote = copy.deepcopy(self.artifacts['artifacts'][0]) + remote['name'] = 'certified-linux-release-zip-2' + client = type('Client', (), {'get': lambda _, path: self.run if '/attempts/' in path else remote})() + bound = bind_publication_artifact(self.certificate, client, 456, '1' * 64, directory) + self.assertEqual(bound['artifacts'][0]['name'], remote['name']) + for key, value in [('digest', 'sha256:' + '2' * 64), ('created_at', '2026-10-10T08:00:00Z'), + ('workflow_run', {'id': 999, 'head_sha': 'c' * 40})]: + saved = remote[key] + remote[key] = value + with self.assertRaises(ValueError): + bind_publication_artifact(self.certificate, client, 456, '1' * 64, directory) + remote[key] = saved + asset.write_bytes(b'changed') + with self.assertRaises(ValueError): + bind_publication_artifact(self.certificate, client, 456, '1' * 64, directory) + def test_workflow_revision_rotates_only_when_reviewed_code_is_unchanged(self): self.trust['reviewed_file_blobs'] = {path: '5' * 40 for path in trusted_file_paths} self.run['head_sha'] = '6' * 40 @@ -236,6 +258,30 @@ def test_smoke_and_soak_durations_cannot_be_lowered(self): inputs = {'source': 'a' * 40, 'version': result['plex_version'], 'variant': 'plexinc', 'arch': 'amd64', 'image': result['candidate']} validate_workload(result, **inputs) + result.update(source_sha=inputs['source'], postgres_image='postgres@sha256:' + '3' * 64, + bundle_sha256='4' * 64) + smoke = copy.deepcopy(result) + paired_soak = dict(result, restart_cycles_requested=1, restart_cycles_completed=1, + soak_seconds_requested=21000, soak_seconds_completed=21000, + real_media_full_decode_verified=False, native_import_and_rollback_verified=False) + validate_workload(paired_soak, soak=True, smoke_result=smoke, **inputs) + with self.assertRaises(ValueError): + validate_workload(paired_soak, soak=True, **inputs) + for key, value in [('candidate', 'foreign'), ('source_sha', 'b' * 40), + ('real_media_full_decode_verified', False), ('native_import_and_rollback_verified', False), + ('postgres_image', 'postgres@sha256:' + '5' * 64), ('bundle_sha256', '6' * 64)]: + invalid = dict(smoke, **{key: value}) + with self.assertRaises(ValueError): + validate_workload(paired_soak, soak=True, smoke_result=invalid, **inputs) + for key in ('source_sha', 'postgres_image', 'bundle_sha256'): + for which in ('smoke', 'soak'): + invalid = dict(smoke if which == 'smoke' else paired_soak) + del invalid[key] + with self.assertRaises(ValueError): + validate_workload(invalid if which == 'soak' else paired_soak, soak=True, + smoke_result=invalid if which == 'smoke' else smoke, **inputs) + with self.assertRaises(ValueError): + validate_workload(dict(paired_soak, soak_seconds_completed=20999), soak=True, smoke_result=smoke, **inputs) result['restart_cycles_completed'] = 99 with self.assertRaises(ValueError): validate_workload(result, **inputs) diff --git a/scripts/tests/test_release_trust_config.py b/scripts/tests/test_release_trust_config.py new file mode 100644 index 00000000..f86d749a --- /dev/null +++ b/scripts/tests/test_release_trust_config.py @@ -0,0 +1,45 @@ +import json +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from load_release_trust import load + + +class ReleaseTrustConfigTests(unittest.TestCase): + def setUp(self): + self.trust = dict(enabled=True, repository='owner/repo', default_branch='main', + workflow_id=123, policy_hash='a' * 64) + + def test_operator_variable_and_file_fallback_have_identical_hash(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'trust.json' + path.write_text(json.dumps(self.trust)) + variable = load(path, json.dumps(self.trust), 'owner/repo', 'main') + self.assertEqual(variable, load(path, '', 'owner/repo', 'main')) + + def test_malformed_configured_variable_never_falls_back(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'trust.json' + path.write_text(json.dumps(self.trust)) + with self.assertRaises(ValueError): + load(path, '{bad', 'owner/repo', 'main') + with self.assertRaises(ValueError): + load(path, ' ', 'owner/repo', 'main') + + def test_identity_and_enable_checks(self): + for key, value in [('enabled', False), ('repository', 'other/repo'), + ('default_branch', 'untrusted'), ('workflow_id', True)]: + with self.subTest(key=key), self.assertRaises(ValueError): + load('unused', json.dumps(dict(self.trust, **{key: value})), 'owner/repo', 'main') + + def test_configuration_change_between_jobs_is_rejected(self): + _, digest = load('unused', json.dumps(self.trust), 'owner/repo', 'main') + with self.assertRaises(ValueError): + load('unused', json.dumps(dict(self.trust, workflow_id=456)), 'owner/repo', 'main', digest) + + +if __name__ == '__main__': + unittest.main() From 8f48bf0750bd94b8e3057b2de4faae843b4f82a4 Mon Sep 17 00:00:00 2001 From: cgnl <120372881+cgnl@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:43:29 +0200 Subject: [PATCH 4/4] Avoid the exhausted ECR allowance in native CI Use Docker Hub for the identical pinned Alpine manifest so builds and metadata tests can start when ECR rejects downloads. Confidence: high Scope-risk: narrow Tested: Docker Hub manifest digest and platforms, actionlint, diff check Not-tested: Updated jobs on GitHub runners --- .github/workflows/ci.yml | 2 +- .github/workflows/runtime-e2e.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 518af3c5..0760d748 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -215,7 +215,7 @@ jobs: docker buildx build \ --platform linux/amd64 \ --target builder \ - --build-arg BUILDER_IMAGE=public.ecr.aws/docker/library/alpine@sha256:19b4bcc4f60e99dd5ebdca0cbce22c503bbcff197549d7e19dab4f22254dc864 \ + --build-arg BUILDER_IMAGE=docker.io/library/alpine@sha256:19b4bcc4f60e99dd5ebdca0cbce22c503bbcff197549d7e19dab4f22254dc864 \ --output type=local,dest=out \ . diff --git a/.github/workflows/runtime-e2e.yml b/.github/workflows/runtime-e2e.yml index 46583d28..79fdfca8 100644 --- a/.github/workflows/runtime-e2e.yml +++ b/.github/workflows/runtime-e2e.yml @@ -163,7 +163,7 @@ jobs: RUNTIME_E2E_ADMIN_DATABASE: runtime_e2e_admin # Pinned alpine 3.15 (musl 1.2.2, the release builder base) via the # ECR mirror to stay independent of Docker Hub auth outages. - ALPINE_BUILDER_IMAGE: public.ecr.aws/docker/library/alpine@sha256:19b4bcc4f60e99dd5ebdca0cbce22c503bbcff197549d7e19dab4f22254dc864 + ALPINE_BUILDER_IMAGE: docker.io/library/alpine@sha256:19b4bcc4f60e99dd5ebdca0cbce22c503bbcff197549d7e19dab4f22254dc864 run: | set -eu docker run --rm --network host \