mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-19 06:53:02 +00:00
Merge branch 'main' into feat/ota-opc-ci
This commit is contained in:
@@ -14,6 +14,9 @@ on:
|
||||
- 'localization/**'
|
||||
- 'resources/**'
|
||||
- ".github/workflows/build_*.yml"
|
||||
- ".github/workflows/unit_tests*.yml"
|
||||
- 'build_win.bat'
|
||||
- 'scripts/test_build_win.ps1'
|
||||
- 'scripts/build_preset_cache.*'
|
||||
- 'scripts/flatpak/**'
|
||||
- 'scripts/msix/**'
|
||||
@@ -30,9 +33,8 @@ on:
|
||||
- '**/CMakeLists.txt'
|
||||
- 'version.inc'
|
||||
- ".github/workflows/build_*.yml"
|
||||
- ".github/workflows/unit_tests*.yml"
|
||||
- 'build_linux.sh'
|
||||
- 'build_release_vs.bat'
|
||||
- 'build_release_vs2022.bat'
|
||||
- 'build_win.bat'
|
||||
- 'scripts/test_build_win.ps1'
|
||||
- 'build_release_macos.sh'
|
||||
@@ -207,7 +209,7 @@ jobs:
|
||||
./validator-bin/OrcaSlicer_profile_validator -p "${{ github.workspace }}/resources/profiles" -s -l 2
|
||||
publish_test_results:
|
||||
name: Publish Test Results
|
||||
needs: [unit_tests_linux_x86_64, unit_tests_linux_aarch64, unit_tests_windows_x64, unit_tests_windows_arm64, unit_tests_macos_arm64]
|
||||
needs: [unit_tests_linux_x86_64, unit_tests_linux_aarch64, unit_tests_windows_x64, unit_tests_windows_arm64, unit_tests_macos_arm64, unit_tests_flatpak_x86_64, unit_tests_flatpak_aarch64]
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -324,9 +326,16 @@ jobs:
|
||||
sed -i '/^build-options:/a\ no-debuginfo: true\n strip: true' \
|
||||
scripts/flatpak/com.orcaslicer.OrcaSlicer.yml
|
||||
shell: bash
|
||||
- name: Inject git commit hash into Flatpak manifest
|
||||
# flatpak-builder reuses a module from its cache when the definition and
|
||||
# sources are unchanged, so a re-run of the same commit would skip the
|
||||
# OrcaSlicer module and ship no test asset. A per-run value in that module's
|
||||
# env keeps it rebuilding; orca_deps stays cached, and the compiler cache
|
||||
# still serves the rebuild.
|
||||
- name: Inject commit hash and flatpak-builder cache buster into Flatpak manifest
|
||||
env:
|
||||
flatpak_builder_cache_buster: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||
run: |
|
||||
sed -i "/name: OrcaSlicer/{n;s|buildsystem: simple|buildsystem: simple\n build-options:\n env:\n git_commit_hash: \"$git_commit_hash\"|}" \
|
||||
sed -i "/name: OrcaSlicer/{n;s|buildsystem: simple|buildsystem: simple\n build-options:\n env:\n flatpak_builder_cache_buster: \"$flatpak_builder_cache_buster\"\n git_commit_hash: \"$git_commit_hash\"|}" \
|
||||
scripts/flatpak/com.orcaslicer.OrcaSlicer.yml
|
||||
shell: bash
|
||||
# flatpak-builder's --ccache only wraps cc and gcc, and the manifest builds
|
||||
@@ -372,6 +381,10 @@ jobs:
|
||||
save-cache: false
|
||||
arch: ${{ matrix.variant.arch }}
|
||||
upload-artifact: false
|
||||
# run-tests fires the module's build-only test-commands; keep-build-dirs
|
||||
# retains the binaries for the packaging step below.
|
||||
run-tests: true
|
||||
keep-build-dirs: true
|
||||
# The build has just touched everything it can use, so an object untouched
|
||||
# for a week is dead, usually orphaned by a flag change.
|
||||
- name: Compiler cache statistics
|
||||
@@ -425,3 +438,46 @@ jobs:
|
||||
asset_name: OrcaSlicer-Linux-flatpak_nightly${{ env.nightly_suffix }}_${{ matrix.variant.arch }}.flatpak
|
||||
asset_content_type: application/octet-stream
|
||||
max_releases: 1 # optional, if there are more releases than this matching the asset_name, the oldest ones are going to be deleted
|
||||
# The asset is /app (the exes link it at runtime) plus the build tree
|
||||
# slimmed to what ctest needs.
|
||||
- name: Package flatpak test asset
|
||||
shell: bash
|
||||
run: |
|
||||
d=$(ls -d .flatpak-builder/build/OrcaSlicer-* | tail -1)
|
||||
find "$d/build_flatpak" -mindepth 1 -maxdepth 1 ! -name tests -exec rm -rf {} +
|
||||
# Strip debug info (the SDK builds with -g, only the app gets stripped);
|
||||
# the bounds checks are compiled in, so a stripped exe still catches them.
|
||||
find "$d/build_flatpak/tests" -type f -perm -u+x -exec strip --strip-unneeded {} + 2>/dev/null || true
|
||||
# At runtime the tests read tests/ (TEST_DATA_DIR), scripts/, and under
|
||||
# resources/ the shipped profiles (PROFILES_DIR) and the printers/ maps.
|
||||
find "$d" -mindepth 1 -maxdepth 1 -type d \
|
||||
! -name tests ! -name build_flatpak ! -name scripts ! -name resources -exec rm -rf {} +
|
||||
find "$d/resources" -mindepth 1 -maxdepth 1 ! -name profiles ! -name printers -exec rm -rf {} +
|
||||
tar -cf flatpak-test-asset.tar flatpak_app "$d"
|
||||
- name: Upload flatpak test asset
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ github.sha }}-flatpak-tests-${{ matrix.variant.arch }}
|
||||
path: flatpak-test-asset.tar
|
||||
retention-days: 1
|
||||
# keep-build-dirs would otherwise land in the flatpak-builder cache saved post-job.
|
||||
- name: Drop the kept build dirs before the flatpak-builder cache saves
|
||||
if: always()
|
||||
shell: bash
|
||||
run: rm -rf .flatpak-builder/build
|
||||
unit_tests_flatpak_x86_64:
|
||||
name: Flatpak x86_64
|
||||
needs: flatpak
|
||||
if: ${{ !cancelled() && success() }}
|
||||
uses: ./.github/workflows/unit_tests_flatpak.yml
|
||||
with:
|
||||
os: ubuntu-24.04
|
||||
artifact: ${{ github.sha }}-flatpak-tests-x86_64
|
||||
unit_tests_flatpak_aarch64:
|
||||
name: Flatpak aarch64
|
||||
needs: flatpak
|
||||
if: ${{ !cancelled() && success() }}
|
||||
uses: ./.github/workflows/unit_tests_flatpak.yml
|
||||
with:
|
||||
os: ubuntu-24.04-arm
|
||||
artifact: ${{ github.sha }}-flatpak-tests-aarch64
|
||||
|
||||
@@ -41,7 +41,8 @@ jobs:
|
||||
# restores one it cannot use. Linux amd64 passes no arch deliberately, so
|
||||
# 'linux-clang' keeps the cache it already has.
|
||||
cache-os: ${{ runner.os == 'macOS' && format('macos-{0}', inputs.arch) || (runner.os == 'Windows' && format('windows-{0}-{1}', inputs.arch, inputs.compiler) || format('linux-clang{0}', inputs.arch && format('-{0}', inputs.arch) || '')) }}
|
||||
# ARM64 builds use the build-arm64 tree (see build_release_vs.bat); x64/other use build.
|
||||
# The Windows ARM64 deps build in build-arm64, all others under build;
|
||||
# build_deps.yml and build_orca.yml pass the Windows directory to build_win.bat.
|
||||
dep-folder-name: ${{ runner.os == 'macOS' && format('/{0}', inputs.arch) || (runner.os == 'Windows' && inputs.arch == 'arm64') && '-arm64/OrcaSlicer_dep' || '/OrcaSlicer_dep' }}
|
||||
output-cmd: ${{ runner.os == 'Windows' && '$env:GITHUB_OUTPUT' || '"$GITHUB_OUTPUT"'}}
|
||||
run: |
|
||||
|
||||
@@ -138,25 +138,11 @@ jobs:
|
||||
if (-not "${{ vars.SELF_HOSTED }}") {
|
||||
choco install strawberryperl
|
||||
}
|
||||
$arch = "${{ inputs.arch }}"
|
||||
# -l selects clang-cl and -x Ninja; together they build the deps with clang.
|
||||
$clang = "${{ inputs.compiler }}" -eq "clang"
|
||||
$flags = if ($clang) { "-l", "-x" } else { @() }
|
||||
if ($clang) {
|
||||
# OpenSSL builds with nmake, which needs a VC environment.
|
||||
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
|
||||
$vs = & $vswhere -latest -property installationPath
|
||||
$devArch = if ($arch -eq "arm64") { "arm64" } else { "amd64" }
|
||||
Import-Module "$vs\Common7\Tools\Microsoft.VisualStudio.DevShell.dll"
|
||||
Enter-VsDevShell -VsInstallPath $vs -SkipAutomaticLocation -DevCmdArguments "-arch=$devArch"
|
||||
}
|
||||
if ($arch -eq "arm64") {
|
||||
.\build_release_vs.bat deps arm64 @flags
|
||||
.\build_release_vs.bat pack arm64
|
||||
} else {
|
||||
.\build_release_vs.bat deps @flags
|
||||
.\build_release_vs.bat pack
|
||||
}
|
||||
# cache-path is the install directory inside the deps build directory.
|
||||
$deps = (Split-Path "${{ inputs.cache-path }}").Replace('\', '/')
|
||||
# -l compiles with Visual Studio's clang-cl and -x builds with Ninja; --msvc --msbuild is cl under the Visual Studio generator.
|
||||
$flags = if ("${{ inputs.compiler }}" -eq "clang") { "-l", "-x" } else { "--msvc", "--msbuild" }
|
||||
.\build_win.bat -d --arch ${{ inputs.arch }} --deps-dir $deps @flags
|
||||
shell: pwsh
|
||||
|
||||
- name: Build on Mac ${{ inputs.arch }}
|
||||
|
||||
@@ -85,6 +85,15 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
leg="${{ runner.os }}-${{ inputs.arch || 'amd64' }}${{ runner.os == 'Windows' && format('-{0}', inputs.compiler) || '' }}"
|
||||
# clang-cl refuses a precompiled header from another cl.exe build and ccache
|
||||
# does not hash that build, so each one gets its own cache. The build number
|
||||
# is read from cl.exe itself; the toolset directory keeps its name across patches.
|
||||
if [ "${{ runner.os }}" = Windows ]; then
|
||||
vswhere='/c/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe'
|
||||
toolset=$(tr -d '\r\n' < "$("$vswhere" -latest -products '*' -find 'VC\Auxiliary\Build\Microsoft.VCToolsVersion.default.txt' | tr -d '\r')")
|
||||
cl=$("$vswhere" -latest -products '*' -find 'VC\Tools\MSVC\'"$toolset"'\**\cl.exe' | tr -d '\r' | head -1)
|
||||
leg="$leg-vc$("$cl" 2>&1 | grep -o -E 'Version [0-9.]+' | cut -d' ' -f2)"
|
||||
fi
|
||||
echo "CCACHE_LEG=$leg" >> "$GITHUB_ENV"
|
||||
echo "CCACHE_ENTRY=ccache-$leg-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -450,21 +459,13 @@ jobs:
|
||||
# env:
|
||||
# WindowsSdkDir: 'C:\Program Files (x86)\Windows Kits\10\'
|
||||
# WindowsSDKVersion: '10.0.26100.0\'
|
||||
# "tests" builds the unit tests too; the unit_tests_windows_* jobs run them.
|
||||
# --tests builds the unit tests too; the unit_tests_windows_* jobs run them.
|
||||
run: |
|
||||
$arch = "${{ inputs.arch }}"
|
||||
# -l selects clang-cl and -x Ninja; together they build the slicer with clang.
|
||||
$clang = "${{ inputs.compiler }}" -eq "clang"
|
||||
$flags = if ($clang) { "-l", "-x" } else { @() }
|
||||
if ($clang) {
|
||||
# Build against the same VC toolchain and SDK as the dependencies.
|
||||
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
|
||||
$vs = & $vswhere -latest -property installationPath
|
||||
$devArch = if ($arch -eq "arm64") { "arm64" } else { "amd64" }
|
||||
Import-Module "$vs\Common7\Tools\Microsoft.VisualStudio.DevShell.dll"
|
||||
Enter-VsDevShell -VsInstallPath $vs -SkipAutomaticLocation -DevCmdArguments "-arch=$devArch"
|
||||
}
|
||||
if ($arch -eq "arm64") { .\build_release_vs.bat slicer arm64 @flags tests } else { .\build_release_vs.bat slicer @flags tests }
|
||||
# cache-path is the install directory inside the deps build directory.
|
||||
$deps = (Split-Path "${{ inputs.cache-path }}").Replace('\', '/')
|
||||
# -l compiles with Visual Studio's clang-cl and -x builds with Ninja; --msvc --msbuild is cl under the Visual Studio generator.
|
||||
$flags = if ("${{ inputs.compiler }}" -eq "clang") { "-l", "-x" } else { "--msvc", "--msbuild" }
|
||||
.\build_win.bat -s --tests -i --arch ${{ inputs.arch }} --build-dir $env:BUILD_DIR --deps-dir $deps @flags
|
||||
shell: pwsh
|
||||
|
||||
- name: Build system preset cache (Windows)
|
||||
|
||||
@@ -9,8 +9,9 @@ on:
|
||||
- release/*
|
||||
paths:
|
||||
- 'resources/profiles/**'
|
||||
# The extra JSON check also validates resources/printers/bambu_filament_ids.json,
|
||||
# and lives in scripts/, so a PR touching only those must still run this workflow.
|
||||
# orca_profile_tool.py also validates resources/printers/bambu_filament_ids.json, and
|
||||
# both it and its tests live in scripts/, so a PR touching only those must still run
|
||||
# this workflow.
|
||||
- 'resources/printers/**'
|
||||
- 'scripts/**'
|
||||
- ".github/workflows/check_profiles.yml"
|
||||
@@ -36,12 +37,23 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Run extra JSON check
|
||||
id: extra_json_check
|
||||
# Deliberately not continue-on-error, unlike every check below: if the tool itself is
|
||||
# broken, nothing it then reports about the profiles is worth reading.
|
||||
- name: Run the profile tool's own unit tests
|
||||
run: python3 -m unittest discover -s scripts/tests -t scripts
|
||||
|
||||
# What the validator below cannot see. It loads the tree the way the slicer does, so
|
||||
# it never notices a profile no <vendor>.json indexes, a preset name two files claim,
|
||||
# an id that is not the mint of its own triple, or a file that normalize and
|
||||
# update-index would still rewrite.
|
||||
# The step id is the handle the PR comment and the failure gate below use; renaming it
|
||||
# silently disables them.
|
||||
- name: Check profiles (orca_profile_tool.py)
|
||||
id: profile_tool
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set +e
|
||||
python3 ./scripts/orca_extra_profile_check.py 2>&1 | tee ${{ runner.temp }}/extra_json_check.log
|
||||
python3 ./scripts/orca_profile_tool.py check 2>&1 | tee ${{ runner.temp }}/profile_tool.log
|
||||
exit ${PIPESTATUS[0]}
|
||||
|
||||
# download
|
||||
@@ -68,8 +80,8 @@ jobs:
|
||||
set +e
|
||||
./OrcaSlicer_profile_validator -p ${{ github.workspace }}/resources/profiles -s -l 2 2>&1 | tee ${{ runner.temp }}/validate_slice.log
|
||||
exit ${PIPESTATUS[0]}
|
||||
# All vendors' filament_id collisions were fixed (see scripts/filament_id_snapshot.json),
|
||||
# so the duplicate-filament-subtype check runs tree-wide.
|
||||
# All vendors' filament_id collisions were fixed, so the duplicate-filament-subtype
|
||||
# check runs tree-wide.
|
||||
- name: validate filament subtype check
|
||||
id: validate_filament_subtypes
|
||||
continue-on-error: true
|
||||
@@ -186,7 +198,7 @@ jobs:
|
||||
echo "${{ github.event.pull_request.number }}" > ${{ runner.temp }}/profile-check-results/pr_number.txt
|
||||
|
||||
- name: Prepare comment artifact
|
||||
if: ${{ always() && github.event_name == 'pull_request' && (steps.extra_json_check.outcome == 'failure' || steps.validate_system.outcome == 'failure' || steps.validate_slice.outcome == 'failure' || steps.validate_filament_subtypes.outcome == 'failure' || steps.validate_custom.outcome == 'failure') }}
|
||||
if: ${{ always() && github.event_name == 'pull_request' && (steps.profile_tool.outcome == 'failure' || steps.validate_system.outcome == 'failure' || steps.validate_slice.outcome == 'failure' || steps.validate_filament_subtypes.outcome == 'failure' || steps.validate_custom.outcome == 'failure') }}
|
||||
run: |
|
||||
{
|
||||
# Marker matched by check_profiles_comment.yml to delete prior comments.
|
||||
@@ -194,11 +206,11 @@ jobs:
|
||||
echo "## :x: Profile Validation Errors"
|
||||
echo ""
|
||||
|
||||
if [ "${{ steps.extra_json_check.outcome }}" = "failure" ]; then
|
||||
echo "### Extra JSON Check Failed"
|
||||
if [ "${{ steps.profile_tool.outcome }}" = "failure" ]; then
|
||||
echo "### Profile Check Failed (orca_profile_tool.py)"
|
||||
echo ""
|
||||
echo '```'
|
||||
head -c 30000 ${{ runner.temp }}/extra_json_check.log || echo "No output captured"
|
||||
head -c 30000 ${{ runner.temp }}/profile_tool.log || echo "No output captured"
|
||||
echo '```'
|
||||
echo ""
|
||||
fi
|
||||
@@ -240,7 +252,7 @@ jobs:
|
||||
fi
|
||||
|
||||
echo "---"
|
||||
echo "*Please fix the above errors and push a new commit.*"
|
||||
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.*'
|
||||
} > ${{ runner.temp }}/profile-check-results/pr_comment.md
|
||||
|
||||
- name: Upload comment artifact
|
||||
@@ -252,7 +264,8 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
- name: Fail if any check failed
|
||||
if: ${{ always() && (steps.extra_json_check.outcome == 'failure' || steps.validate_system.outcome == 'failure' || steps.validate_slice.outcome == 'failure' || steps.validate_filament_subtypes.outcome == 'failure' || steps.validate_custom.outcome == 'failure') }}
|
||||
if: ${{ always() && (steps.profile_tool.outcome == 'failure' || steps.validate_system.outcome == 'failure' || steps.validate_slice.outcome == 'failure' || steps.validate_filament_subtypes.outcome == 'failure' || steps.validate_custom.outcome == 'failure') }}
|
||||
run: |
|
||||
echo "One or more profile checks failed. See above for details."
|
||||
echo "One or more profile checks failed; see the step logs above."
|
||||
echo 'Reproduce the whole run locally with scripts/check_profile.sh (scripts\check_profile.bat on Windows).'
|
||||
exit 1
|
||||
|
||||
@@ -12,6 +12,13 @@ name: PR Merge Bot
|
||||
# PR targets main or release/*, and CI is green on the head commit. Otherwise it
|
||||
# comments naming the files that fell outside the grant.
|
||||
#
|
||||
# When a PR touching resources/profiles/** is opened, two labels are applied
|
||||
# independently of the merge command:
|
||||
# profile every changed path is inside resources/profiles/
|
||||
# orca profile partner the PR author holds a grant covering every changed
|
||||
# path, plus a one-time comment explaining /bot merge
|
||||
# Neither label changes what the merge command checks.
|
||||
#
|
||||
# Grants come from the FOLDER_MERGERS variable in the `merge-delegation`
|
||||
# environment: one per line, `account: path`, `#` comments and blank lines
|
||||
# allowed. Paths may contain spaces. A vendor takes two grants, the folder and
|
||||
@@ -32,10 +39,18 @@ on:
|
||||
issue_comment:
|
||||
types:
|
||||
- created
|
||||
# Labels profile PRs on open, without waiting for a /bot merge command.
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
paths:
|
||||
- 'resources/profiles/**'
|
||||
|
||||
# One merge attempt per PR at a time, so two quick comments cannot race.
|
||||
# Labels run under their own group, so a queued label run is not replaced by
|
||||
# a merge run for the same PR.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number }}
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.issue.number || github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
@@ -43,6 +58,7 @@ jobs:
|
||||
# Skips the job unless a PR comment mentions the command.
|
||||
if: >-
|
||||
github.repository == 'OrcaSlicer/OrcaSlicer'
|
||||
&& github.event_name == 'issue_comment'
|
||||
&& github.event.issue.pull_request != null
|
||||
&& contains(github.event.comment.body, '/bot merge')
|
||||
permissions:
|
||||
@@ -53,7 +69,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# Supplies FOLDER_MERGERS. Must carry no protection rules, or every
|
||||
# delegated merge would wait for a human reviewer.
|
||||
# delegated merge and partner label run would wait for a human reviewer.
|
||||
environment: merge-delegation
|
||||
steps:
|
||||
- name: Merge PR on behalf of a folder delegate
|
||||
@@ -76,7 +92,6 @@ jobs:
|
||||
const ALLOWED_BASE_BRANCH = /^(?:main|release\/.+)$/;
|
||||
const MERGE_METHOD = 'squash';
|
||||
const REQUIRED_CHECK = 'Check profiles'; // job name in check_profiles.yml
|
||||
const MAX_CHANGED_FILES = 500; // policy cap, well under listFiles' 3000
|
||||
const LISTFILES_CAP = 3000;
|
||||
const MAX_REPORTED_FILES = 12;
|
||||
const MERGEABLE_ATTEMPTS = 5;
|
||||
@@ -304,9 +319,6 @@ jobs:
|
||||
'so the file list is truncated and I cannot verify the folder scope. A maintainer must merge this one.'
|
||||
);
|
||||
}
|
||||
if (pr.changed_files > MAX_CHANGED_FILES) {
|
||||
return refuse(`it changes ${pr.changed_files} files; delegated merges are capped at ${MAX_CHANGED_FILES}.`);
|
||||
}
|
||||
|
||||
const deniedFiles = [];
|
||||
const outsideFiles = [];
|
||||
@@ -508,3 +520,392 @@ jobs:
|
||||
} catch (error) {
|
||||
core.warning(`Merged successfully, but dispatching build_all.yml failed: ${error.message}`);
|
||||
}
|
||||
|
||||
label-profile:
|
||||
# Independent of the merge rules: any PR that changes only files inside
|
||||
# resources/profiles/ is labeled `profile`.
|
||||
if: >-
|
||||
github.repository == 'OrcaSlicer/OrcaSlicer'
|
||||
&& github.event_name == 'pull_request_target'
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: write
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Label profile-only PRs
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
function isPermissionDenied(error) {
|
||||
return error && error.status === 403 && /Resource not accessible by integration/i.test(error.message || '');
|
||||
}
|
||||
|
||||
const PROFILE_ROOT = 'resources/profiles/';
|
||||
const LABEL = 'profile';
|
||||
const LISTFILES_CAP = 3000;
|
||||
const ATTEMPTS = 3;
|
||||
|
||||
function profileOnlyProblem(pr, files) {
|
||||
if (!files.length) {
|
||||
return 'PR changes no files; not labeling.';
|
||||
}
|
||||
// A truncated list, or a count that disagrees with the PR, cannot
|
||||
// prove "only profile files".
|
||||
if (files.length >= LISTFILES_CAP || files.length !== pr.changed_files) {
|
||||
return `PR reports ${pr.changed_files} changed files but the API listed ${files.length}; not labeling.`;
|
||||
}
|
||||
|
||||
// Both endpoints of a rename count, so a move out of the profile
|
||||
// root is not mistaken for a profile-only change.
|
||||
const paths = files.flatMap((file) => [file.filename, file.previous_filename].filter(Boolean));
|
||||
const outside = paths.filter((path) => !path.startsWith(PROFILE_ROOT));
|
||||
|
||||
if (outside.length) {
|
||||
return `${outside.length} changed path(s) fall outside ${PROFILE_ROOT}; not labeling.`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const number = context.payload.pull_request.number;
|
||||
|
||||
// The event payload is frozen at `opened`; listFiles is not. Read
|
||||
// fresh PR metadata and retry if either side of the diff changes.
|
||||
for (let attempt = 0; attempt < ATTEMPTS; attempt += 1) {
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: number });
|
||||
|
||||
if (pr.state !== 'open') {
|
||||
core.info(`PR is ${pr.state}; not labeling.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
per_page: 100
|
||||
});
|
||||
const problem = profileOnlyProblem(pr, files);
|
||||
|
||||
const { data: after } = await github.rest.pulls.get({ owner, repo, pull_number: number });
|
||||
if (
|
||||
after.state !== 'open' ||
|
||||
after.head.sha !== pr.head.sha ||
|
||||
after.base.ref !== pr.base.ref ||
|
||||
after.base.sha !== pr.base.sha
|
||||
) {
|
||||
core.info('PR changed while listing files; retrying.');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (problem) {
|
||||
core.info(problem);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
labels: [LABEL]
|
||||
});
|
||||
core.info(`Applied the "${LABEL}" label.`);
|
||||
} catch (error) {
|
||||
if (isPermissionDenied(error)) {
|
||||
core.warning(`Cannot add the "${LABEL}" label because the token cannot write.`);
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
core.warning('PR kept changing during verification; not labeling.');
|
||||
|
||||
label-profile-partner:
|
||||
# Labels a profile PR whose author holds a grant covering every changed
|
||||
# path, and explains the /bot merge command to them once.
|
||||
if: >-
|
||||
github.repository == 'OrcaSlicer/OrcaSlicer'
|
||||
&& github.event_name == 'pull_request_target'
|
||||
permissions:
|
||||
contents: read # delegatable subtree, for file modes
|
||||
pull-requests: read
|
||||
issues: write # label + comment
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# Supplies FOLDER_MERGERS. Must carry no protection rules, or every
|
||||
# qualifying PR open would wait for a human reviewer.
|
||||
environment: merge-delegation
|
||||
steps:
|
||||
- name: Label profile PRs from delegated maintainers
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
# Read as an env var, never interpolated into the script body.
|
||||
FOLDER_MERGERS: ${{ vars.FOLDER_MERGERS }}
|
||||
with:
|
||||
script: |
|
||||
function isPermissionDenied(error) {
|
||||
return error && error.status === 403 && /Resource not accessible by integration/i.test(error.message || '');
|
||||
}
|
||||
|
||||
// Never prints the grant list: this job posts public comments and
|
||||
// its logs are public too.
|
||||
async function bestEffort(call, warning) {
|
||||
try {
|
||||
await call();
|
||||
} catch (error) {
|
||||
if (isPermissionDenied(error)) {
|
||||
core.warning(warning);
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const MARKER = '<!-- profile-partner-bot -->';
|
||||
const LABEL = 'orca profile partner';
|
||||
const ATTEMPTS = 3;
|
||||
|
||||
// ---- scope rules, mirrored from the merge job above ----
|
||||
// Change both together: these decide whether a delegate could merge.
|
||||
const DELEGATABLE_ROOT = 'resources/profiles/';
|
||||
const ALLOWED_BASE_BRANCH = /^(?:main|release\/.+)$/;
|
||||
const LISTFILES_CAP = 3000;
|
||||
const REGULAR_FILE_MODES = new Set(['100644', '100755']);
|
||||
|
||||
const DENIED_PATTERNS = [
|
||||
/^\.github\//,
|
||||
/(^|\/)\.git(attributes|modules|ignore|config)$/,
|
||||
/^(?:src|deps|deps_src|tests|tools|cmake|sandboxes|scripts|docs?|localization|bbl)\//,
|
||||
/(^|\/)cmakelists\.txt$/,
|
||||
/\.cmake$/,
|
||||
/^build_[^/]*\.(?:sh|bat)$/,
|
||||
/^version\.inc$/,
|
||||
// Executables, including those inside the delegatable root.
|
||||
/\.(?:sh|bash|bat|cmd|ps1|py|js|mjs|cjs|ts|rb|pl|php)$/
|
||||
];
|
||||
|
||||
function parseGrants(raw) {
|
||||
// GitHub login: 1-39 chars, alphanumerics with single interior hyphens.
|
||||
const loginPattern = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
|
||||
const grantsByLogin = new Map();
|
||||
const problems = [];
|
||||
|
||||
(raw || '').split(/\r?\n/).forEach((rawLine, index) => {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Splits on the first colon only, so paths may contain ':' and spaces.
|
||||
const separator = line.indexOf(':');
|
||||
if (separator === -1) {
|
||||
problems.push(`line ${index + 1}: expected \`account: path\``);
|
||||
return;
|
||||
}
|
||||
|
||||
const login = line.slice(0, separator).trim().replace(/^@/, '');
|
||||
const path = line.slice(separator + 1).trim().replace(/\/+$/, '');
|
||||
|
||||
if (!loginPattern.test(login)) {
|
||||
problems.push(`line ${index + 1}: \`${login}\` is not a valid GitHub account name`);
|
||||
return;
|
||||
}
|
||||
if (/[\\*?\u0000-\u001f\u007f]/.test(path) || path.split('/').includes('..') || path.includes('//')) {
|
||||
problems.push(`line ${index + 1}: invalid path (no globs, \`..\`, \`//\`, backslashes or control characters)`);
|
||||
return;
|
||||
}
|
||||
// Rejects anything outside the root, and the bare root itself.
|
||||
if (!path.startsWith(DELEGATABLE_ROOT) || path.length <= DELEGATABLE_ROOT.length) {
|
||||
problems.push(`line ${index + 1}: \`${path}\` is not inside \`${DELEGATABLE_ROOT}\``);
|
||||
return;
|
||||
}
|
||||
|
||||
const key = login.toLowerCase();
|
||||
grantsByLogin.set(key, (grantsByLogin.get(key) || []).concat(path));
|
||||
});
|
||||
|
||||
return { grantsByLogin, problems };
|
||||
}
|
||||
|
||||
function isDenied(path) {
|
||||
if (/[\\\u0000-\u001f\u007f]/.test(path) || path.startsWith('/') || path.split('/').includes('..')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalized = path.normalize('NFKC').toLowerCase();
|
||||
return DENIED_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
// Byte-exact match on directory boundaries, so a grant of
|
||||
// `.../Acme` covers neither `.../Acme Labs/x.json` nor `.../Acme.json`.
|
||||
function isGranted(path, grants) {
|
||||
return grants.some((grant) => path === grant || path.startsWith(`${grant}/`));
|
||||
}
|
||||
|
||||
// Both endpoints of a rename; both must satisfy the grant.
|
||||
function pathsFor(file) {
|
||||
return [file.filename, file.previous_filename].filter(Boolean);
|
||||
}
|
||||
// ---- end mirrored rules ----
|
||||
|
||||
function scopeProblem(pr, files, grants) {
|
||||
if (!files.length) {
|
||||
return 'PR changes no files; not labeling.';
|
||||
}
|
||||
if (files.length >= LISTFILES_CAP || files.length !== pr.changed_files) {
|
||||
return `PR reports ${pr.changed_files} changed files but the API listed ${files.length}; not labeling.`;
|
||||
}
|
||||
|
||||
let outsideCount = 0;
|
||||
for (const file of files) {
|
||||
for (const path of pathsFor(file)) {
|
||||
if (isDenied(path) || !isGranted(path, grants)) {
|
||||
outsideCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (outsideCount) {
|
||||
return `PR has ${outsideCount} path(s) outside @${author}'s grants; not labeling.`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- file modes: rejects symlinks and submodules ----
|
||||
function modeProblem(files, tree) {
|
||||
if (tree.truncated) {
|
||||
return 'The profile tree is too large to verify file modes; not labeling.';
|
||||
}
|
||||
|
||||
const modesByPath = new Map(tree.tree.map((entry) => [`${DELEGATABLE_ROOT}${entry.path}`, entry.mode]));
|
||||
const hasIrregularFile = files.some((file) =>
|
||||
file.status !== 'removed' && !REGULAR_FILE_MODES.has(modesByPath.get(file.filename)));
|
||||
|
||||
if (hasIrregularFile) {
|
||||
return 'PR adds symlinks, submodules or files whose modes cannot be verified; not labeling.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const number = context.payload.pull_request.number;
|
||||
const author = context.payload.pull_request.user.login;
|
||||
|
||||
const { grantsByLogin, problems } = parseGrants(process.env.FOLDER_MERGERS);
|
||||
|
||||
// Only the count: the malformed lines may name grant holders.
|
||||
if (problems.length) {
|
||||
core.warning(`FOLDER_MERGERS has ${problems.length} malformed line(s); not labeling.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const grants = grantsByLogin.get(author.toLowerCase()) || [];
|
||||
// Says nothing to accounts with no grant, so it cannot be used to spam.
|
||||
if (!grants.length) {
|
||||
core.info(`Ignoring PR from @${author}: not listed in FOLDER_MERGERS.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Read current PR metadata for the file list and head tree. Retry
|
||||
// if either side of the diff changes during verification.
|
||||
for (let attempt = 0; attempt < ATTEMPTS; attempt += 1) {
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: number });
|
||||
|
||||
if (pr.state !== 'open') {
|
||||
core.info(`PR is ${pr.state}; not labeling.`);
|
||||
return;
|
||||
}
|
||||
if (!ALLOWED_BASE_BRANCH.test(pr.base.ref)) {
|
||||
core.info(`PR targets "${pr.base.ref}", not main or release/*; not labeling.`);
|
||||
return;
|
||||
}
|
||||
// Checked before listing files, so a PR too large to list is
|
||||
// rejected in one call.
|
||||
if (pr.changed_files >= LISTFILES_CAP) {
|
||||
core.info(`PR changes ${pr.changed_files} files, more than the API can list; not labeling.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
per_page: 100
|
||||
});
|
||||
const scopeIssue = scopeProblem(pr, files, grants);
|
||||
|
||||
let modeIssue = null;
|
||||
if (!scopeIssue) {
|
||||
const { data: tree } = await github.rest.git.getTree({
|
||||
owner,
|
||||
repo,
|
||||
tree_sha: `${pr.head.sha}:${DELEGATABLE_ROOT.replace(/\/$/, '')}`,
|
||||
recursive: 'true'
|
||||
});
|
||||
modeIssue = modeProblem(files, tree);
|
||||
}
|
||||
|
||||
const { data: after } = await github.rest.pulls.get({ owner, repo, pull_number: number });
|
||||
if (
|
||||
after.state !== 'open' ||
|
||||
after.head.sha !== pr.head.sha ||
|
||||
after.base.ref !== pr.base.ref ||
|
||||
after.base.sha !== pr.base.sha
|
||||
) {
|
||||
core.info('PR changed while verifying; retrying.');
|
||||
continue;
|
||||
}
|
||||
|
||||
const problem = scopeIssue || modeIssue;
|
||||
if (problem) {
|
||||
core.info(problem);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- label + one-time comment ----
|
||||
await bestEffort(
|
||||
() => github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [LABEL] }),
|
||||
`Cannot add the "${LABEL}" label because the token cannot write.`);
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
per_page: 100
|
||||
});
|
||||
if (comments.some((comment) => (comment.body || '').includes(MARKER))) {
|
||||
core.info('Partner notice already present; skipping comment.');
|
||||
return;
|
||||
}
|
||||
|
||||
await bestEffort(
|
||||
() => github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
body:
|
||||
`${MARKER}\n` +
|
||||
`Hi @${author}, this profile PR is covered by your delegated merge grant.\n\n` +
|
||||
`Once it is ready for review and CI is green, you can merge it yourself:\n\n` +
|
||||
`- \`/bot merge\` - squash-merge into \`main\` or \`release/*\`\n` +
|
||||
`- \`/bot merge --dry-run\` - report the verdict without merging\n\n` +
|
||||
`The bot re-checks the scope, the file modes and the \`Check profiles\` check at merge time.`
|
||||
}),
|
||||
'Cannot post the partner notice because the token cannot write comments.');
|
||||
|
||||
core.info(`Applied the "${LABEL}" label and posted the /bot merge notice.`);
|
||||
return;
|
||||
}
|
||||
|
||||
core.warning('PR kept changing during verification; not labeling.');
|
||||
|
||||
@@ -54,8 +54,10 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
tar -xvf build_tests.tar
|
||||
# Multi-config generators (Windows/macOS) need a config; Linux is single-config.
|
||||
scripts/run_unit_tests.sh "${{ inputs.test-dir }}" "${{ runner.os != 'Linux' && 'Release' || '' }}"
|
||||
# Every platform builds with a multi-config generator (build_linux.sh uses Ninja
|
||||
# Multi-Config), so ctest needs the config: without it, plain add_test() tests
|
||||
# lose their labels and report "Not Run".
|
||||
scripts/run_unit_tests.sh "${{ inputs.test-dir }}" Release
|
||||
- name: Upload Test Logs
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
name: Flatpak Unit Tests
|
||||
|
||||
# Run the flatpak build's test asset inside the sandbox, once per arch. The
|
||||
# GNOME SDK's _GLIBCXX_ASSERTIONS gives a bounds-checked STL that catches
|
||||
# out-of-bounds reads no other test leg does.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
os:
|
||||
required: true
|
||||
type: string
|
||||
artifact:
|
||||
description: Test asset uploaded by the flatpak build leg
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
unit_tests_flatpak:
|
||||
name: Flatpak Unit Tests
|
||||
runs-on: ${{ inputs.os }}
|
||||
container:
|
||||
image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-50
|
||||
options: --privileged
|
||||
steps:
|
||||
- name: Restore test asset
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ inputs.artifact }}
|
||||
- name: Run unit tests (bounds-checked sandbox)
|
||||
timeout-minutes: 20
|
||||
shell: bash
|
||||
run: |
|
||||
tar -xf flatpak-test-asset.tar
|
||||
# Recreate the stable module symlink so /run/build/OrcaSlicer resolves.
|
||||
d=$(ls -d .flatpak-builder/build/OrcaSlicer-* | tail -1)
|
||||
ln -sfn "$(basename "$d")" .flatpak-builder/build/OrcaSlicer
|
||||
# The runtime + SDK + the llvm extension the app metadata references,
|
||||
# which `flatpak build` mounts; best-effort, the image may have them.
|
||||
flatpak remote-add --if-not-exists --user flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak install --user -y --noninteractive flathub \
|
||||
org.gnome.Platform//50 org.gnome.Sdk//50 org.freedesktop.Sdk.Extension.llvm21//25.08 || true
|
||||
# `flatpak build` uses bwrap (no rofiles-fuse, which this container
|
||||
# rejects); bind-mount the build tree so the baked TEST_DATA_DIR resolves.
|
||||
flatpak build --die-with-parent --share=network \
|
||||
--bind-mount=/run/build="$PWD/.flatpak-builder/build" \
|
||||
flatpak_app \
|
||||
bash -c 'cd /run/build/OrcaSlicer && scripts/run_unit_tests.sh build_flatpak/tests'
|
||||
- name: Collect test results
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
d=$(ls -d .flatpak-builder/build/OrcaSlicer-* 2>/dev/null | tail -1 || true)
|
||||
[ -n "$d" ] && [ -f "$d/ctest_results.xml" ] && cp "$d/ctest_results.xml" ctest_results.xml || true
|
||||
- name: Upload Test Results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: test-results-${{ inputs.artifact }}
|
||||
path: ctest_results.xml
|
||||
retention-days: 5
|
||||
if-no-files-found: warn
|
||||
- name: Delete Test Asset
|
||||
if: success()
|
||||
uses: geekyeggo/delete-artifact@v6
|
||||
with:
|
||||
name: ${{ inputs.artifact }}
|
||||
failOnError: false
|
||||
Reference in New Issue
Block a user