Skip to content

Bump the github-actions group across 1 directory with 8 updates #489

Bump the github-actions group across 1 directory with 8 updates

Bump the github-actions group across 1 directory with 8 updates #489

Workflow file for this run

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 ==="