Bump the github-actions group across 1 directory with 8 updates #489
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: bluebuild | |
| on: | |
| schedule: | |
| - cron: | |
| "00 06 * * *" | |
| push: | |
| branches: [main] | |
| pull_request: | |
| workflow_dispatch: | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref || github.run_id }} | |
| cancel-in-progress: true | |
| jobs: | |
| source-prep: | |
| name: "Stage 1: Source Prep" | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| outputs: | |
| bundle_sha256: ${{ steps.package.outputs.bundle_sha256 }} | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 | |
| with: | |
| go-version: "1.26.5" | |
| cache: false | |
| - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | |
| with: | |
| python-version: "3.12" | |
| - name: Install pinned workflow dependencies | |
| run: python -m pip install --require-hashes -r requirements-ci.lock | |
| - name: Verify supported Fedora base pin | |
| run: | | |
| python3 - <<'PY' | |
| import re | |
| import yaml | |
| with open("recipes/recipe.yml", encoding="utf-8") as handle: | |
| recipe = yaml.safe_load(handle) | |
| version = str(recipe.get("image-version", "")) | |
| match = re.fullmatch(r"44@(sha256:[0-9a-f]{64})", version) | |
| if not match: | |
| raise SystemExit( | |
| "recipes/recipe.yml must use Fedora 44 with a canonical digest pin" | |
| ) | |
| print(f"Configured Fedora 44 base digest: {match.group(1)}") | |
| PY | |
| configured_digest=$(python3 -c ' | |
| import yaml | |
| with open("recipes/recipe.yml", encoding="utf-8") as handle: | |
| print(str(yaml.safe_load(handle)["image-version"]).split("@", 1)[1]) | |
| ') | |
| current_digest=$( | |
| skopeo inspect docker://ghcr.io/ublue-os/silverblue-main:44 | | |
| jq -er '.Digest' | |
| ) | |
| if [ "$configured_digest" != "$current_digest" ]; then | |
| echo "::error::Fedora 44 base tag moved. Review the new image and update the recipe digest." | |
| echo "Configured: $configured_digest" | |
| echo "Current: $current_digest" | |
| exit 1 | |
| fi | |
| - name: Verify Fedora 44 package availability | |
| run: | | |
| # The bootc base can exceed Docker's overlay-layer depth even though | |
| # BlueBuild/Podman can consume it. Query the same Fedora 44 repos | |
| # through a shallow, immutable official Fedora package-check image. | |
| package_check_ref="docker.io/library/fedora:44@sha256:6c75d5bf57cb0fa5aa4b92c6a83c86c791644496d9ac230de7711f5b8ec3b898" | |
| mapfile -t packages < <( | |
| python3 - <<'PY' | |
| import yaml | |
| with open("recipes/recipe.yml", encoding="utf-8") as handle: | |
| recipe = yaml.safe_load(handle) | |
| for module in recipe.get("modules", []): | |
| if module.get("type") == "rpm-ostree": | |
| for package in module.get("install", []): | |
| print(package) | |
| PY | |
| ) | |
| if [ "${#packages[@]}" -eq 0 ]; then | |
| echo "::error::Recipe contains no RPM package requirements" | |
| exit 1 | |
| fi | |
| docker pull "$package_check_ref" | |
| docker run --rm --entrypoint /bin/bash "$package_check_ref" \ | |
| -s -- "${packages[@]}" <<'BASH' | |
| set -euo pipefail | |
| dnf5 -q makecache --refresh | |
| missing=0 | |
| for package in "$@"; do | |
| if rpm -q --quiet -- "$package" || | |
| dnf5 -q repoquery --available "$package" | grep -q .; then | |
| echo "OK: ${package}" | |
| else | |
| echo "MISSING: ${package}" >&2 | |
| missing=$((missing + 1)) | |
| fi | |
| done | |
| if [ "$missing" -ne 0 ]; then | |
| echo "Fedora package resolution failed for ${missing} package(s)" >&2 | |
| exit 1 | |
| fi | |
| BASH | |
| - name: Materialize verified Go dependency trees | |
| run: | | |
| while IFS= read -r module; do | |
| service_dir=$(dirname "$module") | |
| echo "Vendoring ${service_dir}" | |
| ( | |
| cd "$service_dir" | |
| go mod verify | |
| go mod vendor | |
| ) | |
| done < <(find services -mindepth 2 -maxdepth 2 -name go.mod -print | sort) | |
| - name: Materialize Python wheelhouse | |
| run: | | |
| mkdir -p vendor/wheels | |
| find vendor/wheels -mindepth 1 -maxdepth 1 -type f -delete | |
| python3 -m pip download \ | |
| --dest vendor/wheels \ | |
| --require-hashes \ | |
| --only-binary=:all: \ | |
| -r vendor/application-requirements.lock | |
| ( | |
| cd vendor/wheels | |
| find . -maxdepth 1 -type f -name '*.whl' -print0 | | |
| sort -z | | |
| xargs -0 sha256sum > SHA256SUMS | |
| test -s SHA256SUMS | |
| sha256sum --check --strict SHA256SUMS | |
| ) | |
| - name: Fetch checksum-pinned external source | |
| run: | | |
| python3 - <<'PY' | |
| import hashlib | |
| import io | |
| import pathlib | |
| import posixpath | |
| import shutil | |
| import tarfile | |
| import urllib.request | |
| import yaml | |
| with open(".upstreams.lock.yaml", encoding="utf-8") as handle: | |
| lock = yaml.safe_load(handle) | |
| for name, entry in sorted(lock.get("upstreams", {}).items()): | |
| commit = str(entry["pinned_commit"]) | |
| expected = str(entry["archive_sha256"]) | |
| if len(commit) != 40 or len(expected) != 64: | |
| raise SystemExit(f"{name}: invalid source pin") | |
| url = entry["upstream_url"].removesuffix(".git") | |
| archive_url = f"{url}/archive/{commit}.tar.gz" | |
| print(f"Fetching {name}@{commit}") | |
| with urllib.request.urlopen(archive_url, timeout=60) as response: | |
| content = response.read() | |
| actual = hashlib.sha256(content).hexdigest() | |
| if actual != expected: | |
| raise SystemExit( | |
| f"{name}: archive mismatch: expected {expected}, got {actual}" | |
| ) | |
| destination = pathlib.Path(entry["local_path"]) | |
| shutil.rmtree(destination, ignore_errors=True) | |
| destination.mkdir(parents=True) | |
| with tarfile.open(fileobj=io.BytesIO(content), mode="r:gz") as archive: | |
| members = archive.getmembers() | |
| if not members: | |
| raise SystemExit(f"{name}: archive is empty") | |
| root = members[0].name.split("/", 1)[0] | |
| if not root or root in {".", ".."}: | |
| raise SystemExit(f"{name}: malformed archive root") | |
| prefix = root + "/" | |
| for member in members: | |
| if member.islnk(): | |
| raise SystemExit(f"{name}: archive contains a hard link") | |
| if member.name == root: | |
| if not member.isdir(): | |
| raise SystemExit(f"{name}: malformed archive root") | |
| continue | |
| if not member.name.startswith(prefix): | |
| raise SystemExit(f"{name}: malformed archive root") | |
| relative = pathlib.PurePosixPath(member.name.removeprefix(prefix)) | |
| if relative.is_absolute() or ".." in relative.parts: | |
| raise SystemExit(f"{name}: unsafe archive path") | |
| if member.issym(): | |
| link = pathlib.PurePosixPath(member.linkname) | |
| resolved_link = pathlib.PurePosixPath( | |
| posixpath.normpath((relative.parent / link).as_posix()) | |
| ) | |
| if ( | |
| link.is_absolute() | |
| or not member.linkname | |
| or resolved_link.is_absolute() | |
| or ".." in resolved_link.parts | |
| ): | |
| raise SystemExit( | |
| f"{name}: archive contains an unsafe symbolic link" | |
| ) | |
| member.name = relative.as_posix() | |
| if member.name and member.name != ".": | |
| archive.extract(member, destination, filter="data") | |
| PY | |
| - name: Freeze pinned SearXNG version metadata | |
| run: | | |
| python3 files/scripts/prepare-searxng-source.py \ | |
| --lock .upstreams.lock.yaml \ | |
| --source upstreams/searxng | |
| - name: Download and verify llama.cpp tarball | |
| run: | | |
| mkdir -p .source-prep | |
| # Read pinned version + checksum from build-services.sh | |
| LLAMA_CPP_VERSION=$(grep -oP 'LLAMA_CPP_VERSION:-\K[^}]+' files/scripts/build-services.sh | head -1) | |
| LLAMA_CPP_SHA256=$(grep -oP 'LLAMA_CPP_SHA256:-\K[^}]+' files/scripts/build-services.sh | head -1) | |
| echo "Downloading llama.cpp ${LLAMA_CPP_VERSION}..." | |
| TARBALL="llama-cpp-${LLAMA_CPP_VERSION}.tar.gz" | |
| curl -fsSL -o "/tmp/${TARBALL}" \ | |
| "https://github.com/ggml-org/llama.cpp/archive/refs/tags/${LLAMA_CPP_VERSION}.tar.gz" | |
| echo "Verifying checksum..." | |
| ACTUAL=$(sha256sum "/tmp/${TARBALL}" | awk '{print $1}') | |
| if [ "$ACTUAL" != "$LLAMA_CPP_SHA256" ]; then | |
| echo "::error::llama.cpp checksum mismatch: expected ${LLAMA_CPP_SHA256}, got ${ACTUAL}" | |
| echo "Update LLAMA_CPP_SHA256 in build-services.sh if the version was bumped." | |
| exit 1 | |
| fi | |
| echo "OK: llama.cpp checksum verified" | |
| echo "TARBALL_SHA256=${ACTUAL}" >> "$GITHUB_ENV" | |
| echo "LLAMA_CPP_VERSION=${LLAMA_CPP_VERSION}" >> "$GITHUB_ENV" | |
| mv "/tmp/${TARBALL}" ".source-prep/llama-cpp-staged.tar.gz" | |
| - name: Emit SOURCE_PREP_MANIFEST.json | |
| run: | | |
| python3 -c " | |
| import json, hashlib, os | |
| from pathlib import Path | |
| from datetime import datetime, timezone | |
| import yaml | |
| def digest(path): | |
| with open(path, 'rb') as handle: | |
| return hashlib.sha256(handle.read()).hexdigest() | |
| manifest = { | |
| 'schema_version': 1, | |
| 'timestamp': datetime.now(timezone.utc).isoformat(), | |
| 'commit_sha': os.environ.get('GITHUB_SHA', 'unknown'), | |
| 'llama_cpp_version': os.environ.get('LLAMA_CPP_VERSION', 'unknown'), | |
| 'llama_cpp_tarball_sha256': os.environ.get('TARBALL_SHA256', 'unknown'), | |
| } | |
| required_files = [ | |
| Path('vendor/wheels/SHA256SUMS'), | |
| Path('vendor/application-requirements.lock'), | |
| Path('.upstreams.lock.yaml'), | |
| ] | |
| missing = [str(path) for path in required_files if not path.is_file()] | |
| if missing: | |
| raise SystemExit(f'missing source-prep inputs: {missing}') | |
| wheel_lines = [ | |
| line for line in required_files[0].read_text().splitlines() if line.strip() | |
| ] | |
| if not wheel_lines: | |
| raise SystemExit('wheelhouse checksum manifest is empty') | |
| manifest['wheelhouse_sha256sums_digest'] = digest(required_files[0]) | |
| manifest['application_requirements_lock_digest'] = digest(required_files[1]) | |
| manifest['upstreams_lock_digest'] = digest(required_files[2]) | |
| manifest['wheel_count'] = len(wheel_lines) | |
| manifest['application_dependency_mode'] = 'staged-offline' | |
| with required_files[2].open(encoding='utf-8') as handle: | |
| upstream_lock = yaml.safe_load(handle) | |
| manifest['upstream_paths'] = sorted( | |
| str(entry['local_path']) | |
| for entry in upstream_lock.get('upstreams', {}).values() | |
| ) | |
| if not manifest['upstream_paths']: | |
| raise SystemExit('upstream source lock contains no materialized paths') | |
| manifest['go_vendor_paths'] = sorted( | |
| path.as_posix() | |
| for path in Path('services').glob('*/vendor') | |
| if path.is_dir() and not path.is_symlink() | |
| ) | |
| if not manifest['go_vendor_paths']: | |
| raise SystemExit('source preparation produced no Go vendor trees') | |
| with open('.source-prep/SOURCE_PREP_MANIFEST.json', 'w') as f: | |
| json.dump(manifest, f, indent=2) | |
| f.write('\n') | |
| print('--- SOURCE_PREP_MANIFEST.json ---') | |
| print(json.dumps(manifest, indent=2)) | |
| " | |
| - name: Package verified source-prep inputs | |
| id: package | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| mapfile -d '' -t go_vendor_paths < <( | |
| find services -mindepth 2 -maxdepth 2 -type d -name vendor -print0 | | |
| sort -z | |
| ) | |
| if [ "${#go_vendor_paths[@]}" -eq 0 ]; then | |
| echo "::error::Source preparation produced no Go vendor trees" | |
| exit 1 | |
| fi | |
| archive_paths=( | |
| .source-prep | |
| .upstreams.lock.yaml | |
| upstreams | |
| vendor/wheels | |
| "${go_vendor_paths[@]}" | |
| ) | |
| for path in "${archive_paths[@]}"; do | |
| if [ ! -e "$path" ] || [ -L "$path" ]; then | |
| echo "::error::Unsafe or missing source-prep root: ${path}" | |
| exit 1 | |
| fi | |
| done | |
| bundle_dir="${RUNNER_TEMP}/source-prep-bundle" | |
| mkdir -p "$bundle_dir" | |
| bundle_path="${bundle_dir}/source-prep.tar.gz" | |
| printf '%s\0' "${archive_paths[@]}" | | |
| tar \ | |
| --create \ | |
| --gzip \ | |
| --file "$bundle_path" \ | |
| --sort=name \ | |
| --mtime='UTC 1970-01-01' \ | |
| --owner=0 \ | |
| --group=0 \ | |
| --numeric-owner \ | |
| --hard-dereference \ | |
| --null \ | |
| --verbatim-files-from \ | |
| --files-from=- | |
| test -s "$bundle_path" | |
| tar --list --gzip --file "$bundle_path" >/dev/null | |
| bundle_sha256=$(sha256sum "$bundle_path" | awk '{print $1}') | |
| if ! [[ "$bundle_sha256" =~ ^[0-9a-f]{64}$ ]]; then | |
| echo "::error::Unable to calculate the source-prep bundle digest" | |
| exit 1 | |
| fi | |
| printf '%s source-prep.tar.gz\n' "$bundle_sha256" > \ | |
| "${bundle_path}.sha256" | |
| echo "bundle_sha256=${bundle_sha256}" >> "$GITHUB_OUTPUT" | |
| - name: Upload staged artifacts | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: source-prep | |
| path: | | |
| ${{ runner.temp }}/source-prep-bundle/source-prep.tar.gz | |
| ${{ runner.temp }}/source-prep-bundle/source-prep.tar.gz.sha256 | |
| if-no-files-found: error | |
| compression-level: 0 | |
| retention-days: 1 | |
| bluebuild_pr: | |
| name: "Stage 2: Build Custom Image (Unprivileged PR)" | |
| if: github.event_name == 'pull_request' | |
| needs: [source-prep] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| recipe: | |
| - recipe.yml | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | |
| with: | |
| python-version: "3.12" | |
| - name: Download verified source-prep inputs | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: source-prep | |
| path: ${{ runner.temp }}/source-prep-download | |
| - name: Verify and restore external source tree | |
| env: | |
| EXPECTED_SOURCE_PREP_SHA256: ${{ needs.source-prep.outputs.bundle_sha256 }} | |
| SOURCE_PREP_ARCHIVE: ${{ runner.temp }}/source-prep-download/source-prep.tar.gz | |
| SOURCE_PREP_CHECKSUM: ${{ runner.temp }}/source-prep-download/source-prep.tar.gz.sha256 | |
| run: >- | |
| python3 .github/scripts/restore-source-prep.py | |
| --archive "$SOURCE_PREP_ARCHIVE" | |
| --checksum "$SOURCE_PREP_CHECKSUM" | |
| - name: Build Custom Image Without Publishing | |
| uses: blue-build/github-action@836161eb076426a451e6a0054f722b1153b8b3ad # v1.12.0 | |
| with: | |
| recipe: ${{ matrix.recipe }} | |
| cli_version: v0.9.36 | |
| skip_checkout: true | |
| verify_install: true | |
| # The action declares this input required, but push=false never signs. | |
| # Pass an explicit non-secret empty value to keep forked PRs isolated. | |
| cosign_private_key: "" | |
| push: false | |
| registry_token: "" | |
| pr_event_number: ${{ github.event.number }} | |
| maximize_build_space: true | |
| bluebuild_publish: | |
| name: "Stage 2: Build, Sign, and Publish Custom Image" | |
| if: github.event_name != 'pull_request' | |
| needs: [source-prep] | |
| runs-on: ubuntu-latest | |
| environment: release | |
| outputs: | |
| digest: ${{ steps.digest.outputs.digest }} | |
| pinned_ref: ${{ steps.digest.outputs.pinned_ref }} | |
| image_ref: ${{ steps.digest.outputs.image_ref }} | |
| permissions: | |
| contents: read | |
| packages: write | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| recipe: | |
| # BlueBuild resolves recipe paths relative to the recipes/ directory. | |
| # "recipe.yml" maps to "recipes/recipe.yml" by convention. | |
| - recipe.yml | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | |
| with: | |
| python-version: "3.12" | |
| - name: Download verified source-prep inputs | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: source-prep | |
| path: ${{ runner.temp }}/source-prep-download | |
| - name: Verify and restore external source tree | |
| env: | |
| EXPECTED_SOURCE_PREP_SHA256: ${{ needs.source-prep.outputs.bundle_sha256 }} | |
| SOURCE_PREP_ARCHIVE: ${{ runner.temp }}/source-prep-download/source-prep.tar.gz | |
| SOURCE_PREP_CHECKSUM: ${{ runner.temp }}/source-prep-download/source-prep.tar.gz.sha256 | |
| run: >- | |
| python3 .github/scripts/restore-source-prep.py | |
| --archive "$SOURCE_PREP_ARCHIVE" | |
| --checksum "$SOURCE_PREP_CHECKSUM" | |
| - name: Build Custom Image | |
| id: build | |
| uses: blue-build/github-action@836161eb076426a451e6a0054f722b1153b8b3ad # v1.12.0 | |
| with: | |
| recipe: ${{ matrix.recipe }} | |
| cli_version: v0.9.36 | |
| skip_checkout: true | |
| verify_install: true | |
| cosign_private_key: ${{ secrets.SIGNING_SECRET }} | |
| push: true | |
| registry_token: ${{ github.token }} | |
| pr_event_number: ${{ github.event.number }} | |
| maximize_build_space: true | |
| - name: Set lowercase image ref | |
| if: github.event_name != 'pull_request' | |
| run: echo "IMAGE_REF=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV" | |
| # Publish the image digest so users can pin installs to an exact build. | |
| # The digest appears in the workflow summary and as an artifact. | |
| - name: Resolve and verify the built image | |
| if: github.event_name != 'pull_request' | |
| id: digest | |
| run: | | |
| inspect_json=$(skopeo inspect "docker://${IMAGE_REF}:latest") | |
| digest=$(jq -er '.Digest' <<<"$inspect_json") | |
| revision=$(jq -er '.Labels["org.opencontainers.image.revision"]' <<<"$inspect_json") | |
| if ! [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then | |
| echo "::error::Registry returned a non-canonical image digest" | |
| exit 1 | |
| fi | |
| if [ "$revision" != "$GITHUB_SHA" ]; then | |
| echo "::error::The published image was built from ${revision}, not ${GITHUB_SHA}" | |
| exit 1 | |
| fi | |
| pinned_ref="${IMAGE_REF}@${digest}" | |
| cosign verify --key cosign.pub "$pinned_ref" >/dev/null | |
| echo "$digest" > IMAGE_DIGEST | |
| echo "$pinned_ref" > IMAGE_REF_PINNED | |
| { | |
| echo "digest=$digest" | |
| echo "pinned_ref=$pinned_ref" | |
| echo "image_ref=$IMAGE_REF" | |
| } >> "$GITHUB_OUTPUT" | |
| { | |
| echo "## Verified image" | |
| echo "" | |
| echo "Source commit: \`${GITHUB_SHA}\`" | |
| echo "Pinned image: \`${pinned_ref}\`" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| release_evidence: | |
| name: "Stage 3: Release Evidence and Attestations" | |
| if: github.event_name != 'pull_request' | |
| needs: [bluebuild_publish] | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 120 | |
| environment: release | |
| permissions: | |
| contents: read | |
| packages: write | |
| id-token: write | |
| attestations: write | |
| artifact-metadata: write | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - name: Reserve disk for rootless OCI materialization | |
| run: | | |
| set -euo pipefail | |
| # This isolated job reinstalls Python and does not use these hosted SDKs. | |
| cleanup_threshold_kib=$((40 * 1024 * 1024)) | |
| available_kib=$(df --output=avail -k / | tail -n 1) | |
| if [ "$available_kib" -lt "$cleanup_threshold_kib" ]; then | |
| sudo rm -rf -- \ | |
| /opt/ghc \ | |
| /opt/hostedtoolcache/CodeQL \ | |
| /opt/hostedtoolcache/PyPy \ | |
| /opt/hostedtoolcache/Python \ | |
| /opt/hostedtoolcache/Ruby \ | |
| /opt/hostedtoolcache/go \ | |
| /opt/hostedtoolcache/node \ | |
| /usr/lib/jvm \ | |
| /usr/local/.ghcup \ | |
| /usr/local/lib/android \ | |
| /usr/share/dotnet \ | |
| /usr/share/miniconda \ | |
| /usr/share/swift | |
| fi | |
| df -h / | |
| - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | |
| with: | |
| python-version: "3.12" | |
| - name: Install pinned cosign | |
| uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 | |
| with: | |
| cosign-release: v3.1.1 | |
| - name: Authenticate to the image registry | |
| env: | |
| REGISTRY_PASSWORD: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| registry_config_dir="${RUNNER_TEMP}/secai-registry-auth" | |
| if [ -e "$registry_config_dir" ] || [ -L "$registry_config_dir" ]; then | |
| echo "::error::Refusing to reuse the registry credential directory" >&2 | |
| exit 1 | |
| fi | |
| install -d -m 0700 "$registry_config_dir" | |
| printf '%s' "$REGISTRY_PASSWORD" | | |
| docker --config "$registry_config_dir" login ghcr.io \ | |
| --username "$GITHUB_ACTOR" --password-stdin | |
| chmod 0600 "$registry_config_dir/config.json" | |
| { | |
| echo "DOCKER_CONFIG=${registry_config_dir}" | |
| echo "REGISTRY_AUTH_FILE=${registry_config_dir}/config.json" | |
| } >> "$GITHUB_ENV" | |
| - name: Reverify published image identity | |
| env: | |
| IMAGE_DIGEST: ${{ needs.bluebuild_publish.outputs.digest }} | |
| IMAGE_REF: ${{ needs.bluebuild_publish.outputs.image_ref }} | |
| PINNED_REF: ${{ needs.bluebuild_publish.outputs.pinned_ref }} | |
| run: | | |
| set -euo pipefail | |
| if ! [[ "$IMAGE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then | |
| echo "::error::Build job returned a non-canonical image digest" | |
| exit 1 | |
| fi | |
| expected_ref="ghcr.io/${GITHUB_REPOSITORY,,}" | |
| if [ "$IMAGE_REF" != "$expected_ref" ]; then | |
| echo "::error::Build job returned an unexpected image repository" | |
| exit 1 | |
| fi | |
| if [ "$PINNED_REF" != "${IMAGE_REF}@${IMAGE_DIGEST}" ]; then | |
| echo "::error::Build job returned an inconsistent pinned image reference" | |
| exit 1 | |
| fi | |
| cosign verify --key cosign.pub "$PINNED_REF" >/dev/null | |
| index_inspect=$(skopeo inspect \ | |
| --override-os linux --override-arch amd64 \ | |
| "docker://${PINNED_REF}") | |
| resolved_digest=$(jq -er '.Digest' <<<"$index_inspect") | |
| if [ "$resolved_digest" != "$IMAGE_DIGEST" ]; then | |
| echo "::error::Registry content no longer matches the published digest" | |
| exit 1 | |
| fi | |
| index_manifest="${RUNNER_TEMP}/secai-image-index.json" | |
| skopeo inspect --raw "docker://${PINNED_REF}" > "$index_manifest" | |
| platform_digest=$( | |
| jq -er ' | |
| if (.mediaType == "application/vnd.oci.image.index.v1+json" or | |
| .mediaType == "application/vnd.docker.distribution.manifest.list.v2+json") | |
| then | |
| [.manifests[]? | | |
| select(.platform.os == "linux" and | |
| .platform.architecture == "amd64")] | | |
| if length == 1 then .[0].digest | |
| else error("expected exactly one linux/amd64 image manifest") | |
| end | |
| else | |
| error("published image is not a multi-platform index") | |
| end | |
| ' "$index_manifest" | |
| ) | |
| if ! [[ "$platform_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then | |
| echo "::error::Image index returned a non-canonical platform digest" | |
| exit 1 | |
| fi | |
| platform_ref="${IMAGE_REF}@${platform_digest}" | |
| platform_inspect=$(skopeo inspect \ | |
| --override-os linux --override-arch amd64 \ | |
| "docker://${platform_ref}") | |
| selected_digest=$(jq -er '.Digest' <<<"$platform_inspect") | |
| revision=$(jq -er '.Labels["org.opencontainers.image.revision"]' \ | |
| <<<"$platform_inspect") | |
| jq -e \ | |
| '.Architecture == "amd64" and .Os == "linux"' \ | |
| <<<"$platform_inspect" >/dev/null | |
| if [ "$selected_digest" != "$platform_digest" ]; then | |
| echo "::error::Selected platform content does not match the image index" | |
| exit 1 | |
| fi | |
| if [ "$revision" != "$GITHUB_SHA" ]; then | |
| echo "::error::Published image revision is not ${GITHUB_SHA}" | |
| exit 1 | |
| fi | |
| printf '%s\n' "$IMAGE_DIGEST" > IMAGE_DIGEST | |
| printf '%s\n' "$PINNED_REF" > IMAGE_REF_PINNED | |
| { | |
| echo "PLATFORM_DIGEST=${platform_digest}" | |
| echo "PLATFORM_REF=${platform_ref}" | |
| } >> "$GITHUB_ENV" | |
| - name: Download and verify pinned OCI tools | |
| env: | |
| SYFT_ARCHIVE_SHA256: 0d6be741479eddd2c8644a288990c04f3df0d609bbc1599a005532a9dff63509 | |
| SYFT_VERSION: 1.42.3 | |
| UMOCI_BINARY_SHA256: b51c267ec394499e42c6fde47f240b7b7dba57ea49df0b5acd304378b82a3b71 | |
| UMOCI_VERSION: 0.6.0 | |
| run: | | |
| set -euo pipefail | |
| syft_dir="${RUNNER_TEMP}/secai-syft" | |
| syft_archive="${RUNNER_TEMP}/syft_${SYFT_VERSION}_linux_amd64.tar.gz" | |
| umoci_path="${RUNNER_TEMP}/secai-umoci" | |
| mkdir -p "$syft_dir" | |
| curl --proto '=https' --tlsv1.2 -fsSL --retry 5 \ | |
| --retry-all-errors --retry-delay 2 \ | |
| -o "$syft_archive" \ | |
| "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/syft_${SYFT_VERSION}_linux_amd64.tar.gz" | |
| printf '%s %s\n' "$SYFT_ARCHIVE_SHA256" "$syft_archive" | | |
| sha256sum --check --strict | |
| tar --extract --gzip --file "$syft_archive" \ | |
| --directory "$syft_dir" --no-same-owner --no-same-permissions syft | |
| test -f "$syft_dir/syft" | |
| test ! -L "$syft_dir/syft" | |
| chmod 0555 "$syft_dir/syft" | |
| "$syft_dir/syft" version -o json | | |
| jq -e --arg version "$SYFT_VERSION" \ | |
| '.version == $version and .platform == "linux/amd64"' >/dev/null | |
| curl --proto '=https' --tlsv1.2 -fsSL --retry 5 \ | |
| --retry-all-errors --retry-delay 2 \ | |
| -o "$umoci_path" \ | |
| "https://github.com/opencontainers/umoci/releases/download/v${UMOCI_VERSION}/umoci.linux.amd64" | |
| printf '%s %s\n' "$UMOCI_BINARY_SHA256" "$umoci_path" | | |
| sha256sum --check --strict | |
| test -f "$umoci_path" | |
| test ! -L "$umoci_path" | |
| chmod 0555 "$umoci_path" | |
| "$umoci_path" --version | | |
| grep -Fx "umoci version ${UMOCI_VERSION}" >/dev/null | |
| { | |
| echo "SYFT_DIR=${syft_dir}" | |
| echo "UMOCI_PATH=${umoci_path}" | |
| } >> "$GITHUB_ENV" | |
| - name: Materialize final-image root without overlay storage | |
| timeout-minutes: 45 | |
| run: | | |
| set -euo pipefail | |
| required_kib=$((30 * 1024 * 1024)) | |
| available_kib=$(df --output=avail -k "$RUNNER_TEMP" | tail -n 1) | |
| if [ "$available_kib" -lt "$required_kib" ]; then | |
| echo "::error::At least 30 GiB is required to materialize the final image" | |
| exit 1 | |
| fi | |
| runner_fs=$(findmnt -no FSTYPE -T "$RUNNER_TEMP") | |
| echo "Runner temp filesystem: ${runner_fs}" | |
| probe_dir="${RUNNER_TEMP}/secai-hardlink-probe" | |
| install -d -m 0700 "$probe_dir" | |
| ln -s missing-target "$probe_dir/dangling-symlink" | |
| if ! ln -P "$probe_dir/dangling-symlink" "$probe_dir/hardlink"; then | |
| echo "::error::Runner filesystem cannot preserve OCI hardlinks to symlinks" | |
| exit 1 | |
| fi | |
| if [ "$(stat -c '%d:%i' "$probe_dir/dangling-symlink")" != \ | |
| "$(stat -c '%d:%i' "$probe_dir/hardlink")" ]; then | |
| echo "::error::Runner filesystem changed OCI hardlink identity" | |
| exit 1 | |
| fi | |
| unlink "$probe_dir/hardlink" | |
| unlink "$probe_dir/dangling-symlink" | |
| rmdir "$probe_dir" | |
| oci_dir="${RUNNER_TEMP}/secai-final-image-oci" | |
| bundle_dir="${RUNNER_TEMP}/secai-final-image-bundle" | |
| test ! -e "$oci_dir" | |
| test ! -e "$bundle_dir" | |
| skopeo copy --retry-times 5 --preserve-digests \ | |
| --override-os linux --override-arch amd64 \ | |
| "docker://${PLATFORM_REF}" "oci:${oci_dir}:secai-os" | |
| local_inspect=$(skopeo inspect "oci:${oci_dir}:secai-os") | |
| local_digest=$(jq -er '.Digest' <<<"$local_inspect") | |
| if [ "$local_digest" != "$PLATFORM_DIGEST" ]; then | |
| echo "::error::Materialized OCI image does not match the selected platform" | |
| exit 1 | |
| fi | |
| "$UMOCI_PATH" unpack --rootless \ | |
| --image "${oci_dir}:secai-os" "$bundle_dir" | |
| test -d "$bundle_dir/rootfs" | |
| test -f "$bundle_dir/config.json" | |
| image_rootfs="${bundle_dir}/rootfs" | |
| scanner_uid=$(stat -c %u "$image_rootfs") | |
| scanner_gid=$(stat -c %g "$image_rootfs") | |
| if [ "$scanner_uid" -eq 0 ] || [ "$scanner_uid" -ne "$(id -u)" ] || \ | |
| [ "$scanner_gid" -ne "$(id -g)" ]; then | |
| echo "::error::Rootless OCI tree has an unexpected owner mapping" | |
| exit 1 | |
| fi | |
| { | |
| echo "IMAGE_ROOTFS=${image_rootfs}" | |
| echo "SCANNER_USER=${scanner_uid}:${scanner_gid}" | |
| } >> "$GITHUB_ENV" | |
| - name: Generate final-image SBOM from squashed root | |
| timeout-minutes: 90 | |
| env: | |
| IMAGE_DIGEST: ${{ needs.bluebuild_publish.outputs.digest }} | |
| IMAGE_REF: ${{ needs.bluebuild_publish.outputs.image_ref }} | |
| SCANNER_IMAGE: docker.io/library/fedora@sha256:89f61a124414261868224666aa7fb8df1b78397a53623774bdfb105d1612b48b | |
| run: | | |
| set -euo pipefail | |
| umask 077 | |
| docker pull --platform linux/amd64 "$SCANNER_IMAGE" | |
| docker run --rm \ | |
| --platform linux/amd64 \ | |
| --user "$SCANNER_USER" \ | |
| --network none \ | |
| --read-only \ | |
| --memory 7g \ | |
| --memory-swap 7g \ | |
| --cpus 2 \ | |
| --pids-limit 256 \ | |
| --cap-drop ALL \ | |
| --security-opt no-new-privileges \ | |
| --tmpfs /tmp:rw,nosuid,nodev,noexec,size=512m,mode=1777 \ | |
| --mount "type=bind,source=${SYFT_DIR},target=/run/secai-sbom-scanner,readonly" \ | |
| --mount "type=bind,source=${IMAGE_ROOTFS},target=/scan-root,readonly" \ | |
| --env GOMEMLIMIT=6GiB \ | |
| --env GOMAXPROCS=2 \ | |
| --env HOME=/tmp \ | |
| --env TMPDIR=/tmp \ | |
| --env XDG_CACHE_HOME=/tmp/syft-cache \ | |
| --env SYFT_CACHE_DIR=/tmp/syft-cache \ | |
| --env SYFT_CHECK_FOR_APP_UPDATE=false \ | |
| --entrypoint /run/secai-sbom-scanner/syft \ | |
| "$SCANNER_IMAGE" \ | |
| scan dir:/scan-root \ | |
| --base-path /scan-root \ | |
| --scope squashed \ | |
| --override-default-catalogers image \ | |
| --parallelism 1 \ | |
| --source-name "$IMAGE_REF" \ | |
| --source-version "$IMAGE_DIGEST" \ | |
| --source-supplier SecAI-Hub \ | |
| --exclude './proc/**' \ | |
| --exclude './sys/**' \ | |
| --exclude './dev/**' \ | |
| --exclude './run/**' \ | |
| --exclude './tmp/**' \ | |
| --exclude './etc/hosts' \ | |
| --exclude './etc/hostname' \ | |
| --exclude './etc/resolv.conf' \ | |
| --output cyclonedx-json > sbom.cdx.json | |
| test -s sbom.cdx.json | |
| - name: Validate final-image SBOM | |
| env: | |
| IMAGE_DIGEST: ${{ needs.bluebuild_publish.outputs.digest }} | |
| IMAGE_REF: ${{ needs.bluebuild_publish.outputs.image_ref }} | |
| run: | | |
| set -euo pipefail | |
| component_count=$(jq -er '(.components // []) | length' sbom.cdx.json) | |
| if [ "$component_count" -lt 1000 ]; then | |
| echo "::error::Final-image SBOM is implausibly small (${component_count} components)" | |
| exit 1 | |
| fi | |
| jq -e \ | |
| --arg image_digest "$IMAGE_DIGEST" \ | |
| --arg image_ref "$IMAGE_REF" \ | |
| '.bomFormat == "CycloneDX" | |
| and .metadata.component.name == $image_ref | |
| and .metadata.component.version == $image_digest | |
| and .metadata.component.type == "file" | |
| and .metadata.component.supplier.name == "SecAI-Hub" | |
| and any(.metadata.tools.components[]?; | |
| .name == "syft" and .version == "1.42.3") | |
| and any(.components[]?; (.purl // "") | startswith("pkg:rpm/")) | |
| and any(.components[]?; (.purl // "") | startswith("pkg:pypi/"))' \ | |
| sbom.cdx.json >/dev/null | |
| - name: Extract release-bound integrity baseline | |
| env: | |
| SCANNER_IMAGE: docker.io/library/fedora@sha256:89f61a124414261868224666aa7fb8df1b78397a53623774bdfb105d1612b48b | |
| run: | | |
| set -euo pipefail | |
| docker run --rm \ | |
| --platform linux/amd64 \ | |
| --user "$SCANNER_USER" \ | |
| --network none \ | |
| --read-only \ | |
| --memory 1g \ | |
| --memory-swap 1g \ | |
| --cpus 1 \ | |
| --pids-limit 128 \ | |
| --cap-drop ALL \ | |
| --security-opt no-new-privileges \ | |
| --tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m,mode=1777 \ | |
| --mount "type=bind,source=${IMAGE_ROOTFS},target=/scan-root,readonly" \ | |
| --env HOME=/tmp \ | |
| --entrypoint /bin/bash "$SCANNER_IMAGE" -c ' | |
| set -euo pipefail | |
| rpm_db=/scan-root/usr/lib/sysimage/rpm | |
| test -d "$rpm_db" | |
| rpm --dbpath "$rpm_db" -q cosign >/dev/null | |
| rpm --dbpath "$rpm_db" -ql cosign | | |
| grep -Fx /usr/bin/cosign >/dev/null | |
| rpm --dbpath "$rpm_db" -ql cosign | | |
| grep -Fx /usr/bin/cosign-linux-amd64 >/dev/null | |
| test -L /scan-root/usr/bin/cosign | |
| test "$(readlink /scan-root/usr/bin/cosign)" = \ | |
| /usr/bin/cosign-linux-amd64 | |
| test -f /scan-root/usr/bin/cosign-linux-amd64 | |
| test -x /scan-root/usr/bin/cosign-linux-amd64 | |
| for runtime_binary in \ | |
| /usr/bin/securectl \ | |
| /usr/bin/secai-registryctl \ | |
| /usr/bin/gguf-guard; do | |
| test -f "/scan-root${runtime_binary}" | |
| test ! -L "/scan-root${runtime_binary}" | |
| test -x "/scan-root${runtime_binary}" | |
| done | |
| for package in golang golang-bin golang-src go-filesystem cmake cmake-data gcc-c++ gcc git git-core git-core-doc perl-Git python3-pip; do | |
| if rpm --dbpath "$rpm_db" -q --quiet -- "$package"; then | |
| echo "FATAL: build-only package remains in final image: $package" >&2 | |
| exit 1 | |
| fi | |
| done | |
| for command_name in go cmake gcc g++ git pip pip3; do | |
| for directory in usr/local/sbin usr/local/bin usr/sbin usr/bin; do | |
| command_path="/scan-root/${directory}/${command_name}" | |
| if [ -e "$command_path" ] || [ -L "$command_path" ]; then | |
| echo "FATAL: build-only command remains in final image: $command_name" >&2 | |
| exit 1 | |
| fi | |
| done | |
| done | |
| ' | |
| install -m 0600 \ | |
| "$IMAGE_ROOTFS/usr/share/secure-ai/integrity/release-baseline.json" \ | |
| RELEASE_BASELINE.json | |
| mkdir -p image-root/usr/lib/systemd image-root/usr image-root/etc | |
| cp -a --no-preserve=ownership \ | |
| "$IMAGE_ROOTFS/usr/lib/systemd/system" image-root/usr/lib/systemd/ | |
| cp -a --no-preserve=ownership \ | |
| "$IMAGE_ROOTFS/usr/libexec" image-root/usr/ | |
| cp -a --no-preserve=ownership \ | |
| "$IMAGE_ROOTFS/etc/greenboot" image-root/etc/ | |
| python3 .github/scripts/check-assembled-execstart.py \ | |
| --rootfs image-root | |
| jq -e \ | |
| --arg source_commit "$GITHUB_SHA" \ | |
| '.version == 1 | |
| and .source_commit == $source_commit | |
| and (.files | type == "array" and length > 0) | |
| and all(.files[]; | |
| (.path | startswith("/")) | |
| and (.sha256 | test("^[0-9a-f]{64}$")) | |
| and (.size | type == "number" and . >= 0))' \ | |
| RELEASE_BASELINE.json >/dev/null | |
| for required_path in \ | |
| /usr/bin/securectl \ | |
| /usr/bin/secai-registryctl \ | |
| /usr/bin/gguf-guard; do | |
| jq -e --arg required_path "$required_path" \ | |
| 'any(.files[]?; .path == $required_path)' \ | |
| RELEASE_BASELINE.json >/dev/null | |
| done | |
| python3 - <<'PY' | |
| import hashlib | |
| import json | |
| import os | |
| import re | |
| import stat | |
| from pathlib import Path, PurePosixPath | |
| root = Path(os.environ["IMAGE_ROOTFS"]).resolve(strict=True) | |
| document = json.loads( | |
| Path("RELEASE_BASELINE.json").read_text(encoding="utf-8") | |
| ) | |
| seen: set[str] = set() | |
| for entry in document["files"]: | |
| raw_path = entry["path"] | |
| image_path = PurePosixPath(raw_path) | |
| if ( | |
| not image_path.is_absolute() | |
| or image_path.as_posix() != raw_path | |
| or any(part in {"", ".", ".."} for part in image_path.parts[1:]) | |
| or raw_path in seen | |
| ): | |
| raise SystemExit(f"unsafe or duplicate baseline path: {raw_path!r}") | |
| seen.add(raw_path) | |
| candidate = root.joinpath(*image_path.parts[1:]) | |
| resolved = candidate.resolve(strict=True) | |
| try: | |
| resolved.relative_to(root) | |
| except ValueError as error: | |
| raise SystemExit( | |
| f"release baseline path escapes image root: {raw_path}" | |
| ) from error | |
| file_stat = candidate.stat(follow_symlinks=False) | |
| if not stat.S_ISREG(file_stat.st_mode): | |
| raise SystemExit(f"release baseline path is not regular: {raw_path}") | |
| if file_stat.st_size != entry["size"]: | |
| raise SystemExit(f"release baseline size mismatch: {raw_path}") | |
| expected = entry["sha256"] | |
| if not isinstance(expected, str) or not re.fullmatch( | |
| r"[0-9a-f]{64}", expected | |
| ): | |
| raise SystemExit(f"invalid baseline digest: {raw_path}") | |
| digest = hashlib.sha256() | |
| with candidate.open("rb") as handle: | |
| for chunk in iter(lambda: handle.read(1024 * 1024), b""): | |
| digest.update(chunk) | |
| if digest.hexdigest() != expected: | |
| raise SystemExit(f"release baseline hash mismatch: {raw_path}") | |
| print(f"Verified {len(seen)} release-baseline files against image root") | |
| PY | |
| - name: Create and attach image attestations | |
| env: | |
| COSIGN_PRIVATE_KEY: ${{ secrets.SIGNING_SECRET }} | |
| IMAGE_DIGEST: ${{ needs.bluebuild_publish.outputs.digest }} | |
| PINNED_REF: ${{ needs.bluebuild_publish.outputs.pinned_ref }} | |
| run: | | |
| set -euo pipefail | |
| readonly attestation_bundle_dir="${RUNNER_TEMP}/secai-attestation-bundles" | |
| if [ -e "$attestation_bundle_dir" ] || [ -L "$attestation_bundle_dir" ]; then | |
| echo "::error::Refusing to reuse the attestation bundle directory" >&2 | |
| exit 1 | |
| fi | |
| install -d -m 0700 "$attestation_bundle_dir" | |
| readonly signing_config_path="${attestation_bundle_dir}/signing-config.json" | |
| cosign signing-config create \ | |
| --with-default-rekor-v2 \ | |
| --out "$signing_config_path" | |
| if [ ! -f "$signing_config_path" ] || [ ! -s "$signing_config_path" ] || [ -L "$signing_config_path" ]; then | |
| echo "::error::Cosign did not create a safe, non-empty signing config" >&2 | |
| exit 1 | |
| fi | |
| jq -e ' | |
| .mediaType == "application/vnd.dev.sigstore.signingconfig.v0.2+json" | |
| and .rekorTlogConfig.selector == "ANY" | |
| and (.rekorTlogUrls | type == "array" and length > 0) | |
| and ( | |
| [.rekorTlogUrls[] | select(.majorApiVersion == 2)] as $rekor_v2 | |
| | ($rekor_v2 | length) > 0 | |
| and all( | |
| $rekor_v2[]; | |
| (.url | type == "string") | |
| and (.url | test("^https://[a-z0-9-]+\\.rekor\\.sigstore\\.dev$")) | |
| and .operator == "sigstore.dev" | |
| ) | |
| ) | |
| and (.tsaUrls | type == "array" and length > 0) | |
| and .tsaConfig.selector == "ANY" | |
| ' "$signing_config_path" >/dev/null | |
| retry_cosign_attest() { | |
| local predicate_type=$1 | |
| local predicate_file=$2 | |
| local bundle_path="${attestation_bundle_dir}/${predicate_type}.sigstore.json" | |
| local attempt delay_seconds | |
| local -r max_attempts=4 | |
| if [ -e "$bundle_path" ] || [ -L "$bundle_path" ]; then | |
| echo "::error::Refusing to overwrite a pre-existing ${predicate_type} attestation bundle" >&2 | |
| return 1 | |
| fi | |
| for ((attempt = 1; attempt <= max_attempts; attempt++)); do | |
| if cosign attest --timeout 3m --yes \ | |
| --type "$predicate_type" \ | |
| --predicate "$predicate_file" \ | |
| --bundle "$bundle_path" \ | |
| --signing-config "$signing_config_path" \ | |
| --key env://COSIGN_PRIVATE_KEY \ | |
| "$PINNED_REF"; then | |
| if [ ! -f "$bundle_path" ] || [ ! -s "$bundle_path" ] || [ -L "$bundle_path" ]; then | |
| echo "::error::Cosign did not create a safe, non-empty ${predicate_type} attestation bundle" >&2 | |
| return 1 | |
| fi | |
| if ! jq -e ' | |
| .mediaType == "application/vnd.dev.sigstore.bundle.v0.3+json" | |
| and (.verificationMaterial.tlogEntries | type == "array" and length > 0) | |
| and all( | |
| .verificationMaterial.tlogEntries[]; | |
| (.kindVersion | type == "object") | |
| and .kindVersion.kind == "hashedrekord" | |
| and .kindVersion.version == "0.0.2" | |
| ) | |
| ' "$bundle_path" >/dev/null; then | |
| echo "::error::Cosign created a ${predicate_type} bundle without a Rekor v2 hashedrekord entry" >&2 | |
| return 1 | |
| fi | |
| return 0 | |
| fi | |
| if [ -e "$bundle_path" ] || [ -L "$bundle_path" ]; then | |
| echo "::error::Cosign failed after creating the ${predicate_type} bundle; refusing an ambiguous re-upload" >&2 | |
| return 1 | |
| fi | |
| if ((attempt == max_attempts)); then | |
| echo "::error::Cosign ${predicate_type} attestation upload failed after ${max_attempts} attempts" >&2 | |
| return 1 | |
| fi | |
| delay_seconds=$((15 * (1 << (attempt - 1)))) | |
| echo "::warning::Cosign ${predicate_type} attestation upload failed on attempt ${attempt}/${max_attempts}; retrying in ${delay_seconds}s" >&2 | |
| sleep "$delay_seconds" | |
| done | |
| } | |
| retry_cosign_verify() { | |
| local predicate_type=$1 | |
| local attempt delay_seconds | |
| local -r max_attempts=4 | |
| for ((attempt = 1; attempt <= max_attempts; attempt++)); do | |
| if cosign verify-attestation --timeout 2m \ | |
| --type "$predicate_type" \ | |
| --key cosign.pub \ | |
| "$PINNED_REF" >/dev/null; then | |
| return 0 | |
| fi | |
| if ((attempt == max_attempts)); then | |
| echo "::error::Cosign ${predicate_type} attestation verification failed after ${max_attempts} attempts" >&2 | |
| return 1 | |
| fi | |
| delay_seconds=$((5 * (1 << (attempt - 1)))) | |
| echo "::warning::Cosign ${predicate_type} attestation verification failed on attempt ${attempt}/${max_attempts}; retrying in ${delay_seconds}s" >&2 | |
| sleep "$delay_seconds" | |
| done | |
| } | |
| baseline_sha256=$(sha256sum RELEASE_BASELINE.json | awk '{print $1}') | |
| base_digest=$( | |
| python3 - <<'PY' | |
| import re | |
| from pathlib import Path | |
| recipe = Path("recipes/recipe.yml").read_text(encoding="utf-8") | |
| match = re.search( | |
| r'^image-version:\s*["\x27]?44@(sha256:[0-9a-f]{64})["\x27]?\s*$', | |
| recipe, | |
| re.MULTILINE, | |
| ) | |
| if not match: | |
| raise SystemExit("unable to derive immutable Fedora base digest") | |
| print(match.group(1)) | |
| PY | |
| ) | |
| jq -n \ | |
| --arg commit "$GITHUB_SHA" \ | |
| --arg repository "https://github.com/${GITHUB_REPOSITORY}" \ | |
| --arg workflow "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ | |
| --arg recipe "recipes/recipe.yml" \ | |
| --arg base_digest "$base_digest" \ | |
| --arg baseline_sha256 "$baseline_sha256" \ | |
| '{ | |
| buildDefinition: { | |
| buildType: "https://blue-build.org/secai-os/v1", | |
| externalParameters: { | |
| source_repository: $repository, | |
| source_commit: $commit, | |
| recipe: $recipe | |
| }, | |
| internalParameters: {}, | |
| resolvedDependencies: [ | |
| {uri: "pkg:oci/ublue-os/silverblue-main@44", digest: {sha256: ($base_digest | sub("^sha256:"; ""))}}, | |
| {uri: "file:/usr/share/secure-ai/integrity/release-baseline.json", digest: {sha256: $baseline_sha256}} | |
| ] | |
| }, | |
| runDetails: { | |
| builder: {id: $workflow}, | |
| metadata: {invocationId: $workflow} | |
| } | |
| }' > image-provenance.json | |
| retry_cosign_attest cyclonedx sbom.cdx.json | |
| retry_cosign_attest slsaprovenance image-provenance.json | |
| retry_cosign_verify cyclonedx | |
| retry_cosign_verify slsaprovenance | |
| - name: Authenticate GitHub provenance registry client | |
| env: | |
| REGISTRY_PASSWORD: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| readonly default_registry_config_dir="${HOME}/.docker" | |
| readonly default_registry_config="${default_registry_config_dir}/config.json" | |
| readonly provenance_login_marker="${RUNNER_TEMP}/secai-provenance-registry-login.marker" | |
| if [ -L "$default_registry_config_dir" ] || { | |
| [ -e "$default_registry_config_dir" ] && [ ! -d "$default_registry_config_dir" ]; | |
| }; then | |
| echo "::error::Unsafe default Docker config directory" >&2 | |
| exit 1 | |
| fi | |
| if [ -d "$default_registry_config_dir" ] && [ ! -O "$default_registry_config_dir" ]; then | |
| echo "::error::Default Docker config directory is not runner-owned" >&2 | |
| exit 1 | |
| fi | |
| if [ -e "$default_registry_config" ] || [ -L "$default_registry_config" ]; then | |
| if [ ! -f "$default_registry_config" ] || [ -L "$default_registry_config" ] || [ ! -O "$default_registry_config" ]; then | |
| echo "::error::Unsafe default Docker config file" >&2 | |
| exit 1 | |
| fi | |
| if ! jq -e ' | |
| type == "object" | |
| and ((has("auths") | not) or (.auths | type == "object")) | |
| and ((has("credHelpers") | not) or (.credHelpers | type == "object")) | |
| and ((has("credsStore") | not) or (.credsStore | type == "string")) | |
| ' "$default_registry_config" >/dev/null; then | |
| echo "::error::Invalid pre-existing default Docker config" >&2 | |
| exit 1 | |
| fi | |
| if jq -e ' | |
| def registry_host: | |
| sub("^https?://"; "") | split("/")[0]; | |
| any((.auths // {}) | to_entries[]; (.key | registry_host) == "ghcr.io") | |
| or any((.credHelpers // {}) | to_entries[]; (.key | registry_host) == "ghcr.io") | |
| or ((.credsStore // "") != "") | |
| ' "$default_registry_config" >/dev/null; then | |
| echo "::error::Refusing to replace pre-existing GHCR credentials or credential helpers" >&2 | |
| exit 1 | |
| fi | |
| chmod 0600 "$default_registry_config" | |
| fi | |
| install -d -m 0700 "$default_registry_config_dir" | |
| chmod 0700 "$default_registry_config_dir" | |
| if [ -e "$provenance_login_marker" ] || [ -L "$provenance_login_marker" ]; then | |
| echo "::error::Refusing to reuse the provenance registry login marker" >&2 | |
| exit 1 | |
| fi | |
| umask 077 | |
| if ! (set -o noclobber; : > "$provenance_login_marker") 2>/dev/null; then | |
| echo "::error::Could not create the provenance registry login marker exclusively" >&2 | |
| exit 1 | |
| fi | |
| chmod 0600 "$provenance_login_marker" | |
| if [ "$(stat -c '%a' "$provenance_login_marker")" != "600" ]; then | |
| echo "::error::Provenance registry login marker has unsafe permissions" >&2 | |
| exit 1 | |
| fi | |
| echo "SECAI_PROVENANCE_LOGIN_MARKER=${provenance_login_marker}" >> "$GITHUB_ENV" | |
| printf '%s' "$REGISTRY_PASSWORD" | | |
| docker --config "$default_registry_config_dir" login ghcr.io \ | |
| --username "$GITHUB_ACTOR" --password-stdin | |
| if [ ! -f "$default_registry_config" ] || [ -L "$default_registry_config" ] || [ ! -O "$default_registry_config" ]; then | |
| echo "::error::Docker did not create a safe default credential file" >&2 | |
| exit 1 | |
| fi | |
| chmod 0600 "$default_registry_config" | |
| jq -e ' | |
| def registry_host: | |
| sub("^https?://"; "") | split("/")[0]; | |
| [(.auths // {}) | to_entries[] | select((.key | registry_host) == "ghcr.io")] as $ghcr_auth | |
| | ($ghcr_auth | length) == 1 | |
| and ($ghcr_auth[0].value.auth | type == "string" and length > 0) | |
| and (any( | |
| (.credHelpers // {}) | to_entries[]; | |
| (.key | registry_host) == "ghcr.io" | |
| ) | not) | |
| and ((.credsStore // "") == "") | |
| ' "$default_registry_config" >/dev/null | |
| - name: Generate GitHub image provenance | |
| uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 | |
| with: | |
| subject-name: ${{ needs.bluebuild_publish.outputs.image_ref }} | |
| subject-digest: ${{ needs.bluebuild_publish.outputs.digest }} | |
| push-to-registry: true | |
| - name: Remove GitHub provenance registry credentials | |
| if: always() | |
| run: | | |
| set -euo pipefail | |
| readonly default_registry_config_dir="${HOME}/.docker" | |
| readonly default_registry_config="${default_registry_config_dir}/config.json" | |
| readonly provenance_login_marker="${RUNNER_TEMP}/secai-provenance-registry-login.marker" | |
| if [ -z "${SECAI_PROVENANCE_LOGIN_MARKER:-}" ]; then | |
| exit 0 | |
| fi | |
| if [ "$SECAI_PROVENANCE_LOGIN_MARKER" != "$provenance_login_marker" ]; then | |
| echo "::error::Refusing to trust an unexpected provenance registry login marker" >&2 | |
| exit 1 | |
| fi | |
| if [ ! -e "$provenance_login_marker" ] && [ ! -L "$provenance_login_marker" ]; then | |
| echo "::error::Provenance registry login marker is missing" >&2 | |
| exit 1 | |
| fi | |
| if [ ! -f "$provenance_login_marker" ] || [ -L "$provenance_login_marker" ] || [ ! -O "$provenance_login_marker" ]; then | |
| echo "::error::Unsafe provenance registry login marker" >&2 | |
| exit 1 | |
| fi | |
| if [ "$(stat -c '%a' "$provenance_login_marker")" != "600" ]; then | |
| echo "::error::Provenance registry login marker has unsafe permissions" >&2 | |
| exit 1 | |
| fi | |
| if [ -L "$default_registry_config_dir" ] || { | |
| [ -e "$default_registry_config_dir" ] && [ ! -d "$default_registry_config_dir" ]; | |
| }; then | |
| echo "::error::Unsafe default Docker config directory during cleanup" >&2 | |
| exit 1 | |
| fi | |
| if [ -d "$default_registry_config_dir" ] && [ ! -O "$default_registry_config_dir" ]; then | |
| echo "::error::Default Docker config directory is not runner-owned during cleanup" >&2 | |
| exit 1 | |
| fi | |
| chmod 0700 "$default_registry_config_dir" | |
| if [ -e "$default_registry_config" ] || [ -L "$default_registry_config" ]; then | |
| if [ ! -f "$default_registry_config" ] || [ -L "$default_registry_config" ] || [ ! -O "$default_registry_config" ]; then | |
| echo "::error::Unsafe default Docker config file during cleanup" >&2 | |
| exit 1 | |
| fi | |
| chmod 0600 "$default_registry_config" | |
| fi | |
| docker --config "$default_registry_config_dir" logout ghcr.io >/dev/null 2>&1 || true | |
| if [ -e "$default_registry_config" ] || [ -L "$default_registry_config" ]; then | |
| if ! jq -e ' | |
| type == "object" | |
| and ((has("auths") | not) or (.auths | type == "object")) | |
| and ((has("credHelpers") | not) or (.credHelpers | type == "object")) | |
| and ((has("credsStore") | not) or (.credsStore | type == "string")) | |
| ' "$default_registry_config" >/dev/null; then | |
| echo "::error::Invalid default Docker config after provenance cleanup" >&2 | |
| exit 1 | |
| fi | |
| if ! jq -e ' | |
| def registry_host: | |
| sub("^https?://"; "") | split("/")[0]; | |
| (any((.auths // {}) | to_entries[]; (.key | registry_host) == "ghcr.io") | not) | |
| and (any( | |
| (.credHelpers // {}) | to_entries[]; | |
| (.key | registry_host) == "ghcr.io" | |
| ) | not) | |
| and ((.credsStore // "") == "") | |
| ' "$default_registry_config" >/dev/null; then | |
| echo "::error::GHCR credentials or credential helpers remain after provenance cleanup" >&2 | |
| exit 1 | |
| fi | |
| fi | |
| rm -f -- "$provenance_login_marker" | |
| - name: Upload image digest artifact | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: image-digest | |
| path: | | |
| IMAGE_DIGEST | |
| IMAGE_REF_PINNED | |
| RELEASE_BASELINE.json | |
| sbom.cdx.json | |
| image-provenance.json | |
| if-no-files-found: error | |
| retention-days: 30 | |
| - name: Upload Sigstore attestation bundles | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: image-attestation-bundles | |
| path: ${{ runner.temp }}/secai-attestation-bundles/*.sigstore.json | |
| if-no-files-found: error | |
| retention-days: 30 | |
| - name: Remove registry credentials | |
| if: always() | |
| run: | | |
| set -euo pipefail | |
| readonly expected_registry_config="${RUNNER_TEMP}/secai-registry-auth" | |
| readonly expected_registry_auth="${expected_registry_config}/config.json" | |
| if [ -n "${DOCKER_CONFIG:-}" ] && [ "$DOCKER_CONFIG" != "$expected_registry_config" ]; then | |
| echo "::error::Refusing to clean an unexpected Docker config path" >&2 | |
| exit 1 | |
| fi | |
| if [ -n "${REGISTRY_AUTH_FILE:-}" ] && [ "$REGISTRY_AUTH_FILE" != "$expected_registry_auth" ]; then | |
| echo "::error::Refusing to clean an unexpected registry auth file" >&2 | |
| exit 1 | |
| fi | |
| if [ -e "$expected_registry_config" ] || [ -L "$expected_registry_config" ]; then | |
| if [ ! -d "$expected_registry_config" ] || [ -L "$expected_registry_config" ] || [ ! -O "$expected_registry_config" ]; then | |
| echo "::error::Unsafe isolated registry config directory during cleanup" >&2 | |
| exit 1 | |
| fi | |
| fi | |
| if [ -e "$expected_registry_auth" ] || [ -L "$expected_registry_auth" ]; then | |
| if [ ! -f "$expected_registry_auth" ] || [ -L "$expected_registry_auth" ] || [ ! -O "$expected_registry_auth" ]; then | |
| echo "::error::Unsafe isolated registry auth file during cleanup" >&2 | |
| exit 1 | |
| fi | |
| fi | |
| docker --config "$expected_registry_config" logout ghcr.io >/dev/null 2>&1 || true | |
| rm -f -- "$expected_registry_auth" | |
| if [ -d "$expected_registry_config" ]; then | |
| rmdir -- "$expected_registry_config" | |
| fi | |
| bluebuild: | |
| name: "Stage 4: BlueBuild Gate" | |
| if: >- | |
| always() && | |
| ((github.event_name == 'pull_request' && | |
| needs.bluebuild_pr.result == 'success') || | |
| (github.event_name != 'pull_request' && | |
| needs.bluebuild_publish.result == 'success' && | |
| needs.release_evidence.result == 'success')) | |
| needs: [bluebuild_pr, bluebuild_publish, release_evidence] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Confirm the event-appropriate build completed | |
| run: echo "BlueBuild completed without crossing the PR trust boundary." | |
| smoke-test: | |
| name: Tier 1 Smoke Test (Artifact Verification) | |
| needs: [bluebuild] | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 | |
| with: | |
| python-version: "3.12" | |
| - name: Install locked validation dependencies | |
| run: python -m pip install --require-hashes -r requirements-ci.lock | |
| - name: Validate recipe systemd units | |
| run: | | |
| python3 .github/scripts/check-assembled-execstart.py | |
| python3 -c " | |
| import yaml, sys | |
| with open('recipes/recipe.yml') as f: | |
| recipe = yaml.safe_load(f) | |
| for module in recipe.get('modules', []): | |
| if module.get('type') != 'systemd': | |
| continue | |
| enabled = set(module.get('system', {}).get('enabled', [])) | |
| disabled = set(module.get('system', {}).get('disabled', [])) | |
| overlap = enabled & disabled | |
| if overlap: | |
| print(f'FAIL: services in both enabled and disabled: {overlap}') | |
| sys.exit(1) | |
| # Diffusion must be disabled by default | |
| if 'secure-ai-diffusion.service' in enabled: | |
| print('FAIL: secure-ai-diffusion.service must be in disabled list') | |
| sys.exit(1) | |
| if 'secure-ai-diffusion.service' not in disabled: | |
| print('FAIL: secure-ai-diffusion.service missing from disabled list') | |
| sys.exit(1) | |
| # Core services must be enabled | |
| core = [ | |
| 'secure-ai-registry.service', | |
| 'secure-ai-tool-firewall.service', | |
| 'secure-ai-ui.service', | |
| 'secure-ai-policy-engine.service', | |
| 'nftables.service', | |
| ] | |
| for svc in core: | |
| if svc not in enabled: | |
| print(f'FAIL: core service {svc} not in enabled list') | |
| sys.exit(1) | |
| print(f'OK: {len(enabled)} enabled, {len(disabled)} disabled, no overlap') | |
| " | |
| - name: Validate YAML config files | |
| run: | | |
| python3 -c " | |
| import yaml, sys, glob | |
| errors = 0 | |
| for pattern in ['files/system/etc/secure-ai/**/*.yaml', 'recipes/*.yml']: | |
| for f in glob.glob(pattern, recursive=True): | |
| try: | |
| with open(f) as fh: | |
| yaml.safe_load(fh) | |
| print(f'OK: {f}') | |
| except Exception as e: | |
| print(f'FAIL: {f}: {e}') | |
| errors += 1 | |
| sys.exit(errors) | |
| " | |
| - name: Verify build script is hermetic-ready | |
| run: | | |
| echo "=== Checking build-services.sh for network fetch patterns ===" | |
| SCRIPT="files/scripts/build-services.sh" | |
| # Must have hermetic guard | |
| grep -q "HERMETIC_BUILD" "$SCRIPT" || { echo "FAIL: no HERMETIC_BUILD guard"; exit 1; } | |
| echo "OK: HERMETIC_BUILD guard present" | |
| # Must have LLAMA_CPP_SHA256 | |
| grep -q "LLAMA_CPP_SHA256" "$SCRIPT" || { echo "FAIL: no LLAMA_CPP_SHA256"; exit 1; } | |
| echo "OK: LLAMA_CPP_SHA256 checksum present" | |
| # Must have GOPROXY=off in hermetic mode | |
| grep -q "GOPROXY=off" "$SCRIPT" || { echo "FAIL: no GOPROXY=off"; exit 1; } | |
| echo "OK: GOPROXY=off in hermetic mode" | |
| # Must not have --clone in locate_source calls | |
| if grep -n "locate_source.*--clone" "$SCRIPT"; then | |
| echo "FAIL: locate_source still uses --clone" | |
| exit 1 | |
| fi | |
| echo "OK: no --clone in locate_source" | |
| # Must not have dnf install | |
| if grep -n "dnf install" "$SCRIPT" | grep -v "^#" | grep -v "dnf remove"; then | |
| echo "FAIL: dnf install found in build script" | |
| exit 1 | |
| fi | |
| echo "OK: no dnf install" | |
| echo "=== Build script hermetic checks passed ===" | |
| - name: Verify systemd units use wrappers | |
| run: | | |
| echo "=== Checking systemd units ===" | |
| UNITS_DIR="files/system/usr/lib/systemd/system" | |
| # UI must use wrapper, not python3 directly | |
| if grep -q "ExecStart=/usr/bin/python3" "${UNITS_DIR}/secure-ai-ui.service"; then | |
| echo "FAIL: UI service still uses python3 directly" | |
| exit 1 | |
| fi | |
| echo "OK: UI uses wrapper" | |
| # Diffusion must not use python3 directly | |
| if grep -q "ExecStart=/usr/bin/python3" "${UNITS_DIR}/secure-ai-diffusion.service"; then | |
| echo "FAIL: Diffusion service still uses python3 directly" | |
| exit 1 | |
| fi | |
| echo "OK: Diffusion uses wrapper/placeholder" | |
| echo "=== Systemd unit checks passed ===" |