├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── codeql.yml ├── .gitignore ├── LICENSE.md ├── Makefile ├── README.md ├── cmd └── hookah │ ├── favicon.ico │ └── main.go ├── exec.go ├── exec_test.go ├── go.mod ├── go.sum ├── server.go └── testdata ├── env-test-server └── env.sh └── exec-only-test-server ├── @@ ├── @@ │ ├── event │ │ ├── action │ │ │ ├── @@error.exec.sh │ │ │ └── exec.sh │ │ └── exec.sh │ └── exec.sh ├── exec.sh ├── exec.symlink.symlink.sh ├── noexec.symlink.symlink.sh └── repo │ ├── event │ ├── action │ │ ├── @@error.exec.sh │ │ └── exec.sh │ └── exec.sh │ └── exec.sh ├── @@error.exec.sh ├── @@error.noexec.sh ├── exec.sh ├── exec.symlink.sh ├── noexec.sh ├── noexec.symlink.sh └── user ├── @@ ├── event │ ├── action │ │ ├── @@error.exec.sh │ │ └── exec.sh │ └── exec.sh └── exec.sh ├── @@error.exec.sh ├── @@error.noexec.sh ├── exec.sh ├── noexec.sh └── repo ├── @@error.exec.sh ├── @@error.noexec.sh ├── event ├── action │ ├── @@error.exec.sh │ └── exec.sh └── exec.sh ├── exec.sh └── noexec.sh /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "11:00" 8 | open-pull-requests-limit: 10 9 | - package-ecosystem: "github-actions" 10 | directory: "/" 11 | schedule: 12 | interval: daily 13 | time: "11:00" 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: CI 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go-version: [1.21.x] 8 | platform: [ubuntu-latest, macos-latest] 9 | runs-on: ${{ matrix.platform }} 10 | steps: 11 | - name: Install Go 12 | uses: actions/setup-go@v5 13 | with: 14 | go-version: ${{ matrix.go-version }} 15 | 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Build 20 | run: go build ./... 21 | 22 | - name: Test 23 | run: go test ./... 24 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "master" ] 17 | pull_request: 18 | branches: [ "master" ] 19 | schedule: 20 | - cron: '16 17 * * 2' 21 | 22 | jobs: 23 | analyze: 24 | name: Analyze 25 | # Runner size impacts CodeQL analysis time. To learn more, please see: 26 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 27 | # - https://gh.io/supported-runners-and-hardware-resources 28 | # - https://gh.io/using-larger-runners 29 | # Consider using larger runners for possible analysis time improvements. 30 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 31 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} 32 | permissions: 33 | # required for all workflows 34 | security-events: write 35 | 36 | # only required for workflows in private repositories 37 | actions: read 38 | contents: read 39 | 40 | strategy: 41 | fail-fast: false 42 | matrix: 43 | language: [ 'go' ] 44 | # CodeQL supports [ 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' ] 45 | # Use only 'java-kotlin' to analyze code written in Java, Kotlin or both 46 | # Use only 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both 47 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 48 | 49 | steps: 50 | - name: Checkout repository 51 | uses: actions/checkout@v4 52 | 53 | # Initializes the CodeQL tools for scanning. 54 | - name: Initialize CodeQL 55 | uses: github/codeql-action/init@v3 56 | with: 57 | languages: ${{ matrix.language }} 58 | # If you wish to specify custom queries, you can do so here or in a config file. 59 | # By default, queries listed here will override any specified in a config file. 60 | # Prefix the list here with "+" to use these queries and those in the config file. 61 | 62 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 63 | # queries: security-extended,security-and-quality 64 | 65 | 66 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). 67 | # If this step fails, then you should remove it and run the build manually (see below) 68 | - name: Autobuild 69 | uses: github/codeql-action/autobuild@v3 70 | 71 | # ℹ️ Command-line programs to run using the OS shell. 72 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 73 | 74 | # If the Autobuild fails above, remove it and uncomment the following three lines. 75 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 76 | 77 | # - run: | 78 | # echo "Run, Build Application using script" 79 | # ./location_of_script_within_repo/buildscript.sh 80 | 81 | - name: Perform CodeQL Analysis 82 | uses: github/codeql-action/analyze@v3 83 | with: 84 | category: "/language:${{matrix.language}}" 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /hookah/ 2 | /hookah 3 | /cmd/hookah/hookah/ 4 | 5 | /release/ 6 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License 2 | =============== 3 | 4 | Copyright (c) 2016 Jesse G. Donat 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BIN=hookah 2 | HEAD=$(shell git describe --tags 2> /dev/null || git rev-parse --short HEAD) 3 | 4 | default: setup test install 5 | 6 | setup: 7 | ifeq ($(shell echo $$CI),true) 8 | cd cmd/hookah && go get -u -v 9 | endif 10 | 11 | test: 12 | go test ./... 13 | 14 | install: 15 | go install ./cmd/hookah 16 | 17 | .PHONY: clean 18 | clean: 19 | -rm -rf release 20 | mkdir release 21 | 22 | .PHONY: release 23 | release: clean release/darwin_amd64 release/darwin_arm64 release/linux_amd64 24 | cd release/darwin_amd64 && zip -9 ../$(BIN).darwin_amd64.$(HEAD).zip $(BIN) 25 | cd release/darwin_arm64 && zip -9 ../$(BIN).darwin_arm64.$(HEAD).zip $(BIN) 26 | cd release/linux_amd64 && zip -9 ../$(BIN).linux_amd64.$(HEAD).zip $(BIN) 27 | 28 | release/darwin_amd64: 29 | env GOOS=darwin GOARCH=amd64 go build -o release/darwin_amd64/$(BIN) ./cmd/hookah 30 | 31 | release/darwin_arm64: 32 | env GOOS=darwin GOARCH=arm64 go build -o release/darwin_arm64/$(BIN) ./cmd/hookah 33 | 34 | release/linux_amd64: 35 | env GOOS=linux GOARCH=amd64 go build -o release/linux_amd64/$(BIN) ./cmd/hookah 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hookah 2 | 3 | [![Join the chat at https://gitter.im/hookah-server/Lobby](https://badges.gitter.im/hookah-server/Lobby.svg)](https://gitter.im/hookah-server/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 4 | 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/donatj/hookah/v3)](https://goreportcard.com/report/github.com/donatj/hookah/v3) 6 | [![GoDoc](https://godoc.org/github.com/donatj/hookah/v3?status.svg)](https://godoc.org/github.com/donatj/hookah/v3) 7 | [![CI](https://github.com/donatj/hookah/actions/workflows/ci.yml/badge.svg)](https://github.com/donatj/hookah/actions/workflows/ci.yml) 8 | 9 | Hookah is a simple server for GitHub Webhooks that forwards the hooks message to any series of scripts, be they PHP, Ruby, Python or even straight up shell. 10 | 11 | It simply passes the message on to the STDIN of any script. 12 | 13 | ## Installation 14 | 15 | ### From Source 16 | 17 | Building v3 requires Go 1.20+ 18 | 19 | ```bash 20 | go install github.com/donatj/hookah/v3/cmd/hookah@latest 21 | ``` 22 | 23 | ### From Binary 24 | 25 | see: [Releases](https://github.com/donatj/hookah/releases). 26 | 27 | ## Basic Usage 28 | 29 | When receiving a Webhook request from GitHub, Hookah checks `{server-root}/{vendor}/{repo}/{X-GitHub-Event}/*` for any ***executable*** scripts, and executes them sequentially passing the JSON payload to it's standard in. 30 | 31 | This allows actual hook scripts to be written in any language you prefer. 32 | 33 | For example, a script `server/donatj/hookah/push/log.rb` would be executed every time a "push" event Webhook was received from GitHub on the donatj/hookah repo. 34 | 35 | ## Example Hook Scripts 36 | 37 | ### bash + [jq](https://stedolan.github.io/jq/) 38 | 39 | ```bash 40 | #!/bin/bash 41 | 42 | set -e 43 | 44 | json=`cat` 45 | ref=$(<<< "$json" jq -r .ref) 46 | 47 | echo "$ref" 48 | if [ "$ref" == "refs/heads/master" ] 49 | then 50 | echo "Ref was Master" 51 | else 52 | echo "Ref was not Master" 53 | fi 54 | 55 | ``` 56 | 57 | ### PHP 58 | 59 | ```php 60 | #!/usr/bin/php 61 | `, and having a [shebang](https://en.m.wikipedia.org/wiki/Shebang_(Unix)) pointing to your desired interpreter, i.e. `#!/bin/bash` 73 | 74 | ## Documentation 75 | 76 | Standard input (stdin) contains the unparsed JSON body of the request. 77 | 78 | ### Execution 79 | 80 | The server root layout looks like `{server-root}/{vendor}/{repo}/{X-GitHub-Event}/{script-name}` 81 | 82 | Scripts are executed at each level, in order of least specific to most specific. At an individual level, the execution order is **file system specific** and *must not* be depended upon. 83 | 84 | A directory at the vendor or repo level named `@@` will behave as a wildcard. As such a file named `server-root/donatj/@@/pull_request_review_comment/script.sh` would execute for all of @donatj's `pull_request_review_comment` events regardless of repo. 85 | 86 | ### Error Handling 87 | 88 | Error handlers are scripts prefixed with `@@error.` and function similarly to standard scripts. Error handlers however are only triggered when the executiono of a normal script returns a **non-zero** exit code. 89 | 90 | Error handlers like normal scripts trigger in order up from the root to the specificity level of the script. 91 | 92 | ### Example 93 | 94 | Consider the following server file system. 95 | 96 | ``` 97 | ├── @@error.rootlevel.sh 98 | ├── run-for-everything.sh 99 | └── donatj 100 | ├── @@error.userlevel.sh 101 | ├── run-for-donatj-repos.sh 102 | ├── @@ 103 | │ └── pull_request_review_comment 104 | │ └── all-of-donatjs-pr-comments.sh 105 | └── hookah 106 | └── pull_request_review_comment 107 | ├── @@error.event-level.sh 108 | ├── likes-to-fail.sh 109 | └── handle-review.php 110 | ``` 111 | 112 | The execution order of a `pull_request_review_comment` event is as follows: 113 | 114 | ``` 115 | run-for-everything.sh 116 | donatj/run-for-donatj-repos.sh 117 | donatj/hookah/pull_request_review_comment/likes-to-fail.sh 118 | donatj/hookah/pull_request_review_comment/handle-review.php 119 | donatj/@@/pull_request_review_comment/all-of-donatjs-pr-comments.sh 120 | ``` 121 | 122 | Now let's consider if `likes-to-fail.sh` lives up to it's namesake and returns a non-zero exit code. The execution order then becomes: 123 | 124 | ``` 125 | run-for-everything.sh 126 | donatj/run-for-donatj-repos.sh 127 | donatj/hookah/pull_request_review_comment/likes-to-fail.sh 128 | @@error.rootlevel.sh 129 | @@error.userlevel.sh 130 | @@error.event-level.sh 131 | donatj/hookah/pull_request_review_comment/handle-review.php 132 | donatj/@@/pull_request_review_comment/all-of-donatjs-pr-comments.sh 133 | ``` 134 | 135 | In contrast, imagining `donatj/run-for-donatj-repos.sh` returned a non-zero status, the execution would look as follows: 136 | 137 | ``` 138 | run-for-everything.sh 139 | donatj/run-for-donatj-repos.sh 140 | @@error.rootlevel.sh 141 | @@error.userlevel.sh 142 | donatj/hookah/pull_request_review_comment/likes-to-fail.sh 143 | donatj/hookah/pull_request_review_comment/handle-review.php 144 | donatj/@@/pull_request_review_comment/all-of-donatjs-pr-comments.sh 145 | ``` 146 | 147 | ### Environment Reference 148 | 149 | #### All Executions 150 | 151 | `GITHUB_EVENT` : The contents of the `X-Github-Event` header. 152 | 153 | `GITHUB_DELIVERY` : The contents of the `X-GitHub-Delivery` header. A Unique ID for the Given Request 154 | 155 | `GITHUB_LOGIN` : The GitHub login of the owner of the repository. 156 | 157 | `GITHUB_REPO` : The name portion of the repository, e.g. `hookah`. 158 | 159 | `GITHUB_ACTION` : The action of the event, e.g. `opened`. 160 | 161 | `HOOKAH_SERVER_ROOT` : The absolute path of the root directory of the hookah server. 162 | 163 | #### Error Handler Executions 164 | 165 | `HOOKAH_EXEC_ERROR_FILE` : The path to the executable that failed to execute. 166 | 167 | `HOOKAH_EXEC_ERROR` : The error message received while trying to execute the script. 168 | 169 | `HOOKAH_EXEC_EXIT_STATUS` : The exit code of the script. This may **not** be defined in certain cases where execution failed entirely. 170 | -------------------------------------------------------------------------------- /cmd/hookah/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/donatj/hookah/18ce68c853f7de385df9366321979acdcadd7f1c/cmd/hookah/favicon.ico -------------------------------------------------------------------------------- /cmd/hookah/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "embed" 5 | "flag" 6 | "log" 7 | "net/http" 8 | "os" 9 | "strconv" 10 | "time" 11 | 12 | "github.com/donatj/hmacsig" 13 | "github.com/donatj/hookah/v3" 14 | ) 15 | 16 | var ( 17 | httpPort = flag.Uint("http-port", 8080, "HTTP port to listen on") 18 | serverRoot = flag.String("server-root", ".", "The root directory of the hook script hierarchy") 19 | secret = flag.String("secret", "", "Optional GitHub HMAC secret key") 20 | timeout = flag.Duration("timeout", 10*time.Minute, "Exec timeout on hook scripts") 21 | verbose = flag.Bool("v", false, "Enable verbose logger output") 22 | 23 | errlog = flag.String("err-log", "", "Path to write the error log to. Defaults to standard error.") 24 | ) 25 | 26 | //go:embed favicon.ico 27 | var favicon []byte 28 | 29 | func init() { 30 | flag.Parse() 31 | } 32 | 33 | func main() { 34 | logger := getLogger(*errlog) 35 | options := []hookah.ServerOption{ 36 | hookah.ServerExecTimeout(*timeout), 37 | hookah.ServerErrorLog(logger), 38 | } 39 | 40 | if *verbose { 41 | options = append(options, hookah.ServerInfoLog(logger)) 42 | } 43 | 44 | hServe, err := hookah.NewHookServer(*serverRoot, options...) 45 | if err != nil { 46 | log.Fatal(err) 47 | } 48 | 49 | var serve http.Handler = hServe 50 | if *secret != "" { 51 | serve = hmacsig.Handler256(hServe, *secret) 52 | } 53 | 54 | mux := http.NewServeMux() 55 | mux.Handle("/", serve) 56 | mux.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { 57 | w.Header().Set("Content-Type", "image/x-icon") 58 | w.Write(favicon) 59 | }) 60 | 61 | logger.Println("listening on port", *httpPort) 62 | err = http.ListenAndServe(":"+strconv.Itoa(int(*httpPort)), mux) 63 | if err != nil { 64 | log.Fatal(err) 65 | } 66 | } 67 | 68 | func getLogger(filename string) hookah.Logger { 69 | if filename != "" { 70 | f, err := os.OpenFile(*errlog, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) 71 | if err != nil { 72 | log.Fatal(err) 73 | } 74 | return log.New(f, "", log.LstdFlags) 75 | } 76 | 77 | return log.New(os.Stderr, "", log.LstdFlags) 78 | } 79 | -------------------------------------------------------------------------------- /exec.go: -------------------------------------------------------------------------------- 1 | package hookah 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "sort" 11 | "strings" 12 | "syscall" 13 | "time" 14 | 15 | "github.com/hashicorp/go-multierror" 16 | ) 17 | 18 | // HookExec represents a call to a hook 19 | type HookExec struct { 20 | RootDir string 21 | Data io.ReadSeeker 22 | InfoLog Logger 23 | 24 | Stdout io.Writer 25 | Stderr io.Writer 26 | } 27 | 28 | // GetPathExecs fetches the executable filenames for the given path 29 | func (h *HookExec) GetPathExecs(owner, repo, event, action string) ([]string, []string, error) { 30 | outfiles := []string{} 31 | outErrHandlers := []string{} 32 | 33 | var pathSets [][]string 34 | if action == "" { 35 | pathSets = [][]string{ 36 | {h.RootDir, owner, repo, event}, 37 | {filepath.Join(h.RootDir, "@@"), repo, event}, 38 | {filepath.Join(h.RootDir, owner, "@@"), event}, 39 | {filepath.Join(h.RootDir, "@@", "@@"), event}, 40 | } 41 | } else { 42 | pathSets = [][]string{ 43 | {h.RootDir, owner, repo, event, action}, 44 | {filepath.Join(h.RootDir, "@@"), repo, event, action}, 45 | {filepath.Join(h.RootDir, owner, "@@"), event, action}, 46 | {filepath.Join(h.RootDir, "@@", "@@"), event, action}, 47 | } 48 | } 49 | 50 | for _, paths := range pathSets { 51 | workpath := "" 52 | for _, path := range paths { 53 | workpath = filepath.Join(workpath, path) 54 | 55 | files, errHandlers, err := pathScan(workpath) 56 | if err != nil { 57 | return []string{}, []string{}, err 58 | } 59 | outfiles = append(outfiles, files...) 60 | outErrHandlers = append(outErrHandlers, errHandlers...) 61 | } 62 | } 63 | 64 | return outfiles, outErrHandlers, nil 65 | } 66 | 67 | // pathScan scans the given path for executable files 68 | // returns a list of files and a list of error handlers 69 | // error handlers are files that start with @@error. 70 | func pathScan(path string) ([]string, []string, error) { 71 | files := []string{} 72 | errHandlers := []string{} 73 | 74 | fs, err := os.Stat(path) 75 | if err != nil { 76 | if os.IsNotExist(err) { 77 | return files, errHandlers, nil 78 | } 79 | 80 | return files, errHandlers, err 81 | } 82 | 83 | if fs.IsDir() { 84 | d, err := os.Open(path) 85 | defer d.Close() 86 | if err != nil { 87 | return files, errHandlers, err 88 | } 89 | 90 | fi, err := d.Readdir(-1) 91 | if err != nil { 92 | return files, errHandlers, err 93 | } 94 | // I don't think this is necessary but it makes the tests deterministic 95 | sort.Slice(fi, func(i, j int) bool { return fi[i].Name() < fi[j].Name() }) 96 | 97 | for _, fi := range fi { 98 | fpath := filepath.Join(path, fi.Name()) 99 | is, err := isExecFile(fpath) 100 | if err != nil { 101 | return files, errHandlers, err 102 | } 103 | 104 | if is { 105 | if strings.HasPrefix(fi.Name(), "@@error.") { 106 | errHandlers = append(errHandlers, filepath.Join(path, fi.Name())) 107 | } else { 108 | files = append(files, filepath.Join(path, fi.Name())) 109 | } 110 | } 111 | } 112 | 113 | } else if is, _ := isExecFile(path); is { 114 | // fmt.Println(fs.Name(), fs.Size(), "bytes") 115 | // files = append(files, filepath.Join(path, fs.Name())) 116 | // this should be picked up on a different sweep 117 | } else { 118 | return files, errHandlers, errors.New("bad file mumbo jumbo") 119 | } 120 | 121 | return files, errHandlers, nil 122 | } 123 | 124 | // InfoLogf logs to the info logger if not nil 125 | func (h *HookExec) InfoLogf(format string, v ...any) { 126 | if h.InfoLog != nil { 127 | h.InfoLog.Printf(format, v...) 128 | } 129 | } 130 | 131 | func (h *HookExec) InfoLogln(msg string) { 132 | if h.InfoLog != nil { 133 | h.InfoLog.Println(msg) 134 | } 135 | } 136 | 137 | // Exec triggers the execution of all scripts associated with the given Hook 138 | func (h *HookExec) Exec(owner, repo, event, action string, timeout time.Duration, env ...string) error { 139 | files, errHandlers, err := h.GetPathExecs(owner, repo, event, action) 140 | 141 | if err != nil { 142 | return err 143 | } 144 | 145 | if len(files) > 0 { 146 | msg := fmt.Sprintf("executing hook scripts (%d) for %s/%s %s.%s", len(files), owner, repo, event, action) 147 | msg = strings.TrimRight(msg, ".") 148 | h.InfoLogln(msg) 149 | } 150 | 151 | var result *multierror.Error 152 | for _, f := range files { 153 | h.InfoLogf("beginning execution of %#v", f) 154 | 155 | err := h.execFile(f, h.Data, timeout, env...) 156 | 157 | if err != nil { 158 | h.InfoLogf("exec error: %s", err) 159 | 160 | for _, e := range errHandlers { 161 | h.InfoLogf("beginning error handler execution of %#v", e) 162 | 163 | env2 := append(env, getErrorHandlerEnv(f, err)...) 164 | err2 := h.execFile(e, h.Data, timeout, env2...) 165 | result = multierror.Append(result, err2) 166 | } 167 | } 168 | result = multierror.Append(result, err) 169 | } 170 | 171 | return result.ErrorOrNil() 172 | } 173 | 174 | func getErrorHandlerEnv(f string, err error) []string { 175 | env := []string{ 176 | "HOOKAH_EXEC_ERROR_FILE=" + f, 177 | "HOOKAH_EXEC_ERROR=" + err.Error(), 178 | } 179 | 180 | if exiterr, ok := err.(*exec.ExitError); ok { 181 | if status, ok := exiterr.Sys().(syscall.WaitStatus); ok { 182 | env = append(env, fmt.Sprintf("HOOKAH_EXEC_EXIT_STATUS=%d", status.ExitStatus())) 183 | } 184 | } 185 | 186 | return env 187 | } 188 | 189 | func (h *HookExec) execFile(f string, data io.ReadSeeker, timeout time.Duration, env ...string) error { 190 | cmd := exec.Command(f) 191 | 192 | if h.Stdout != nil { 193 | cmd.Stdout = h.Stdout 194 | } else { 195 | cmd.Stdout = os.Stdout 196 | } 197 | 198 | if h.Stderr != nil { 199 | cmd.Stderr = h.Stderr 200 | } else { 201 | cmd.Stderr = os.Stderr 202 | } 203 | 204 | cmd.Env = append(os.Environ(), env...) 205 | 206 | stdin, err := cmd.StdinPipe() 207 | if err != nil { 208 | return err 209 | } 210 | defer stdin.Close() 211 | 212 | err = cmd.Start() 213 | if err != nil { 214 | return err 215 | } 216 | 217 | _, err = data.Seek(0, 0) 218 | if err != nil { 219 | return err 220 | } 221 | 222 | _, err = io.Copy(stdin, data) 223 | if err != nil { 224 | return err 225 | } 226 | stdin.Close() 227 | 228 | timer := time.AfterFunc(timeout, func() { 229 | cmd.Process.Kill() 230 | }) 231 | 232 | err = cmd.Wait() 233 | timer.Stop() 234 | 235 | return err 236 | } 237 | 238 | // todo: base this on OS 239 | func isExecFile(fss ...string) (bool, error) { 240 | if len(fss) > 10 { 241 | paths := []string{} 242 | for _, f := range fss { 243 | paths = append(paths, f) 244 | } 245 | 246 | return false, fmt.Errorf("maximum symlink depth exceeded: %s", strings.Join(paths, " -> ")) 247 | } 248 | 249 | if len(fss) == 0 { 250 | return false, errors.New("no file info provided") 251 | } 252 | 253 | fs := fss[len(fss)-1] 254 | fi, err := os.Stat(fs) 255 | if err != nil { 256 | return false, err 257 | } 258 | 259 | mode := fi.Mode() 260 | if mode.IsRegular() && mode|0111 == mode { 261 | return true, nil 262 | } 263 | 264 | if mode&os.ModeSymlink != 0 { 265 | link, err := os.Readlink(fi.Name()) 266 | if err != nil { 267 | return false, err 268 | } 269 | 270 | fss = append(fss, link) 271 | return isExecFile(fss...) 272 | } 273 | 274 | return false, nil 275 | } 276 | -------------------------------------------------------------------------------- /exec_test.go: -------------------------------------------------------------------------------- 1 | package hookah 2 | 3 | import ( 4 | "bytes" 5 | "log" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestOnlyExecutableBinsFound(t *testing.T) { 14 | 15 | expectedScripts := []string{ 16 | "testdata/exec-only-test-server/exec.sh", 17 | "testdata/exec-only-test-server/exec.symlink.sh", 18 | "testdata/exec-only-test-server/user/exec.sh", 19 | "testdata/exec-only-test-server/user/repo/exec.sh", 20 | "testdata/exec-only-test-server/user/repo/event/exec.sh", 21 | "testdata/exec-only-test-server/@@/exec.sh", 22 | "testdata/exec-only-test-server/@@/exec.symlink.symlink.sh", 23 | "testdata/exec-only-test-server/@@/repo/exec.sh", 24 | "testdata/exec-only-test-server/@@/repo/event/exec.sh", 25 | "testdata/exec-only-test-server/user/@@/exec.sh", 26 | "testdata/exec-only-test-server/user/@@/event/exec.sh", 27 | "testdata/exec-only-test-server/@@/@@/exec.sh", 28 | "testdata/exec-only-test-server/@@/@@/event/exec.sh", 29 | } 30 | 31 | expectedErrhandlers := []string{ 32 | "testdata/exec-only-test-server/@@error.exec.sh", 33 | "testdata/exec-only-test-server/user/@@error.exec.sh", 34 | "testdata/exec-only-test-server/user/repo/@@error.exec.sh", 35 | } 36 | 37 | data := strings.NewReader(`{"foo": "bar"}`) 38 | 39 | h := HookExec{ 40 | RootDir: "./testdata/exec-only-test-server", 41 | Data: data, 42 | } 43 | 44 | scripts, errhandlers, err := h.GetPathExecs("user", "repo", "event", "") 45 | if err != nil { 46 | t.Error(err) 47 | return 48 | } 49 | 50 | log.Printf("%#v", scripts) 51 | 52 | assert.EqualValues(t, expectedScripts, scripts) 53 | 54 | assert.EqualValues(t, expectedErrhandlers, errhandlers) 55 | } 56 | 57 | func TestActionDirectoriesWorkAsExpected(t *testing.T) { 58 | 59 | expectedScripts := []string{ 60 | "testdata/exec-only-test-server/exec.sh", 61 | "testdata/exec-only-test-server/exec.symlink.sh", 62 | "testdata/exec-only-test-server/user/exec.sh", 63 | "testdata/exec-only-test-server/user/repo/exec.sh", 64 | "testdata/exec-only-test-server/user/repo/event/exec.sh", 65 | "testdata/exec-only-test-server/user/repo/event/action/exec.sh", 66 | "testdata/exec-only-test-server/@@/exec.sh", 67 | "testdata/exec-only-test-server/@@/exec.symlink.symlink.sh", 68 | "testdata/exec-only-test-server/@@/repo/exec.sh", 69 | "testdata/exec-only-test-server/@@/repo/event/exec.sh", 70 | "testdata/exec-only-test-server/@@/repo/event/action/exec.sh", 71 | "testdata/exec-only-test-server/user/@@/exec.sh", 72 | "testdata/exec-only-test-server/user/@@/event/exec.sh", 73 | "testdata/exec-only-test-server/user/@@/event/action/exec.sh", 74 | "testdata/exec-only-test-server/@@/@@/exec.sh", 75 | "testdata/exec-only-test-server/@@/@@/event/exec.sh", 76 | "testdata/exec-only-test-server/@@/@@/event/action/exec.sh", 77 | } 78 | expectedErrhandlers := []string{ 79 | "testdata/exec-only-test-server/@@error.exec.sh", 80 | "testdata/exec-only-test-server/user/@@error.exec.sh", 81 | "testdata/exec-only-test-server/user/repo/@@error.exec.sh", 82 | "testdata/exec-only-test-server/user/repo/event/action/@@error.exec.sh", 83 | "testdata/exec-only-test-server/@@/repo/event/action/@@error.exec.sh", 84 | "testdata/exec-only-test-server/user/@@/event/action/@@error.exec.sh", 85 | "testdata/exec-only-test-server/@@/@@/event/action/@@error.exec.sh", 86 | } 87 | 88 | data := strings.NewReader(`{"foo": "bar"}`) 89 | 90 | h := HookExec{ 91 | RootDir: "./testdata/exec-only-test-server", 92 | Data: data, 93 | } 94 | 95 | scripts, errhandlers, err := h.GetPathExecs("user", "repo", "event", "action") 96 | if err != nil { 97 | t.Error(err) 98 | return 99 | } 100 | 101 | log.Printf("%#v", scripts) 102 | 103 | assert.EqualValues(t, expectedScripts, scripts) 104 | 105 | assert.EqualValues(t, expectedErrhandlers, errhandlers) 106 | } 107 | 108 | func TestEnvPopulatedCorrectly(t *testing.T) { 109 | 110 | out := &bytes.Buffer{} 111 | 112 | data := strings.NewReader(`{"foo": "bar"}`) 113 | 114 | h := HookExec{ 115 | RootDir: "./testdata/env-test-server", 116 | Data: data, 117 | 118 | Stdout: out, 119 | } 120 | 121 | err := h.Exec("user", "repo", "event", "action", 1*time.Minute, "FOO=BAR", "BAZ=QUX") 122 | if err != nil { 123 | t.Error(err) 124 | } 125 | 126 | env := out.String() 127 | lines := strings.Split(strings.TrimSpace(env), "\n") 128 | envMap := make(map[string]string, len(lines)) 129 | 130 | for _, line := range lines { 131 | parts := strings.SplitN(line, "=", 2) 132 | envMap[parts[0]] = parts[1] 133 | } 134 | 135 | expectedEnv := map[string]string{ 136 | "FOO": "BAR", 137 | "BAZ": "QUX", 138 | } 139 | 140 | for k, expectedV := range expectedEnv { 141 | if actualV, ok := envMap[k]; !ok || actualV != expectedV { 142 | t.Error("expected", k, "to be", expectedV, "got", actualV) 143 | } 144 | } 145 | 146 | } 147 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/donatj/hookah/v3 2 | 3 | go 1.21 4 | 5 | toolchain go1.21.4 6 | 7 | require ( 8 | github.com/donatj/hmacsig v1.1.0 9 | github.com/hashicorp/go-multierror v1.1.1 10 | github.com/stretchr/testify v1.10.0 11 | ) 12 | 13 | require ( 14 | github.com/davecgh/go-spew v1.1.1 // indirect 15 | github.com/hashicorp/errwrap v1.1.0 // indirect 16 | github.com/pmezard/go-difflib v1.0.0 // indirect 17 | gopkg.in/yaml.v3 v3.0.1 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/donatj/hmacsig v1.1.0 h1:DbBIW1ZTMfJoJhDGPVpkatYyxhrR2xVoHAokPTrlw50= 4 | github.com/donatj/hmacsig v1.1.0/go.mod h1:rh/7q72Fo5oYc7bcKgvGHWsfHcs8jKhJdFgCZcvZ/G0= 5 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 6 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 7 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 8 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 9 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 10 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 11 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 12 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 13 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 14 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 15 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 16 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 17 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 18 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package hookah 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "log" 10 | "net/http" 11 | "os" 12 | "path/filepath" 13 | "regexp" 14 | "sync" 15 | "time" 16 | 17 | multierror "github.com/hashicorp/go-multierror" 18 | ) 19 | 20 | var ErrPathIsNotDir = errors.New("path is not a dir") 21 | var validGhEvent = regexp.MustCompile(`^[a-z\d_]{1,30}$`) 22 | 23 | // Logger handles Printf 24 | type Logger interface { 25 | Printf(format string, v ...any) 26 | Println(v ...any) 27 | } 28 | 29 | // HookServer implements net/http.Handler 30 | type HookServer struct { 31 | RootDir string 32 | 33 | Timeout time.Duration 34 | ErrorLog Logger 35 | InfoLog Logger 36 | 37 | sync.Mutex 38 | } 39 | 40 | // ServerOption sets an option of the HookServer 41 | type ServerOption func(*HookServer) error 42 | 43 | // NewHookServer instantiates a new HookServer with some basic validation 44 | // on the root directory 45 | func NewHookServer(rootDir string, options ...ServerOption) (*HookServer, error) { 46 | absRootDir, err := filepath.Abs(rootDir) 47 | if err != nil { 48 | return nil, fmt.Errorf("failed converting server-root '%s' to an absolute path: %w", rootDir, err) 49 | } 50 | 51 | f, err := os.Open(absRootDir) 52 | if err != nil { 53 | return nil, err 54 | } 55 | defer f.Close() 56 | 57 | fi, err := f.Stat() 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | if !fi.IsDir() { 63 | return nil, ErrPathIsNotDir 64 | } 65 | 66 | server := &HookServer{ 67 | RootDir: absRootDir, 68 | } 69 | 70 | var result *multierror.Error 71 | 72 | for _, option := range options { 73 | err := option(server) 74 | result = multierror.Append(result, err) 75 | } 76 | 77 | return server, result.ErrorOrNil() 78 | } 79 | 80 | // ServerExecTimeout configures the HookServer per-script execution timeout 81 | func ServerExecTimeout(timeout time.Duration) ServerOption { 82 | return func(h *HookServer) error { 83 | h.Timeout = timeout 84 | return nil 85 | } 86 | } 87 | 88 | // ServerErrorLog configures the HookServer error logger 89 | func ServerErrorLog(log Logger) ServerOption { 90 | return func(h *HookServer) error { 91 | h.ErrorLog = log 92 | return nil 93 | } 94 | } 95 | 96 | // ServerInfoLog configures the HookServer info logger 97 | func ServerInfoLog(log Logger) ServerOption { 98 | return func(h *HookServer) error { 99 | h.InfoLog = log 100 | return nil 101 | } 102 | } 103 | 104 | func (h *HookServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { 105 | ghEvent := r.Header.Get("X-Github-Event") 106 | 107 | if !validGhEvent.MatchString(ghEvent) { 108 | http.Error(w, "Request requires valid X-Github-Event", http.StatusBadRequest) 109 | return 110 | } 111 | 112 | if ghEvent == "ping" { 113 | fmt.Fprintln(w, "pong") 114 | return 115 | } 116 | 117 | ghDelivery := r.Header.Get("X-GitHub-Delivery") 118 | if ghDelivery == "" { 119 | http.Error(w, "Request requires valid X-GitHub-Delivery", http.StatusBadRequest) 120 | return 121 | } 122 | 123 | b, err := io.ReadAll(r.Body) 124 | if err != nil { 125 | http.Error(w, err.Error(), http.StatusInternalServerError) 126 | log.Println(ghDelivery, err) 127 | return 128 | } 129 | buff := bytes.NewReader(b) 130 | 131 | basicHook := &HookJSON{} 132 | 133 | decoder := json.NewDecoder(buff) 134 | err = decoder.Decode(basicHook) 135 | if err != nil { 136 | http.Error(w, err.Error(), http.StatusBadRequest) 137 | log.Println(ghDelivery, err) 138 | return 139 | } 140 | 141 | login := basicHook.Repository.Owner.GetLogin() 142 | repo := basicHook.Repository.Name 143 | if repo == "" || login == "" { 144 | msg := "Unexpected JSON HTTP Body" 145 | http.Error(w, msg, http.StatusBadRequest) 146 | log.Println(ghDelivery, msg) 147 | return 148 | } 149 | 150 | action := basicHook.Action 151 | 152 | fmt.Fprintf(w, "%s/%s", login, repo) 153 | 154 | hook := HookExec{ 155 | RootDir: h.RootDir, 156 | Data: buff, 157 | InfoLog: h.InfoLog, 158 | } 159 | 160 | go func() { 161 | h.Lock() 162 | defer h.Unlock() 163 | 164 | err := hook.Exec(login, repo, ghEvent, action, h.Timeout, 165 | "GITHUB_DELIVERY="+ghDelivery, 166 | "GITHUB_LOGIN="+login, 167 | "GITHUB_REPO="+repo, 168 | "GITHUB_EVENT="+ghEvent, 169 | "GITHUB_ACTION="+action, 170 | "HOOKAH_SERVER_ROOT="+h.RootDir, 171 | ) 172 | if err != nil && h.ErrorLog != nil { 173 | h.ErrorLog.Printf("%s - %s/%s:%s - '%s'", ghDelivery, login, repo, ghEvent, err) 174 | } 175 | }() 176 | } 177 | 178 | // HookUserJSON exists because some hooks use Login, some use Name 179 | // - it's horribly inconsistent and a bad flaw on GitHubs part 180 | type HookUserJSON struct { 181 | Login string `json:"login"` 182 | Name string `json:"name"` 183 | } 184 | 185 | // GetLogin is used to get the login from the data github decided to pass today 186 | func (h *HookUserJSON) GetLogin() string { 187 | if h.Login != "" { 188 | return h.Login 189 | } 190 | 191 | return h.Name 192 | } 193 | 194 | // HookJSON represents the minimum body we need to parse 195 | type HookJSON struct { 196 | Action string `json:"action,omitempty"` 197 | Repository struct { 198 | Name string `json:"name"` 199 | Owner HookUserJSON `json:"owner"` 200 | } `json:"repository"` 201 | Sender HookUserJSON `json:"sender"` 202 | } 203 | -------------------------------------------------------------------------------- /testdata/env-test-server/env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | env 4 | -------------------------------------------------------------------------------- /testdata/exec-only-test-server/@@/@@/event/action/@@error.exec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "YES" -------------------------------------------------------------------------------- /testdata/exec-only-test-server/@@/@@/event/action/exec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "YES" -------------------------------------------------------------------------------- /testdata/exec-only-test-server/@@/@@/event/exec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "YES" -------------------------------------------------------------------------------- /testdata/exec-only-test-server/@@/@@/exec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "YES" -------------------------------------------------------------------------------- /testdata/exec-only-test-server/@@/exec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "YES" -------------------------------------------------------------------------------- /testdata/exec-only-test-server/@@/exec.symlink.symlink.sh: -------------------------------------------------------------------------------- 1 | ../exec.symlink.sh -------------------------------------------------------------------------------- /testdata/exec-only-test-server/@@/noexec.symlink.symlink.sh: -------------------------------------------------------------------------------- 1 | ../noexec.symlink.sh -------------------------------------------------------------------------------- /testdata/exec-only-test-server/@@/repo/event/action/@@error.exec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "YES" -------------------------------------------------------------------------------- /testdata/exec-only-test-server/@@/repo/event/action/exec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "YES" -------------------------------------------------------------------------------- /testdata/exec-only-test-server/@@/repo/event/exec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "YES" -------------------------------------------------------------------------------- /testdata/exec-only-test-server/@@/repo/exec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "YES" -------------------------------------------------------------------------------- /testdata/exec-only-test-server/@@error.exec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "YES" -------------------------------------------------------------------------------- /testdata/exec-only-test-server/@@error.noexec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "NO" -------------------------------------------------------------------------------- /testdata/exec-only-test-server/exec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "YES" -------------------------------------------------------------------------------- /testdata/exec-only-test-server/exec.symlink.sh: -------------------------------------------------------------------------------- 1 | exec.sh -------------------------------------------------------------------------------- /testdata/exec-only-test-server/noexec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "NO" -------------------------------------------------------------------------------- /testdata/exec-only-test-server/noexec.symlink.sh: -------------------------------------------------------------------------------- 1 | noexec.sh -------------------------------------------------------------------------------- /testdata/exec-only-test-server/user/@@/event/action/@@error.exec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "YES" -------------------------------------------------------------------------------- /testdata/exec-only-test-server/user/@@/event/action/exec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "YES" -------------------------------------------------------------------------------- /testdata/exec-only-test-server/user/@@/event/exec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "YES" -------------------------------------------------------------------------------- /testdata/exec-only-test-server/user/@@/exec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "YES" -------------------------------------------------------------------------------- /testdata/exec-only-test-server/user/@@error.exec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "YES" -------------------------------------------------------------------------------- /testdata/exec-only-test-server/user/@@error.noexec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "NO" -------------------------------------------------------------------------------- /testdata/exec-only-test-server/user/exec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "YES" -------------------------------------------------------------------------------- /testdata/exec-only-test-server/user/noexec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "NO" -------------------------------------------------------------------------------- /testdata/exec-only-test-server/user/repo/@@error.exec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "YES" -------------------------------------------------------------------------------- /testdata/exec-only-test-server/user/repo/@@error.noexec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "NO" -------------------------------------------------------------------------------- /testdata/exec-only-test-server/user/repo/event/action/@@error.exec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "YES" -------------------------------------------------------------------------------- /testdata/exec-only-test-server/user/repo/event/action/exec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "YES" -------------------------------------------------------------------------------- /testdata/exec-only-test-server/user/repo/event/exec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "YES" -------------------------------------------------------------------------------- /testdata/exec-only-test-server/user/repo/exec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "YES" -------------------------------------------------------------------------------- /testdata/exec-only-test-server/user/repo/noexec.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "NO" --------------------------------------------------------------------------------