Files
AnyPS5/.github/workflows/build.yml
T
Adrià FranchandClaude e0b9572e3b ci(windows): find the CA bundle of Git for Windows under ucrt64 too
Git for Windows 2.56 (runner image 20261002) moved from mingw64 to ucrt64,
so the fixed SSL_CERT_FILE path no longer exists and the WinLibs CMake
fails the FFmpeg download with status 60 on every Windows build. Take the
bundle from whichever of ucrt64 and mingw64 exists.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-15 15:20:44 +02:00

434 lines
21 KiB
YAML

name: Build
on:
push:
branches: [main]
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
workflow_dispatch:
inputs:
pr:
description: Pull request numbers to build together, separated by spaces or commas (up to 40)
required: true
type: string
linux:
description: Also build and test on Linux
required: false
default: true
type: boolean
full:
description: Run the full Linux and Windows builds (off runs only the relinker builds and tests on Linux, Windows and macOS)
required: false
default: true
type: boolean
permissions:
contents: read
concurrency:
group: build-${{ github.event_name }}-${{ github.event.pull_request.number || inputs.pr || github.ref }}
cancel-in-progress: ${{ github.event_name != 'push' }}
env:
CONFIGURE: >-
-G Ninja -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON
-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++
-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache
jobs:
eligibility:
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
pull-requests: read
outputs:
sha: ${{ steps.gate.outputs.sha }}
pr: ${{ steps.gate.outputs.pr }}
head: ${{ steps.gate.outputs.head }}
relinker: ${{ steps.gate.outputs.relinker }}
register-context: ${{ steps.gate.outputs.register-context }}
write-watch: ${{ steps.gate.outputs.write-watch }}
full: ${{ steps.gate.outputs.full }}
relinker-hosts: ${{ steps.gate.outputs.relinker-hosts }}
steps:
- uses: actions/github-script@v8
id: gate
env:
PR_NUMBER: ${{ inputs.pr }}
LINUX_BUILD: ${{ inputs.linux }}
FULL_BUILD: ${{ inputs.full }}
with:
script: |
const { owner, repo } = context.repo;
const workflow = '.github/workflows/build.yml';
const libc = 'core/libs/prx/libc/';
const select = (paths, all) => {
const touches = (...prefixes) => all ||
paths.some((path) => prefixes.some((prefix) => prefix.endsWith('/') ? path.startsWith(prefix) : path === prefix));
return {
relinker: touches(
'core/relinker/', libc, 'core/libs/prx/libkernel/AppMetadata/', 'core/libs/tests/ParamJsonParser.cpp',
'core/shader/recompiler/tests/CacheFileReadTests.cpp', 'core/shader/recompiler/ShaderCacheDirectory.hpp',
'tools/', 'cmake/', 'CMakeLists.txt', workflow
),
registerContext: touches('core/libs/tests/register_context/', 'core/libs/tests/RegisterContext.cpp', libc, workflow),
writeWatch: touches('core/libs/tests/write_watch/', 'core/libs/tests/WriteWatch.cpp', libc, workflow),
};
};
const relinkerHosts = (full, linux) => JSON.stringify([
...(full && linux ? [] : [{ os: 'ubuntu-latest', compiler: 'g++' }]),
...(full ? [] : [{ os: 'windows-latest', compiler: 'g++' }]),
{ os: 'macos-latest', compiler: 'clang++' },
]);
if (context.eventName === 'push') {
if (context.ref !== 'refs/heads/main') {
throw new Error('Automatic full builds only run on main');
}
let paths = [];
let all = true;
if (!/^0+$/.test(context.payload.before)) {
try {
const { data } = await github.rest.repos.compareCommitsWithBasehead({
owner, repo, basehead: `${context.payload.before}...${context.sha}`
});
const files = data.files ?? [];
all = files.length >= 300;
paths = files.flatMap((file) => [file.filename, file.previous_filename].filter(Boolean));
} catch (error) {
core.warning(`Could not list the files of this push, so every test job runs: ${error.message}`);
}
}
const run = select(paths, all);
core.setOutput('sha', context.sha);
core.setOutput('pr', '');
core.setOutput('head', '');
core.setOutput('relinker', run.relinker);
core.setOutput('register-context', run.registerContext);
core.setOutput('write-watch', run.writeWatch);
core.setOutput('full', true);
core.setOutput('relinker-hosts', relinkerHosts(true, true));
await core.summary.addRaw(
`Building main at ${context.sha}: full builds enabled, ` +
`macOS relinker ${run.relinker ? 'enabled' : 'skipped'}, ` +
`register context tests ${run.registerContext ? 'enabled' : 'skipped'}, write watch tests ${run.writeWatch ? 'enabled' : 'skipped'}`
).write();
return;
}
const manual = context.eventName === 'workflow_dispatch';
const linux = process.env.LINUX_BUILD !== 'false';
const full = manual && process.env.FULL_BUILD !== 'false';
if (manual) {
for (const username of new Set([context.actor, process.env.GITHUB_TRIGGERING_ACTOR])) {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username });
if (!['write', 'maintain', 'admin'].includes(data.permission)) {
throw new Error('Only collaborators with write access can start or rerun builds');
}
}
if (!/^[1-9][0-9]*([ ,]+[1-9][0-9]*){0,39}$/.test(process.env.PR_NUMBER.trim())) {
throw new Error('Enter up to 40 positive pull request numbers separated by spaces or commas');
}
}
const numbers = manual
? [...new Set(process.env.PR_NUMBER.trim().split(/[ ,]+/).map(Number))]
: [context.payload.pull_request.number];
if (numbers.some((pull_number) => !Number.isSafeInteger(pull_number) || pull_number < 1)) {
throw new Error('Invalid pull request number');
}
const pulls = [];
const paths = [];
let truncated = false;
for (const pull_number of numbers) {
let { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number });
for (let attempt = 0; pr.mergeable === null && attempt < 10; ++attempt) {
await new Promise((resolve) => setTimeout(resolve, 3000));
({ data: pr } = await github.rest.pulls.get({ owner, repo, pull_number }));
}
if (!manual && pr.state !== 'open') {
await core.summary.addRaw(`PR #${pull_number} was ${pr.merged ? 'merged' : 'closed'} before this run started: nothing to build`).write();
return;
}
if (pr.state !== 'open' || pr.base.ref !== 'main') {
throw new Error(`Pull request #${pull_number} must be open and target main`);
}
if (!manual && pr.head.sha !== context.payload.pull_request.head.sha) {
throw new Error('The pull request has newer commits; use the latest run');
}
if (pr.mergeable === false) {
throw new Error(`Pull request #${pull_number} does not merge cleanly into main; rebase it first`);
}
const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number, per_page: 100 });
truncated ||= files.length >= 3000;
paths.push(...files.flatMap((file) => [file.filename, file.previous_filename].filter(Boolean)));
pulls.push({ number: pull_number, head: pr.head.sha });
}
const run = select(paths, truncated);
let relinker = run.relinker;
if (relinker && !manual) {
const author = context.payload.pull_request.user.login;
const { data: commits } = await github.rest.repos.listCommits({
owner, repo, sha: 'main', author, per_page: 1
});
relinker = commits.length > 0;
if (!relinker) {
try {
const { data: mergedPRs } = await github.rest.search.issuesAndPullRequests({
q: `repo:${owner}/${repo} is:pr is:merged base:main author:${author}`,
per_page: 1
});
if (mergedPRs.incomplete_results) {
throw new Error('the search results are incomplete');
}
relinker = mergedPRs.total_count > 0;
} catch (error) {
core.warning(`Skipping the relinker: could not search the merged pull requests of ${author}: ${error.message}`);
}
}
}
const registerContext = run.registerContext;
const writeWatch = run.writeWatch;
const { data: main } = await github.rest.repos.getBranch({ owner, repo, branch: 'main' });
core.setOutput('sha', main.commit.sha);
core.setOutput('pr', pulls.map((pull) => pull.number).join(' '));
core.setOutput('head', pulls.map((pull) => pull.head).join(' '));
core.setOutput('relinker', relinker);
core.setOutput('register-context', registerContext);
core.setOutput('write-watch', writeWatch);
core.setOutput('full', full);
core.setOutput('relinker-hosts', relinkerHosts(full, linux));
const merged = pulls.map((pull) => `PR #${pull.number} at ${pull.head} merged into main at ${main.commit.sha}`);
merged.forEach((line) => core.info(line));
await core.summary.addRaw(
`${merged.join(', then ')}: full builds ${full ? 'enabled' : 'disabled'}, ` +
`relinker ${relinker ? (full ? (linux ? 'enabled on macOS (the full builds run the Linux and Windows relinker tests)' : 'enabled on Linux and macOS (the full Windows build runs the Windows relinker tests)') : 'enabled') : 'skipped: no relinker file changed, or no commit or merged pull request in main was found for the author'}, ` +
`register context tests ${registerContext ? 'enabled' : 'skipped'}, write watch tests ${writeWatch ? 'enabled' : 'skipped'}`
).write();
relinker:
needs: eligibility
if: ${{ needs.eligibility.outputs.relinker == 'true' }}
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.eligibility.outputs.relinker-hosts) }}
env:
WINLIBS_RELEASE: 15.2.0posix-14.0.0-ucrt-r7
WINLIBS_ZIP: winlibs-x86_64-posix-seh-gcc-15.2.0-mingw-w64ucrt-14.0.0-r7.zip
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.eligibility.outputs.sha }}
persist-credentials: false
- name: Merge the pull request into main
if: ${{ needs.eligibility.outputs.head != '' }}
shell: bash
env:
PR_NUMBER: ${{ needs.eligibility.outputs.pr }}
PR_HEAD: ${{ needs.eligibility.outputs.head }}
run: |
read -ra numbers <<< "$PR_NUMBER"
read -ra heads <<< "$PR_HEAD"
refspecs=()
for number in "${numbers[@]}"; do refspecs+=("+refs/pull/$number/head:refs/remotes/pull/$number"); done
git fetch --no-tags --unshallow origin "${refspecs[@]}"
for i in "${!numbers[@]}"; do
if [ "$(git rev-parse "refs/remotes/pull/${numbers[i]}")" != "${heads[i]}" ]; then echo "::error::Pull request #${numbers[i]} has newer commits; start a new build"; exit 1; fi
if ! git -c user.name=ci -c user.email=ci@localhost merge --no-ff --no-edit "refs/remotes/pull/${numbers[i]}"; then echo "::error::Pull request #${numbers[i]} does not merge cleanly into main and the pull requests before it; rebase it first"; exit 1; fi
done
- if: runner.os == 'Windows'
id: toolchain
uses: actions/cache@v6
with:
path: C:\winlibs
key: ${{ env.WINLIBS_ZIP }}
- if: runner.os == 'Windows' && steps.toolchain.outputs.cache-hit != 'true'
run: |
curl -sSfL -o "$RUNNER_TEMP/winlibs.zip" "https://github.com/brechtsanders/winlibs_mingw/releases/download/$WINLIBS_RELEASE/$WINLIBS_ZIP"
7z x "$RUNNER_TEMP/winlibs.zip" -oC:/winlibs > /dev/null
- if: runner.os == 'Windows'
run: echo 'C:\winlibs\mingw64\bin' >> "$GITHUB_PATH"
- run: cmake -S . -B build-relinker -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER=${{ matrix.compiler }} -DANYPS5_RELINKER_ONLY=ON -DBUILD_TESTING=ON
- run: cmake --build build-relinker --parallel 3
- run: ctest --test-dir build-relinker --output-on-failure --timeout 120
register-context:
needs: eligibility
if: ${{ needs.eligibility.outputs.register-context == 'true' }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-15-intel]
configuration: [Debug, Release]
runs-on: ${{ matrix.os }}
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
- run: cmake -S core/libs/tests/register_context -B build/register-context -DCMAKE_BUILD_TYPE=${{ matrix.configuration }}
- run: cmake --build build/register-context --parallel
- run: ctest --test-dir build/register-context --output-on-failure --repeat until-fail:3 --no-tests=error
write-watch:
needs: eligibility
if: ${{ needs.eligibility.outputs.write-watch == 'true' }}
strategy:
fail-fast: false
matrix:
os: [macos-15-intel, macos-15]
runs-on: ${{ matrix.os }}
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
- run: cmake -S core/libs/tests/write_watch -B build/write-watch -DCMAKE_BUILD_TYPE=Release
- run: cmake --build build/write-watch --parallel
- run: ctest --test-dir build/write-watch --output-on-failure --repeat until-fail:3 --no-tests=error
linux:
needs: eligibility
if: ${{ (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.linux)) && needs.eligibility.outputs.full == 'true' }}
runs-on: [self-hosted, linux, x64, anyps5]
timeout-minutes: 60
env:
CCACHE_DIR: /var/cache/anyps5/ccache
CCACHE_MAXSIZE: 30G
CCACHE_COMPILERCHECK: content
MESA_SHADER_CACHE_DIR: /var/cache/anyps5/mesa
MESA_SHADER_CACHE_MAX_SIZE: 4G
CMAKE_BUILD_PARALLEL_LEVEL: 1
CTEST_PARALLEL_LEVEL: 1
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.eligibility.outputs.sha }}
persist-credentials: false
submodules: recursive
- name: Merge the pull request into main
if: ${{ needs.eligibility.outputs.head != '' }}
shell: bash
env:
PR_NUMBER: ${{ needs.eligibility.outputs.pr }}
PR_HEAD: ${{ needs.eligibility.outputs.head }}
run: |
read -ra numbers <<< "$PR_NUMBER"
read -ra heads <<< "$PR_HEAD"
refspecs=()
for number in "${numbers[@]}"; do refspecs+=("+refs/pull/$number/head:refs/remotes/pull/$number"); done
fetch_options=()
if [ "$(git rev-parse --is-shallow-repository)" = true ]; then fetch_options+=(--unshallow); fi
git fetch --no-tags "${fetch_options[@]}" origin "${refspecs[@]}"
for i in "${!numbers[@]}"; do
if [ "$(git rev-parse "refs/remotes/pull/${numbers[i]}")" != "${heads[i]}" ]; then echo "::error::Pull request #${numbers[i]} has newer commits; start a new build"; exit 1; fi
if ! git -c user.name=ci -c user.email=ci@localhost merge --no-ff --no-edit "refs/remotes/pull/${numbers[i]}"; then echo "::error::Pull request #${numbers[i]} does not merge cleanly into main and the pull requests before it; rebase it first"; exit 1; fi
done
git submodule update --init --recursive
- name: Prepare persistent caches
run: |
mkdir -p "$CCACHE_DIR" "$MESA_SHADER_CACHE_DIR"
test -w "$CCACHE_DIR" && test -w "$MESA_SHADER_CACHE_DIR"
ccache --zero-stats
ccache --show-config
dpkg-query -W mesa-vulkan-drivers
grep -m1 '^model name' /proc/cpuinfo
- run: cmake -S . -B build $CONFIGURE -DANYPS5_ENABLE_SPIRV_TOOLS=ON
- name: Check that every test executable is registered with ctest
run: python3 tools/check_test_registration.py build
- run: cmake --build build
- run: cmake --build build --target libs
- run: ctest --test-dir build --output-on-failure --timeout 120 --output-log ctest.log --output-junit ctest.xml
env:
ANYPS5_REQUIRE_VULKAN: 1
GALLIUM_OVERRIDE_CPU_CAPS: avx
- run: python3 tools/check_skips.py build/ctest.xml
- name: Report compiler cache statistics
if: ${{ !cancelled() }}
run: ccache --show-stats
- name: Report tests slower than 30 s
if: ${{ !cancelled() }}
shell: bash
run: |
[ -f ctest.log ] || exit 0
awk '/Test +#[0-9]+: / && $NF == "sec" { for (i = 1; i <= NF; i++) if ($i ~ /^#[0-9]+:$/) name = $(i + 1); t = $(NF - 1) + 0; if (t > max[name]) max[name] = t }
END { for (name in max) if (max[name] > 30) printf "::warning title=Slow test::%s took %.1f s on %s; tests must finish in 30 s (CONTRIBUTING.md)\n", name, max[name], ENVIRON["RUNNER_OS"] }' ctest.log
windows:
needs: eligibility
if: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && needs.eligibility.outputs.full == 'true' }}
runs-on: windows-latest
timeout-minutes: 45
env:
WINLIBS_RELEASE: 15.2.0posix-14.0.0-ucrt-r7
WINLIBS_ZIP: winlibs-x86_64-posix-seh-gcc-15.2.0-mingw-w64ucrt-14.0.0-r7.zip
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.eligibility.outputs.sha }}
persist-credentials: false
submodules: recursive
- name: Merge the pull request into main
if: ${{ needs.eligibility.outputs.head != '' }}
shell: bash
env:
PR_NUMBER: ${{ needs.eligibility.outputs.pr }}
PR_HEAD: ${{ needs.eligibility.outputs.head }}
run: |
read -ra numbers <<< "$PR_NUMBER"
read -ra heads <<< "$PR_HEAD"
refspecs=()
for number in "${numbers[@]}"; do refspecs+=("+refs/pull/$number/head:refs/remotes/pull/$number"); done
git fetch --no-tags --unshallow origin "${refspecs[@]}"
for i in "${!numbers[@]}"; do
if [ "$(git rev-parse "refs/remotes/pull/${numbers[i]}")" != "${heads[i]}" ]; then echo "::error::Pull request #${numbers[i]} has newer commits; start a new build"; exit 1; fi
if ! git -c user.name=ci -c user.email=ci@localhost merge --no-ff --no-edit "refs/remotes/pull/${numbers[i]}"; then echo "::error::Pull request #${numbers[i]} does not merge cleanly into main and the pull requests before it; rebase it first"; exit 1; fi
done
git submodule update --init --recursive
- name: Point the WinLibs CMake at the CA bundle of Git for Windows
run: |
for prefix in ucrt64 mingw64; do
bundle="C:/Program Files/Git/$prefix/etc/ssl/certs/ca-bundle.crt"
if [ -f "$bundle" ]; then echo "SSL_CERT_FILE=$bundle" >> "$GITHUB_ENV"; exit 0; fi
done
echo "::error::No CA bundle found in Git for Windows"; exit 1
- id: toolchain
uses: actions/cache@v6
with:
path: C:\winlibs
key: ${{ env.WINLIBS_ZIP }}
- if: steps.toolchain.outputs.cache-hit != 'true'
run: |
curl -sSfL -o "$RUNNER_TEMP/winlibs.zip" "https://github.com/brechtsanders/winlibs_mingw/releases/download/$WINLIBS_RELEASE/$WINLIBS_ZIP"
7z x "$RUNNER_TEMP/winlibs.zip" -oC:/winlibs > /dev/null
- run: echo 'C:\winlibs\mingw64\bin' >> "$GITHUB_PATH"
- uses: hendrikmuhs/ccache-action@v1.2.24
with:
key: windows
save: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
evict-old-files: job
- shell: cmd
run: cmake -S . -B build %CONFIGURE% "-DANYPS5_AGC_EXECUTION_TESTS=F16ToF32Unpack;F64FmaSubnormals;RegistrationPrepareShutdown"
- name: Check that every test executable is registered with ctest
shell: cmd
run: python tools/check_test_registration.py build
- shell: cmd
run: cmake --build build
- shell: cmd
run: cmake --build build --target libs
- shell: cmd
run: ctest --test-dir build --output-on-failure --timeout 120 --parallel 4 --output-log ctest.log --output-junit ctest.xml
- run: python tools/check_skips.py build/ctest.xml --job windows
- name: Report tests slower than 30 s
if: ${{ !cancelled() }}
shell: bash
run: |
[ -f ctest.log ] || exit 0
awk '/Test +#[0-9]+: / && $NF == "sec" { for (i = 1; i <= NF; i++) if ($i ~ /^#[0-9]+:$/) name = $(i + 1); t = $(NF - 1) + 0; if (t > max[name]) max[name] = t }
END { for (name in max) if (max[name] > 30) printf "::warning title=Slow test::%s took %.1f s on %s; tests must finish in 30 s (CONTRIBUTING.md)\n", name, max[name], ENVIRON["RUNNER_OS"] }' ctest.log