Skip to content

Commit 23ebf7b

Browse files
committed
Make it work on Nix
1 parent dd6744c commit 23ebf7b

File tree

10 files changed

+662
-209
lines changed

10 files changed

+662
-209
lines changed

README.md

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,31 +13,51 @@ To install manually, create a GitHub App in your personal account on an organiza
1313

1414
Acquire client ID webhook secret, private key (PEM file). Install the application on your own account.
1515

16+
Put private key in `/etc/gh-deploy/key.pem` and webhook secret in `/etc/gh-deploy/webhook-secret` file.
17+
1618
Then follow with one of the installation options:
1719

1820
### Systems that have systemd enabled
1921

20-
Decide which user will be running the tool. The following instructions suppose this user will be named `user` and has default group named `usergroup`. Execute these commands:
21-
22-
```bash
23-
sudo install -Ddm 755 -o root -g usergroup /etc/gh-deploy
24-
```
22+
Run this command. It will download the latest release to `/usr/local/bin/gh-deploy`, create default config, install and start systemd unit.
2523

26-
Then create files `/etc/gh-deploy/key.pem` and `/etc/gh-deploy/webhook-secret` with PEM file and webhook secret respectively. Then:
24+
Set `CLIENT_ID` variable to your GitHub App Client ID (required). Also it’s recommended (but not required) to set `APP_USER` to the name
25+
of your Linux user that will pull the repositories and run the deploy scripts. It should have enough privileges to do that. If not provided,
26+
the current user will be used.
2727

2828
```bash
29-
sudo chmod 640 /etc/gh-deploy/key.pem /etc/gh-deploy/webhook-secret
30-
sudo chown root:usergroup /etc/gh-deploy/key.pem /etc/gh-deploy/webhook-secret
31-
32-
# This will download the latest release to /usr/local/bin/gh-deploy, create default config, install and start systemd unit.
33-
curl https://teamteam.dev/gh-deploy/install.sh | sudo APP_ID=<your-github-app-id> USER=user bash -
29+
curl https://teamteam.dev/gh-deploy/install.sh | sudo CLIENT_ID=<your-github-app-id> APP_USER=<username> bash -
3430
```
3531

36-
Then update `/etc/gh-deploy/config.toml` to your preference and make the webhook available at URL specified before.
32+
Then update `/etc/gh-deploy/config.toml` to your preference and make the webhook available at URL specified when creating the app.
33+
Use `systemctl restart gh-deploy` to restart app after configuration changes.
3734

3835
### NixOS
3936

40-
Use `flake.nix` that provides `gh-deploy` service.
37+
Use `flake.nix` that provides `gh-deploy` service. Example configuration:
38+
39+
```nix
40+
services.gh-deploy = {
41+
enable = true;
42+
domain = "prod.teamteam.dev";
43+
gitLfs = true;
44+
githubApp = {
45+
clientId = "123456abcdef";
46+
privateKeyFile = "/etc/gh-deploy/key.pem";
47+
webhookSecretFile = "/etc/gh-deploy/webhook-secret";
48+
};
49+
projects = [{
50+
repository = "teamteamdev/gh-deploy";
51+
branch = "pages";
52+
timeout = 300;
53+
path = ''
54+
systemctl restart nginx
55+
'';
56+
}];
57+
};
58+
```
59+
60+
> Note that NixOS module registers user and group `gh-deploy` to run the service, so change file ownership respectively.
4161
4262
### Other systems
4363

@@ -59,6 +79,9 @@ go build -o gh-deploy
5979

6080
See [config.example.toml](config.example.toml) for configuration examples.
6181

82+
Use `systemctl reload gh-deploy` to reload the configuration. Updating HTTP server settings (bind address, port or TLS) on-the-fly is not supported,
83+
except for replacing TLS key pair.
84+
6285
## License
6386

6487
Contents of this repository are available under [MIT License](LICENSE).

