Rearranged the code and added CLI that installs tools locally.
All checks were successful
Test Action / test (push) Successful in 3s

This commit is contained in:
2026-01-11 10:54:25 +01:00
parent ef971a6da4
commit 620da93338
12 changed files with 559 additions and 188 deletions

130
src/cli.ts Normal file
View File

@@ -0,0 +1,130 @@
import { parseArgs } from 'util';
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
import { getPlatformInfo } from './core/platform';
import { getMatchingAsset } from './core/matcher';
import { findBinary } from './core/finder';
import { fetchLatestRelease, downloadAsset } from './core/downloader';
import { extractAsset } from './core/extractor';
async function run() {
const { values, positionals } = parseArgs({
options: {
'file-name': { type: 'string', short: 'f' },
'binary-name': { type: 'string', short: 'b' },
'file-type': { type: 'string', short: 't', default: 'archive' },
'token': { type: 'string', short: 'k' },
'debug': { type: 'boolean', short: 'd', default: false },
'help': { type: 'boolean', short: 'h' }
},
allowPositionals: true
});
if (values.help || positionals.length === 0) {
console.log(`
Usage: install-github-release [options] <repository>
Arguments:
repository The GitHub repository (owner/repo)
Options:
-f, --file-name <name> Asset file name or regex pattern (prefixed with ~)
-b, --binary-name <name> Binary to search for (prefixed with ~ for regex)
-t, --file-type <type> 'archive', 'package', or custom regex (default: archive)
-k, --token <token> GitHub token
-d, --debug Enable debug logging
-h, --help Show this help message
`);
process.exit(0);
}
const repository = positionals[0];
if (!repository) {
console.error('Error: Repository is required.');
process.exit(1);
}
const fileNameInput = values['file-name'];
const binaryInput = values['binary-name'];
const fileType = values['file-type'];
const debug = !!values.debug;
const token = values.token || process.env.GITHUB_TOKEN;
try {
const platformInfo = getPlatformInfo();
const toolName = repository.split('/').pop() || repository;
console.log(`Fetching latest release for ${repository}...`);
const release = await fetchLatestRelease(repository, token);
const asset = getMatchingAsset(release.assets, platformInfo, {
fileName: fileNameInput,
fileType: fileType
});
console.log(`Selected asset: ${asset.name}`);
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'setup-gh-release-'));
const downloadPath = path.join(tempDir, asset.name);
console.log(`Downloading ${asset.name}...`);
await downloadAsset(asset.browser_download_url, downloadPath, token);
const extractDir = path.join(tempDir, 'extract');
console.log(`Extracting ${asset.name}...`);
await extractAsset(downloadPath, extractDir);
const binaryName = binaryInput || toolName;
let binaryPattern: string | RegExp;
if (binaryName.startsWith('~')) {
binaryPattern = new RegExp(binaryName.substring(1), 'i');
} else {
binaryPattern = binaryName;
}
const binaryPath = findBinary(extractDir, binaryPattern, debug, console.log);
if (!binaryPath) {
throw new Error(`Could not find binary "${binaryName}" in the extracted asset.`);
}
// Determine install directory
let installDir: string;
const isRoot = process.getuid && process.getuid() === 0;
if (isRoot) {
installDir = '/usr/local/bin';
} else {
const homeBin = path.join(os.homedir(), 'bin');
if (fs.existsSync(homeBin)) {
installDir = homeBin;
} else {
// Fallback or error? Let's use a local bin if possible or /usr/local/bin (might fail)
installDir = '/usr/local/bin';
}
}
if (!fs.existsSync(installDir)) {
fs.mkdirSync(installDir, { recursive: true });
}
const finalName = path.basename(binaryPath);
const destPath = path.join(installDir, finalName);
console.log(`Installing ${finalName} to ${destPath}...`);
fs.copyFileSync(binaryPath, destPath);
if (process.platform !== 'win32') {
fs.chmodSync(destPath, '755');
}
// Cleanup
fs.rmSync(tempDir, { recursive: true, force: true });
console.log('Installation successful!');
} catch (error: any) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
}
run();

52
src/core/downloader.ts Normal file
View File

