Compare commits
6 Commits
v1.1.0
...
b20a066030
| Author | SHA1 | Date | |
|---|---|---|---|
| b20a066030 | |||
| c8669a3a66 | |||
| 66bc2b3acd | |||
| 41c3945650 | |||
| 620da93338 | |||
| ef971a6da4 |
@@ -1,5 +1,15 @@
|
||||
name: Test Action
|
||||
on: [push, workflow_dispatch]
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- '.gitea/workflows/test.yml'
|
||||
- 'action.yml'
|
||||
- 'src/**'
|
||||
- 'dist/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
||||
45
.github/workflows/test.yml
vendored
Normal file
45
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
name: Test Action
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- '.github/workflows/test.yml'
|
||||
- 'action.yml'
|
||||
- 'src/**'
|
||||
- 'dist/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Go ACME Setup
|
||||
uses: skoszewski/setup-github-release@v1
|
||||
with:
|
||||
repository: 'go-acme/lego'
|
||||
|
||||
- name: Setup Hugo
|
||||
uses: skoszewski/setup-github-release@v1
|
||||
with:
|
||||
repository: 'gohugoio/hugo'
|
||||
file-name: '~hugo_extended_[^a-z]'
|
||||
|
||||
- name: Setup RClone
|
||||
uses: skoszewski/setup-github-release@v1
|
||||
with:
|
||||
repository: 'rclone/rclone'
|
||||
|
||||
- name: Verify Installation
|
||||
run: |
|
||||
echo "Verifying installed tools..."
|
||||
printf "\nGo ACME Lego:\n"
|
||||
lego -v
|
||||
printf "\nHugo:\n"
|
||||
hugo version
|
||||
printf "\nRClone:\n"
|
||||
rclone version
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,4 +3,4 @@ lib/
|
||||
*.log
|
||||
*.map
|
||||
.DS_Store
|
||||
Notes.md
|
||||
.github/*.md
|
||||
|
||||
130
README.md
130
README.md
@@ -1,6 +1,51 @@
|
||||
# Setup GitHub Release Action
|
||||
|
||||
This GitHub/Gitea Action downloads a tool from a GitHub release, extracts it, automatically finds the executable, and adds it to the system PATH. It supports platform-aware selection, recursive binary search, and tool caching.
|
||||
This project implements a GitHub Action (`setup-github-release`) and a CLI tool (`install-github-release`) that downloads a release asset from a specified GitHub repository, extracts it, searches for a binary within the extracted files, and prepares the runtime environment.
|
||||
|
||||
## Installation / Setup
|
||||
|
||||
### GitHub/Gitea Action
|
||||
|
||||
Add the action to your workflow. Authenticate with `github.token` (default) or a custom token for Gitea/private repos.
|
||||
|
||||
```yaml
|
||||
- name: Install Tool
|
||||
uses: koszewscy/setup-github-release@v1
|
||||
with:
|
||||
repository: 'owner/repo'
|
||||
```
|
||||
|
||||
### CLI Tool
|
||||
|
||||
Install the CLI tool on any destination system with Node.js 24 or newer.
|
||||
|
||||
**From Source:**
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/koszewscy/setup-github-release
|
||||
cd setup-github-release
|
||||
```
|
||||
|
||||
2. Install dependencies and build the project:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
3. Install the tool locally:
|
||||
|
||||
```bash
|
||||
npm install -g .
|
||||
```
|
||||
|
||||
After installation, the tool will be available as `install-github-release`:
|
||||
|
||||
```bash
|
||||
install-github-release rclone/rclone
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
@@ -59,13 +104,80 @@ If you are unsure how the binary is named, use the `debug` flag to list all file
|
||||
debug: true
|
||||
```
|
||||
|
||||
## Inputs
|
||||
## Inputs / Options
|
||||
|
||||
- `repository` (required): GitHub repository in `owner/repo` format.
|
||||
- `file-name` (optional): Literal name or regex pattern (if starts with `~`) to match the asset.
|
||||
- `binary-name` (optional): The name or regex pattern (if starts with `~`) of the binary to find. Defaults to the repository name.
|
||||
- `file-type` (optional, default: `archive`): Predefined keywords `archive`, `package`, or a custom regex pattern.
|
||||
- `debug` (optional, default: `false`): Set to `true` to log the contents of the unpacked asset.
|
||||
- `token` (optional): GitHub token for authentication (defaults to `${{ github.token }}` that is an equivalent of `${{ secrets.GITHUB_TOKEN }}`). Use `${{ secrets.GITEA_TOKEN }}` for Gitea, or create a personal access token.
|
||||
The following inputs are available for the GitHub Action, and as options for the CLI tool:
|
||||
|
||||
- `repository` (required): The GitHub repository in the format `owner/repo` from which to download the release.
|
||||
- `file-name` (optional): The name or the regex pattern (prefixed with `~`) of the asset file to download from the release.
|
||||
- `binary-name` (optional): The name or regex pattern (prefixed with `~`) of the binary to search for within the downloaded asset. Defaults to the repository name.
|
||||
- `file-type` (optional, default: 'archive'): The regex pattern to identify the type of the file to be downloaded. There are two predefined keywords:
|
||||
- 'archive': matches common archive file extensions like .zip, .tar.gz, .tar, .tgz, .7z.
|
||||
- 'package': matches common package file extensions like .deb, .rpm, .pkg.
|
||||
- or a custom regex pattern can be provided to match specific file types.
|
||||
- `install-path` (optional, CLI only): Custom installation directory for the CLI tool.
|
||||
- `update-cache` (optional, default: 'false', Action only): When set to 'false', the action will use the cached version of the tool if it is already available. If set to 'true', the action will check the latest release and update the cache if a newer version is found. If set to 'always', it will always download and install, updating the cache regardless.
|
||||
- `debug` (optional, default: 'false'): When set to `true`, the action will log the contents of the unpacked directory to the console.
|
||||
- `token` (optional): A GitHub token for authentication, useful for accessing private repositories or increasing rate limits.
|
||||
|
||||
> **Important:** Default authentication will only work if the action is used within GitHub workflow. For Gitea or the CLI, you must provide a token explicitly (e.g. `GITHUB_TOKEN` environment variable).
|
||||
|
||||
## CLI Usage
|
||||
|
||||
The `install-github-release` tool follows the same logic as the Action.
|
||||
|
||||
```bash
|
||||
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)
|
||||
-p, --install-path <path> Custom installation directory
|
||||
-k, --token <token> GitHub token
|
||||
-d, --debug Enable debug logging
|
||||
-h, --help Show this help message
|
||||
```
|
||||
|
||||
## Asset Selection Procedure
|
||||
|
||||
The list of assets from the latest release is filtered based on the following rules:
|
||||
|
||||
1. If neither `file-name` nor `file-type` is provided, the tool defaults to selecting assets that match the following regular expression: `{{SYSTEM}}[_-]{{ARCH}}.*{{EXT_PATTERN}}$`, where:
|
||||
- `{{SYSTEM}}` is replaced with the detected operating system regex.
|
||||
- `{{ARCH}}` is replaced with the detected architecture regex.
|
||||
- `{{EXT_PATTERN}}` is a regex pattern defined by the `file-type` input (defaulting to 'archive' if not specified).
|
||||
|
||||
2. If `file-name` is provided literally, the tool uses it directly to match the asset name by using exact string comparison.
|
||||
|
||||
3. If `file-name` is provided as a regex pattern (prefixed with `~`), then:
|
||||
- If the pattern does not end with `$` and does not include any placeholders, the tool appends `.*{{SYSTEM}}[_-]{{ARCH}}.*{{EXT_PATTERN}}$` to the provided pattern.
|
||||
- If it already ends with `$` or includes all three placeholders, the tool uses it as-is to match the asset name using regex.
|
||||
- If only `{{SYSTEM}}` and `{{ARCH}}` placeholders are included, the tool appends `.*{{EXT_PATTERN}}$`.
|
||||
|
||||
4. If `file-type` is not equal to 'archive' or 'package', it is treated as a custom regex pattern to match the file extension.
|
||||
|
||||
5. The tool applies the constructed regex pattern to filter the assets from the latest release.
|
||||
|
||||
6. If multiple assets match the criteria, the tool fails.
|
||||
|
||||
7. After download and extraction, the tool recursively searches for the binary specified by `binary-name` (or the repository name). If found, the directory containing the binary is used as the tool directory and added to the PATH (or used for installation). If the binary is not found, the tool fails.
|
||||
|
||||
8. `{{SYSTEM}}` is replaced with the detected operating system regex:
|
||||
- For Linux: `linux`.
|
||||
- For MacOS: `(darwin|macos|mac|osx)`.
|
||||
- For Windows: `(windows|win)`.
|
||||
|
||||
9. `{{ARCH}}` is replaced with the detected architecture regex:
|
||||
- For x64: `(x86_64|x64|amd64)`.
|
||||
- For arm64: `(aarch64|arm64)`.
|
||||
|
||||
10. All regular expression matches are case-insensitive.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
> **Important:** Default authentication will will only work if the action is used within GitHub workflow. For Gitea, you must provide a token explicitly.
|
||||
@@ -15,6 +15,10 @@ inputs:
|
||||
description: 'The type of the file to be downloaded (archive, package, or custom regex).'
|
||||
required: false
|
||||
default: 'archive'
|
||||
update-cache:
|
||||
description: 'How to handle the tool cache (false, true, or always). Defaults to false.'
|
||||
required: false
|
||||
default: 'false'
|
||||
debug:
|
||||
description: 'When set to true, displays the contents of the unpacked archive or directory.'
|
||||
required: false
|
||||
|
||||
16
dist/cli.js
vendored
Executable file
16
dist/cli.js
vendored
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
"use strict";var F=Object.create;var N=Object.defineProperty;var H=Object.getOwnPropertyDescriptor;var O=Object.getOwnPropertyNames;var U=Object.getPrototypeOf,X=Object.prototype.hasOwnProperty;var G=(t,e,n,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of O(e))!X.call(t,s)&&s!==n&&N(t,s,{get:()=>e[s],enumerable:!(r=H(e,s))||r.enumerable});return t};var f=(t,e,n)=>(n=t!=null?F(U(t)):{},G(e||!t||!t.__esModule?N(n,"default",{value:t,enumerable:!0}):n,t));var B=require("util"),p=f(require("path")),c=f(require("fs")),P=f(require("os"));var S=f(require("os")),Y={linux:"linux",darwin:"(darwin|macos|mac|osx)",win32:"(windows|win)"},q={x64:"(x86_64|x64|amd64)",arm64:"(aarch64|arm64)"};function _(){let t=S.platform(),e=S.arch();return{system:t,arch:e,systemPattern:Y[t]||t,archPattern:q[e]||e}}function C(t,e,n){let{fileName:r,fileType:s="archive"}=n,o;if(s==="archive"?o="\\.(zip|tar\\.gz|tar|tgz|7z)":s==="package"?o="\\.(deb|rpm|pkg)":o=s,r)if(r.startsWith("~")){let i=r.substring(1),l=i.includes("{{SYSTEM}}"),a=i.includes("{{ARCH}}"),h=i.includes("{{EXT_PATTERN}}"),$=i.endsWith("$");!l&&!a&&!h&&!$?i+=".*{{SYSTEM}}[_-]{{ARCH}}.*{{EXT_PATTERN}}$":l&&a&&!h&&!$&&(i+=".*{{EXT_PATTERN}}$");let m=i.replace(/{{SYSTEM}}/g,e.systemPattern).replace(/{{ARCH}}/g,e.archPattern).replace(/{{EXT_PATTERN}}/g,o),x=new RegExp(m,"i"),g=t.filter(w=>x.test(w.name));if(g.length===0)throw new Error(`No assets matched the regex: ${m}`);if(g.length>1)throw new Error(`Multiple assets matched the criteria: ${g.map(w=>w.name).join(", ")}`);return g[0]}else{let i=t.find(l=>l.name===r);if(!i)throw new Error(`No asset found matching the exact name: ${r}`);return i}else{let i=`${e.systemPattern}[_-]${e.archPattern}.*${o}$`,l=new RegExp(i,"i"),a=t.filter(h=>l.test(h.name));if(a.length===0)throw new Error(`No assets matched the default criteria: ${i}`);if(a.length>1)throw new Error(`Multiple assets matched the default criteria: ${a.map(h=>h.name).join(", ")}`);return a[0]}}var b=f(require("fs")),M=f(require("path"));function k(t,e,n,r){let s=b.readdirSync(t);n&&(r(`Searching for binary in ${t}...`),s.forEach(o=>r(` - ${o}`)));for(let o of s){let i=M.join(t,o);if(b.statSync(i).isDirectory()){let a=k(i,e,n,r);if(a)return a}else{let a=!1;if(e instanceof RegExp?a=e.test(o):(a=o===e,!a&&process.platform==="win32"&&!e.toLowerCase().endsWith(".exe")&&(a=o.toLowerCase()===`${e.toLowerCase()}.exe`)),a)return i}}}async function j(t,e){let n=`https://api.github.com/repos/${t}/releases/latest`,r={Accept:"application/vnd.github.v3+json","User-Agent":"setup-github-release-action"};e&&(r.Authorization=`token ${e}`);let s=await fetch(n,{headers:r});if(!s.ok){let o=await s.text();throw new Error(`Failed to fetch latest release for ${t}: ${s.statusText}. ${o}`)}return await s.json()}async function W(t,e,n){let r={"User-Agent":"setup-github-release-action"};n&&(r.Authorization=`token ${n}`);let s=await fetch(t,{headers:r});if(!s.ok)throw new Error(`Failed to download asset: ${s.statusText}`);let o=await import("fs"),{Readable:i}=await import("stream"),{finished:l}=await import("stream/promises"),a=o.createWriteStream(e);await l(i.fromWeb(s.body).pipe(a))}var E=require("child_process"),d=f(require("path")),y=f(require("fs"));async function L(t,e){let n=d.extname(t).toLowerCase(),r=d.basename(t).toLowerCase();if(y.existsSync(e)||y.mkdirSync(e,{recursive:!0}),r.endsWith(".tar.gz")||r.endsWith(".tgz")||r.endsWith(".tar")){let o=(0,E.spawnSync)("tar",["-xf",t,"-C",e]);if(o.status!==0)throw new Error(`tar failed with status ${o.status}: ${o.stderr.toString()}`)}else if(r.endsWith(".zip"))if(process.platform==="win32"){let s=`Expand-Archive -Path "${t}" -DestinationPath "${e}" -Force`,o=(0,E.spawnSync)("powershell",["-Command",s]);if(o.status!==0)throw new Error(`powershell Expand-Archive failed with status ${o.status}: ${o.stderr.toString()}`)}else{let s=(0,E.spawnSync)("unzip",["-q",t,"-d",e]);if(s.status!==0)throw new Error(`unzip failed with status ${s.status}: ${s.stderr.toString()}`)}else if(r.endsWith(".7z")){let s=(0,E.spawnSync)("7z",["x",t,`-o${e}`,"-y"]);if(s.status!==0)throw new Error(`7z failed with status ${s.status}. Make sure 7z is installed.`)}else{let s=d.join(e,d.basename(t));y.copyFileSync(t,s)}}async function K(){let{values:t,positionals:e}=(0,B.parseArgs)({options:{"file-name":{type:"string",short:"f"},"binary-name":{type:"string",short:"b"},"file-type":{type:"string",short:"t",default:"archive"},"install-path":{type:"string",short:"p"},token:{type:"string",short:"k"},debug:{type:"boolean",short:"d",default:!1},help:{type:"boolean",short:"h"}},allowPositionals:!0});(t.help||e.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)
|
||||
-p, --install-path <path> Custom installation directory
|
||||
-k, --token <token> GitHub token
|
||||
-d, --debug Enable debug logging
|
||||
-h, --help Show this help message
|
||||
`),process.exit(0));let n=e[0];n||(console.error("Error: Repository is required."),process.exit(1));let r=t["file-name"],s=t["binary-name"],o=t["file-type"],i=!!t.debug,l=t.token||process.env.GITHUB_TOKEN;try{let a=_(),h=n.split("/").pop()||n;console.log(`Fetching latest release for ${n}...`);let $=await j(n,l),m=C($.assets,a,{fileName:r,fileType:o});console.log(`Selected asset: ${m.name}`);let x=c.mkdtempSync(p.join(P.tmpdir(),"setup-gh-release-")),g=p.join(x,m.name);console.log(`Downloading ${m.name}...`),await W(m.browser_download_url,g,l);let w=p.join(x,"extract");console.log(`Extracting ${m.name}...`),await L(g,w);let A=s||h,R;A.startsWith("~")?R=new RegExp(A.substring(1),"i"):R=A;let T=k(w,R,i,console.log);if(!T)throw new Error(`Could not find binary "${A}" in the extracted asset.`);let u;if(t["install-path"])u=p.resolve(t["install-path"]);else if(process.getuid&&process.getuid()===0)u="/usr/local/bin";else{let z=p.join(P.homedir(),"bin");c.existsSync(z)?u=z:u="/usr/local/bin"}c.existsSync(u)||c.mkdirSync(u,{recursive:!0});let I=p.basename(T),v=p.join(u,I);console.log(`Installing ${I} to ${v}...`),c.copyFileSync(T,v),process.platform!=="win32"&&c.chmodSync(v,"755"),c.rmSync(x,{recursive:!0,force:!0}),console.log("Installation successful!")}catch(a){console.error(`Error: ${a.message}`),process.exit(1)}}K();
|
||||
76
dist/index.js
vendored
76
dist/index.js
vendored
File diff suppressed because one or more lines are too long
11
package.json
11
package.json
@@ -1,10 +1,15 @@
|
||||
{
|
||||
"name": "setup-github-release",
|
||||
"name": "install-github-release",
|
||||
"version": "1.0.0",
|
||||
"description": "A GitHub Action to download a tool",
|
||||
"description": "A GitHub Action and CLI tool to download and install binaries from GitHub releases",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"install-github-release": "dist/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "esbuild src/index.ts --bundle --platform=node --target=node24 --outfile=dist/index.js --minify",
|
||||
"build:action": "esbuild src/index.ts --bundle --platform=node --target=node24 --outfile=dist/index.js --minify",
|
||||
"build:cli": "esbuild src/cli.ts --bundle --platform=node --target=node24 --outfile=dist/cli.js --minify --banner:js=\"#!/usr/bin/env node\"",
|
||||
"build": "npm run build:action && npm run build:cli",
|
||||
"format": "prettier --write '**/*.ts'",
|
||||
"format-check": "prettier --check '**/*.ts'",
|
||||
"lint": "eslint src/**/*.ts",
|
||||
|
||||
137
src/cli.ts
Normal file
137
src/cli.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
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' },
|
||||
'install-path': { type: 'string', short: 'p' },
|
||||
'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)
|
||||
-p, --install-path <path> Custom installation directory
|
||||
-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;
|
||||
|
||||
if (values['install-path']) {
|
||||
installDir = path.resolve(values['install-path']);
|
||||
} else {
|
||||
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
52
src/core/downloader.ts
Normal 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
42
src/core/extractor.ts
Normal 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
38
src/core/finder.ts
Normal 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
67
src/core/matcher.ts
Normal 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
31
src/core/platform.ts
Normal 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
|
||||
};
|
||||
}
|
||||
176
src/index.ts
176
src/index.ts
@@ -3,159 +3,66 @@ 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;
|
||||
|
||||
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}`;
|
||||
// Rule for update-cache: 'false' means use ANY cached version if available
|
||||
if (updateCache === 'false') {
|
||||
const allVersions = tc.findAllVersions(toolName, platformInfo.arch);
|
||||
if (allVersions.length > 0) {
|
||||
const latestVersion = allVersions.sort().pop();
|
||||
if (latestVersion) {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 toolName = repository.split('/').pop() || repository;
|
||||
const version = release.tag_name.replace(/^v/, '');
|
||||
const binaryName = binaryInput || toolName;
|
||||
|
||||
// Check if the tool is already in the cache
|
||||
const cachedDir = tc.find(toolName, version, arch);
|
||||
if (cachedDir) {
|
||||
core.info(`Found ${toolName} version ${version} in cache at ${cachedDir}`);
|
||||
core.addPath(cachedDir);
|
||||
return;
|
||||
// Check if the tool is already in the cache (if not 'always' update)
|
||||
if (updateCache !== 'always') {
|
||||
const cachedDir = tc.find(toolName, version, platformInfo.arch);
|
||||
if (cachedDir) {
|
||||
core.info(`Found ${toolName} version ${version} in cache at ${cachedDir}`);
|
||||
core.addPath(cachedDir);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -179,7 +86,6 @@ async function run() {
|
||||
}
|
||||
fs.renameSync(downloadPath, destPath);
|
||||
|
||||
// Make it executable on Linux/macOS
|
||||
if (process.platform !== 'win32') {
|
||||
fs.chmodSync(destPath, '755');
|
||||
}
|
||||
@@ -195,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') {
|
||||
@@ -210,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);
|
||||
|
||||
Reference in New Issue
Block a user