From 3c683169efea04467f0a06f21d9efc7910e09ee7 Mon Sep 17 00:00:00 2001 From: Joseph Mattiello Date: Tue, 20 Oct 2026 18:39:49 -0400 Subject: [PATCH] ci(core): share compile-only release builds and preserve CI artifacts --- .github/ci/core-matrix.json | 127 ++++++++ .github/scripts/release-cores.cjs | 52 +++ .github/scripts/release-packages.cjs | 21 +- .../scripts/tests/core-workflow-contract.rb | 36 +++ .github/scripts/tests/release-cores.test.cjs | 155 +++++++++ .github/workflows/c-cpp.yml | 295 +++++++++--------- .github/workflows/release.yml | 76 ++++- docs/agent/github.md | 31 ++ scripts/build-ndk-ci.sh | 38 +++ scripts/stage-ci-core.sh | 24 ++ 10 files changed, 690 insertions(+), 165 deletions(-) create mode 100644 .github/ci/core-matrix.json create mode 100644 .github/scripts/release-cores.cjs create mode 100644 .github/scripts/tests/core-workflow-contract.rb create mode 100644 .github/scripts/tests/release-cores.test.cjs create mode 100644 scripts/build-ndk-ci.sh create mode 100644 scripts/stage-ci-core.sh diff --git a/.github/ci/core-matrix.json b/.github/ci/core-matrix.json new file mode 100644 index 00000000..d8e8ff87 --- /dev/null +++ b/.github/ci/core-matrix.json @@ -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" + } +] diff --git a/.github/scripts/release-cores.cjs b/.github/scripts/release-cores.cjs new file mode 100644 index 00000000..76419ce0 --- /dev/null +++ b/.github/scripts/release-cores.cjs @@ -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 }; diff --git a/.github/scripts/release-packages.cjs b/.github/scripts/release-packages.cjs index 6c19841a..b80b7b95 100644 --- a/.github/scripts/release-packages.cjs +++ b/.github/scripts/release-packages.cjs @@ -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 }; diff --git a/.github/scripts/tests/core-workflow-contract.rb b/.github/scripts/tests/core-workflow-contract.rb new file mode 100644 index 00000000..1aa5dda7 --- /dev/null +++ b/.github/scripts/tests/core-workflow-contract.rb @@ -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.' diff --git a/.github/scripts/tests/release-cores.test.cjs b/.github/scripts/tests/release-cores.test.cjs new file mode 100644 index 00000000..4ae0f896 --- /dev/null +++ b/.github/scripts/tests/release-cores.test.cjs @@ -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')); +}); diff --git a/.github/workflows/c-cpp.yml b/.github/workflows/c-cpp.yml index 196830e2..8d573302 100644 --- a/.github/workflows/c-cpp.yml +++ b/.github/workflows/c-cpp.yml @@ -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_: - # 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:-}" - 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 738a8b26..7a04b0f6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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- 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- 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 diff --git a/docs/agent/github.md b/docs/agent/github.md index 30a2a91f..aabde913 100644 --- a/docs/agent/github.md +++ b/docs/agent/github.md @@ -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-` 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. diff --git a/scripts/build-ndk-ci.sh b/scripts/build-ndk-ci.sh new file mode 100644 index 00000000..f5d301ee --- /dev/null +++ b/scripts/build-ndk-ci.sh @@ -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_: +# 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:-}" + exit 1 +fi +echo "==> ${ABI}: built ${FOUND}" diff --git a/scripts/stage-ci-core.sh b/scripts/stage-ci-core.sh new file mode 100644 index 00000000..41038389 --- /dev/null +++ b/scripts/stage-ci-core.sh @@ -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"