@@ -0,0 +1,52 @@
import { getMatchingAsset } from './matcher';
import { PlatformInfo } from './platform';
export interface ReleaseAsset {
name: string;
browser_download_url: string;
}
export interface ReleaseInfo {
tag_name: string;
assets: ReleaseAsset[];
}
export async function fetchLatestRelease(repository: string, token?: string): Promise<ReleaseInfo> {
const url = `https://api.github.com/repos/${repository}/releases/latest`;
const headers: Record<string, string> = {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'setup-github-release-action'
};
if (token) {
headers['Authorization'] = `token ${token}`;
}
const response = await fetch(url, { headers });
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`Failed to fetch latest release for ${repository}: ${response.statusText}. ${errorBody}`);
}
return await response.json() as ReleaseInfo;
}
export async function downloadAsset(url: string, destPath: string, token?: string): Promise<void> {
const headers: Record<string, string> = {
'User-Agent': 'setup-github-release-action'
};
if (token) {
headers['Authorization'] = `token ${token}`;
}
const response = await fetch(url, { headers });
if (!response.ok) {
throw new Error(`Failed to download asset: ${response.statusText}`);
}
const fs = await import('fs');
const { Readable } = await import('stream');
const { finished } = await import('stream/promises');
const fileStream = fs.createWriteStream(destPath);
await finished(Readable.fromWeb(response.body as any).pipe(fileStream));
}

42
src/core/extractor.ts Normal file
View File

@@ -0,0 +1,42 @@
import { spawnSync } from 'child_process';
import * as path from 'path';
import * as fs from 'fs';
export async function extractAsset(filePath: string, destDir: string): Promise<void> {
const ext = path.extname(filePath).toLowerCase();
const name = path.basename(filePath).toLowerCase();
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
}
if (name.endsWith('.tar.gz') || name.endsWith('.tgz') || name.endsWith('.tar')) {
const args = ['-xf', filePath, '-C', destDir];
const result = spawnSync('tar', args);
if (result.status !== 0) {
throw new Error(`tar failed with status ${result.status}: ${result.stderr.toString()}`);
}
} else if (name.endsWith('.zip')) {
if (process.platform === 'win32') {
const command = `Expand-Archive -Path "${filePath}" -DestinationPath "${destDir}" -Force`;
const result = spawnSync('powershell', ['-Command', command]);
if (result.status !== 0) {
throw new Error(`powershell Expand-Archive failed with status ${result.status}: ${result.stderr.toString()}`);
}
} else {
const result = spawnSync('unzip', ['-q', filePath, '-d', destDir]);
if (result.status !== 0) {
throw new Error(`unzip failed with status ${result.status}: ${result.stderr.toString()}`);
}
}
} else if (name.endsWith('.7z')) {
const result = spawnSync('7z', ['x', filePath, `-o${destDir}`, '-y']);
if (result.status !== 0) {
throw new Error(`7z failed with status ${result.status}. Make sure 7z is installed.`);
}
} else {
// For other files, we just copy them to the destination directory
const destPath = path.join(destDir, path.basename(filePath));
fs.copyFileSync(filePath, destPath);
}
}

38
src/core/finder.ts Normal file
View File

@@ -0,0 +1,38 @@
import * as fs from 'fs';
import * as path from 'path';
export function findBinary(dir: string, pattern: string | RegExp, debug: boolean, logger: (msg: string) => void): string | undefined {
const items = fs.readdirSync(dir);
if (debug) {
logger(`Searching for binary in ${dir}...`);
items.forEach(item => logger(` - ${item}`));
}
for (const item of items) {
const fullPath = path.join(dir, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
const found = findBinary(fullPath, pattern, debug, logger);
if (found) return found;
} else {
let isMatch = false;
if (pattern instanceof RegExp) {
isMatch = pattern.test(item);
} else {
isMatch = item === pattern;
// On Windows, also check for .exe extension if the pattern doesn't have it
if (!isMatch && process.platform === 'win32' && !pattern.toLowerCase().endsWith('.exe')) {
isMatch = item.toLowerCase() === `${pattern.toLowerCase()}.exe`;
}
}
if (isMatch) return fullPath;
}
}
return undefined;
}
export function setExecutable(filePath: string): void {
if (process.platform !== 'win32') {
fs.chmodSync(filePath, '755');
}
}

