Files
OrcaSlicer/scripts/check_profile.sh
HanifKoh be4eff43f4 Fail the Profile Checks When a Changed Bundle Keeps Its Version (#16923)
The app installs a bundled profile set only when its version is newer than
the installed one, and the OTA profile update is named after the version, so
a vendor bundle merged without a bump in its <Vendor>.json never reaches a
user. Nothing checked that: two recent OrcaFilamentLibrary additions and a
Qidi printer model merged with their version unchanged.

Add a version-check command to orca_profile_tool.py that diffs two revisions
(or the working tree against the commit it left main at), groups the changed
files by vendor bundle, and fails unless every changed bundle's version is
newer than before and written as four numbers. The fourth component folds
into the third as the app reads it, so 02.04.00.100 is not newer than
02.04.01.00. A bundle the base does not have, blacklist.json and the user
preset directory are not judged.

Run it as the version_bump check in the Check profiles workflow against the
branch a PR merges into, and in check_profile.sh and check_profile.ps1,
which take --base / -Base to compare with another ref and skip the check for
a --profiles tree outside a git checkout.
2026-10-27 21:10:54 +08:00

745 lines
32 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Local twin of the "Check profiles" CI job (.github/workflows/check_profiles.yml).
#
# Runs the same seven checks, in the same order, with the same validator flags, and with the
# same semantics: every check runs even after an earlier one fails (the workflow's
# continue-on-error), then the script exits non-zero once at the end.
#
# Everything that has to be downloaded - the profile validator, the custom-preset fixture
# archives and the v2.4.2 profile tree - lands under a per-user cache directory and is reused on
# the next run. Being outside
# the checkout, that directory is shared by every worktree on the machine. It also holds one log
# per check plus a copy of the comment CI would post on the PR.
#
# resources/profiles/user, which the validator creates as its data dir but a CI checkout never
# has, is moved aside for the duration of the run and restored on exit. Only one run per work
# dir at a time.
#
# -v/--vendor narrows a run to one vendor while working on that vendor's profiles - the one
# deliberate divergence from CI, which always checks the whole tree. A check that cannot be
# narrowed is left out of the run and reported as skipped; see usage(). version_bump compares
# the tree with the commit it left main at, where CI compares a PR with the branch it merges
# into; --base points it elsewhere.
#
# Usage: scripts/check_profile.sh [OPTION]... [CHECK]...
# The check_* functions run through run_check, which dispatches on the check name, so
# ShellCheck cannot see that they (and what they call) are used.
# shellcheck disable=SC2329
set -uo pipefail
VALIDATOR_RELEASE_URL="https://github.com/OrcaSlicer/OrcaSlicer/releases/download/nightly-builds"
FIXTURE_RELEASE_URL="https://github.com/OrcaSlicer/OrcaSlicer-profile-validator/releases/download/fixture-archive"
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
HOST_ARCH="$(uname -m)"
HOST_OS="$(uname -s)"
case "${HOST_OS}" in
Darwin*) HOST_OS=Darwin ;;
MINGW*|MSYS*|CYGWIN*) HOST_OS=Windows ;;
Linux*) HOST_OS=Linux ;;
esac
PROFILES_DIR="${REPO_ROOT}/resources/profiles"
case "${HOST_OS}" in
Darwin) DEFAULT_WORK_DIR="${HOME}/Library/Caches/orca-profile-check" ;;
Windows) DEFAULT_WORK_DIR="${LOCALAPPDATA:-${HOME}/AppData/Local}/orca-profile-check" ;;
*) DEFAULT_WORK_DIR="${XDG_CACHE_HOME:-${HOME}/.cache}/orca-profile-check" ;;
esac
WORK_DIR="${DEFAULT_WORK_DIR}"
VALIDATOR="${ORCA_PROFILE_VALIDATOR:-}"
# Vendor to check, named after its <Vendor>.json - empty means every vendor, which is exactly what
# both the validator's -v and orca_profile_tool.py check's --vendor take an empty value to mean.
# So the flag is passed unconditionally below rather than kept in an array bash 3.2 cannot expand
# empty under `set -u`.
VENDOR=""
BASE_REF=""
LOG_LEVEL=2
PREFER_DOWNLOAD=0
REFRESH=0
ALL_CHECKS=(profile_tool version_bump legacy_compat validate_system validate_slice validate_filament_subtypes validate_custom)
CHECKS=()
# "<check><TAB>pass|fail" per check that ran, plus "<check><TAB>skip<TAB>why" for one a vendor
# scope left out; a string rather than an array because bash 3.2 (still the /bin/bash on macOS)
# cannot expand an empty array under `set -u`.
RESULTS=""
usage() {
cat <<EOF
Run the profile checks from .github/workflows/check_profiles.yml locally.
Usage: scripts/check_profile.sh [OPTION]... [CHECK]...
Checks (default: all, in this order):
profile_tool scripts/orca_profile_tool.py check
version_bump scripts/orca_profile_tool.py version-check: every changed
bundle's <Vendor>.json has a newer version than at --base
legacy_compat scripts/orca_profile_tool.py legacy-compat --check against the
v2.4.2 profiles
validate_system validator -p <profiles> -l <level>
validate_slice validator -p <profiles> -s -l <level>
validate_filament_subtypes validator -p <profiles> -l <level> -f
validate_custom validator against every released custom-preset fixture
Options:
-p, --profiles DIR profile tree to validate (default: resources/profiles)
-v, --vendor NAME check only this vendor, named after its <Vendor>.json (e.g. "Co Print")
--base REF the git ref version_bump compares the tree with (default: where the
branch left upstream/main, else origin/main)
--validator BIN OrcaSlicer_profile_validator to use; also \$ORCA_PROFILE_VALIDATOR.
Default: the local build*/ Release build (then RelWithDebInfo, then
Debug) for this architecture, else the nightly release build is
downloaded for this platform
--download ignore local builds and use the downloaded nightly validator
--refresh re-download the validator and fixtures instead of using the cache
--work-dir DIR downloads, logs and fixture trees (default: ${DEFAULT_WORK_DIR})
-l, --log-level N validator log level (default: ${LOG_LEVEL}, as in CI)
-h, --help show this help
Note: profile_tool, version_bump and legacy_compat are the checks that are not the validator
binary; they make the static checks the validator cannot, because the validator loads the tree
the way the slicer does and so never sees a profile no <vendor>.json indexes, a preset name
two files claim, a file normalize and update-index would still rewrite, or a bundle that
changed without its version being bumped. version_bump is skipped, unless named, for a
--profiles tree that is not inside a git checkout, or one with neither upstream/main nor
origin/main to compare with when no --base is given.
Note: --vendor narrows validate_custom too, by keeping only that vendor's presets in each
fixture tree. The one check it cannot narrow is validate_slice for a vendor that ships no
printers; the summary reports that one as skipped, and naming it explicitly still runs it.
profile_tool keeps its two cross-vendor checks (setting_id and filament_id) tree-wide, so a
scoped run can still fail on another vendor's files, and legacy_compat is tree-wide too: the map
it checks covers every vendor.
EOF
}
msg() { printf '%s\n' "$*" >&2; }
die() { printf 'check_profile.sh: %s\n' "$*" >&2; exit 2; }
if [ -t 1 ]; then
C_RED=$'\033[91m'; C_GREEN=$'\033[92m'; C_BOLD=$'\033[1m'; C_RESET=$'\033[0m'
else
C_RED=''; C_GREEN=''; C_BOLD=''; C_RESET=''
fi
# ---------------------------------------------------------------------------- arguments
while [ $# -gt 0 ]; do
case "$1" in
-h|--help) usage; exit 0 ;;
-p|--profiles) [ $# -ge 2 ] || die "$1 needs a directory"; PROFILES_DIR="$2"; shift 2 ;;
-v|--vendor) [ $# -ge 2 ] || die "$1 needs a vendor name"; VENDOR="$2"; shift 2 ;;
--base) [ $# -ge 2 ] || die "$1 needs a git ref"; BASE_REF="$2"; shift 2 ;;
--validator) [ $# -ge 2 ] || die "$1 needs a path"; VALIDATOR="$2"; shift 2 ;;
--work-dir) [ $# -ge 2 ] || die "$1 needs a directory"; WORK_DIR="$2"; shift 2 ;;
-l|--log-level) [ $# -ge 2 ] || die "$1 needs a number"; LOG_LEVEL="$2"; shift 2 ;;
--download) PREFER_DOWNLOAD=1; shift ;;
--refresh) REFRESH=1; shift ;;
-*) die "unknown option '$1' (try --help)" ;;
*)
known=0
for check in "${ALL_CHECKS[@]}"; do
[ "$1" = "${check}" ] && known=1
done
[ "${known}" -eq 1 ] || die "unknown check '$1' (try --help)"
CHECKS[${#CHECKS[@]}]="$1"
shift
;;
esac
done
# A check named on the command line always runs; only the default set is narrowed (see the run
# loop below).
NAMED_CHECKS="${#CHECKS[@]}"
[ "${NAMED_CHECKS}" -gt 0 ] || CHECKS=("${ALL_CHECKS[@]}")
[ -d "${PROFILES_DIR}" ] || die "profile directory not found: ${PROFILES_DIR}"
PROFILES_DIR="$(cd -- "${PROFILES_DIR}" && pwd)"
# A vendor neither tool knows is not an error to them: the static checks load nothing of their own
# and still report success, so a typo would otherwise be three green checks and one baffling slice
# failure. The match has to be made on the names themselves rather than with a -f test - the
# validator compares the <Vendor>.json stem case-sensitively, while a case-insensitive filesystem
# (macOS, Windows) would let "creality" pass a file test and then match no vendor. A vendor is a
# <name>.json with a sibling <name>/ directory; that pair is also what tells one apart from
# blacklist.json, which sits in the same folder.
if [ -n "${VENDOR}" ]; then
wanted="$(printf '%s' "${VENDOR}" | tr '[:upper:]' '[:lower:]')"
found=""
suggestion=""
for file in "${PROFILES_DIR}"/*.json; do
name="${file##*/}"; name="${name%.json}"
[ -d "${PROFILES_DIR}/${name}" ] || continue
if [ "${name}" = "${VENDOR}" ]; then
found=1
break
fi
[ "$(printf '%s' "${name}" | tr '[:upper:]' '[:lower:]')" = "${wanted}" ] && suggestion="${name}"
done
if [ -z "${found}" ]; then
[ -z "${suggestion}" ] || die "unknown vendor '${VENDOR}'; vendor names are case-sensitive, did you mean '${suggestion}'?"
die "unknown vendor '${VENDOR}': no such vendor in ${PROFILES_DIR}"
fi
fi
LOG_DIR="${WORK_DIR}/logs"
mkdir -p "${LOG_DIR}" || die "cannot create ${LOG_DIR}"
WORK_DIR="$(cd -- "${WORK_DIR}" && pwd)"
LOG_DIR="${WORK_DIR}/logs"
wants() {
local check
for check in "${CHECKS[@]}"; do
[ "${check}" = "$1" ] && return 0
done
return 1
}
# ------------------------------------------------------------------- clean profile tree
# The validator points its data dir at the profile tree, so it creates - and, with -g, fills -
# <profiles>/user. A CI checkout never has that directory, and anything left in it from an
# earlier local run would be loaded as user presets and validated too. Move it aside for the
# duration of the run so what gets checked is what CI checks.
STASHED_USER_DIR=""
stash_user_presets() {
[ -d "${PROFILES_DIR}/user" ] || return 0
STASHED_USER_DIR="${WORK_DIR}/user-presets-$$"
rm -rf "${STASHED_USER_DIR}"
mv "${PROFILES_DIR}/user" "${STASHED_USER_DIR}" || { STASHED_USER_DIR=""; die "cannot move ${PROFILES_DIR}/user aside"; }
msg "moved ${PROFILES_DIR}/user aside for the run (restored on exit)"
}
restore_user_presets() {
# The validator leaves an empty user/default/{filament,machine,process} skeleton behind.
# Prune it with rmdir, never rm -rf: a directory that holds a real file survives and is
# reported instead of being deleted. Runs even when nothing was stashed, so a tree that had
# no user/ before the run does not gain one.
find "${PROFILES_DIR}/user" -depth -type d -exec rmdir {} + 2>/dev/null
[ -n "${STASHED_USER_DIR}" ] || return 0
if [ -d "${PROFILES_DIR}/user" ]; then
msg "${PROFILES_DIR}/user is not empty; your presets stay in ${STASHED_USER_DIR}"
STASHED_USER_DIR=""
return 0
fi
mv "${STASHED_USER_DIR}" "${PROFILES_DIR}/user"
STASHED_USER_DIR=""
}
# The fixture trees under the work dir are shared scratch space keyed by fixture version, so a
# second run would delete a tree the first one is validating.
LOCK_DIR="${WORK_DIR}/.lock"
mkdir "${LOCK_DIR}" 2>/dev/null ||
die "another run is using ${WORK_DIR} (pass --work-dir, or remove ${LOCK_DIR} if no run is active)"
cleanup() {
restore_user_presets
rmdir "${LOCK_DIR}" 2>/dev/null
}
# Installed only once the lock is ours, so a refused start never releases someone else's.
trap cleanup EXIT
trap 'cleanup; exit 130' INT TERM
stash_user_presets
# ---------------------------------------------------------------------------- downloads
# fetch URL DEST [force] - cached; --refresh, or a non-empty third argument, forces a new
# download. Release assets never change, so caching them is safe; the fixture manifest is the
# index that grows with every release, and CI re-reads it on every run.
fetch() {
local url="$1" dest="$2" force="${3:-}"
if [ "${REFRESH}" -eq 0 ] && [ -z "${force}" ] && [ -s "${dest}" ]; then
return 0
fi
mkdir -p "$(dirname -- "${dest}")" || return 1
msg "downloading $(basename -- "${dest}") ..."
if ! curl -fsSL --retry 3 -o "${dest}.part" "${url}"; then
rm -f "${dest}.part"
msg "download failed: ${url}"
return 1
fi
mv -f "${dest}.part" "${dest}"
}
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | cut -d' ' -f1
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | cut -d' ' -f1
else
return 1
fi
}
# Directories a built validator can sit in, best first: every Ninja Multi-Config Release tree,
# then RelWithDebInfo, then Debug, then a single-config generator's plain src/. Each pattern is
# matched both directly under a build root (build/) and one level down (build/arm64, build/x64),
# and unmatched globs are dropped by the caller's -d test.
validator_search_dirs() {
local config
for config in Release RelWithDebInfo Debug; do
printf '%s\n' "${REPO_ROOT}"/build*/src/"${config}" "${REPO_ROOT}"/build*/*/src/"${config}"
done
printf '%s\n' "${REPO_ROOT}"/build*/src "${REPO_ROOT}"/build*/*/src
}
# A build tree named for the other architecture (build/x86_64 on an arm64 host, build/arm64 on
# an x64 one) is a last resort: on Linux it will not run at all, on macOS it goes via Rosetta.
# Takes a repo-relative path - an absolute one would also match an arch name in the checkout path.
is_foreign_arch_dir() {
case "${HOST_ARCH}" in
arm64|aarch64) case "$1" in *x86_64*|*x86-64*|*x64*|*amd64*) return 0 ;; esac ;;
x86_64|amd64) case "$1" in *arm64*|*aarch64*) return 0 ;; esac ;;
esac
return 1
}
# First locally built OrcaSlicer_profile_validator, in the order above. macOS puts it in an .app
# bundle, Linux and Windows next to the other binaries.
find_local_validator() {
local dir candidate foreign=""
while IFS= read -r dir; do
[ -d "${dir}" ] || continue
for candidate in \
"${dir}/OrcaSlicer_profile_validator" \
"${dir}/OrcaSlicer_profile_validator.exe" \
"${dir}/OrcaSlicer_profile_validator.app/Contents/MacOS/OrcaSlicer_profile_validator"; do
[ -f "${candidate}" ] && [ -x "${candidate}" ] || continue
if ! is_foreign_arch_dir "${dir#"${REPO_ROOT}"/}"; then
printf '%s\n' "${candidate}"
return 0
fi
[ -n "${foreign}" ] || foreign="${candidate}"
done
done <<EOF
$(validator_search_dirs)
EOF
[ -n "${foreign}" ] || return 1
msg "no ${HOST_ARCH} build found, falling back to ${foreign}"
printf '%s\n' "${foreign}"
}
# The nightly release build, same one CI uses. Linux ships the bare binary, macOS a .dmg
# holding the signed .app, Windows an .exe.
download_validator() {
local dest="${WORK_DIR}/validator" binary dmg app mounted app_src
case "${HOST_OS}" in
Linux)
case "${HOST_ARCH}" in
arm64|aarch64) msg "the nightly Linux validator is x86_64; build it locally for ${HOST_ARCH}" ;;
esac
binary="${dest}/OrcaSlicer_profile_validator"
fetch "${VALIDATOR_RELEASE_URL}/OrcaSlicer_profile_validator_Linux_Ubuntu2404_nightly" "${binary}" || return 1
chmod +x "${binary}" || return 1
;;
Darwin)
dmg="${dest}/OrcaSlicer_profile_validator.dmg"
app="${dest}/OrcaSlicer_profile_validator.app"
binary="${app}/Contents/MacOS/OrcaSlicer_profile_validator"
fetch "${VALIDATOR_RELEASE_URL}/OrcaSlicer_profile_validator_Mac_universal_nightly.dmg" "${dmg}" || return 1
if [ ! -x "${binary}" ] || [ "${REFRESH}" -eq 1 ]; then
mounted="${dest}/mnt"
rm -rf "${mounted}" "${app}"
mkdir -p "${mounted}" || return 1
hdiutil attach -nobrowse -readonly -mountpoint "${mounted}" "${dmg}" >/dev/null || return 1
app_src="$(find "${mounted}" -maxdepth 1 -name '*.app' -print 2>/dev/null | head -n 1)"
if [ -n "${app_src}" ]; then
cp -R "${app_src}" "${app}"
fi
hdiutil detach "${mounted}" >/dev/null 2>&1
rmdir "${mounted}" 2>/dev/null
[ -x "${binary}" ] || { msg "no validator app inside ${dmg}"; return 1; }
fi
;;
Windows)
binary="${dest}/OrcaSlicer_profile_validator.exe"
fetch "${VALIDATOR_RELEASE_URL}/OrcaSlicer_profile_validator_Windows_nightly.exe" "${binary}" || return 1
chmod +x "${binary}" || return 1
;;
*)
msg "no nightly validator published for ${HOST_OS}; build it (-DORCA_TOOLS=ON) and pass --validator"
return 1
;;
esac
printf '%s\n' "${binary}"
}
resolve_validator() {
if [ -n "${VALIDATOR}" ]; then
[ -x "${VALIDATOR}" ] || die "validator not executable: ${VALIDATOR}"
return 0
fi
if [ "${PREFER_DOWNLOAD}" -eq 0 ]; then
VALIDATOR="$(find_local_validator)"
if [ -n "${VALIDATOR}" ]; then
msg "using locally built validator: ${VALIDATOR}"
return 0
fi
fi
VALIDATOR="$(download_validator)" || die "could not obtain a profile validator"
msg "using downloaded validator: ${VALIDATOR}"
}
# ---------------------------------------------------------------------------- checks
check_profile_tool() {
python3 "${REPO_ROOT}/scripts/orca_profile_tool.py" check --profiles "${PROFILES_DIR}" --vendor "${VENDOR}"
}
# The app installs a bundle only when its version is newer than the installed one, and the OTA
# profile update is named after it, so a changed bundle whose <Vendor>.json keeps its version
# never reaches users.
check_version_bump() {
python3 "${REPO_ROOT}/scripts/orca_profile_tool.py" version-check --profiles "${PROFILES_DIR}" \
--vendor "${VENDOR}" ${BASE_REF:+--base "${BASE_REF}"}
}
# Why the default run leaves version_bump out, on stdout; nothing, and status 1, when it can run.
version_bump_skip_reason() {
local main
if ! git -C "${PROFILES_DIR}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "${PROFILES_DIR} is not in a git checkout"
return 0
fi
[ -z "${BASE_REF}" ] || return 1
for main in upstream/main origin/main; do
git -C "${PROFILES_DIR}" rev-parse -q --verify "${main}^{commit}" >/dev/null 2>&1 && return 1
done
echo "neither upstream/main nor origin/main exists to compare with; pass --base"
return 0
}
# The profile tree of the release resources/info/legacy_compatible_printers.json is generated
# against. The tag is immutable, so the extracted tree is cached in the work dir and --refresh
# re-extracts it. A checkout without the tag (a shallow clone, a fork that never fetched tags)
# gets it from upstream, as CI does.
LEGACY_BASE_TAG="v2.4.2"
LEGACY_BASE_URL="https://github.com/OrcaSlicer/OrcaSlicer.git"
legacy_base_profiles() {
local base="${WORK_DIR}/legacy-base/${LEGACY_BASE_TAG}"
if [ "${REFRESH}" -eq 1 ] || [ ! -d "${base}/resources/profiles" ]; then
if ! git -C "${REPO_ROOT}" rev-parse -q --verify "refs/tags/${LEGACY_BASE_TAG}^{commit}" >/dev/null; then
msg "fetching tag ${LEGACY_BASE_TAG} from ${LEGACY_BASE_URL} ..."
git -C "${REPO_ROOT}" fetch --no-tags "${LEGACY_BASE_URL}" \
"refs/tags/${LEGACY_BASE_TAG}:refs/tags/${LEGACY_BASE_TAG}" || return 1
fi
# Extracted beside its final place and moved there whole, so an interrupted extraction is
# never mistaken for the tree on the next run.
local partial="${base}.partial"
rm -rf "${base}" "${partial}"
mkdir -p "${partial}" || return 1
if ! git -C "${REPO_ROOT}" archive "${LEGACY_BASE_TAG}" resources/profiles | tar -x -C "${partial}"; then
rm -rf "${partial}"
return 1
fi
mv "${partial}" "${base}" || return 1
fi
printf '%s\n' "${base}/resources/profiles"
}
# The map says which printers a user filament preset made against 2.4.2 can be migrated back
# to, so a profile change that renames a filament preset or narrows its compatible_printers has
# to regenerate it (the CI comment gives the command).
check_legacy_compat() {
local base
base="$(legacy_base_profiles)" || return 1
python3 "${REPO_ROOT}/scripts/orca_profile_tool.py" legacy-compat --profiles "${PROFILES_DIR}" --base "${base}" --check
}
check_validate_system() {
"${VALIDATOR}" -p "${PROFILES_DIR}" -v "${VENDOR}" -l "${LOG_LEVEL}"
}
# Slices a two-colour cube through every printer so all custom g-code (incl. change_filament_gcode)
# is expanded - catches undefined-placeholder / invalid-flow bugs the static checks cannot see.
check_validate_slice() {
"${VALIDATOR}" -p "${PROFILES_DIR}" -v "${VENDOR}" -s -l "${LOG_LEVEL}"
}
check_validate_filament_subtypes() {
"${VALIDATOR}" -p "${PROFILES_DIR}" -v "${VENDOR}" -l "${LOG_LEVEL}" -f
}
# The fixtures name every preset "<vendor>_<parent preset>_orca_test", after the "name" inside
# <Vendor>.json rather than the file stem --vendor takes - BBL.json is "Bambulab", iQ.json is
# "innovatiQ". Falls back to the stem for a vendor file with no readable name.
vendor_display_name() {
local name
name="$(VENDOR_JSON="${PROFILES_DIR}/${VENDOR}.json" python3 - <<'PY'
import json
import os
try:
with open(os.environ["VENDOR_JSON"], encoding="utf-8") as fh:
print(json.load(fh).get("name", ""))
except Exception:
pass
PY
)"
printf '%s\n' "${name:-${VENDOR}}"
}
# Every released fixture is a snapshot of user presets saved by that OrcaSlicer version; each is
# unpacked over the current system profiles and validated, so a profile change that would break
# an existing user's presets fails here.
#
# Under --vendor only that vendor's presets are unpacked, which is what makes the validator's -v
# usable here: -v filters the system vendors but never the user presets, so on a whole-tree
# snapshot it reports every other vendor's presets as unresolvable parents - thousands of errors
# saying nothing about the vendor under test. Teaching -v to filter user presets too is the deeper
# fix, but this runs against whatever validator is to hand, the published nightly included, so the
# picking has to happen here. It picks on the "<vendor display name>_" filename prefix that
# generate_custom_presets() (the validator's -g mode) gave every preset in these fixtures; a
# fixture cut from a renamed generator would surface as the "checked nothing" warning below.
# Presets whose source had no vendor carry no prefix - a few of those still name one in the
# middle, like "PET @BBL A1_orca_test" - and only an unscoped run covers them.
check_validate_custom() {
local fixtures_dir="${WORK_DIR}/profile-fixtures"
local output_dir="${WORK_DIR}/custom-preset-validation"
local summary="${output_dir}/summary.md"
local status=0 failed_logs="" vendor_prefix="" total_kept=0
local version asset expected_sha256 asset_url fixture_zip profile_tree log_path actual_sha256 result kept
command -v unzip >/dev/null 2>&1 || { msg "unzip is required for validate_custom"; return 1; }
[ -z "${VENDOR}" ] || vendor_prefix="$(vendor_display_name)_"
mkdir -p "${fixtures_dir}" "${output_dir}" || return 1
fetch "${FIXTURE_RELEASE_URL}/manifest.json" "${fixtures_dir}/manifest.json" force || return 1
MANIFEST_PATH="${fixtures_dir}/manifest.json" python3 - > "${fixtures_dir}/fixtures.tsv" <<'PY'
import json
import os
with open(os.environ["MANIFEST_PATH"], encoding="utf-8") as fh:
manifest = json.load(fh)
if isinstance(manifest, dict):
entries = manifest.get("fixtures", [])
else:
entries = manifest
for entry in entries:
version = entry.get("version", "")
asset = entry.get("asset", "")
sha256 = entry.get("asset_sha256", "")
if not version or not asset:
continue
print(f"{version}\t{asset}\t{sha256}")
PY
if [ ! -s "${fixtures_dir}/fixtures.tsv" ]; then
echo "No custom preset fixtures found in ${FIXTURE_RELEASE_URL}/manifest.json"
return 1
fi
{
echo "## Custom Preset Fixture Validation"
echo ""
echo "| Version | Status | Log |"
echo "| --- | --- | --- |"
} > "${summary}"
while IFS=$'\t' read -r version asset expected_sha256; do
[ -n "${version}" ] || continue
fixture_zip="${fixtures_dir}/${asset}"
profile_tree="${output_dir}/profiles-${version}"
log_path="${output_dir}/${version}.log"
asset_url="${FIXTURE_RELEASE_URL}/$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "${asset}")"
fetch "${asset_url}" "${fixture_zip}" || return 1
if [ -n "${expected_sha256}" ] && [ "${expected_sha256}" != "<sha256>" ]; then
actual_sha256="$(sha256_of "${fixture_zip}")"
if [ -z "${actual_sha256}" ]; then
msg "no sha256 tool available, skipping checksum of ${asset}"
elif [ "${actual_sha256}" != "${expected_sha256}" ]; then
# A cached zip can be stale or truncated; the release asset itself is immutable,
# so deleting it forces fetch to pull a fresh copy.
msg "checksum mismatch for ${asset}, re-downloading"
rm -f "${fixture_zip}"
fetch "${asset_url}" "${fixture_zip}" || return 1
actual_sha256="$(sha256_of "${fixture_zip}")"
[ "${actual_sha256}" = "${expected_sha256}" ] || { msg "${asset}: expected ${expected_sha256}, got ${actual_sha256}"; return 1; }
fi
fi
msg "validating custom presets from ${version} ..."
rm -rf "${profile_tree}"
mkdir -p "${profile_tree}" || return 1
if [ -n "${VENDOR}" ]; then
# -v has the validator open this vendor and OrcaFilamentLibrary and skip the rest
# (PresetBundle::load_system_presets_from_json), and unzip's * spans '/', so one
# pattern reaches every preset type. Copying and unpacking only those turns a ~5s
# setup per fixture into ~0.5s. Naming the vendor twice, when it is the library
# itself, is not an error to cp; exit 11 is unzip's "nothing matched", which is the
# fixture-predates-the-vendor case the warning below reports.
cp -a "${PROFILES_DIR}"/*.json "${PROFILES_DIR}/${VENDOR}" \
"${PROFILES_DIR}/OrcaFilamentLibrary" "${profile_tree}/" || return 1
unzip -q "${fixture_zip}" "user/*/${vendor_prefix}*" -d "${profile_tree}"
result=$?
[ "${result}" -eq 0 ] || [ "${result}" -eq 11 ] || return 1
kept="$(find "${profile_tree}/user" -type f 2>/dev/null | wc -l | tr -d ' ')"
total_kept=$((total_kept + kept))
msg " ${kept} ${VENDOR} preset file(s)"
else
cp -a "${PROFILES_DIR}/." "${profile_tree}/" || return 1
rm -rf "${profile_tree}/user"
unzip -q "${fixture_zip}" -d "${profile_tree}" || return 1
fi
"${VALIDATOR}" -p "${profile_tree}" -v "${VENDOR}" -l "${LOG_LEVEL}" > "${log_path}" 2>&1
result=$?
if [ "${result}" -eq 0 ]; then
echo "| ${version} | PASS | ${version}.log |" >> "${summary}"
# Only failures are worth keeping; each tree is a full copy of resources/profiles.
rm -rf "${profile_tree}"
else
echo "| ${version} | FAIL | ${version}.log |" >> "${summary}"
failed_logs="${failed_logs}${log_path}"$'\n'
status=1
fi
done < "${fixtures_dir}/fixtures.tsv"
# A vendor added after the newest fixture was cut appears in none of them: nothing failed, but
# nothing was checked either.
if [ -n "${VENDOR}" ] && [ "${total_kept}" -eq 0 ]; then
msg "no ${VENDOR} presets in any fixture; validate_custom checked nothing"
fi
cat "${summary}"
if [ -n "${failed_logs}" ]; then
echo ""
echo "## Failed Fixture Logs"
while IFS= read -r log_path; do
[ -n "${log_path}" ] || continue
echo ""
echo "### $(basename "${log_path}" .log)"
echo '```'
head -c 12000 "${log_path}" || echo "No output captured"
echo '```'
done <<EOF
${failed_logs}
EOF
fi
return "${status}"
}
# Heading CI puts above this check's log in the PR comment.
comment_heading() {
case "$1" in
profile_tool) echo "### Profile Check Failed (orca_profile_tool.py)" ;;
version_bump) echo "### Profile Version Not Bumped" ;;
legacy_compat) echo "### Legacy Compatible Printers Map Out of Date" ;;
validate_system) echo "### System Profile Validation Failed" ;;
validate_slice) echo "### Slice Validation Failed (custom g-code expansion)" ;;
validate_filament_subtypes) echo "### Filament Subtype Validation Failed" ;;
validate_custom) echo "### Custom Preset Validation Failed" ;;
esac
}
# ---------------------------------------------------------------------------- run
run_check() {
local name="$1"
local log="${LOG_DIR}/${name}.log"
local result
printf '\n%s==> %s%s%s\n' "${C_BOLD}" "${name}" "${VENDOR:+ (${VENDOR})}" "${C_RESET}"
"check_${name}" 2>&1 | tee "${log}"
result="${PIPESTATUS[0]}"
if [ "${result}" -eq 0 ]; then
printf '%s %s passed%s\n' "${C_GREEN}" "${name}" "${C_RESET}"
RESULTS="${RESULTS}${name}"$'\t'"pass"$'\n'
else
printf '%s %s failed (exit %s)%s\n' "${C_RED}" "${name}" "${result}" "${C_RESET}"
RESULTS="${RESULTS}${name}"$'\t'"fail"$'\n'
fi
}
if wants validate_system || wants validate_slice || wants validate_filament_subtypes || wants validate_custom; then
resolve_validator
fi
# An empty printer set is a failure to the sweep, so validate_slice is recorded as skipped rather
# than run for a vendor that ships no printers (the filament-only OrcaFilamentLibrary); naming the
# check explicitly still runs it. So is version_bump for a --profiles tree git cannot diff, such
# as a copy made to try a change on, or one with no main to diff against.
for check in "${ALL_CHECKS[@]}"; do
wants "${check}" || continue
if [ "${check}" = validate_slice ] && [ "${NAMED_CHECKS}" -eq 0 ] && [ -n "${VENDOR}" ] &&
[ ! -d "${PROFILES_DIR}/${VENDOR}/machine" ]; then
RESULTS="${RESULTS}${check}"$'\t'"skip"$'\t'"${VENDOR} ships no printers"$'\n'
elif [ "${check}" = version_bump ] && [ "${NAMED_CHECKS}" -eq 0 ] &&
why="$(version_bump_skip_reason)"; then
RESULTS="${RESULTS}${check}"$'\t'"skip"$'\t'"${why}"$'\n'
else
run_check "${check}"
fi
done
# Summary, plus the comment check_profiles_comment.yml would post when something fails.
failed=0
skipped=0
printf '\n%s==> summary%s\n' "${C_BOLD}" "${C_RESET}"
while IFS=$'\t' read -r name result why; do
[ -n "${name}" ] || continue
if [ "${result}" = "pass" ]; then
printf '%s PASS%s %s\n' "${C_GREEN}" "${C_RESET}" "${name}"
elif [ "${result}" = "skip" ]; then
printf '%s SKIP%s %s (%s)\n' "${C_BOLD}" "${C_RESET}" "${name}" "${why}"
skipped=1
else
printf '%s FAIL%s %s (%s)\n' "${C_RED}" "${C_RESET}" "${name}" "${LOG_DIR}/${name}.log"
failed=1
fi
done <<EOF
${RESULTS}
EOF
if [ "${failed}" -eq 0 ]; then
rm -f "${WORK_DIR}/pr_comment.md"
if [ "${skipped}" -eq 0 ]; then
printf '\n%sAll checks passed.%s Logs: %s\n' "${C_GREEN}" "${C_RESET}" "${LOG_DIR}"
else
printf '\n%sEvery check that ran passed%s, but CI runs the skipped ones too. Logs: %s\n' \
"${C_GREEN}" "${C_RESET}" "${LOG_DIR}"
fi
exit 0
fi
{
# Marker matched by check_profiles_comment.yml to delete prior comments.
echo "<!-- profile-validation-comment -->"
echo "## :x: Profile Validation Errors"
echo ""
while IFS=$'\t' read -r name result; do
[ "${result}" = "fail" ] || continue
comment_heading "${name}"
echo ""
echo '```'
head -c 30000 "${LOG_DIR}/${name}.log" || echo "No output captured"
echo '```'
echo ""
done <<INNER
${RESULTS}
INNER
echo "---"
# Single-quoted on purpose: the backticks below are markdown, not command substitution.
# shellcheck disable=SC2016
echo '*Fix the errors above and push a new commit. To reproduce this run locally: `scripts/check_profile.sh`, or `scripts\check_profile.bat` on Windows.*'
} > "${WORK_DIR}/pr_comment.md"
printf '\n%sOne or more profile checks failed.%s Logs: %s\n' "${C_RED}" "${C_RESET}" "${LOG_DIR}"
printf 'The comment CI would post: %s\n' "${WORK_DIR}/pr_comment.md"
exit 1