Skip to content

Automated Ruby Gem release pipeline and Cross Platform CI fixes #738

Open
avik22835 wants to merge 44 commits intometacall:developfrom
avik22835:final/ruby-cross-platform-fix
Open

Automated Ruby Gem release pipeline and Cross Platform CI fixes #738
avik22835 wants to merge 44 commits intometacall:developfrom
avik22835:final/ruby-cross-platform-fix

Conversation

@avik22835
Copy link
Copy Markdown

Hi Vicente,

The goal of this PR is to enable a fully automated release pipeline for the Metacall Ruby port.

I've implemented a hybrid workflow (release-ruby.yml) and a helper script (upload.sh) that handles everything from environment detection to the final push to RubyGems.org.

The Release pipeline logic

  1. upload.sh
  • Version Parsing: It uses a Ruby one-liner (ruby -e) to load the .gemspec and extract the local version.
  • Remote Check: Before building, it uses curl to check the RubyGems API. If the current version already exists on the registry, the script exits silently.
  • Secure Auth: It uses the RUBYGEMS_API_KEY to temporarily create a credentials file for the push.
  1. release-ruby.yml
  • Job 1: Test: It runs the integration suite across ubuntu-latest, macos-latest, and windows-latest. I've solved the fragility issues within them.
  • Job 2: Release: This job is locked until its needed. It only fires when a version tag (like v0.1.0) is pushed or manually triggered via workflow dispatch. It requires the test job to pass first.

Solving the Windows "Error 126". I've fixed this by:

  • Standardizing on the bundled Metacall Ruby 3.5 runtime to ensure binary compatibility.
  • Fixing a UTF-8/BOM encoding issue where Powershell was corrupting the environment file, making it unreadable to the Github Runner.

Current Status and Evidence
I have verified this entire flow on my fork. As you can in the attached screenshot, the integration tests are now 3/3 green across the entire matrix, and the release job currently says skipped cause no version tags have been pushed.
WhatsApp Image 2026-03-27 at 13 04 46

To enable the automated gem push, please add the following Secret to the metacall/core repository:

  • Name: RUBYGEMS_API_KEY
  • Value: Your API key from RubyGems.org with "Push Gem" permissions.

@viferga
Copy link
Copy Markdown
Member

viferga commented Mar 27, 2026

It looks pretty good but the algorithm for searching the library must be in ruby itself, not in bash or ps1:

Nodejs:

const findFilesRecursively = (directory, filePattern, depthLimit = Infinity) => {
const stack = [{ dir: directory, depth: 0 }];
const files = [];
const fileRegex = new RegExp(filePattern);
while (stack.length > 0) {
const { dir, depth } = stack.pop();
try {
if (depth > depthLimit) {
continue;
}
const items = (() => {
try {
return fs.readdirSync(dir);
} catch (e) {
return [];
}
})();
for (const item of items) {
const fullPath = path.join(dir, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
stack.push({ dir: fullPath, depth: depth + 1 });
} else if (stat.isFile() && fileRegex.test(item)) {
files.push(fullPath);
}
}
} catch (err) {
console.error(`Error reading directory '${dir}' while searching for MetaCall Library:`, err);
}
}
return files;
};
const platformInstallPaths = () => {
switch (process.platform) {
case 'win32':
return {
paths: [ path.join(process.env['LOCALAPPDATA'], 'MetaCall', 'metacall') ],
name: /^metacall(d)?\.dll$/
}
case 'darwin':
return {
paths: [ '/opt/homebrew/lib/', '/usr/local/lib/' ],
name: /^libmetacall(d)?\.dylib$/
}
case 'linux':
return {
paths: [ '/usr/local/lib/', '/gnu/lib/' ],
name: /^libmetacall(d)?\.so$/
}
}
throw new Error(`Platform ${process.platform} not supported`)
};
const searchPaths = () => {
const customPath = process.env['METACALL_INSTALL_PATH'];
if (customPath) {
return {
paths: [ customPath ],
name: /^(lib)?metacall(d)?\.(so|dylib|dll)$/
}
}
return platformInstallPaths()
};
const findLibrary = () => {
const searchData = searchPaths();
for (const p of searchData.paths) {
const files = findFilesRecursively(p, searchData.name, 0);
if (files.length !== 0) {
return files[0];
}
}
throw new Error('MetaCall library not found, if you have it in a special folder, define it through METACALL_INSTALL_PATH')
};
const addon = (() => {
try {
/* If the binding can be loaded, it means MetaCall is being
* imported from the node_loader, in that case the runtime
* was initialized by node_loader itself and we can proceed.
*/
return process._linkedBinding('node_loader_port_module');
} catch (e) {
/* If the port cannot be found, it means MetaCall port has
* been imported for the first time from node.exe, the
* runtime in this case has been initialized by node.exe,
* and MetaCall is not initialized
*/
process.env['METACALL_HOST'] = 'node';
try {
const library = findLibrary();
const { constants } = require('os');
const m = { exports: {} };
process.dlopen(m, library, constants.dlopen.RTLD_GLOBAL | constants.dlopen.RTLD_NOW);
/* Save argv */
const argv = process.argv;
process.argv = [];
/* Pass the require function in order to import bootstrap.js and register it */
const args = m.exports.register_bootstrap_startup();
const bootstrap = require(args[0]);
bootstrap(args[1], args[2], args[3]);
/* Restore argv */
process.argv = argv;
return m.exports;
} catch (err) {
console.log(err);
process.exit(1);
}
}
})();

