28 Commits
Author SHA1 Message Date
slawek d2f1cb9c1a Fix: Ensure cache is always updated for test run.
Test Action / test (push) Failing after 7s
2026-04-06 23:38:07 +02:00
slawek 1a01c8fa5f Updated Action transpiled code.
Test Action / test (push) Successful in 6s
2026-04-06 23:35:42 +02:00
slawek cbfadf3488 Fix: Enhance binary pattern matching with system and architecture replacements
Test Action / test (push) Successful in 14s
2026-04-06 23:33:46 +02:00
slawek 01d844f6d9 Feat: Integrate commander for improved CLI argument parsing 2026-04-06 23:21:03 +02:00
slawek e50ae03336 Fix: Add chmod command to make CLI and check-token scripts executable 2026-04-06 22:55:40 +02:00
slawek 1896949b27 Removed cli.js from the repository - it is not needed there. 2026-04-06 22:53:21 +02:00
slawek d9d7f67247 Refactored asset matching code. 2026-04-06 22:50:25 +02:00
slawek 0132ee6665 Refactor: Simplify getMatchingAsset function parameters in CLI and matcher modules 2026-04-06 18:46:31 +02:00
slawek 89ea0cecb0 Refactor: Consolidate asset matching logic into a single function 2026-04-06 12:30:28 +02:00
slawek 3a75adedc6 Refactor: Extract filename matching logic into a separate function 2026-04-06 12:18:19 +02:00
slawek dfa641afd4 Matched functionality of the CLI with the Bash predecessor. 2026-04-06 12:00:09 +02:00
slawek 483a1c5f13 Fix: README corrections. 2026-01-11 20:03:25 +01:00
slawek 90033e0b9e A dummy change to trigger the workflow. 2026-01-11 19:56:45 +01:00
slawek f8a559538e Add default token to check-token subaction 2026-01-11 19:54:25 +01:00
slawek 09f36edc01 Updated GitHub workflow with new Check Token action. 2026-01-11 19:46:28 +01:00
slawek 55d6019d0f Fix: call correct action.
Test Action / test (push) Successful in 4s
2026-01-11 19:31:53 +01:00
slawek 11a26bd176 Added check-token-action build and action.
Test Action / test (push) Failing after 4s
2026-01-11 19:25:14 +01:00
slawek 32a4011b54 Added Check Token subaction.
Test Action / test (push) Successful in 4s
2026-01-11 19:24:23 +01:00
slawek 2bb60fc0ed Added an utility that validates GitHub token.
Test Action / test (push) Successful in 3s
2026-01-11 14:27:32 +01:00
slawek fc727877e6 Fix: Minor formatting issue. 2026-01-11 14:27:08 +01:00
slawek 120b16b56e Update: Eliminated minor formatting issues.
Test Action / test (push) Successful in 3s
2026-01-11 14:03:36 +01:00
slawek 4ee62009bb Fix: Windows decompression and installation paths. 2026-01-11 14:03:10 +01:00
slawek b20a066030 Added test workflow for Github. 2026-01-11 11:38:24 +01:00
slawek c8669a3a66 Update: Add 'install-path' option for custom installation directory in CLI and documentation
Test Action / test (push) Successful in 3s
2026-01-11 11:12:06 +01:00
slawek 66bc2b3acd Update: Refactor trigger events in test workflow to specify push conditions and paths
Test Action / test (push) Successful in 3s
2026-01-11 11:04:29 +01:00
slawek 41c3945650 Fix: Updated README to include correct CLI installation procedure.
Test Action / test (push) Successful in 5s
2026-01-11 11:02:44 +01:00
slawek 620da93338 Rearranged the code and added CLI that installs tools locally.
Test Action / test (push) Successful in 3s
2026-01-11 10:54:25 +01:00
slawek ef971a6da4 Update: Added option update-cache, and changed default action behavior to always use cached version without checking for a new one.
Test Action / test (push) Successful in 3s
2026-01-11 08:05:18 +01:00
21 changed files with 44784 additions and 220 deletions
+19 -1
View File
@@ -1,5 +1,15 @@
name: Test Action 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: jobs:
test: test:
@@ -8,11 +18,17 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Check Authentication
uses: ./check-token
with:
token: ${{ secrets.GH_TOKEN }}
- name: Go ACME Setup - name: Go ACME Setup
uses: ./ uses: ./
with: with:
repository: 'go-acme/lego' repository: 'go-acme/lego'
token: ${{ secrets.GH_TOKEN }} token: ${{ secrets.GH_TOKEN }}
update-cache: 'always'
- name: Setup Hugo - name: Setup Hugo
uses: ./ uses: ./
@@ -20,12 +36,14 @@ jobs:
repository: 'gohugoio/hugo' repository: 'gohugoio/hugo'
file-name: '~hugo_extended_[^a-z]' file-name: '~hugo_extended_[^a-z]'
token: ${{ secrets.GH_TOKEN }} token: ${{ secrets.GH_TOKEN }}
update-cache: 'always'
- name: Setup RClone - name: Setup RClone
uses: ./ uses: ./
with: with:
repository: 'rclone/rclone' repository: 'rclone/rclone'
token: ${{ secrets.GH_TOKEN }} token: ${{ secrets.GH_TOKEN }}
update-cache: 'always'
- name: Verify Installation - name: Verify Installation
run: | run: |
+49
View File
@@ -0,0 +1,49 @@
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
# Let's test the token first.
- name: Check Authentication
uses: skoszewski/setup-github-release/check-token@v1
- 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 -1
View File
@@ -3,4 +3,5 @@ lib/
*.log *.log
*.map *.map
.DS_Store .DS_Store
Notes.md .github/*.md
dist/cli.js
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Slawomir Koszewski
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+180 -14
View File
@@ -1,6 +1,55 @@
# Setup GitHub Release Action # 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: skoszewski/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/skoszewski/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`. By default, it installs binaries to:
- Linux/macOS (root): `/usr/local/bin`
- Linux/macOS (user): `~/bin` (if exists) or `/usr/local/bin`
- Windows: `%LOCALAPPDATA%\bin`
```bash
install-github-release rclone/rclone
```
## Features ## Features
@@ -16,8 +65,8 @@ This GitHub/Gitea Action downloads a tool from a GitHub release, extracts it, au
The action will automatically detect your OS (Linux, Windows, macOS) and architecture (x64, ARM64) and look for a matching archive. It will search for a binary named after the repository. The action will automatically detect your OS (Linux, Windows, macOS) and architecture (x64, ARM64) and look for a matching archive. It will search for a binary named after the repository.
```yaml ```yaml
- name: Install Hugo - name: Install LEGO
uses: koszewscy/setup-github-release@v1 uses: skoszewski/setup-github-release@v1
with: with:
repository: 'go-acme/lego' repository: 'go-acme/lego'
``` ```
@@ -30,7 +79,7 @@ For projects with multiple binary versions, you can use a regex pattern (prefixe
```yaml ```yaml
- name: Install Extended Hugo - name: Install Extended Hugo
uses: koszewscy/setup-github-release@v1 uses: skoszewski/setup-github-release@v1
with: with:
repository: 'gohugoio/hugo' repository: 'gohugoio/hugo'
file-name: '~hugo_extended_[^a-z]' # Regex to match extended version file-name: '~hugo_extended_[^a-z]' # Regex to match extended version
@@ -42,7 +91,7 @@ If the binary name is different from the repository name, like in the example of
```yaml ```yaml
- name: Install GitHub CLI - name: Install GitHub CLI
uses: koszewscy/setup-github-release@v1 uses: skoszewski/setup-github-release@v1
with: with:
repository: 'cli/cli' repository: 'cli/cli'
binary-name: 'gh' # Searches for 'gh' (or 'gh.exe') inside the extracted release binary-name: 'gh' # Searches for 'gh' (or 'gh.exe') inside the extracted release
@@ -53,19 +102,136 @@ If the binary name is different from the repository name, like in the example of
If you are unsure how the binary is named, use the `debug` flag to list all files in the unpacked asset, or download the asset manually to inspect its structure. If you are unsure how the binary is named, use the `debug` flag to list all files in the unpacked asset, or download the asset manually to inspect its structure.
```yaml ```yaml
- uses: koszewscy/setup-github-release@v1 - uses: skoszewski/setup-github-release@v1
with: with:
repository: 'owner/repo' repository: 'owner/repo'
debug: true debug: true
``` ```
## Inputs ## Inputs / Options
- `repository` (required): GitHub repository in `owner/repo` format. The following inputs are available for the GitHub Action, and as options for the CLI tool:
- `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. - `repository` (required): The GitHub repository in the format `owner/repo` from which to download the release.
- `file-type` (optional, default: `archive`): Predefined keywords `archive`, `package`, or a custom regex pattern. - `file-name` (optional): The name or the regex pattern (prefixed with `~`) of the asset file to download from the release.
- `debug` (optional, default: `false`): Set to `true` to log the contents of the unpacked asset. - `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.
- `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. - `file-type` (optional): Asset type selector.
- `archive`: matches `.zip`, `.tar.gz`, `.tgz`.
- `package`: matches `.deb`, `.pkg`, `.rpm`.
- short forms: `zip`, `gzip`, `gz`, `tar`, `tar.gz`, `tgz`, `deb`, `pkg`, `rpm`.
If not provided, selection defaults to OS-aware combined package/archive patterns:
- Linux: `.deb`, `.rpm`, `.zip`, `.tar.gz`, `.tgz`
- macOS: `.pkg`, `.zip`, `.tar.gz`, `.tgz`
- other: `.zip`, `.tar.gz`, `.tgz`
- `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:
--dry-run [level] Run in test mode (default level: 1)
Or set TEST_MODE environment variable to a value > 0
-l, --list [repository] List available assets from latest release and exit
-a, --app-name <name> Application name (optional, for output messages)
-f, --file-name <name> Asset file name or regex pattern (prefixed with ~)
-b, --binary-name <name> Binary name (supports source:destination form)
-t, --file-type <type> archive|package|zip|gzip|gz|tar|tar.gz|tgz|deb|pkg|rpm
-p, --install-path <path> Custom installation directory
-o, --output-directory <path>
Only download selected asset to the specified directory
-j, --releases-json Download latest release JSON only
--system <name> Override detected system for asset matching
--arch <name> Override detected architecture for asset matching
-k, --token <token> GitHub token
-d, --debug Enable debug logging
-h, --help Show this help message
```
## GitHub Token Verification
The project includes a utility to verify the validity of your GitHub token.
### CLI Utility
```bash
check-github-token <token>
```
If no token is provided as an argument, it will attempt to read from the `GITHUB_TOKEN` environment variable.
### GitHub Action
You can also use the `check-token` subaction in your workflows:
```yaml
- name: Verify Token
uses: skoszewski/setup-github-release/check-token@v1
with:
repository: 'actions/checkout' # Optional, defaults to actions/checkout
token: ${{ secrets.MY_TOKEN }}
```
If the `token` input is not provided, it will read from the `GITHUB_TOKEN` environment variable.
## 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 with an OS-aware extension pattern and this regular expression shape: `{{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 selected by OS:
Linux: `\.(deb|rpm|zip|tar\.gz|tgz)$`
macOS: `\.(pkg|zip|tar\.gz|tgz)$`
other: `\.(zip|tar\.gz|tgz)$`
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 provided, supported values are: `archive`, `package`, `zip`, `gzip`, `gz`, `tar`, `tar.gz`, `tgz`, `deb`, `pkg`, `rpm`.
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)`.
- 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.
+5 -2
View File
@@ -12,9 +12,12 @@ inputs:
description: 'The name or regex pattern (prefixed with ~) of the binary to search for within the asset. Defaults to the repository name.' description: 'The name or regex pattern (prefixed with ~) of the binary to search for within the asset. Defaults to the repository name.'
required: false required: false
file-type: file-type:
description: 'The type of the file to be downloaded (archive, package, or custom regex).' description: 'Asset type selector: archive, package, zip, gzip, gz, tar, tar.gz, tgz, deb, pkg, rpm.'
required: false 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: debug:
description: 'When set to true, displays the contents of the unpacked archive or directory.' description: 'When set to true, displays the contents of the unpacked archive or directory.'
required: false required: false
+15
View File
@@ -0,0 +1,15 @@
name: 'check-token'
description: 'Verify the validity of a GitHub token'
author: 'Slawomir Koszewski with GitHub Copilot assistance'
inputs:
repository:
description: 'The GitHub repository to check (e.g., owner/repo)'
required: false
default: 'actions/checkout'
token:
description: 'The GitHub token to verify'
required: false
default: ${{ github.token }}
runs:
using: 'node24'
main: '../dist/check-token-action.js'
+19860
View File
File diff suppressed because one or more lines are too long
Vendored Executable
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
"use strict";
// src/check-token.ts
var import_util = require("util");
// src/core/downloader.ts
function getGithubApiHeaders(token) {
const headers = {
"Accept": "application/vnd.github.v3+json",
"User-Agent": "setup-github-release-action"
};
if (token) {
headers["Authorization"] = `token ${token}`;
}
return headers;
}
async function fetchLatestRelease(repository, token) {
const url = `https://api.github.com/repos/${repository}/releases/latest`;
const headers = getGithubApiHeaders(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();
}
// src/check-token.ts
async function run() {
const { positionals } = (0, import_util.parseArgs)({
allowPositionals: true
});
const token = positionals[0] || process.env.GITHUB_TOKEN;
if (!token) {
console.error("Error: No GitHub token provided as an argument or found in GITHUB_TOKEN environment variable.");
process.exit(1);
}
try {
console.log("Verifying GitHub token...");
await fetchLatestRelease("actions/checkout", token);
console.log("\x1B[32mSuccess: The provided GitHub token is valid and has sufficient permissions to access public repositories.\x1B[0m");
} catch (error) {
console.error("\x1B[31mError: GitHub token verification failed.\x1B[0m");
console.error(`Reason: ${error.message}`);
process.exit(1);
}
}
run();
+23832 -59
View File
File diff suppressed because one or more lines are too long
+54 -3
View File
@@ -1,16 +1,22 @@
{ {
"name": "setup-github-release", "name": "install-github-release",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "setup-github-release", "name": "install-github-release",
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@actions/core": "^1.11.0", "@actions/core": "^1.11.0",
"@actions/tool-cache": "^2.0.2" "@actions/tool-cache": "^2.0.2",
"commander": "^14.0.3",
"minimatch": "^10.2.5"
},
"bin": {
"check-github-token": "dist/check-token.js",
"install-github-release": "dist/cli.js"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.0.0", "@types/node": "^25.0.0",
@@ -530,6 +536,36 @@
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
}, },
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.27.2", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
@@ -572,6 +608,21 @@
"@esbuild/win32-x64": "0.27.2" "@esbuild/win32-x64": "0.27.2"
} }
}, },
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/semver": { "node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+15 -11
View File
@@ -1,30 +1,34 @@
{ {
"name": "setup-github-release", "name": "install-github-release",
"version": "1.0.0", "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", "main": "dist/index.js",
"bin": {
"install-github-release": "dist/cli.js",
"check-github-token": "dist/check-token.js"
},
"scripts": { "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",
"format": "prettier --write '**/*.ts'", "build:cli": "esbuild src/cli.ts --bundle --platform=node --target=node24 --outfile=dist/cli.js --banner:js=\"#!/usr/bin/env node\" && chmod +x dist/cli.js",
"format-check": "prettier --check '**/*.ts'", "build:check-token": "esbuild src/check-token.ts --bundle --platform=node --target=node24 --outfile=dist/check-token.js --banner:js=\"#!/usr/bin/env node\" && chmod +x dist/check-token.js",
"lint": "eslint src/**/*.ts", "build:check-token-action": "esbuild src/check-token-action.ts --bundle --platform=node --target=node24 --outfile=dist/check-token-action.js",
"package": "npm run build", "build": "npm run build:action && npm run build:cli && npm run build:check-token && npm run build:check-token-action"
"test": "jest",
"all": "npm run format && npm run lint && npm run test && npm run package"
}, },
"keywords": [ "keywords": [
"actions", "actions",
"node", "node",
"setup" "setup"
], ],
"author": "", "author": "Sławomir Koszewski",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=24" "node": ">=24"
}, },
"dependencies": { "dependencies": {
"@actions/core": "^1.11.0", "@actions/core": "^1.11.0",
"@actions/tool-cache": "^2.0.2" "@actions/tool-cache": "^2.0.2",
"commander": "^14.0.3",
"minimatch": "^10.2.5"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.0.0", "@types/node": "^25.0.0",
+24
View File
@@ -0,0 +1,24 @@
import * as core from '@actions/core';
import { fetchLatestRelease } from './core/downloader';
async function run() {
try {
const repository = core.getInput('repository') || 'actions/checkout';
const token = core.getInput('token') || process.env.GITHUB_TOKEN;
if (!token) {
core.setFailed('No GitHub token provided as an input or found in GITHUB_TOKEN environment variable.');
return;
}
core.info(`Verifying GitHub token using repository ${repository}...`);
// Attempt to list latest release of the specified repository as a test
await fetchLatestRelease(repository, token);
core.info('Success: The provided GitHub token is valid and has sufficient permissions to access the repository.');
} catch (error: any) {
core.setFailed(`GitHub token verification failed. Reason: ${error.message}`);
}
}
run();
+29
View File
@@ -0,0 +1,29 @@
import { parseArgs } from 'util';
import { fetchLatestRelease } from './core/downloader';
async function run() {
const { positionals } = parseArgs({
allowPositionals: true
});
const token = positionals[0] || process.env.GITHUB_TOKEN;
if (!token) {
console.error('Error: No GitHub token provided as an argument or found in GITHUB_TOKEN environment variable.');
process.exit(1);
}
try {
console.log('Verifying GitHub token...');
// Attempt to list latest release of actions/checkout as a test
await fetchLatestRelease('actions/checkout', token);
console.log('\x1b[32mSuccess: The provided GitHub token is valid and has sufficient permissions to access public repositories.\x1b[0m');
} catch (error: any) {
console.error('\x1b[31mError: GitHub token verification failed.\x1b[0m');
console.error(`Reason: ${error.message}`);
process.exit(1);
}
}
run();
+261
View File
@@ -0,0 +1,261 @@
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
import { spawnSync } from 'child_process';
import { Command } from 'commander';
import { getPlatformInfo } from './core/platform';
import { getMatchingAsset } from './core/matcher';
import { findBinary } from './core/finder';
import { fetchLatestRelease, fetchLatestReleaseRaw, downloadAsset } from './core/downloader';
import { extractAsset } from './core/extractor';
interface CliOptions {
appName?: string;
fileName?: string;
binaryName?: string;
fileType?: string;
installPath?: string;
outputDirectory?: string;
releasesJsonOnly: boolean;
listOnly: boolean;
token?: string;
debug: boolean;
dryRun: boolean;
systemOverride?: string;
archOverride?: string;
listRepo?: string;
positionals: string[];
}
function validateOutputDirectory(outputDirectory: string): string {
const resolvedPath = path.resolve(outputDirectory);
if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isDirectory()) {
throw new Error(`Output directory "${resolvedPath}" does not exist.`);
}
return resolvedPath;
}
function getInstallDir(installPath?: string): string {
if (installPath) {
return path.resolve(installPath);
}
if (process.platform === 'win32') {
const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
return path.join(localAppData, 'bin');
}
const isRoot = process.getuid && process.getuid() === 0;
if (isRoot) {
return '/usr/local/bin';
}
const homeBin = path.join(os.homedir(), 'bin');
if (fs.existsSync(homeBin)) {
return homeBin;
}
return '/usr/local/bin';
}
function installSystemPackage(downloadPath: string): void {
const fileName = path.basename(downloadPath).toLowerCase();
const command: { binary: string; args: string[] } | undefined = fileName.endsWith('.deb')
? { binary: 'dpkg', args: ['-i', downloadPath] }
: fileName.endsWith('.pkg')
? { binary: 'installer', args: ['-pkg', downloadPath, '-target', '/'] }
: fileName.endsWith('.rpm')
? { binary: 'rpm', args: ['-i', downloadPath] }
: undefined;
if (!command) {
throw new Error(`Unsupported package type: ${fileName}`);
}
const isRoot = process.getuid && process.getuid() === 0;
const commandToRun = isRoot ? command.binary : 'sudo';
const argsToRun = isRoot ? command.args : [command.binary, ...command.args];
const result = spawnSync(commandToRun, argsToRun, { stdio: 'inherit' });
if (result.status !== 0) {
throw new Error(`Failed to install package using ${commandToRun} ${argsToRun.join(' ')}.`);
}
}
async function run() {
let tempDir: string | undefined;
const program = new Command();
program
.name('install-github-release')
.usage('[options] <repository>')
.argument('[repository]', 'The GitHub repository (owner/repo)')
.option('--dry-run', 'Run in test mode')
.option('-l, --list [repository]', 'List available assets from latest release and exit')
.option('-a, --app-name <name>', 'Application name (optional, for output messages)')
.option('-f, --file-name <name>', 'Asset file name or regex pattern (prefixed with ~)')
.option('-b, --binary-name <name>', 'Binary name (supports source:destination form)')
.option('-t, --file-type <type>', 'Known: archive|package|linux|macos|targz; custom: ~<regex> or extension')
.option('-p, --install-path <path>', 'Custom installation directory')
.option('-o, --output-directory <path>', 'Only download selected asset to the specified directory')
.option('-j, --releases-json', 'Download latest release JSON only')
.option('--system <name>', 'Override detected system for asset matching')
.option('--arch <name>', 'Override detected architecture for asset matching')
.option('-k, --token <token>', 'GitHub token')
.option('-d, --debug', 'Enable debug logging')
.allowUnknownOption(false);
const cleanupTempDir = () => {
if (tempDir && fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
};
program.parse(process.argv);
const parsedOptions = program.opts();
const rawFileType = parsedOptions.fileType;
const fileType = typeof rawFileType === 'string' ? rawFileType.trim() : undefined;
if (rawFileType !== undefined && !fileType) {
throw new Error(`Unknown asset type: ${rawFileType}`);
}
const listValue = parsedOptions.list as string | boolean | undefined;
const options: CliOptions = {
appName: parsedOptions.appName as string | undefined,
fileName: parsedOptions.fileName as string | undefined,
binaryName: parsedOptions.binaryName as string | undefined,
fileType,
installPath: parsedOptions.installPath as string | undefined,
outputDirectory: parsedOptions.outputDirectory as string | undefined,
releasesJsonOnly: Boolean(parsedOptions.releasesJson),
listOnly: listValue !== undefined,
token: parsedOptions.token as string | undefined,
debug: Boolean(parsedOptions.debug),
dryRun: Boolean(parsedOptions.dryRun),
systemOverride: parsedOptions.system as string | undefined,
archOverride: parsedOptions.arch as string | undefined,
listRepo: typeof listValue === 'string' ? listValue : undefined,
positionals: program.args
};
const repository = options.listRepo || options.positionals[0];
if (!repository) {
program.outputHelp();
process.exit(1);
}
const token = options.token;
if (options.listOnly) {
const release = await fetchLatestRelease(repository, token);
release.assets.forEach((asset) => console.log(`- ${asset.browser_download_url}`));
process.exit(0);
}
const toolName = repository.split('/').pop() || repository;
const appName = options.appName || (toolName.charAt(0).toUpperCase() + toolName.slice(1));
const binaryOption = options.binaryName || toolName;
const [binarySource, binaryDestination] = binaryOption.includes(':')
? [binaryOption.split(':')[0], binaryOption.split(':')[1]]
: [binaryOption, binaryOption];
if (options.releasesJsonOnly) {
const rawRelease = await fetchLatestReleaseRaw(repository, token);
const outputBase = binaryDestination || toolName;
const outputName = `${outputBase}.releases.json`;
const outputPath = options.outputDirectory
? path.join(validateOutputDirectory(options.outputDirectory), outputName)
: outputName;
fs.writeFileSync(outputPath, rawRelease, 'utf8');
console.log(`Downloaded GitHub releases to ${outputPath}.`);
process.exit(0);
}
const platformInfo = getPlatformInfo({
system: options.systemOverride,
arch: options.archOverride
});
console.log(`Fetching latest release for ${repository}...`);
const release = await fetchLatestRelease(repository, token);
const asset = getMatchingAsset(release.assets, platformInfo, options.fileName, options.fileType);
const version = release.tag_name.replace(/^v/i, '');
const downloadUrl = asset.browser_download_url;
console.log(`Will download '${appName}' version: ${version}`);
console.log(`Download URL: "${downloadUrl}".`);
if (options.dryRun) {
process.exit(0);
}
if (options.outputDirectory) {
const outputDir = validateOutputDirectory(options.outputDirectory);
const outputPath = path.join(outputDir, path.basename(downloadUrl));
console.log(`Downloading '${appName}' version ${version} to '${outputPath}'...`);
await downloadAsset(downloadUrl, outputPath, token);
process.exit(0);
}
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'setup-gh-release-'));
process.once('exit', cleanupTempDir);
const downloadPath = path.join(tempDir, asset.name);
await downloadAsset(downloadUrl, downloadPath, token);
if (/\.(deb|pkg|rpm)$/i.test(asset.name)) {
installSystemPackage(downloadPath);
console.log('Installation successful!');
process.exit(0);
}
const extractDir = path.join(tempDir, 'extract');
console.log(`Extracting ${asset.name}...`);
await extractAsset(downloadPath, extractDir);
let binaryPattern: string | RegExp;
if (binarySource.startsWith('~')) {
const binaryRegex = binarySource
.substring(1)
.replace(/{{SYSTEM}}/g, platformInfo.systemPattern)
.replace(/{{ARCH}}/g, platformInfo.archPattern);
binaryPattern = new RegExp(binaryRegex, 'i');
} else {
binaryPattern = binarySource
.replace(/{{SYSTEM}}/g, platformInfo.system)
.replace(/{{ARCH}}/g, platformInfo.arch);
}
const binaryPath = findBinary(extractDir, binaryPattern, options.debug, console.log);
if (!binaryPath) {
throw new Error(`Could not find binary "${binarySource}" in the extracted asset.`);
}
const installDir = getInstallDir(options.installPath);
if (!fs.existsSync(installDir)) {
fs.mkdirSync(installDir, { recursive: true });
}
const finalName = binaryDestination || 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');
}
console.log('Installation successful!');
process.exit(0);
}
void run().catch((error: unknown) => {
if (error instanceof Error && error.message) {
console.error(`Error: ${error.message}`);
} else {
console.error('Error: Unknown failure.');
}
process.exit(1);
});
+66
View File
@@ -0,0 +1,66 @@
export interface ReleaseAsset {
name: string;
browser_download_url: string;
}
export interface ReleaseInfo {
tag_name: string;
assets: ReleaseAsset[];
}
function getGithubApiHeaders(token?: string): Record<string, string> {
const headers: Record<string, string> = {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'setup-github-release-action'
};
if (token) {
headers['Authorization'] = `token ${token}`;
}
return headers;
}
export async function fetchLatestRelease(repository: string, token?: string): Promise<ReleaseInfo> {
const url = `https://api.github.com/repos/${repository}/releases/latest`;
const headers = getGithubApiHeaders(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 fetchLatestReleaseRaw(repository: string, token?: string): Promise<string> {
const url = `https://api.github.com/repos/${repository}/releases/latest`;
const headers = getGithubApiHeaders(token);
const response = await fetch(url, { headers });
const body = await response.text();
if (!response.ok) {
throw new Error(`Failed to fetch latest release for ${repository}: ${response.statusText}. ${body}`);
}
return body;
}
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));
}
+54
View File
@@ -0,0 +1,54 @@
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') {
// Modern Windows 10/11 has tar that handles zip
const tarResult = spawnSync('tar', ['-xf', filePath, '-C', destDir]);
if (tarResult.status === 0) return;
// Fallback: Use .NET ZipFile class to bypass PowerShell module trust issues (Microsoft.PowerShell.Archive)
// We escape single quotes for PowerShell.
const escapedFilePath = filePath.replace(/'/g, "''");
const escapedDestDir = destDir.replace(/'/g, "''");
const dotNetCommand = `Add-Type -AssemblyName System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::ExtractToDirectory('${escapedFilePath}', '${escapedDestDir}')`;
// Try pwsh (PowerShell 7) then powershell (Windows PowerShell)
for (const shell of ['pwsh', 'powershell']) {
const result = spawnSync(shell, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', dotNetCommand]);
if (result.status === 0) return;
}
throw new Error(`Extraction failed: Both tar and PowerShell fallback failed. Make sure your system can extract ZIP files.`);
} 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
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');
}
}
+76
View File
@@ -0,0 +1,76 @@
import { PlatformInfo } from './platform';
import { minimatch } from 'minimatch';
type ReleaseAsset = { name: string; browser_download_url: string };
const knownFileTypes: Record<string, string> = {
archive: '*.{zip,tar.gz,tgz}',
package: '*.{deb,pkg,rpm}',
linux: '*.{deb,rpm}',
macos: '*.pkg',
targz: '*.{tgz,tar.gz}',
};
function filterByRegex(assets: ReleaseAsset[], pattern: string): ReleaseAsset[] {
const regex = new RegExp(pattern, 'i');
return assets.filter((asset) => regex.test(asset.name));
}
function replacePlatformPlaceholders(pattern: string, platform: PlatformInfo): string {
return pattern
.replace(/{{SYSTEM}}/g, platform.systemPattern)
.replace(/{{ARCH}}/g, platform.archPattern);
}
export function getMatchingAsset(assets: ReleaseAsset[], platform: PlatformInfo, fileName?: string, fileType?: string): ReleaseAsset {
// Filename provided as literal string (no ~): exact match.
if (fileName && !fileName.startsWith('~')) {
const exactMatches = assets.filter((asset) => asset.name === fileName);
if (exactMatches.length !== 1) {
throw new Error(`Expected exactly one asset to match the provided filename, matched: ${exactMatches.length}`);
}
return exactMatches[0];
}
// Filetype filtering stage (or passthrough when not provided).
let fileTypeFilteredAssets: ReleaseAsset[] = assets;
if (fileType) {
if (Object.hasOwn(knownFileTypes, fileType)) {
// 2. Known fileType key: use predefined glob.
const fileTypeGlob = knownFileTypes[fileType];
fileTypeFilteredAssets = assets.filter((asset) => minimatch(asset.name, fileTypeGlob, { nocase: true }));
} else if (fileType.startsWith('~')) {
// 3. Custom regex fileType: match regex at end of string.
const fileTypeRegex = `${fileType.substring(1)}$`;
fileTypeFilteredAssets = filterByRegex(assets, fileTypeRegex);
} else {
// 4. Custom extension fileType: treat as plain extension glob.
const extension = fileType.replace(/^\./, '');
const fileTypeGlob = `*.${extension}`;
fileTypeFilteredAssets = assets.filter((asset) => minimatch(asset.name, fileTypeGlob, { nocase: true }));
}
}
// 4. Filename provided with ~: platform placeholder expansion and regex filtering.
if (fileName && fileName.startsWith('~')) {
const fileNamePattern = replacePlatformPlaceholders(fileName.substring(1), platform);
const fileNameFilteredAssets = filterByRegex(fileTypeFilteredAssets, fileNamePattern);
if (fileNameFilteredAssets.length !== 1) {
throw new Error(`Expected exactly one asset to match the filename regex, matched: ${fileNameFilteredAssets.length}`);
}
return fileNameFilteredAssets[0];
}
// 5. No filename: use default {{SYSTEM}}-{{ARCH}} regex.
const defaultPattern = replacePlatformPlaceholders('{{SYSTEM}}[_-]{{ARCH}}', platform);
const defaultFilteredAssets = filterByRegex(fileTypeFilteredAssets, defaultPattern);
// 6. Zero or multiple matches are errors.
if (defaultFilteredAssets.length !== 1) {
const errorMessage = defaultFilteredAssets.length === 0
? `No assets matched the default criteria: ${defaultPattern}`
: `Multiple assets matched the default criteria: ${defaultFilteredAssets.map((asset) => asset.name).join(', ')}`;
throw new Error(errorMessage);
}
return defaultFilteredAssets[0];
}
+36
View File
@@ -0,0 +1,36 @@
import * as os from 'os';
export interface PlatformInfo {
system: string;
arch: string;
systemPattern: string;
archPattern: string;
}
export interface PlatformOverrides {
system?: string;
arch?: 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(overrides?: PlatformOverrides): PlatformInfo {
const system = (overrides?.system || os.platform()).toLowerCase();
const arch = (overrides?.arch || os.arch()).toLowerCase();
return {
system,
arch,
systemPattern: systemPatterns[system] || system,
archPattern: archPatterns[arch] || arch
};
}
+93 -123
View File
@@ -1,165 +1,136 @@
import * as core from '@actions/core'; import * as core from '@actions/core';
import * as tc from '@actions/tool-cache'; import * as tc from '@actions/tool-cache';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os';
import * as fs from 'fs'; import * as fs from 'fs';
import { spawnSync } from 'child_process';
import { getPlatformInfo } from './core/platform';
import { getMatchingAsset } from './core/matcher';
import { findBinary } from './core/finder';
import { fetchLatestRelease } from './core/downloader';
function findBinary(dir: string, pattern: string | RegExp, debug: boolean): string | undefined { function installSystemPackage(downloadPath: string): void {
const items = fs.readdirSync(dir); const fileName = path.basename(downloadPath).toLowerCase();
if (debug) {
core.info(`Searching for binary in ${dir}...`); const command: { binary: string; args: string[] } | undefined = fileName.endsWith('.deb')
items.forEach(item => core.info(` - ${item}`)); ? { binary: 'dpkg', args: ['-i', downloadPath] }
: fileName.endsWith('.pkg')
? { binary: 'installer', args: ['-pkg', downloadPath, '-target', '/'] }
: fileName.endsWith('.rpm')
? { binary: 'rpm', args: ['-i', downloadPath] }
: undefined;
if (!command) {
throw new Error(`Unsupported package type: ${fileName}`);
} }
for (const item of items) { const isRoot = process.getuid && process.getuid() === 0;
const fullPath = path.join(dir, item); const commandToRun = isRoot ? command.binary : 'sudo';
const stat = fs.statSync(fullPath); const argsToRun = isRoot ? command.args : [command.binary, ...command.args];
if (stat.isDirectory()) {
const found = findBinary(fullPath, pattern, debug); const result = spawnSync(commandToRun, argsToRun, { stdio: 'inherit' });
if (found) return found; if (result.status !== 0) {
} else { throw new Error(`Failed to install package using ${commandToRun} ${argsToRun.join(' ')}.`);
let isMatch = false; }
if (pattern instanceof RegExp) { }
isMatch = pattern.test(item);
} else { function findInstalledBinary(binaryName: string): string | undefined {
isMatch = item === pattern; const isRegex = binaryName.startsWith('~');
// On Windows, also check for .exe extension if the pattern doesn't have it if (!isRegex) {
if (!isMatch && process.platform === 'win32' && !pattern.toLowerCase().endsWith('.exe')) { const whichResult = spawnSync('which', [binaryName], { encoding: 'utf8' });
isMatch = item.toLowerCase() === `${pattern.toLowerCase()}.exe`; if (whichResult.status === 0) {
const resolvedPath = (whichResult.stdout || '').trim();
if (resolvedPath) {
return resolvedPath;
} }
} }
if (isMatch) return fullPath; }
const candidates = ['/usr/local/bin', '/usr/bin', '/opt/homebrew/bin', '/opt/local/bin'];
const pattern: string | RegExp = isRegex ? new RegExp(binaryName.substring(1), 'i') : binaryName;
for (const candidateDir of candidates) {
if (!fs.existsSync(candidateDir)) {
continue;
}
const candidatePath = findBinary(candidateDir, pattern, false, () => undefined);
if (candidatePath) {
return candidatePath;
} }
} }
return undefined; return undefined;
} }
async function run() { async function run() {
try { try {
const repository = core.getInput('repository', { required: true }); 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 binaryInput = core.getInput('binary-name');
const fileType = core.getInput('file-type') || 'archive'; const fileType = core.getInput('file-type');
const updateCache = core.getInput('update-cache') || 'false';
const debug = core.getBooleanInput('debug'); const debug = core.getBooleanInput('debug');
const token = core.getInput('token') || process.env.GITHUB_TOKEN; const token = core.getInput('token') || process.env.GITHUB_TOKEN;
// Detect system and architecture const platformInfo = getPlatformInfo();
const platform = os.platform(); // 'linux', 'darwin', 'win32' const toolName = repository.split('/').pop() || repository;
const arch = os.arch(); // 'x64', 'arm64'
const systemPatterns: Record<string, string> = { // Rule for update-cache: 'false' means use ANY cached version if available
linux: 'linux', if (updateCache === 'false') {
darwin: '(darwin|macos|mac)', const allVersions = tc.findAllVersions(toolName, platformInfo.arch);
win32: '(windows|win)' if (allVersions.length > 0) {
}; const latestVersion = allVersions.sort().pop();
if (latestVersion) {
const archPatterns: Record<string, string> = { const cachedDir = tc.find(toolName, latestVersion, platformInfo.arch);
x64: '(x86_64|x64|amd64)', if (cachedDir) {
arm64: '(aarch64|arm64)' core.info(`Found ${toolName} version ${latestVersion} in local cache (update-cache: false)`);
}; core.addPath(cachedDir);
return;
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}...`); core.info(`Fetching latest release information for ${repository}...`);
const response = await fetch(url, { headers }); const release = await fetchLatestRelease(repository, token);
if (!response.ok) { const asset = getMatchingAsset(release.assets, platformInfo, fileNameInput, fileType);
throw new Error(`Failed to fetch release: ${response.statusText} (${response.status})`);
}
const data: any = await response.json(); core.info(`Selected asset: ${asset.name}`);
let asset;
if (!fileName) { const version = release.tag_name.replace(/^v/, '');
// 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 binaryName = binaryInput || toolName; const binaryName = binaryInput || toolName;
// Check if the tool is already in the cache // Check if the tool is already in the cache (if not 'always' update)
const cachedDir = tc.find(toolName, version, arch); if (updateCache !== 'always') {
const cachedDir = tc.find(toolName, version, platformInfo.arch);
if (cachedDir) { if (cachedDir) {
core.info(`Found ${toolName} version ${version} in cache at ${cachedDir}`); core.info(`Found ${toolName} version ${version} in cache at ${cachedDir}`);
core.addPath(cachedDir); core.addPath(cachedDir);
return; return;
} }
}
const downloadUrl = asset.browser_download_url; const downloadUrl = asset.browser_download_url;
core.info(`Downloading ${asset.name} from ${downloadUrl}...`); 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(); const nameLower = asset.name.toLowerCase();
let toolDir: string; let toolDir: string;
if (/\.(deb|pkg|rpm)$/i.test(nameLower)) {
core.info(`Installing package asset ${asset.name}...`);
installSystemPackage(downloadPath);
const binaryPath = findInstalledBinary(binaryName);
if (!binaryPath) {
throw new Error(`Package installed, but binary "${binaryName}" could not be located in common executable paths.`);
}
const binaryDir = path.dirname(binaryPath);
core.addPath(binaryDir);
core.info(`Binary found at ${binaryPath}. Added ${binaryDir} to PATH.`);
return;
}
// Determine extraction method based on extension // Determine extraction method based on extension
if (/\.(tar\.gz|tar|tgz)$/i.test(nameLower)) { if (/\.(tar\.gz|tar|tgz)$/i.test(nameLower)) {
toolDir = await tc.extractTar(downloadPath); toolDir = await tc.extractTar(downloadPath);
@@ -179,7 +150,6 @@ async function run() {
} }
fs.renameSync(downloadPath, destPath); fs.renameSync(downloadPath, destPath);
// Make it executable on Linux/macOS
if (process.platform !== 'win32') { if (process.platform !== 'win32') {
fs.chmodSync(destPath, '755'); fs.chmodSync(destPath, '755');
} }
@@ -195,14 +165,14 @@ async function run() {
core.info(`Searching for binary named: ${binaryName}`); 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) { if (!binaryPath) {
throw new Error(`Could not find binary "${binaryName}" in the extracted asset.`); throw new Error(`Could not find binary "${binaryName}" in the extracted asset.`);
} }
// The tool directory is the one containing the binary // The tool directory is the one containing the binary
toolDir = path.dirname(binaryPath); 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 // Make binary executable just in case it's not
if (process.platform !== 'win32') { if (process.platform !== 'win32') {
@@ -210,7 +180,7 @@ async function run() {
} }
// Cache the tool // 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.info(`Cached ${toolName} version ${version} to ${finalCachedDir}`);
core.addPath(finalCachedDir); core.addPath(finalCachedDir);