feat: add github-release custom strategy

This commit is contained in:
2026-04-05 14:58:49 +03:00
parent ea79fa91a1
commit 900fa9681c
6 changed files with 278 additions and 9 deletions

View File

@@ -245,7 +245,8 @@ For a full list with all the supported options, see [the docs](./docs/installer-
repository path, e.g. `chenasraf/sofmani`, GitHub is assumed.
- **`github-release`**
- Downloads a GitHub release asset. Optionally untar, unzip, or gunzip the downloaded file.
- Downloads a GitHub release asset. Optionally untar, unzip, gunzip, or run a custom
shell hook to extract the downloaded file.
- **`manifest`**
- Installs an entire manifest from a local or remote file.

View File

@@ -385,6 +385,11 @@ Available variables:
| `{{ .DeviceIDAlias }}` | Friendly alias for the current machine, if defined in `machine_aliases` | `work-laptop` |
| `{{ .Tag }}` | Full tag name (only available in `github-release` `download_filename`) | `v1.0.0` |
| `{{ .Version }}` | Version without leading "v" (only available in `github-release` `download_filename`) | `1.0.0` |
| `{{ .DownloadFile }}` | Absolute path to the downloaded asset (only in `github-release` `extract_command`) | `/tmp/sofmani.../app.download` |
| `{{ .ExtractDir }}` | Temp directory to extract into (only in `github-release` `extract_command`) | `/tmp/sofmani...` |
| `{{ .Destination }}` | Final destination directory (only in `github-release` `extract_command`) | `~/.local/bin` |
| `{{ .BinName }}` | Expected output binary name (only in `github-release` `extract_command`) | `my-tool` |
| `{{ .ArchiveBinName }}`| Filename sofmani copies from `ExtractDir` → `Destination` (only in `extract_command`)| `my-tool` |
In addition, `DEVICE_ID` and `DEVICE_ID_ALIAS` are injected as **environment variables** into all
command executions, so they can also be referenced as `$DEVICE_ID` and `$DEVICE_ID_ALIAS` in shell
@@ -454,7 +459,8 @@ Downloads a GitHub release asset. Optionally untar/unzip the downloaded file.
- `opts.repository`: The repository to download from. Should be in the format:
`user/repository-name`
- `opts.destination`: The target directory to extract the files to.
- `opts.strategy`: The download strategy. Can be one of: `tar`, `zip`, `gzip`, `none` (default)
- `opts.strategy`: The download strategy. Can be one of: `tar`, `zip`, `gzip`, `custom`, `none`
(default)
- `none` - the release file is not compressed, and should be copied directly
- `tar` - the release file is a tar file, and should be extracted
- `zip` - the release file is a zip file, and should be extracted
@@ -472,6 +478,54 @@ Downloads a GitHub release asset. Optionally untar/unzip the downloaded file.
strategy: gzip
download_filename: tree-sitter-{{ .OS }}-{{ .ArchAlias }}.gz
```
- `custom` - run a user-provided shell hook (`opts.extract_command`) to extract the
downloaded asset yourself. After the command finishes, sofmani copies
`{{ .ExtractDir }}/{{ .ArchiveBinName }}` to `{{ .Destination }}/{{ .BinName }}` and
sets the executable bit — exactly like the `tar` and `zip` strategies do. Use this
for unusual archive formats (7-Zip, xz, self-extracting installers, ...).
- `opts.extract_command`: The shell command to run when `strategy: custom`. It goes through
the same Go template substitution as other sofmani shell hooks, with extra variables
specific to the extract context:
| Variable | Description |
| ---------------------- | ----------------------------------------------------------------------- |
| `{{ .DownloadFile }}` | Absolute path to the downloaded asset |
| `{{ .ExtractDir }}` | Temp directory — your command should place extracted files here |
| `{{ .Destination }}` | Final destination directory (from `opts.destination`) |
| `{{ .BinName }}` | The expected output binary name (`bin_name` or installer name) |
| `{{ .ArchiveBinName }}`| Filename sofmani will copy from `ExtractDir` → `Destination` afterwards |
All the usual template variables (`{{ .OS }}`, `{{ .Arch }}`, `{{ .Tag }}`, ...) are also
available. `extract_command` is required when `strategy: custom`, and is not allowed
with any other strategy.
Example — extracting a `.tar.xz` asset by shelling out to `tar`:
```yaml
- name: my-tool
type: github-release
opts:
repository: example/my-tool
destination: ~/.local/bin
strategy: custom
download_filename: my-tool-{{ .Version }}-{{ .OS }}.tar.xz
extract_command: tar -xJf {{ .DownloadFile }} -C {{ .ExtractDir }}
```
Example — extracting a 7-Zip asset:
```yaml
- name: weird-tool
type: github-release
opts:
repository: example/weird-tool
destination: ~/.local/bin
strategy: custom
download_filename: weird-tool-{{ .Version }}.7z
extract_command: 7z x {{ .DownloadFile }} -o{{ .ExtractDir }}
```
- `opts.download_filename`: The filename of the release asset to download.
This should either be a string, or a map of platforms to filenames.

View File

@@ -60,6 +60,17 @@ type GitHubReleaseOpts struct {
// a symlink at Target pointing to Source; on Windows, the file is copied instead (since
// symlinks require elevated privileges). Only meaningful with ExtractTo.
BinLinks []GitHubReleaseBinLink
// ExtractCommand is a user-provided shell command that performs the extraction when
// Strategy is "custom". The command is run through Go template substitution with these
// extra variables available (in addition to the usual .OS, .Arch, .Tag, ...):
// {{ .DownloadFile }} - absolute path to the downloaded asset
// {{ .ExtractDir }} - temp directory where the command should place extracted files
// {{ .Destination }} - final destination directory
// {{ .BinName }} - expected binary name (matches GetBinName())
// {{ .ArchiveBinName }} - the filename sofmani will copy from ExtractDir to Destination
// After the command finishes, sofmani copies ExtractDir/ArchiveBinName to
// Destination/BinName, the same way the tar and zip strategies do.
ExtractCommand *string
}
// GitHubReleaseBinLink describes a single binary exposed from a tree-mode install.
@@ -76,10 +87,11 @@ type GitHubReleaseInstallStrategy string
// Constants for GitHub release installation strategies.
const (
GitHubReleaseInstallStrategyNone GitHubReleaseInstallStrategy = "none" // GitHubReleaseInstallStrategyNone means no special handling, just download the file.
GitHubReleaseInstallStrategyTar GitHubReleaseInstallStrategy = "tar" // GitHubReleaseInstallStrategyTar means extract a tar archive.
GitHubReleaseInstallStrategyZip GitHubReleaseInstallStrategy = "zip" // GitHubReleaseInstallStrategyZip means extract a zip archive.
GitHubReleaseInstallStrategyGzip GitHubReleaseInstallStrategy = "gzip" // GitHubReleaseInstallStrategyGzip means decompress a single gzip-compressed file (not a tar archive).
GitHubReleaseInstallStrategyNone GitHubReleaseInstallStrategy = "none" // GitHubReleaseInstallStrategyNone means no special handling, just download the file.
GitHubReleaseInstallStrategyTar GitHubReleaseInstallStrategy = "tar" // GitHubReleaseInstallStrategyTar means extract a tar archive.
GitHubReleaseInstallStrategyZip GitHubReleaseInstallStrategy = "zip" // GitHubReleaseInstallStrategyZip means extract a zip archive.
GitHubReleaseInstallStrategyGzip GitHubReleaseInstallStrategy = "gzip" // GitHubReleaseInstallStrategyGzip means decompress a single gzip-compressed file (not a tar archive).
GitHubReleaseInstallStrategyCustom GitHubReleaseInstallStrategy = "custom" // GitHubReleaseInstallStrategyCustom runs a user-provided shell command to extract the asset.
)
// Validate validates the installer configuration.
@@ -107,12 +119,22 @@ func (i *GitHubReleaseInstaller) Validate() []ValidationError {
case GitHubReleaseInstallStrategyNone,
GitHubReleaseInstallStrategyTar,
GitHubReleaseInstallStrategyZip,
GitHubReleaseInstallStrategyGzip:
GitHubReleaseInstallStrategyGzip,
GitHubReleaseInstallStrategyCustom:
// valid
default:
errors = append(errors, ValidationError{FieldName: "strategy", Message: validationInvalidFormat(), InstallerName: *info.Name})
}
}
// extract_command only makes sense with strategy: custom, and strategy: custom requires it.
strategyIsCustom := opts.Strategy != nil && *opts.Strategy == GitHubReleaseInstallStrategyCustom
hasExtractCommand := opts.ExtractCommand != nil && *opts.ExtractCommand != ""
if strategyIsCustom && !hasExtractCommand {
errors = append(errors, ValidationError{FieldName: "extract_command", Message: validationIsRequired(), InstallerName: *info.Name})
}
if hasExtractCommand && !strategyIsCustom {
errors = append(errors, ValidationError{FieldName: "extract_command", Message: "extract_command requires strategy: custom", InstallerName: *info.Name})
}
if opts.ExtractTo != nil {
// Tree mode requires an archive strategy — a single downloaded file has no tree to
// extract. We check explicitly rather than relying on the Install-time error so
@@ -305,6 +327,28 @@ func (i *GitHubReleaseInstaller) Install() error {
}
success = true
err = nil
case GitHubReleaseInstallStrategyCustom:
logger.Debug("Strategy 'custom': running user extract_command against %s", tmpOut.Name())
if opts.ExtractCommand == nil || *opts.ExtractCommand == "" {
return fmt.Errorf("strategy 'custom' requires opts.extract_command")
}
extractVars := *templateVars
extractVars.DownloadFile = tmpOut.Name()
extractVars.ExtractDir = tmpDir
extractVars.Destination = *opts.Destination
extractVars.BinName = i.GetBinName()
extractVars.ArchiveBinName = i.GetArchiveBinName()
if err = i.runCustomExtract(*opts.ExtractCommand, &extractVars); err != nil {
return fmt.Errorf("custom extract failed: %w", err)
}
logger.Debug("Strategy 'custom': copying binary '%s' to destination", i.GetArchiveBinName())
success, err = i.CopyExtractedFile(out, tmpDir)
if !success {
return fmt.Errorf("failed to copy extracted file: %w", err)
}
if err != nil {
return err
}
default:
logger.Debug("Strategy 'none': copying downloaded file directly to destination")
// Seek back to beginning of temp file before copying
@@ -414,6 +458,29 @@ func (i *GitHubReleaseInstaller) GetArchiveBinName() string {
return i.GetBinName()
}
// runCustomExtract runs a user-provided extract command through the platform's
// default shell. The command is first rendered with ApplyTemplate so users can
// reference {{ .DownloadFile }}, {{ .ExtractDir }}, {{ .Destination }},
// {{ .BinName }}, {{ .ArchiveBinName }}, and all the usual template variables
// (.OS, .Arch, .Tag, ...).
func (i *GitHubReleaseInstaller) runCustomExtract(command string, vars *TemplateVars) error {
rendered, err := ApplyTemplate(command, vars, *i.Info.Name)
if err != nil {
return fmt.Errorf("failed to render extract_command template: %w", err)
}
logger.Debug("Custom extract command: %s", rendered)
shell := utils.GetOSShell(i.GetData().EnvShell)
args := utils.GetOSShellArgs(rendered)
success, err := i.RunCmdGetSuccessPassThrough(shell, args...)
if err != nil {
return err
}
if !success {
return fmt.Errorf("extract_command exited non-zero")
}
return nil
}
// decompressGzip reads a gzip-compressed stream from src and writes the
// decompressed bytes to dst. It is used by the "gzip" github-release strategy
// for single-file gzipped assets (i.e. not tarballs).
@@ -597,6 +664,9 @@ func (i *GitHubReleaseInstaller) GetOpts() *GitHubReleaseOpts {
if archiveBinName, ok := (*info.Opts)["archive_bin_name"].(string); ok {
opts.ArchiveBinName = &archiveBinName
}
if extractCommand, ok := (*info.Opts)["extract_command"].(string); ok {
opts.ExtractCommand = &extractCommand
}
if extractTo, ok := (*info.Opts)["extract_to"].(string); ok {
extractTo = utils.GetRealPath(i.GetData().Environ(), extractTo)
opts.ExtractTo = &extractTo

View File

@@ -102,6 +102,47 @@ func TestGitHubReleaseValidation(t *testing.T) {
},
}
assertNoValidationErrors(t, newTestGitHubReleaseInstaller(gzipStrategy).Validate())
// 🟢 Valid custom strategy with extract_command
customStrategy := &appconfig.InstallerData{
Name: lo.ToPtr("ghr-custom"),
Type: appconfig.InstallerTypeGitHubRelease,
Opts: &map[string]any{
"repository": "owner/repo",
"destination": "/some/path",
"download_filename": "file.weird",
"strategy": "custom",
"extract_command": "7z x {{ .DownloadFile }} -o{{ .ExtractDir }}",
},
}
assertNoValidationErrors(t, newTestGitHubReleaseInstaller(customStrategy).Validate())
// 🔴 custom strategy without extract_command
customMissingCmd := &appconfig.InstallerData{
Name: lo.ToPtr("ghr-custom-missing"),
Type: appconfig.InstallerTypeGitHubRelease,
Opts: &map[string]any{
"repository": "owner/repo",
"destination": "/some/path",
"download_filename": "file.weird",
"strategy": "custom",
},
}
assertValidationError(t, newTestGitHubReleaseInstaller(customMissingCmd).Validate(), "extract_command")
// 🔴 extract_command without strategy: custom
extractCmdWrongStrategy := &appconfig.InstallerData{
Name: lo.ToPtr("ghr-custom-wrong-strategy"),
Type: appconfig.InstallerTypeGitHubRelease,
Opts: &map[string]any{
"repository": "owner/repo",
"destination": "/some/path",
"download_filename": "file.tar.gz",
"strategy": "tar",
"extract_command": "echo nope",
},
}
assertValidationError(t, newTestGitHubReleaseInstaller(extractCmdWrongStrategy).Validate(), "extract_command")
}
func TestGitHubReleaseGetOpts(t *testing.T) {
@@ -195,6 +236,89 @@ func TestGitHubReleaseGetOpts(t *testing.T) {
assert.Equal(t, GitHubReleaseInstallStrategyGzip, *opts.Strategy)
})
t.Run("handles custom strategy with extract_command", func(t *testing.T) {
data := &appconfig.InstallerData{
Name: lo.ToPtr("test-release"),
Type: appconfig.InstallerTypeGitHubRelease,
Opts: &map[string]any{
"strategy": "custom",
"extract_command": "cp {{ .DownloadFile }} {{ .ExtractDir }}/{{ .ArchiveBinName }}",
},
}
installer := newTestGitHubReleaseInstaller(data)
opts := installer.GetOpts()
assert.Equal(t, GitHubReleaseInstallStrategyCustom, *opts.Strategy)
assert.NotNil(t, opts.ExtractCommand)
assert.Contains(t, *opts.ExtractCommand, "{{ .DownloadFile }}")
})
}
func TestGitHubReleaseCustomExtract(t *testing.T) {
logger.InitLogger(false)
if runtime.GOOS == "windows" {
t.Skip("custom extract test uses a POSIX shell command")
}
// Prepare a fake "downloaded" asset on disk and an extract dir.
tmpDir := t.TempDir()
downloadFile := filepath.Join(tmpDir, "asset.weird")
payload := []byte("binary payload from sofmani custom extract test")
assert.NoError(t, os.WriteFile(downloadFile, payload, 0644))
extractDir := filepath.Join(tmpDir, "extract")
assert.NoError(t, os.Mkdir(extractDir, 0755))
data := &appconfig.InstallerData{
Name: lo.ToPtr("custom-tool"),
Type: appconfig.InstallerTypeGitHubRelease,
Opts: &map[string]any{
// The user's command references template variables directly instead of env vars.
// Here we pretend the "weird" asset really just needs to be copied.
"extract_command": "cp {{ .DownloadFile }} {{ .ExtractDir }}/{{ .ArchiveBinName }}",
},
}
installer := newTestGitHubReleaseInstaller(data)
vars := NewTemplateVars("v1.2.3", nil)
vars.DownloadFile = downloadFile
vars.ExtractDir = extractDir
vars.Destination = filepath.Join(tmpDir, "dest")
vars.BinName = "custom-tool"
vars.ArchiveBinName = "custom-tool"
err := installer.runCustomExtract("cp {{ .DownloadFile }} {{ .ExtractDir }}/{{ .ArchiveBinName }}", vars)
assert.NoError(t, err)
// The command should have produced extractDir/custom-tool with the original payload.
got, err := os.ReadFile(filepath.Join(extractDir, "custom-tool"))
assert.NoError(t, err)
assert.Equal(t, payload, got)
}
func TestGitHubReleaseCustomExtractFailures(t *testing.T) {
logger.InitLogger(false)
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell command")
}
data := &appconfig.InstallerData{
Name: lo.ToPtr("custom-tool"),
Type: appconfig.InstallerTypeGitHubRelease,
}
installer := newTestGitHubReleaseInstaller(data)
vars := NewTemplateVars("v0.0.0", nil)
t.Run("non-zero exit is surfaced", func(t *testing.T) {
err := installer.runCustomExtract("exit 42", vars)
assert.Error(t, err)
})
t.Run("invalid template is surfaced", func(t *testing.T) {
err := installer.runCustomExtract("echo {{ .NopeField", vars)
assert.Error(t, err)
})
}
func TestDecompressGzip(t *testing.T) {

View File

@@ -28,6 +28,22 @@ type TemplateVars struct {
DeviceID string
// DeviceIDAlias is the friendly alias for the current machine, if one is defined in machine_aliases.
DeviceIDAlias string
// DownloadFile is the absolute path to the downloaded asset. Only populated for
// github-release custom extract commands.
DownloadFile string
// ExtractDir is the temp directory where a custom extract command should place
// extracted files. Only populated for github-release custom extract commands.
ExtractDir string
// Destination is the final destination directory. Only populated for github-release
// custom extract commands.
Destination string
// BinName is the expected output binary name. Only populated for github-release
// custom extract commands.
BinName string
// ArchiveBinName is the filename sofmani will copy from ExtractDir to Destination
// after the custom extract command finishes. Only populated for github-release
// custom extract commands.
ArchiveBinName string
}
// legacyTokens maps old-style tokens to their TemplateVars field names.

View File

@@ -304,9 +304,13 @@
"destination": { "type": "string" },
"strategy": {
"type": "string",
"enum": ["tar", "zip", "gzip", "gz", "none"],
"enum": ["tar", "zip", "gzip", "gz", "none", "custom"],
"default": "none",
"description": "How to handle the downloaded asset. 'none' copies it directly; 'tar' and 'zip' extract an archive; 'gzip' (alias 'gz') decompresses a single gzip-compressed file (not a tarball)."
"description": "How to handle the downloaded asset. 'none' copies it directly; 'tar' and 'zip' extract an archive; 'gzip' (alias 'gz') decompresses a single gzip-compressed file (not a tarball); 'custom' runs opts.extract_command as a user-supplied hook."
},
"extract_command": {
"type": "string",
"description": "Shell command to run when strategy is 'custom'. Supports Go template variables — in addition to the usual {{ .OS }}, {{ .Arch }}, {{ .Tag }}, etc., the following extract-specific variables are available: {{ .DownloadFile }}, {{ .ExtractDir }}, {{ .Destination }}, {{ .BinName }}, {{ .ArchiveBinName }}. After the command finishes, sofmani copies {{ .ExtractDir }}/{{ .ArchiveBinName }} to {{ .Destination }}/{{ .BinName }}."
},
"download_filename": {
"description": "Asset filename, or a per-platform map. Supports Go template variables.",