Python:

def find_files_recursively(root_dir, pattern):
regex = re.compile(pattern)
matches = []
for dirpath, dirnames, filenames in os.walk(root_dir):
for filename in filenames:
if regex.search(filename):
matches.append(os.path.join(dirpath, filename))
return matches
def platform_install_paths():
if sys.platform == 'win32':
return {
'paths': [ os.path.join(os.environ.get('LOCALAPPDATA', ''), 'MetaCall', 'metacall') ],
'name': r'^metacall(d)?\.dll$'
}
elif sys.platform == 'darwin':
return {
'paths': [ '/opt/homebrew/lib/', '/usr/local/lib/' ],
'name': r'^libmetacall(d)?\.dylib$'
}
elif sys.platform == 'linux':
return {
'paths': [ '/usr/local/lib/', '/gnu/lib/' ],
'name': r'^libmetacall(d)?\.so$'
}
else:
raise RuntimeError(f"Platform {sys.platform} not supported")
def search_paths():
custom_path = os.environ.get('METACALL_INSTALL_PATH')
if custom_path:
return {
'paths': [ custom_path ],
'name': r'^(lib)?metacall(d)?\.(so|dylib|dll)$'
}
return platform_install_paths()
def find_library():
search_data = search_paths()
for path in search_data['paths']:
files = find_files_recursively(path, search_data['name'])
if files:
return files[0]
raise ImportError("""
MetaCall library not found, if you have it in a special folder, define it through METACALL_INSTALL_PATH'.
"""
+ "Looking for it in the following paths: " + ', '.join(search_data['paths']) + """
If you do not have it installed, you have three options:
1) Go to https://github.com/metacall/install and install it.
2) Contribute to https://github.com/metacall/distributable by providing support for your platform and architecture.
3) Be a x10 programmer and compile it by yourself, then define the install folder if it is different from the default in os.environ['METACALL_INSTALL_PATH'].
""")
def metacall_module_load():
# Check if it is loaded from MetaCall or from Python
if 'py_port_impl_module' in sys.modules:
return sys.modules['py_port_impl_module']
# Define the Python host
os.environ['METACALL_HOST'] = 'py'
# Find the shared library
library_path = find_library()
# Load MetaCall
lib = ctypes.CDLL(library_path, mode=ctypes.RTLD_GLOBAL)
# Python Port must have been loaded at this point
if 'py_port_impl_module' in sys.modules:
port = sys.modules['py_port_impl_module']
# For some reason, Windows deadlocks on initializing asyncio
# but if it is delayed, it works, so we initialize it after here
if sys.platform == 'win32':
port.py_loader_port_asyncio_initialize()
return port
else:
raise ImportError(
'MetaCall was found but failed to load'
)
# Load metacall extension depending on the platform
module = metacall_module_load()

paths: [
'/opt/homebrew/lib/',
'/usr/local/lib/',
File.join(home_dir, '.metacall', 'lib'),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why those paths?


# Set environment variable for the host
ENV['METACALL_HOST'] ||= 'rb'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all those env vars in theory are setup automatically, we should check if it works after deleting them

root_dir = File.dirname(install_dir)

# Set environment variable for the host
ENV['METACALL_HOST'] ||= 'rb'
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this one is correct

config_path = File.join(root_dir, 'configurations')
ENV['CONFIGURATION_PATH'] ||= config_path if Dir.exist?(config_path)

# Platform-specific environment fixes
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

respect to this, I've seen the issue twice...

maybe we should solve it externally with something like what linux distributable does, it provides a file for defining all env vars before hand that you can use to load them...

although I'm not sure what is the best method, if we do it here we will need to replicate it everywhere

also I think this issue also comes because we are relocating the runtimes in windows, maybe this is the main issue

@avik22835
Copy link
Copy Markdown
Author

So my PR is now clean. All env related parts are removed while keeping the matching logic intact.
Is it ready for merge?

@avik22835 avik22835 marked this pull request as draft March 29, 2026 20:36
@avik22835 avik22835 marked this pull request as ready for review March 29, 2026 20:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants