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/docker-publish.yml b/.github/workflows/docker-publish.yml index 58057e93..d1fbfd10 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" @@ -335,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 @@ -348,7 +442,7 @@ jobs: coredumpctl --version - name: Verify the exact candidate digest env: - CANDIDATE_IMAGE: ${{ steps.image.outputs.repository }}@${{ steps.build.outputs.digest }} + 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 }} @@ -358,20 +452,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 @@ -440,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: @@ -461,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 }} @@ -535,6 +628,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/.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 1e52dd4f..1982e1dc 100644 --- a/.github/workflows/release-certification.yml +++ b/.github/workflows/release-certification.yml @@ -104,13 +104,28 @@ 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: 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/.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 \ 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/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 b3e49f73..c0df42aa 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)) @@ -422,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), @@ -479,8 +488,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 +500,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/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_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() 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()