config.example.toml

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# Server configuration
2-
# bind = "127.0.0.1" # Default is "::" (all IPv4 and IPv6 interfaces)"
3-
port = 8080 # Required
2+
bind = "[::1]:8080" # Required
3+
# Examples:
4+
# "[::]:80" - listen on port 80 on all IPv4/IPv6 addresses
5+
# "[::1]:80" - listen on port 80 on localhost
6+
# "192.168.1.1:12345" - listen on port 12345 on a specific IPv4 address
7+
# "unix:/run/gh-deploy/http.sock" - listen on a Unix socket
48

59
# Enable Git LFS support
610
# git_lfs = true
@@ -12,14 +16,14 @@ port = 8080 # Required
1216

1317
# GitHub App configuration
1418
[github_app]
15-
client_id = "123456" # GitHub App client ID
16-
private_key_file = "/path/to/github-app-private-key.pem" # GitHub App private key file
17-
webhook_secret_file = "/path/to/webhook-secret" # File with webhook secret
19+
client_id = "123456abcdefGHIJKLMN" # GitHub App client ID
20+
private_key_file = "/etc/gh-deploy/key.pem" # GitHub App private key file
21+
webhook_secret_file = "/etc/gh-deploy/webhook-secret" # File with webhook secret
1822

19-
# Repository definitions
20-
[[repos]]
21-
full_name = "owner/repo"
22-
branch = "main" # Branch to watch for changes
23-
clone_path = "/var/www/repo" # This folder should be writable by user running the gh-deploy
24-
command = "make deploy" # Optional: command to run after update
25-
timeout = 300 # in seconds, default is 120
23+
# Repositories and branches to deploy
24+
[[projects]]
25+
repository = "owner/repo"
26+
branch = "main" # Branch to watch for changes
27+
path = "/var/www/repo" # This folder should be writable by user running the gh-deploy
28+
command = "make deploy" # Optional: command to run after update
29+
timeout = 300 # in seconds, default is 120

