ci(core): share compile-only release builds and preserve CI artifacts

This commit is contained in:
Joseph Mattiello committed 2026-10-20 18:39:49 -04:00
1 parent 2e46ca4492
commit 3c683169ef
10 files changed
+690 -165

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
[
{
"displayTargetName": "Linux x86_64 (GCC)",
"artifact": "virtualjaguar_libretro.so",
"os": "ubuntu-latest",
"cc": "gcc",
"cxx": "g++"
},
{
"displayTargetName": "Linux x86_64 (Clang)",
"artifact": "virtualjaguar_libretro.so",
"os": "ubuntu-latest",
"cc": "clang",
"cxx": "clang++"
},
{
"displayTargetName": "Linux aarch64",
"artifact": "virtualjaguar_libretro.so",
"os": "ubuntu-24.04-arm",
"cc": "gcc",
"cxx": "g++"
},
{
"displayTargetName": "Linux i686",
"artifact": "virtualjaguar_libretro.so",
"os": "ubuntu-latest",
"cc": "gcc -m32",
"cxx": "g++ -m32",
"multilib": true
},
{
"displayTargetName": "macOS arm64 (Clang)",
"artifact": "virtualjaguar_libretro.dylib",
"os": "macos-latest",
"cc": "clang",
"cxx": "clang++"
},
{
"displayTargetName": "macOS x86_64 (Clang, cross)",
"artifact": "virtualjaguar_libretro.dylib",
"os": "macos-latest",
"cc": "clang -arch x86_64",
"cxx": "clang++ -arch x86_64",
"make_extra": "BLITTER_SIMD=sse2",
"cross": true,
"release_platform": "macos-x86_64"
},
{
"displayTargetName": "Windows x86_64 (MSYS2)",
"artifact": "virtualjaguar_libretro.dll",
"os": "windows-latest",
"cc": "gcc",
"cxx": "g++",
"shell": "msys2 {0}",
"msystem": "MINGW64",
"msys2_packages": "mingw-w64-x86_64-gcc make"
},
{
"displayTargetName": "Windows i686 (MSYS2)",
"artifact": "virtualjaguar_libretro.dll",
"os": "windows-latest",
"cc": "gcc",
"cxx": "g++",
"shell": "msys2 {0}",
"msystem": "MINGW32",
"msys2_packages": "mingw-w64-i686-gcc make"
},
{
"displayTargetName": "Emscripten (WASM)",
"artifact": "virtualjaguar_libretro_emscripten.bc",
"os": "ubuntu-latest",
"emscripten": true,
"release_platform": "emscripten-wasm"
},
{
"displayTargetName": "Android arm64-v8a",
"artifact": "libs/arm64-v8a/libretro.so",
"os": "ubuntu-latest",
"android": true,
"android_abi": "arm64-v8a",
"android_simd": "neon",
"release_platform": "android-arm64-v8a"
},
{
"displayTargetName": "Android armeabi-v7a",
"artifact": "libs/armeabi-v7a/libretro.so",
"os": "ubuntu-latest",
"android": true,
"android_abi": "armeabi-v7a",
"android_simd": "neon",
"release_platform": "android-armeabi-v7a"
},
{
"displayTargetName": "Android x86_64",
"artifact": "libs/x86_64/libretro.so",
"os": "ubuntu-latest",
"android": true,
"android_abi": "x86_64",
"android_simd": "sse2",
"release_platform": "android-x86_64"
},
{
"displayTargetName": "Android x86",
"artifact": "libs/x86/libretro.so",
"os": "ubuntu-latest",
"android": true,
"android_abi": "x86",
"android_simd": "sse2",
"release_platform": "android-x86"
},
{
"displayTargetName": "iOS arm64",
"artifact": "virtualjaguar_libretro_ios.dylib",
"os": "macos-latest",
"make_platform": "ios-arm64",
"cross": true,
"release_platform": "ios-arm64"
},
{
"displayTargetName": "tvOS arm64",
"artifact": "virtualjaguar_libretro_tvos.dylib",
"os": "macos-latest",
"make_platform": "tvos-arm64",
"cross": true,
"release_platform": "tvos-arm64"
}
]
+52
View File
@@ -0,0 +1,52 @@
// Compile-only C/C++ rows share production artifacts; native/Clang/MSVC/sanitizer/test-ABI builds stay independent.
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { plan, waitForPackages } = require('./release-packages.cjs');
const MATRIX = require('../ci/core-matrix.json');
const CORES = [
...MATRIX.filter(c => c.release_platform).map(c => ({ platform: c.release_platform,
name: c.displayTargetName, file: path.basename(c.artifact) })),
{ platform: 'vita', name: 'PS Vita', file: 'virtualjaguar_libretro_vita.a' },
{ platform: 'switch', name: 'Nintendo Switch', file: 'virtualjaguar_libretro_libnx.a' },
];
async function planCores(options) {
const reuse = await plan(options);
options.core.setOutput('matrix', JSON.stringify(MATRIX.filter(c => !reuse || !c.release_platform)));
}
function coreStatus(jobs, artifacts) {
const failed = jobs.find(job => CORES.some(c => job.name === `build-${c.platform}`) &&
job.status === 'completed' && job.conclusion !== 'success');
if (failed) throw new Error(`Release production build did not pass: ${failed.name} (${failed.conclusion}).`);
for (const core of CORES) {
const name = `build-${core.platform}`;
const job = jobs.find(j => j.name === name);
if (!job || job.status !== 'completed') return false;
for (const name of [`ci-core-${core.platform}`, `virtualjaguar_libretro-${core.platform}`]) {
if (!artifacts.some(a => a.name === name && !a.expired && a.size_in_bytes > 0))
throw new Error(`Missing, empty or expired production artifact: ${name}.`);
}
}
return true;
}
async function waitForCores(options) {
return waitForPackages({ ...options, checkStatus: coreStatus, subject: 'production core' });
}
function verifyDownloads(root, head) {
for (const core of CORES) {
const dir = path.join(root, `ci-core-${core.platform}`);
const meta = JSON.parse(fs.readFileSync(path.join(dir, 'BUILD.json'), 'utf8'));
if (meta.head_sha !== head || meta.platform !== core.platform || meta.file !== core.file ||
meta.test_exports !== 0 || meta.release_debug_info !== 1)
throw new Error(`Wrong commit, platform, filename or production ABI for ${core.platform}.`);
const data = fs.readFileSync(path.join(dir, core.file));
if (!data.length || crypto.createHash('sha256').update(data).digest('hex') !== meta.sha256)
throw new Error(`Production binary checksum mismatch for ${core.platform}.`);
}
}
module.exports = { MATRIX, CORES, planCores, coreStatus, waitForCores, verifyDownloads };
+11 -10
View File
@@ -22,13 +22,14 @@ async function plan({ github, context, core }) {
const pr = context.payload.pull_request;
const fullName = `${context.repo.owner}/${context.repo.repo}`;
if (context.eventName !== 'pull_request' || pr?.head.repo?.full_name !== fullName ||
!pr.head.ref.startsWith('release/')) return;
!pr.head.ref.startsWith('release/')) return false;
if (!await sameTree(github, context.repo, pr.head.sha, context.sha)) {
core.info('The PR merge tree differs from the release head: keep independent PR package builds.');
return;
return false;
}
core.setOutput('reuse', 'true');
core.info(`Share Release push packages for ${pr.head.ref}@${pr.head.sha}; the PR merge tree is identical.`);
core.info(`Share Release push artifacts for ${pr.head.ref}@${pr.head.sha}; the PR merge tree is identical.`);
return true;
}
function selectRun(runs, pr, repo) {
@@ -58,7 +59,7 @@ async function assertCurrentMerge(github, repo, pr) {
}
async function waitForPackages({ github, context, core, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)),
now = Date.now, timeoutMs = 345 * 60000 }) {
now = Date.now, timeoutMs = 345 * 60000, checkStatus = packageStatus, subject = 'package' }) {
const pr = context.payload.pull_request;
const repo = context.repo, fullName = `${repo.owner}/${repo.repo}`;
// Recheck independently: the workflow must never turn a fork or another branch into trusted Release artifacts.
@@ -77,26 +78,26 @@ async function waitForPackages({ github, context, core, sleep = ms => new Promis
{ ...repo, run_id: run.id, filter: 'latest', per_page: 100 });
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts,
{ ...repo, run_id: run.id, per_page: 100 });
if (packageStatus(jobs, artifacts)) {
if (checkStatus(jobs, artifacts)) {
// A rerun can replace jobs while the API pages are being read. Never approve an older attempt's evidence.
const { data: current } = await github.rest.actions.getWorkflowRun({ ...repo, run_id: run.id });
if (current.run_attempt === run.run_attempt && current.head_sha === pr.head.sha) {
await assertCurrentMerge(github, repo, pr);
core.info(`All package checks and six artifact groups verified: ${run.html_url}`);
await core.summary.addRaw(`Shared Release packages: [run ${run.id}](${run.html_url}), head \`${pr.head.sha}\`.\n`).write();
core.info(`All ${subject} checks and required artifact groups verified: ${run.html_url}`);
await core.summary.addRaw(`Shared Release ${subject} artifacts: [run ${run.id}](${run.html_url}), head \`${pr.head.sha}\`.\n`).write();
return;
}
} else if (run.status === 'completed') {
throw new Error(`Release run ${run.id} ended without all required package checks/artifacts.`);
throw new Error(`Release run ${run.id} ended without all required ${subject} checks/artifacts.`);
}
}
const status = run ? `Waiting for package checks in Release run ${run.id} (attempt ${run.run_attempt}).` :
const status = run ? `Waiting for ${subject} checks in Release run ${run.id} (attempt ${run.run_attempt}).` :
`Waiting for the Release push run for ${pr.head.sha}.`;
if (status !== previous) core.info(status);
previous = status;
await sleep(60000);
}
throw new Error('Timed out waiting for this head\'s Release packages; no package check was bypassed.');
throw new Error(`Timed out waiting for this head's Release ${subject} artifacts; no check was bypassed.`);
}
module.exports = { ARTIFACTS, PACKAGE_JOBS, sameTree, plan, selectRun, packageStatus, waitForPackages };
@@ -0,0 +1,36 @@
# Reject toolchain/architecture drift before omitting any compile-only PR builds.
require 'json'
require 'yaml'
root = File.expand_path('../../..', __dir__)
read_workflow = ->(name) { YAML.safe_load(File.read("#{root}/.github/workflows/#{name}.yml"), aliases: true).fetch('jobs') }
cpp = read_workflow.call('c-cpp')
release = read_workflow.call('release')
matrix = JSON.parse(File.read("#{root}/.github/ci/core-matrix.json"))
rows = release.fetch('build').fetch('strategy').fetch('matrix').fetch('config')
shared = matrix.select { |config| config['release_platform'] }
raise 'Expected eight shared matrix rows' unless shared.length == 8
shared.each do |config|
peer = rows.find { |row| row['platform'] == config['release_platform'] }
raise "Missing Release row: #{config['release_platform']}" unless peer
%w[artifact os cc cxx make_extra emscripten android android_abi android_simd make_platform].each do |key|
raise "#{config['displayTargetName']}: mismatched #{key}" unless config[key] == peer[key]
end
end
%w[vita-build switch-build].each do |job|
raise "#{job}: SDK container differs" unless cpp.fetch(job).fetch('container') == release.fetch(job).fetch('container')
end
steps = ->(job) { job.fetch('steps') }
%w[Set\ up\ Emscripten Set\ up\ Android\ NDK].each do |name|
a = steps.call(cpp.fetch('build')).find { |step| step['name'] == name }
b = steps.call(release.fetch('build')).find { |step| step['name'] == name }
raise "#{name}: SDK setup/version differs" unless a['uses'] == b['uses'] && a['with'] == b['with']
end
%w[build vita-build switch-build].each do |name|
steps.call(release.fetch(name)).select { |step| step['name']&.match?(/\ABuild(?: \(|\z)/) }.each do |step|
env = step.fetch('env')
raise "#{name}: canonical core is not production" unless env['TEST_EXPORTS'] == '0' && env['RELEASE_DEBUG_INFO'] == '1'
end
end
raise 'Native Clang row removed' unless matrix.any? { |row| row['cc'] == 'clang' && !row['release_platform'] }
%w[msvc-check sanitizers coverage clang-tidy].each { |job| cpp.fetch(job) }
puts 'Core workflow contract: eight matching toolchains, two matching SDK containers; native compiler, sanitizer and ABI coverage retained.'
@@ -0,0 +1,155 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync, spawnSync } = require('node:child_process');
const { MATRIX, CORES, planCores, coreStatus, verifyDownloads } = require('../release-cores.cjs');
const root = path.resolve(__dirname, '../../..');
const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim();
function complete() {
return { jobs: CORES.map(c => ({ name: `build-${c.platform}`, status: 'completed', conclusion: 'success' })),
artifacts: CORES.flatMap(c => [`ci-core-${c.platform}`, `virtualjaguar_libretro-${c.platform}`]
.map(name => ({ name, expired: false, size_in_bytes: 123 }))) };
}
async function route(same = true, fork = false) {
const out = {};
await planCores({ context: { repo: { owner: 'o', repo: 'r' }, eventName: 'pull_request', sha: 'merge',
payload: { pull_request: { head: { sha: 'head', ref: 'release/v1', repo: { full_name: fork ? 'fork/r' : 'o/r' } } } } },
github: { rest: { git: { getCommit: async ({ commit_sha }) => ({ data: { tree: {
sha: same || commit_sha === 'head' ? 'same' : 'different' } } }) } } },
core: { setOutput: (k,v) => { out[k] = v; }, info: () => {} } });
return { ...out, matrix: JSON.parse(out.matrix) };
}
test('matching release PR retains all seven native compiler/test rows and shares only eight matrix rows plus consoles', async () => {
const r = await route(); assert.equal(r.reuse, 'true'); assert.equal(r.matrix.length, 7);
assert.equal(MATRIX.length, 15); assert.equal(CORES.length, 10);
assert.deepEqual(r.matrix, MATRIX.filter(c => !c.release_platform));
for (const name of ['Linux x86_64 (GCC)', 'Linux x86_64 (Clang)', 'Linux aarch64', 'Linux i686',
'macOS arm64 (Clang)', 'Windows x86_64 (MSYS2)', 'Windows i686 (MSYS2)'])
assert(r.matrix.some(c => c.displayTargetName === name));
});
test('different merge trees and forks keep all fifteen original matrix configurations', async () => {
for (const r of [await route(false), await route(true, true)]) {
assert.equal(r.reuse, 'false'); assert.deepEqual(r.matrix, MATRIX);
}
});
test('all ten production jobs and BOTH raw and distributable artifact groups are required', () => {
const f = complete(); assert(coreStatus(f.jobs, f.artifacts));
for (const c of CORES) {
const fewer = f.jobs.filter(j => j.name !== `build-${c.platform}`);
assert.equal(coreStatus(fewer, f.artifacts), false);
for (const name of [`ci-core-${c.platform}`, `virtualjaguar_libretro-${c.platform}`])
assert.throws(() => coreStatus(f.jobs, f.artifacts.filter(a => a.name !== name)), /Missing/);
}
});
test('failures, cancelled jobs, skipped jobs, stale and empty artifacts cannot approve a core', () => {
for (const conclusion of ['failure', 'cancelled', 'skipped', 'timed_out']) {
const f = complete(); f.jobs[0].conclusion = conclusion;
assert.throws(() => coreStatus(f.jobs, f.artifacts), /did not pass/);
}
for (const change of [a => { a.expired = true; }, a => { a.size_in_bytes = 0; }]) {
const f = complete(); change(f.artifacts[0]); assert.throws(() => coreStatus(f.jobs, f.artifacts), /expired/);
}
});
function staged() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vj-core-contract-'));
for (const c of CORES) {
const src = path.join(dir, c.platform, c.file); fs.mkdirSync(path.dirname(src), { recursive: true });
fs.writeFileSync(src, `fixture production bytes for ${c.platform}`);
execFileSync('bash', [path.join(root, 'scripts/stage-ci-core.sh'), c.platform, src, path.join(dir, `ci-core-${c.platform}`)],
{ cwd: root, env: { ...process.env, GITHUB_SHA: head, TEST_EXPORTS: '0', RELEASE_DEBUG_INFO: '1' } });
}
return dir;
}
test('real staging shell and download verifier preserve production bytes and exact provenance', () => {
const dir = staged(); verifyDownloads(dir, head);
for (const c of CORES)
assert.deepEqual(fs.readFileSync(path.join(dir, c.platform, c.file)), fs.readFileSync(path.join(dir, `ci-core-${c.platform}`, c.file)));
});
test('wrong head, platform, filename, test ABI and build flags are rejected after download', () => {
const dir = staged(), c = CORES[0], file = path.join(dir, `ci-core-${c.platform}`, 'BUILD.json');
const meta = JSON.parse(fs.readFileSync(file));
for (const delta of [{ head_sha: 'old' }, { platform: 'wrong' }, { file: 'other.so' }, { test_exports: 1 }, { release_debug_info: 0 }]) {
fs.writeFileSync(file, JSON.stringify({ ...meta, ...delta }));
assert.throws(() => verifyDownloads(dir, head), /Wrong commit/);
}
fs.writeFileSync(file, JSON.stringify(meta)); verifyDownloads(dir, head);
});
test('tampered or empty production bytes fail checksum verification', () => {
const dir = staged(), c = CORES[0], file = path.join(dir, `ci-core-${c.platform}`, c.file);
for (const bytes of ['changed', '']) {
fs.writeFileSync(file, bytes); assert.throws(() => verifyDownloads(dir, head), /checksum mismatch/);
}
});
test('staging rejects the wide test ABI and a wrong workflow commit', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vj-core-stage-')), c = CORES[0], src = path.join(dir, c.file);
fs.writeFileSync(src, 'binary fixture');
for (const delta of [{ TEST_EXPORTS: '1' }, { GITHUB_SHA: 'old' }, { RELEASE_DEBUG_INFO: '0' }]) {
const r = spawnSync('bash', [path.join(root, 'scripts/stage-ci-core.sh'), c.platform, src, path.join(dir, 'out')],
{ cwd: root, env: { ...process.env, GITHUB_SHA: head, TEST_EXPORTS: '0', RELEASE_DEBUG_INFO: '1', ...delta } });
assert.notEqual(r.status, 0);
}
});
test('all original compile-only artifact aliases remain in C/C++ CI, with native tests and ABI transitions', () => {
const ci = fs.readFileSync(path.join(root, '.github/workflows/c-cpp.yml'), 'utf8');
for (const c of CORES) { assert(ci.includes(`name: '${c.name}'`)); assert(ci.includes(`shared-cores/ci-core-${c.platform}/${c.file}`)); }
for (const gate of ['Run cheat engine unit tests', 'Verify TEST_EXPORTS -> production mode switch relinks',
'MSVC ${{ matrix.arch }} compilation check', 'AddressSanitizer + UndefinedBehaviorSanitizer',
'Code coverage (gcov + codecov)', 'clang-tidy on changed files']) assert(ci.includes(gate), gate);
});
test('both Release publishers exclude raw intermediates; shipped core and debug artifact upload stays intact', () => {
const release = fs.readFileSync(path.join(root, '.github/workflows/release.yml'), 'utf8');
assert.equal((release.match(/-not -path 'artifacts\/ci-core-\*'/g) || []).length, 2);
assert(release.includes('name: virtualjaguar_libretro-${{ matrix.config.platform }}'));
assert(release.includes('path: dist/'));
assert(release.includes('Verify compile-only Mach-O production exports'));
assert(release.includes('bash scripts/build-ndk-ci.sh'));
});
function ndkCase(mode) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vj-ndk-check-'));
fs.mkdirSync(path.join(dir, 'scripts')); fs.writeFileSync(path.join(dir, 'scripts/gen-version-h.sh'), '#!/bin/sh\nexit 0\n');
const sdk = path.join(dir, 'sdk'); fs.mkdirSync(sdk);
fs.writeFileSync(path.join(sdk, 'ndk-build'), `#!/bin/sh
case "$*" in
*-n*) echo 'clang -c src/tom/blitter.c ${mode === 'missing-define' ? '' : '-DBLITTER_SIMD_NEON'}' ;;
*) mkdir -p obj/local/arm64-v8a; touch obj/local/arm64-v8a/blitter_simd_${mode === 'wrong-object' ? 'scalar' : 'neon'}.o ;;
esac
`, { mode: 0o755 });
return spawnSync('bash', [path.join(root, 'scripts/build-ndk-ci.sh'), sdk, 'arm64-v8a', 'neon'], { cwd: dir, encoding: 'utf8' });
}
test('shared NDK builder enforces compile definition and selected SIMD object', () => {
assert.equal(ndkCase('good').status, 0);
assert.notEqual(ndkCase('missing-define').status, 0);
assert.notEqual(ndkCase('wrong-object').status, 0);
});
test('the shared wait loop requires core evidence, not the independent app package set', async () => {
const { waitForCores } = require('../release-cores.cjs');
const f = complete(); let approved = false;
const pr = { number: 1, head: { sha: 'head', ref: 'release/v1', repo: { full_name: 'o/r' } }, merge_commit_sha: 'merge' };
const run = { id: 42, head_sha: 'head', head_branch: 'release/v1', head_repository: { full_name: 'o/r' },
event: 'push', run_attempt: 1, status: 'in_progress', html_url: 'https://example/42' };
const github = { rest: {
git: { getCommit: async () => ({ data: { tree: { sha: 'tree' } } }) },
pulls: { get: async () => ({ data: pr }) },
actions: { listWorkflowRuns: async () => ({ data: { workflow_runs: [run] } }),
listJobsForWorkflowRun: 'jobs', listWorkflowRunArtifacts: 'artifacts',
getWorkflowRun: async () => ({ data: run }) },
}, paginate: async method => method === 'jobs' ? f.jobs : f.artifacts };
const options = { github, context: { eventName: 'pull_request', repo: { owner: 'o', repo: 'r' }, payload: { pull_request: pr } },
core: { setOutput: () => {}, info: () => {}, summary: { addRaw: () => ({ write: async () => { approved = true; } }) } } };
await waitForCores(options); assert(approved);
approved = false; f.jobs[0].conclusion = 'failure';
await assert.rejects(waitForCores(options), /did not pass/); assert.equal(approved, false);
});
test('release staging excludes raw CI cores and runtimes while keeping shipped binaries and debug archives', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vj-release-stage-'));
for (const name of ['ci-core-vita/private-raw.a', 'librashader-aarch64/runtime.so',
'virtualjaguar_libretro-vita/shipped.a', 'virtualjaguar_libretro-vita/shipped-debug.tar.gz']) {
const p = path.join(dir, 'artifacts', name); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, 'fixture');
}
const out = execFileSync('find', ['artifacts/', '-mindepth', '2', '-type', 'f', '-not', '-path', 'artifacts/librashader-*',
'-not', '-path', 'artifacts/ci-core-*', '-print'], { cwd: dir, encoding: 'utf8' });
assert(out.includes('shipped.a')); assert(out.includes('shipped-debug.tar.gz'));
assert(!out.includes('private-raw')); assert(!out.includes('runtime.so'));
});
+147 -148
View File
@@ -15,123 +15,151 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
core-plan:
name: Production core routing and safety tests
runs-on: ${{ contains(fromJSON(vars.VJ_TEMP_RUNNERS || '{"refs":[]}').refs, github.ref) && fromJSON(vars.VJ_TEMP_RUNNERS)['ubuntu-latest'] || 'ubuntu-latest' }}
permissions:
contents: read
outputs:
reuse: ${{ steps.plan.outputs.reuse }}
matrix: ${{ steps.plan.outputs.matrix }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: '22'
- name: Ensure the workflow contract parser is available
run: |
if ! command -v ruby >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y --no-install-recommends ruby
fi
- name: Verify compiler, SDK and production ABI contracts
run: ruby .github/scripts/tests/core-workflow-contract.rb
- run: node --test .github/scripts/tests/release-packages.test.cjs .github/scripts/tests/release-cores.test.cjs
- id: plan
uses: actions/github-script@v9
with:
script: |
const { planCores } = require('./.github/scripts/release-cores.cjs');
await planCores({ github, context, core });
core-reuse:
name: Verify and retain shared production cores
needs: [core-plan]
if: needs.core-plan.outputs.reuse == 'true'
runs-on: ubuntu-latest
timeout-minutes: 360
permissions:
actions: read
contents: read
pull-requests: read
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: '22'
- name: Require all exact-head production jobs and artifacts
id: verify
uses: actions/github-script@v9
with:
script: |
const { waitForCores } = require('./.github/scripts/release-cores.cjs');
await waitForCores({ github, context, core });
- name: Download the unstripped production intermediates
uses: actions/download-artifact@v8
with:
github-token: ${{ github.token }}
run-id: ${{ steps.verify.outputs.run_id }}
pattern: ci-core-*
path: shared-cores
- name: Verify exact commit, production ABI and binary checksums
env:
CORE_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
node -e 'require("./.github/scripts/release-cores.cjs").verifyDownloads("shared-cores", process.env.CORE_HEAD_SHA)'
- name: Retain macOS x86_64 (Clang, cross) artifact
uses: actions/upload-artifact@v7
with:
name: 'macOS x86_64 (Clang, cross)'
path: shared-cores/ci-core-macos-x86_64/virtualjaguar_libretro.dylib
if-no-files-found: error
overwrite: true
- name: Retain Emscripten (WASM) artifact
uses: actions/upload-artifact@v7
with:
name: 'Emscripten (WASM)'
path: shared-cores/ci-core-emscripten-wasm/virtualjaguar_libretro_emscripten.bc
if-no-files-found: error
overwrite: true
- name: Retain Android arm64-v8a artifact
uses: actions/upload-artifact@v7
with:
name: 'Android arm64-v8a'
path: shared-cores/ci-core-android-arm64-v8a/libretro.so
if-no-files-found: error
overwrite: true
- name: Retain Android armeabi-v7a artifact
uses: actions/upload-artifact@v7
with:
name: 'Android armeabi-v7a'
path: shared-cores/ci-core-android-armeabi-v7a/libretro.so
if-no-files-found: error
overwrite: true
- name: Retain Android x86_64 artifact
uses: actions/upload-artifact@v7
with:
name: 'Android x86_64'
path: shared-cores/ci-core-android-x86_64/libretro.so
if-no-files-found: error
overwrite: true
- name: Retain Android x86 artifact
uses: actions/upload-artifact@v7
with:
name: 'Android x86'
path: shared-cores/ci-core-android-x86/libretro.so
if-no-files-found: error
overwrite: true
- name: Retain iOS arm64 artifact
uses: actions/upload-artifact@v7
with:
name: 'iOS arm64'
path: shared-cores/ci-core-ios-arm64/virtualjaguar_libretro_ios.dylib
if-no-files-found: error
overwrite: true
- name: Retain tvOS arm64 artifact
uses: actions/upload-artifact@v7
with:
name: 'tvOS arm64'
path: shared-cores/ci-core-tvos-arm64/virtualjaguar_libretro_tvos.dylib
if-no-files-found: error
overwrite: true
- name: Retain PS Vita artifact
uses: actions/upload-artifact@v7
with:
name: 'PS Vita'
path: shared-cores/ci-core-vita/virtualjaguar_libretro_vita.a
if-no-files-found: error
overwrite: true
- name: Retain Nintendo Switch artifact
uses: actions/upload-artifact@v7
with:
name: 'Nintendo Switch'
path: shared-cores/ci-core-switch/virtualjaguar_libretro_libnx.a
if-no-files-found: error
overwrite: true
build:
needs: [core-plan]
strategy:
fail-fast: false
matrix:
config:
# Linux
- displayTargetName: 'Linux x86_64 (GCC)'
artifact: 'virtualjaguar_libretro.so'
os: ubuntu-latest
cc: 'gcc'
cxx: 'g++'
- displayTargetName: 'Linux x86_64 (Clang)'
artifact: 'virtualjaguar_libretro.so'
os: ubuntu-latest
cc: 'clang'
cxx: 'clang++'
- displayTargetName: 'Linux aarch64'
artifact: 'virtualjaguar_libretro.so'
os: ubuntu-24.04-arm
cc: 'gcc'
cxx: 'g++'
- displayTargetName: 'Linux i686'
artifact: 'virtualjaguar_libretro.so'
os: ubuntu-latest
cc: 'gcc -m32'
cxx: 'g++ -m32'
multilib: true
# macOS
- displayTargetName: 'macOS arm64 (Clang)'
artifact: 'virtualjaguar_libretro.dylib'
os: macos-latest
cc: 'clang'
cxx: 'clang++'
# Cross-compiled from macos-latest (arm64) -- avoids the
# scarce macos-13 Intel runner pool. See release.yml for
# rationale.
- displayTargetName: 'macOS x86_64 (Clang, cross)'
artifact: 'virtualjaguar_libretro.dylib'
os: macos-latest
cc: 'clang -arch x86_64'
cxx: 'clang++ -arch x86_64'
make_extra: 'BLITTER_SIMD=sse2'
cross: true
# Windows (MinGW)
- displayTargetName: 'Windows x86_64 (MSYS2)'
artifact: 'virtualjaguar_libretro.dll'
os: windows-latest
cc: 'gcc'
cxx: 'g++'
shell: 'msys2 {0}'
msystem: 'MINGW64'
msys2_packages: 'mingw-w64-x86_64-gcc make'
- displayTargetName: 'Windows i686 (MSYS2)'
artifact: 'virtualjaguar_libretro.dll'
os: windows-latest
cc: 'gcc'
cxx: 'g++'
shell: 'msys2 {0}'
msystem: 'MINGW32'
msys2_packages: 'mingw-w64-i686-gcc make'
# Emscripten (WebAssembly)
- displayTargetName: 'Emscripten (WASM)'
artifact: 'virtualjaguar_libretro_emscripten.bc'
os: ubuntu-latest
emscripten: true
# Android NDK
- displayTargetName: 'Android arm64-v8a'
artifact: 'libs/arm64-v8a/libretro.so'
os: ubuntu-latest
android: true
android_abi: 'arm64-v8a'
android_simd: 'neon'
- displayTargetName: 'Android armeabi-v7a'
artifact: 'libs/armeabi-v7a/libretro.so'
os: ubuntu-latest
android: true
android_abi: 'armeabi-v7a'
android_simd: 'neon'
- displayTargetName: 'Android x86_64'
artifact: 'libs/x86_64/libretro.so'
os: ubuntu-latest
android: true
android_abi: 'x86_64'
android_simd: 'sse2'
- displayTargetName: 'Android x86'
artifact: 'libs/x86/libretro.so'
os: ubuntu-latest
android: true
android_abi: 'x86'
android_simd: 'sse2'
# iOS / tvOS
- displayTargetName: 'iOS arm64'
artifact: 'virtualjaguar_libretro_ios.dylib'
os: macos-latest
make_platform: 'ios-arm64'
cross: true
- displayTargetName: 'tvOS arm64'
artifact: 'virtualjaguar_libretro_tvos.dylib'
os: macos-latest
make_platform: 'tvos-arm64'
cross: true
config: ${{ fromJSON(needs.core-plan.outputs.matrix) }}
name: build-${{ matrix.config.displayTargetName }}
env:
# Compile-only rows match Release's production configuration; native test mode switches stay unchanged.
RELEASE_DEBUG_INFO: ${{ matrix.config.release_platform && '1' || '0' }}
runs-on: ${{ matrix.config.os }}
defaults:
@@ -181,43 +209,7 @@ jobs:
- name: Build (Android NDK)
if: matrix.config.android
run: |
# ndk-build uses jni/Android.mk (not the project Makefile),
# so the parse-time version.h regen in Makefile doesn't run.
bash scripts/gen-version-h.sh
NDK="${{ steps.setup-ndk.outputs.ndk-path }}"
ABI="${{ matrix.config.android_abi }}"
SIMD="${{ matrix.config.android_simd }}"
UPPER=$(echo "$SIMD" | tr '[:lower:]' '[:upper:]')
# Dry run first, while nothing is built, so make actually prints
# the compile lines. blitter.c MUST carry -DBLITTER_SIMD_<ARCH>:
# without it blitter.c inlines the scalar ops and still links
# happily against the neon/sse2 vtable, so a missing define is a
# silent slow path that no build failure would reveal.
"$NDK/ndk-build" APP_ABI="$ABI" -n > ndk-dry.log 2>&1
BLIT_LINES=$(grep -c '[/ ]blitter\.c' ndk-dry.log || true)
if [ "$BLIT_LINES" = "0" ]; then
echo "::error::ndk-build -n printed no compile line for blitter.c; this check needs rework"
exit 1
fi
if ! grep '[/ ]blitter\.c' ndk-dry.log | grep -q -- "-DBLITTER_SIMD_${UPPER}"; then
echo "::error::blitter.c is not compiled with -DBLITTER_SIMD_${UPPER} on ${ABI}"
grep '[/ ]blitter\.c' ndk-dry.log | head -3
exit 1
fi
echo "==> ${ABI}: blitter.c carries -DBLITTER_SIMD_${UPPER}"
"$NDK/ndk-build" APP_ABI="$ABI" -j4
# And the arch .c that define has to agree with.
FOUND=$(find "obj/local/$ABI" -name 'blitter_simd_*.o' -exec basename {} \; | sort | tr '\n' ' ')
if [ "$FOUND" != "blitter_simd_${SIMD}.o " ]; then
echo "::error::expected blitter_simd_${SIMD}.o on ${ABI}, found: ${FOUND:-<none>}"
exit 1
fi
echo "==> ${ABI}: built ${FOUND}"
run: bash scripts/build-ndk-ci.sh "${{ steps.setup-ndk.outputs.ndk-path }}" "${{ matrix.config.android_abi }}" "${{ matrix.config.android_simd }}"
# Mach-O symbol gating: production builds must export retro_* only.
# Runs before the test step (which would relink with TEST_EXPORTS=1
@@ -727,6 +719,8 @@ jobs:
src\tom\blitter_simd_sse2.c
vita-build:
needs: [core-plan]
if: needs.core-plan.outputs.reuse != 'true'
name: build-PS Vita
runs-on: ubuntu-latest
container:
@@ -735,6 +729,8 @@ jobs:
- uses: actions/checkout@v7
- name: Build
env:
RELEASE_DEBUG_INFO: '1'
run: make -j4 platform=vita
- name: Upload artifact
@@ -745,6 +741,8 @@ jobs:
if-no-files-found: error
switch-build:
needs: [core-plan]
if: needs.core-plan.outputs.reuse != 'true'
name: build-Nintendo Switch
runs-on: ubuntu-latest
container:
@@ -756,6 +754,7 @@ jobs:
run: make -j4 platform=libnx
env:
DEVKITPRO: /opt/devkitpro
RELEASE_DEBUG_INFO: '1'
- name: Upload artifact
uses: actions/upload-artifact@v7
+69 -7
View File
@@ -226,24 +226,28 @@ jobs:
os: ubuntu-latest
android: true
android_abi: 'arm64-v8a'
android_simd: 'neon'
- platform: android-armeabi-v7a
artifact: libs/armeabi-v7a/libretro.so
os: ubuntu-latest
android: true
android_abi: 'armeabi-v7a'
android_simd: 'neon'
- platform: android-x86_64
artifact: libs/x86_64/libretro.so
os: ubuntu-latest
android: true
android_abi: 'x86_64'
android_simd: 'sse2'
- platform: android-x86
artifact: libs/x86/libretro.so
os: ubuntu-latest
android: true
android_abi: 'x86'
android_simd: 'sse2'
# iOS / tvOS
- platform: ios-arm64
@@ -311,30 +315,56 @@ jobs:
if: ${{ !matrix.config.emscripten && !matrix.config.android && !matrix.config.make_platform }}
env:
RELEASE_DEBUG_INFO: '1'
TEST_EXPORTS: '0'
run: make -j4 CC="${{ matrix.config.cc }}" CXX="${{ matrix.config.cxx }}" ${{ matrix.config.make_extra || '' }}
- name: Build (platform)
if: matrix.config.make_platform
env:
RELEASE_DEBUG_INFO: '1'
TEST_EXPORTS: '0'
run: make -j4 platform=${{ matrix.config.make_platform }}
- name: Build (Emscripten)
if: matrix.config.emscripten
env:
RELEASE_DEBUG_INFO: '1'
TEST_EXPORTS: '0'
run: emmake make -j4 platform=emscripten
- name: Build (Android NDK)
if: matrix.config.android
env:
RELEASE_DEBUG_INFO: '1'
TEST_EXPORTS: '0'
run: bash scripts/build-ndk-ci.sh "${{ steps.setup-ndk.outputs.ndk-path }}" "${{ matrix.config.android_abi }}" "${{ matrix.config.android_simd }}"
- name: Verify compile-only Mach-O production exports
if: ${{ matrix.config.platform == 'macos-x86_64' || matrix.config.platform == 'ios-arm64' || matrix.config.platform == 'tvos-arm64' }}
run: |
# ndk-build uses jni/Android.mk (not the project Makefile),
# so the parse-time version.h regen in Makefile doesn't run.
bash scripts/gen-version-h.sh
${{ steps.setup-ndk.outputs.ndk-path }}/ndk-build \
APP_ABI=${{ matrix.config.android_abi }} -j4
LEAKS=$(nm -gU "${{ matrix.config.artifact }}" | grep -v ' _retro_' || true)
if [ -n "$LEAKS" ]; then
echo "::error::Production dylib leaked non-retro_* symbols:"
echo "$LEAKS"
exit 1
fi
- name: Preserve unstripped compile-only core for PR validation
if: ${{ matrix.config.emscripten || matrix.config.android || matrix.config.platform == 'macos-x86_64' || matrix.config.make_platform }}
env:
TEST_EXPORTS: '0'
RELEASE_DEBUG_INFO: '1'
run: bash scripts/stage-ci-core.sh "${{ matrix.config.platform }}" "${{ matrix.config.artifact }}" ci-core
- name: Upload production CI intermediate
if: ${{ matrix.config.emscripten || matrix.config.android || matrix.config.platform == 'macos-x86_64' || matrix.config.make_platform }}
uses: actions/upload-artifact@v7
with:
name: ci-core-${{ matrix.config.platform }}
overwrite: true
path: ci-core/
if-no-files-found: error
retention-days: 14
# ----------------------------------------------------------------
# Package: emit
@@ -510,8 +540,24 @@ jobs:
- name: Build
env:
RELEASE_DEBUG_INFO: '1'
TEST_EXPORTS: '0'
run: make -j4 platform=vita
- name: Preserve unstripped production core for PR validation
env:
TEST_EXPORTS: '0'
RELEASE_DEBUG_INFO: '1'
run: bash scripts/stage-ci-core.sh vita virtualjaguar_libretro_vita.a ci-core
- name: Upload production CI intermediate
uses: actions/upload-artifact@v7
with:
name: ci-core-vita
overwrite: true
path: ci-core/
if-no-files-found: error
retention-days: 14
- name: Package
run: |
set -e
@@ -549,8 +595,24 @@ jobs:
env:
DEVKITPRO: /opt/devkitpro
RELEASE_DEBUG_INFO: '1'
TEST_EXPORTS: '0'
run: make -j4 platform=libnx
- name: Preserve unstripped production core for PR validation
env:
TEST_EXPORTS: '0'
RELEASE_DEBUG_INFO: '1'
run: bash scripts/stage-ci-core.sh switch virtualjaguar_libretro_libnx.a ci-core
- name: Upload production CI intermediate
uses: actions/upload-artifact@v7
with:
name: ci-core-switch
overwrite: true
path: ci-core/
if-no-files-found: error
retention-days: 14
- name: Package
run: |
set -e
@@ -642,7 +704,7 @@ jobs:
# binary + matching -debug.tar.gz; copy them all into release/.
# librashader-<triple> are intermediates of the app packages (the shader runtime built
# from source, librashader.yml): already inside the packages, not release assets.
find artifacts/ -mindepth 2 -type f -not -path 'artifacts/librashader-*' -exec cp {} release/ \;
find artifacts/ -mindepth 2 -type f -not -path 'artifacts/librashader-*' -not -path 'artifacts/ci-core-*' -exec cp {} release/ \;
# Include the RetroArch core .info file so frontend maintainers
# / users can drop it into RetroArch's info/ dir. The same file
# is what gets PR'd to libretro/libretro-super after the release.
@@ -788,7 +850,7 @@ jobs:
set -e
mkdir -p release
# (librashader-<triple> are intermediates of the app packages, not release assets.)
find artifacts/ -mindepth 2 -type f -not -path 'artifacts/librashader-*' -exec cp {} release/ \;
find artifacts/ -mindepth 2 -type f -not -path 'artifacts/librashader-*' -not -path 'artifacts/ci-core-*' -exec cp {} release/ \;
# The .info file's display_version names the last TAGGED release, so
# shipping it verbatim would label a nightly "v2.3.2". Rewrite it to
# the build's actual identity — this also makes it obvious that a
+31
View File
@@ -151,3 +151,34 @@ runner must use a unique custom label with default labels disabled, preserve the
requested OS/architecture, and be removed together with the variable after its
checks finish. Do not map Ubuntu 22.04 packaging jobs onto Ubuntu 24.04: that would
change the supported glibc floor.
## Shared production core builds
For same-repository `release/**` PRs with a merge tree identical to the release head,
C/C++ CI shares ten compile-only production builds from the exact-head Release push run:
macOS Intel, WASM, four Android ABIs, iOS, tvOS, Vita and Switch. Other PRs, forks,
manual runs and integration-branch pushes keep the full compiler matrix. The configurations
live in `.github/ci/core-matrix.json`; a Ruby workflow-contract check rejects mismatched
compiler/SDK/architecture settings before any rows are omitted.
The seven native C/C++ matrix rows, GCC/Clang differences, MSVC checks, coverage,
ASan/UBSan, clang-tidy and production -> `TEST_EXPORTS=1` -> production mode-switch
coverage stay independent. Compile-only fallback builds use the same `RELEASE_DEBUG_INFO=1`
configuration as Release (adds debug information to the optimized production build).
Android's compile-definition and selected-SIMD-object assertions run through
`scripts/build-ndk-ci.sh` in both workflows. Compile-only Mach-O production exports are
checked before Release uploads a shared binary.
Release retains an unstripped `ci-core-<platform>` intermediate before packaging/signing.
`BUILD.json` records the exact commit, platform, filename, production ABI setting and
SHA-256. C/C++ CI requires successful production jobs, both raw and distributable artifact
groups, and verifies the downloaded metadata/checksums before retaining all ten original
C/C++ artifact names. Failed, cancelled, skipped, stale, missing or mismatched evidence
cannot make the shared check green. The hosted waiting job does not occupy the temporary
runner needed by Release metadata jobs.
These intermediates have 14-day retention and are explicitly excluded by **both** release
publishers' staging filters. Shipped binaries, split debug archives, their names and checksum
collection remain unchanged. The C/C++ artifact aliases retain their normal artifact retention.
The shared core verifier and shared app-package verifier must both pass before merging a
release PR that uses these routes; a configured skip of duplicate build rows is not sufficient.
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
# ndk-build uses jni/Android.mk (not the project Makefile),
# so the parse-time version.h regen in Makefile doesn't run.
bash scripts/gen-version-h.sh
NDK=${1:?NDK path required}
ABI=${2:?ABI required}
SIMD=${3:?SIMD required}
UPPER=$(echo "$SIMD" | tr '[:lower:]' '[:upper:]')
# Dry run first, while nothing is built, so make actually prints
# the compile lines. blitter.c MUST carry -DBLITTER_SIMD_<ARCH>:
# without it blitter.c inlines the scalar ops and still links
# happily against the neon/sse2 vtable, so a missing define is a
# silent slow path that no build failure would reveal.
"$NDK/ndk-build" APP_ABI="$ABI" -n > ndk-dry.log 2>&1
BLIT_LINES=$(grep -c '[/ ]blitter\.c' ndk-dry.log || true)
if [ "$BLIT_LINES" = "0" ]; then
echo "::error::ndk-build -n printed no compile line for blitter.c; this check needs rework"
exit 1
fi
if ! grep '[/ ]blitter\.c' ndk-dry.log | grep -q -- "-DBLITTER_SIMD_${UPPER}"; then
echo "::error::blitter.c is not compiled with -DBLITTER_SIMD_${UPPER} on ${ABI}"
grep '[/ ]blitter\.c' ndk-dry.log | head -3
exit 1
fi
echo "==> ${ABI}: blitter.c carries -DBLITTER_SIMD_${UPPER}"
"$NDK/ndk-build" APP_ABI="$ABI" -j4
# And the arch .c that define has to agree with.
FOUND=$(find "obj/local/$ABI" -name 'blitter_simd_*.o' -exec basename {} \; | sort | tr '\n' ' ')
if [ "$FOUND" != "blitter_simd_${SIMD}.o " ]; then
echo "::error::expected blitter_simd_${SIMD}.o on ${ABI}, found: ${FOUND:-<none>}"
exit 1
fi
echo "==> ${ABI}: built ${FOUND}"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Preserve the production binary before stripping/signing; never ship these intermediates in release assets.
set -euo pipefail
platform=${1:?platform required}
source_file=${2:?core binary required}
out=${3:?output directory required}
case "$platform" in
macos-x86_64|emscripten-wasm|android-arm64-v8a|android-armeabi-v7a|android-x86_64|android-x86|ios-arm64|tvos-arm64|vita|switch) ;;
*) echo "Unsupported shared core: $platform" >&2; exit 1 ;;
esac
[ "${TEST_EXPORTS:?explicit production ABI required}" = 0 ]
[ "${RELEASE_DEBUG_INFO:?explicit release flags required}" = 1 ]
[ -s "$source_file" ]
[ "$(git rev-parse HEAD)" = "${GITHUB_SHA:?exact workflow SHA required}" ]
mkdir -p "$out"
file=$(basename "$source_file")
command cp -f "$source_file" "$out/$file"
if command -v sha256sum >/dev/null 2>&1; then
checksum=$(sha256sum "$out/$file" | cut -d' ' -f1)
else
checksum=$(shasum -a 256 "$out/$file" | cut -d' ' -f1)
fi
printf '{"head_sha":"%s","platform":"%s","file":"%s","test_exports":0,"release_debug_info":1,"sha256":"%s"}\n' \
"$GITHUB_SHA" "$platform" "$file" "$checksum" > "$out/BUILD.json"