67
src/core/matcher.ts Normal file
View File

@@ -0,0 +1,67 @@
import { PlatformInfo } from './platform';
export interface MatchOptions {
fileName?: string;
fileType?: string;
}
export function getMatchingAsset(assets: any[], platform: PlatformInfo, options: MatchOptions): any {
const { fileName, fileType = 'archive' } = options;
let extPattern: string;
if (fileType === 'archive') {
extPattern = '\\.(zip|tar\\.gz|tar|tgz|7z)';
} else if (fileType === 'package') {
extPattern = '\\.(deb|rpm|pkg)';
} else {
extPattern = fileType;
}
if (!fileName) {
// Rule 1: Default matching rule
const pattern = `${platform.systemPattern}[_-]${platform.archPattern}.*${extPattern}$`;
const regex = new RegExp(pattern, 'i');
const matchingAssets = assets.filter((a: any) => regex.test(a.name));
if (matchingAssets.length === 0) {
throw new Error(`No assets matched the default criteria: ${pattern}`);
}
if (matchingAssets.length > 1) {
throw new Error(`Multiple assets matched the default criteria: ${matchingAssets.map((a: any) => a.name).join(', ')}`);
}
return matchingAssets[0];
} else if (fileName.startsWith('~')) {
// Rule 3: Regex matching rule
let pattern = fileName.substring(1);
const hasSystem = pattern.includes('{{SYSTEM}}');
const hasArch = pattern.includes('{{ARCH}}');
const hasExt = pattern.includes('{{EXT_PATTERN}}');
const hasEnd = pattern.endsWith('$');
if (!hasSystem && !hasArch && !hasExt && !hasEnd) {
pattern += `.*{{SYSTEM}}[_-]{{ARCH}}.*{{EXT_PATTERN}}$`;
} else if (hasSystem && hasArch && !hasExt && !hasEnd) {
pattern += `.*{{EXT_PATTERN}}$`;
}
const finalPattern = pattern
.replace(/{{SYSTEM}}/g, platform.systemPattern)
.replace(/{{ARCH}}/g, platform.archPattern)
.replace(/{{EXT_PATTERN}}/g, extPattern);
const regex = new RegExp(finalPattern, 'i');
const matchingAssets = assets.filter((a: any) => regex.test(a.name));
if (matchingAssets.length === 0) {
throw new Error(`No assets matched the regex: ${finalPattern}`);
}
if (matchingAssets.length > 1) {
throw new Error(`Multiple assets matched the criteria: ${matchingAssets.map((a: any) => a.name).join(', ')}`);
}
return matchingAssets[0];
} else {
// Rule 2: Literal matching rule
const asset = assets.find((a: any) => a.name === fileName);
if (!asset) {
throw new Error(`No asset found matching the exact name: ${fileName}`);
}
return asset;
}
}

31
src/core/platform.ts Normal file
View File

@@ -0,0 +1,31 @@
import * as os from 'os';
export interface PlatformInfo {
system: string;
arch: string;
systemPattern: string;
archPattern: string;
}
export const systemPatterns: Record<string, string> = {
linux: 'linux',
darwin: '(darwin|macos|mac|osx)',
win32: '(windows|win)'
};
export const archPatterns: Record<string, string> = {
x64: '(x86_64|x64|amd64)',
arm64: '(aarch64|arm64)'
};
export function getPlatformInfo(): PlatformInfo {
const system = os.platform();
const arch = os.arch();
return {
system,
arch,
systemPattern: systemPatterns[system] || system,
archPattern: archPatterns[arch] || arch
};
}

View File

