#!/usr/bin/env bash # # Local twin of the "Check profiles" CI job (.github/workflows/check_profiles.yml). # # Runs the same five 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 and the custom-preset fixture # archives - lands under /.test/check_profiles/ and is reused on the next run. That # directory 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(). # # 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)" PROFILES_DIR="${REPO_ROOT}/resources/profiles" WORK_DIR="${REPO_ROOT}/.test/check_profiles" VALIDATOR="${ORCA_PROFILE_VALIDATOR:-}" # Vendor to check, named after its .json - empty means every vendor, which is exactly what # both the validator's -v and orca_extra_profile_check.py'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="" LOG_LEVEL=2 PREFER_DOWNLOAD=0 REFRESH=0 ALL_CHECKS=(extra_json_check validate_system validate_slice validate_filament_subtypes validate_custom) CHECKS=() # "pass|fail" per check that ran, plus "skipwhy" 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 < -l validate_slice validator -p -s -l validate_filament_subtypes validator -p -l -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 .json (e.g. "Co Print") --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: .test/check_profiles) -l, --log-level N validator log level (default: ${LOG_LEVEL}, as in CI) -h, --help show this help Note: extra_json_check always looks at the tree next to the script (/resources/profiles); --profiles only redirects the validator checks. 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. extra_json_check 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. 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 ;; --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 .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 # .json with a sibling / 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 - # /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 </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 ;; MINGW*|MSYS*|CYGWIN*) 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 $(uname -s); 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_extra_json_check() { python3 "${REPO_ROOT}/scripts/orca_extra_profile_check.py" --vendor "${VENDOR}" } 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 "__orca_test", after the "name" inside # .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 "_" 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}" != "" ]; 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 < %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. 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' 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 <" 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 < "${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