config.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package main
2+
3+
import (
4+
"crypto/rsa"
5+
"crypto/tls"
6+
"fmt"
7+
"log"
8+
"os"
9+
"os/signal"
10+
"strings"
11+
"sync"
12+
"syscall"
13+
14+
"github.com/BurntSushi/toml"
15+
"github.com/golang-jwt/jwt/v5"
16+
)
17+
18+
type Config struct {
19+
Bind string `toml:"bind"`
20+
TLS *struct {
21+
CertFile string `toml:"cert_file"`
22+
KeyFile string `toml:"key_file"`
23+
certificate tls.Certificate
24+
} `toml:"tls"`
25+
GitHubApp struct {
26+
ClientID string `toml:"client_id"`
27+
PrivateKeyFile string `toml:"private_key_file"`
28+
WebhookSecretFile string `toml:"webhook_secret_file"`
29+
privateKey *rsa.PrivateKey
30+
webhookSecret string
31+
} `toml:"github_app"`
32+
GitLFS bool `toml:"git_lfs"`
33+
Projects []RepoConfig `toml:"projects"`
34+
}
35+
36+
type RepoConfig struct {
37+
Repository string `toml:"repository"`
38+
Branch string `toml:"branch"`
39+
Path string `toml:"path"`
40+
Command string `toml:"command"`
41+
Timeout int `toml:"timeout"`
42+
}
43+
44+
var (
45+
config Config
46+
configPath string
47+
configMutex = sync.Mutex{}
48+
)
49+
50+
func loadConfig(initial bool) error {
51+
var newConfig Config
52+
_, err := toml.DecodeFile(configPath, &newConfig)
53+
if err != nil {
54+
return fmt.Errorf("failed to decode TOML config: %w", err)
55+
}
56+
57+
if newConfig.Bind == "" {
58+
return fmt.Errorf("configuration 'bind' parameter is required")
59+
}
60+
61+
// Weird way to set default values
62+
for i, repo := range newConfig.Projects {
63+
if repo.Timeout == 0 {
64+
newConfig.Projects[i].Timeout = 120
65+
}
66+
}
67+
68+
if newConfig.TLS != nil {
69+
newConfig.TLS.certificate, err = tls.LoadX509KeyPair(os.ExpandEnv(newConfig.TLS.CertFile), os.ExpandEnv(newConfig.TLS.KeyFile))
70+
if err != nil {
71+
return fmt.Errorf("failed to load TLS certificate: %w", err)
72+
}
73+
}
74+
75+
keyBytes, err := os.ReadFile(os.ExpandEnv(newConfig.GitHubApp.PrivateKeyFile))
76+
if err != nil {
77+
return fmt.Errorf("failed to read PEM key: %w", err)
78+
}
79+
80+
newConfig.GitHubApp.privateKey, err = jwt.ParseRSAPrivateKeyFromPEM(keyBytes)
81+
if err != nil {
82+
return fmt.Errorf("failed to parse PEM key: %w", err)
83+
}
84+
85+
webhookSecretBytes, err := os.ReadFile(os.ExpandEnv(newConfig.GitHubApp.WebhookSecretFile))
86+
if err != nil {
87+
return fmt.Errorf("failed to read webhook secret: %w", err)
88+
}
89+
90+
newConfig.GitHubApp.webhookSecret = strings.TrimSpace(string(webhookSecretBytes))
91+
92+
if !initial && ((config.TLS == nil) != (newConfig.TLS == nil) || config.Bind != newConfig.Bind) {
93+
log.Printf("Warning: Changing bind address or toggling TLS requires a restart of the server, updates to these settings are ignored")
94+
}
95+
96+
config = newConfig
97+
98+
return nil
99+
}
100+
101+
func handleConfigReload() {
102+
sigChan := make(chan os.Signal, 1)
103+
signal.Notify(sigChan, syscall.SIGHUP)
104+
105+
go func() {
106+
for range sigChan {
107+
configMutex.Lock()
108+
log.Printf("Received SIGHUP, reloading configuration...")
109+
110+
if err := loadConfig(false); err != nil {
111+
log.Fatalf("Failed to reload config: %v", err)
112+
}
113+
114+
log.Printf("Configuration reloaded successfully")
115+
116+
tokenCache.Storage.Flush()
117+
}
118+
}()
119+
}

creds.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/golang-jwt/jwt/v5"
9+
"github.com/google/go-github/v57/github"
10+
"github.com/kofalt/go-memoize"
11+
"github.com/patrickmn/go-cache"
12+
)
13+
14+
var (
15+
tokenCache = memoize.NewMemoizer(
16+
10*time.Minute,
17+
cache.NoExpiration,
18+
)
19+
)
20+
21+
func fetchAccessToken(installationID int64) (string, error) {
22+
now := time.Now()
23+
claims := jwt.MapClaims{
24+
"iat": now.Unix(),
25+
"exp": now.Add(10 * time.Minute).Unix(),
26+
"iss": config.GitHubApp.ClientID,
27+
}
28+
29+
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
30+
tokenString, err := token.SignedString(config.GitHubApp.privateKey)
31+
if err != nil {
32+
return "", fmt.Errorf("failed to sign JWT: %w", err)
33+
}
34+
35+
client := github.NewClient(nil).WithAuthToken(tokenString)
36+
37+
installationToken, _, err := client.Apps.CreateInstallationToken(
38+
context.Background(),
39+
installationID,
40+
&github.InstallationTokenOptions{},
41+
)
42+
if err != nil {
43+
return "", fmt.Errorf("failed to create installation token: %w", err)
44+
}
45+
46+
return installationToken.GetToken(), nil
47+
}
48+
49+
func getAccessToken(installationID int64) (string, error) {
50+
fetch := func() (string, error) {
51+
return fetchAccessToken(installationID)
52+
}
53+
54+
token, err, _ := memoize.Call(tokenCache, fmt.Sprintf("fetch-access-token-%d", installationID), fetch)
55+
return token, err
56+
}

0 commit comments

Comments
 (0)