@@ -3,61 +3,31 @@ import * as tc from '@actions/tool-cache';
import * as path from 'path';
import * as os from 'os';
import * as fs from 'fs';
function findBinary(dir: string, pattern: string | RegExp, debug: boolean): string | undefined {
const items = fs.readdirSync(dir);
if (debug) {
core.info(`Searching for binary in ${dir}...`);
items.forEach(item => core.info(` - ${item}`));
}
for (const item of items) {
const fullPath = path.join(dir, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
const found = findBinary(fullPath, pattern, debug);
if (found) return found;
} else {
let isMatch = false;
if (pattern instanceof RegExp) {
isMatch = pattern.test(item);
} else {
isMatch = item === pattern;
// On Windows, also check for .exe extension if the pattern doesn't have it
if (!isMatch && process.platform === 'win32' && !pattern.toLowerCase().endsWith('.exe')) {
isMatch = item.toLowerCase() === `${pattern.toLowerCase()}.exe`;
}
}
if (isMatch) return fullPath;
}
}
return undefined;
}
import { getPlatformInfo } from './core/platform';
import { getMatchingAsset } from './core/matcher';
import { findBinary } from './core/finder';
import { fetchLatestRelease } from './core/downloader';
async function run() {
try {
const repository = core.getInput('repository', { required: true });
let fileName = core.getInput('file-name');
const fileNameInput = core.getInput('file-name');
const binaryInput = core.getInput('binary-name');
const fileType = core.getInput('file-type') || 'archive';
const updateCache = core.getInput('update-cache') || 'false';
const debug = core.getBooleanInput('debug');
const token = core.getInput('token') || process.env.GITHUB_TOKEN;
// Detect system and architecture
const platform = os.platform(); // 'linux', 'darwin', 'win32'
const arch = os.arch(); // 'x64', 'arm64'
const platformInfo = getPlatformInfo();
const toolName = repository.split('/').pop() || repository;
// Rule for update-cache: 'false' means use ANY cached version if available
if (updateCache === 'false') {
const allVersions = tc.findAllVersions(toolName, arch);
const allVersions = tc.findAllVersions(toolName, platformInfo.arch);
if (allVersions.length > 0) {
// Simple sort to pick the 'latest' local version
const latestVersion = allVersions.sort().pop();
if (latestVersion) {
const cachedDir = tc.find(toolName, latestVersion, arch);
const cachedDir = tc.find(toolName, latestVersion, platformInfo.arch);
if (cachedDir) {
core.info(`Found ${toolName} version ${latestVersion} in local cache (update-cache: false)`);
core.addPath(cachedDir);
@@ -67,105 +37,21 @@ async function run() {
}
}
const systemPatterns: Record<string, string> = {
linux: 'linux',
darwin: '(darwin|macos|mac)',
win32: '(windows|win)'
};
const archPatterns: Record<string, string> = {
x64: '(x86_64|x64|amd64)',
arm64: '(aarch64|arm64)'
};
const systemPattern = systemPatterns[platform] || platform;
const archPattern = archPatterns[arch] || arch;
let extPattern: string;
if (fileType === 'archive') {
extPattern = '\\.(zip|tar\\.gz|tar|tgz|7z)';
} else if (fileType === 'package') {
extPattern = '\\.(deb|rpm|pkg)';
} else {
extPattern = fileType;
}
const url = `https://api.github.com/repos/${repository}/releases/latest`;
const headers: Record<string, string> = {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'setup-github-release-action'
};
if (token) {
headers['Authorization'] = `token ${token}`;
}
core.info(`Fetching latest release information for ${repository}...`);
const response = await fetch(url, { headers });
if (!response.ok) {
throw new Error(`Failed to fetch release: ${response.statusText} (${response.status})`);
}
const release = await fetchLatestRelease(repository, token);
const asset = getMatchingAsset(release.assets, platformInfo, {
fileName: fileNameInput,
fileType: fileType
});
const data: any = await response.json();
let asset;
core.info(`Selected asset: ${asset.name}`);
if (!fileName) {
// Rule 1: Default matching rule
const pattern = `${systemPattern}[_-]${archPattern}.*${extPattern}$`;
const regex = new RegExp(pattern, 'i');
core.info(`No file-name provided. Using default pattern: ${pattern}`);
const matchingAssets = data.assets.filter((a: any) => regex.test(a.name));
if (matchingAssets.length === 0) {
throw new Error(`No assets matched the default criteria: ${pattern}`);
}
if (matchingAssets.length > 1) {
throw new Error(`Multiple assets matched the default criteria: ${matchingAssets.map((a: any) => a.name).join(', ')}`);
}
asset = matchingAssets[0];
} else if (fileName.startsWith('~')) {
// Rule 3: Regex matching rule
let pattern = fileName.substring(1);
const hasSystem = pattern.includes('{{SYSTEM}}');
const hasArch = pattern.includes('{{ARCH}}');
const hasExt = pattern.includes('{{EXT_PATTERN}}');
const hasEnd = pattern.endsWith('$');
if (!hasSystem && !hasArch && !hasExt && !hasEnd) {
pattern += `.*{{SYSTEM}}[_-]{{ARCH}}.*{{EXT_PATTERN}}$`;
} else if (hasSystem && hasArch && !hasExt && !hasEnd) {
pattern += `.*{{EXT_PATTERN}}$`;
}
const finalPattern = pattern
.replace(/{{SYSTEM}}/g, systemPattern)
.replace(/{{ARCH}}/g, archPattern)
.replace(/{{EXT_PATTERN}}/g, extPattern);
const regex = new RegExp(finalPattern, 'i');
core.info(`Using regex pattern: ${finalPattern}`);
const matchingAssets = data.assets.filter((a: any) => regex.test(a.name));
if (matchingAssets.length === 0) {
throw new Error(`No assets matched the regex: ${finalPattern}`);
}
if (matchingAssets.length > 1) {
throw new Error(`Multiple assets matched the criteria: ${matchingAssets.map((a: any) => a.name).join(', ')}`);
}
asset = matchingAssets[0];
} else {
// Literal matching rule
core.info(`Using literal match for: ${fileName}`);
asset = data.assets.find((a: any) => a.name === fileName);
}
if (!asset) {
throw new Error(`No asset found matching the criteria in release ${data.tag_name}`);
}
const version = data.tag_name.replace(/^v/, '');
const version = release.tag_name.replace(/^v/, '');
const binaryName = binaryInput || toolName;
// Check if the tool is already in the cache (if not 'always' update)
if (updateCache !== 'always') {
const cachedDir = tc.find(toolName, version, arch);
const cachedDir = tc.find(toolName, version, platformInfo.arch);
if (cachedDir) {
core.info(`Found ${toolName} version ${version} in cache at ${cachedDir}`);
core.addPath(cachedDir);
@@ -176,7 +62,7 @@ async function run() {
const downloadUrl = asset.browser_download_url;
core.info(`Downloading ${asset.name} from ${downloadUrl}...`);
const downloadPath = await tc.downloadTool(downloadUrl);
const downloadPath = await tc.downloadTool(downloadUrl, undefined, token ? `token ${token}` : undefined);
const nameLower = asset.name.toLowerCase();
let toolDir: string;
@@ -200,7 +86,6 @@ async function run() {
}
fs.renameSync(downloadPath, destPath);
// Make it executable on Linux/macOS
if (process.platform !== 'win32') {
fs.chmodSync(destPath, '755');
}
@@ -216,14 +101,14 @@ async function run() {
core.info(`Searching for binary named: ${binaryName}`);
}
const binaryPath = findBinary(toolDir, binaryPattern, debug);
const binaryPath = findBinary(toolDir, binaryPattern, debug, (msg) => core.info(msg));
if (!binaryPath) {
throw new Error(`Could not find binary "${binaryName}" in the extracted asset.`);
}
// The tool directory is the one containing the binary
toolDir = path.dirname(binaryPath);
core.info(`Binary found at ${binaryPath}. Setting tool directory to ${toolDir}`);
core.info(`Binary found at ${binaryPath}.`);
// Make binary executable just in case it's not
if (process.platform !== 'win32') {
@@ -231,7 +116,7 @@ async function run() {
}
// Cache the tool
const finalCachedDir = await tc.cacheDir(toolDir, toolName, version, arch);
const finalCachedDir = await tc.cacheDir(toolDir, toolName, version, platformInfo.arch);
core.info(`Cached ${toolName} version ${version} to ${finalCachedDir}`);
core.addPath(finalCachedDir);