Golang implementation with native git hook support

- Re-implement with Golang in place of of Bash
- Add `env-sample-sync install` to support native git hooks (instead of
  relying on `pre-commit`)
This commit is contained in:
Adriano Caloiaro 2023-05-27 13:31:40 -06:00
parent 9132970933
commit af99fc849a
No known key found for this signature in database
GPG key ID: C2BC56DE73CE3F75
7 changed files with 369 additions and 78 deletions

35
.github/workflows/goreleaser.yml vendored Normal file
View file

@ -0,0 +1,35 @@
name: goreleaser
on:
push:
branches:
- main
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
-
name: Bump version and push tag
uses: anothrNick/github-tag-action@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WITH_V: true
DEFAULT_BUMP: minor
-
name: Set up Go
uses: actions/setup-go@v3
-
name: Run GoReleaser
uses: goreleaser/goreleaser-action@v4
with:
distribution: goreleaser
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

33
.github/workflows/post-release.yml vendored Normal file
View file

@ -0,0 +1,33 @@
name: Update README.md with latest Version
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
token: ${{ secrets.GH_PATH_TOKEN }} # Needs write access to the repo
ref: main
- name: Update files
uses: MathieuSoysal/file-updater-for-release@v1.0.3
with:
files: README.md # List of files to update
prefix: "rev: " # Prefix before the version, default is: ""
with-checkout: false # If you don't want to checkout the repo, default is: true
- name: Push changes
uses: EndBug/add-and-commit@v9
with:
committer_name: GitHub Actions
committer_email: actions@github.com
add: README.md
message: 'Updated README.md with latest version number'

View file

@ -1,8 +1,8 @@
- id: env-sample-sync
name: env.sample sync
name: env-sample-sync env sample file synchronizer
description: Automatically and safely synchronize .env files with env.sample
entry: env-sample-sync.sh
language: script
language: golang
entry: env-sample-sync
always_run: true
pass_filenames: false
stages: [commit]

View file

@ -1,75 +0,0 @@
#!/usr/bin/env bash
set -e
# A mapping of environment variables names to the the values used in `env.sample` as examples
declare -A examples
for i in "$@"; do
case $i in
-e=*|--env-file=*)
ENV_FILE_NAME="${i#*=}"
shift
;;
-s=*|--sample-file=*)
SAMPLE_FILE_NAME="${i#*=}"
shift
;;
-x=*|--example=*)
varExample="${i#*=}"
IFS='='; exampleTuple=($varExample); unset IFS;
examples[${exampleTuple[0]}]=${exampleTuple[1]}
shift
;;
-*|--*)
echo "Unknown option $i"
exit 1
;;
*)
;;
esac
done
ENV_FILE_NAME=${ENV_FILE_NAME:-.env}
SAMPLE_FILE_NAME=${SAMPLE_FILE_NAME:-env.sample}
function scrub_env_file() {
local envFile=$1
local sampleFile=$2
local isComment='^[[:space:]]*#'
local isBlank='^[[:space:]]*$'
local sampleContent=""
while IFS= read -r line; do
# If the line contains an environment variable definition, scrub the variable value
# and set a placeholder of the form: ENV_VAR=<ENV_VAR>
if [[ ! $line =~ $isComment && ! $line =~ $isBlank ]]; then
envVar=$(echo "$line" | cut -d '=' -f 1)
# If the user supplied an example for this environment variable, use their example
# instead of the environemnt variable name
if [[ ${examples[$envVar]+FOUND} == "FOUND" ]]; then
exampleValue="${examples[$envVar]}"
else
exampleValue="<$envVar>"
fi
sampleContent="${sampleContent}${envVar}=$exampleValue\n"
else
sampleContent="${sampleContent}${line}\n"
fi
done < <( cat "$envFile" )
echo -e $sampleContent > $sampleFile
}
repoDir=$(git rev-parse --show-toplevel)
envFilePath="$repoDir/$ENV_FILE_NAME"
sampleFilePath="$repoDir/$SAMPLE_FILE_NAME"
if [[ -f $envFilePath ]]; then
scrub_env_file $envFilePath $sampleFilePath
git add $SAMPLE_FILE_NAME
else
echo "No .env file found at: $envFilePath"
fi

5
go.mod Normal file
View file

@ -0,0 +1,5 @@
module github.com/acaloiaro/env-sample-sync
go 1.20
require github.com/hashicorp/go-envparse v0.1.0 // indirect

2
go.sum Normal file
View file

@ -0,0 +1,2 @@
github.com/hashicorp/go-envparse v0.1.0 h1:bE++6bhIsNCPLvgDZkYqo3nA+/PFI51pkrHdmPSDFPY=
github.com/hashicorp/go-envparse v0.1.0/go.mod h1:OHheN1GoygLlAkTlXLXvAdnXdZxy8JUweQ1rAXx1xnc=

291
main.go Normal file
View file

@ -0,0 +1,291 @@
package main
import (
"bufio"
"bytes"
"errors"
"flag"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"text/template"
"github.com/hashicorp/go-envparse"
)
type exampleFlag map[string]string
func (e exampleFlag) String() string {
var str string
for key, val := range e {
str = fmt.Sprintf(`%s --example="%s=%s"`, str, key, val)
}
return str
}
func (e exampleFlag) Set(value string) (err error) {
exampleParts := strings.Split(value, "=")
if len(exampleParts) != 2 {
err = errors.New("examples must be provided in KEY=VALUE format")
return
}
e[exampleParts[0]] = exampleParts[1]
return nil
}
var envFileFlag string
var sampleFileFlag string
var examplesFlag = make(exampleFlag)
func init() {
flag.StringVar(&envFileFlag, "env-file", ".env", "-env-file=.env_file")
flag.StringVar(&sampleFileFlag, "sample-file", "env.sample", "-sample-file=env_var.sample")
flag.Var(examplesFlag, "example", "--example=FOO=\"my foo value\" --example=BAR=\"my bar value\"")
flag.Usage = func() {
cmd := os.Args[0]
fmt.Fprintf(os.Stderr, "Usage of %s:\n", cmd)
fmt.Fprintf(os.Stderr, "%s [-flags] %s \n\n", cmd, "[install|sync]")
flag.PrintDefaults()
}
flag.Parse()
}
func main() {
command := "sync"
args := flag.Args()
if len(args) > 0 {
command = args[0]
}
projectPath, gitDirPath := gitDirPaths()
switch command {
case "sync":
sync(projectPath)
cmd := exec.Command("git", "add", filepath.Join(projectPath, sampleFileFlag))
err := cmd.Run()
if err != nil {
fmt.Printf("unable to add sample file '%s' to git: %v", sampleFileFlag, err)
os.Exit(1)
}
case "install":
err := installHook(gitDirPath)
if err != nil {
fmt.Println("unable to install pre-commit hook:", err)
os.Exit(1)
}
default:
fmt.Printf("env-sample-sync: unknown command '%s'\n\n", command)
flag.Usage()
}
}
func sync(dir string) {
envFilePath := filepath.Join(dir, envFileFlag)
sampleFilePath := filepath.Join(dir, sampleFileFlag)
envFileReader, err := os.Open(envFilePath)
if err != nil {
fmt.Printf("env file '%s' was not found. skipping sync.\n", envFilePath)
os.Exit(0)
}
envFile, err := envparse.Parse(envFileReader)
if err != nil {
fmt.Println("unable to parse env file:", err)
os.Exit(1)
}
scrubEnvFile(envFile, examplesFlag)
err = writeSampleFile(envFile, envFilePath, sampleFilePath)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
}
func writeSampleFile(sampleFileContent map[string]string, envFilePath, sampleFilePath string) (err error) {
sampleFile, err := os.Create(sampleFilePath)
if err != nil {
err = fmt.Errorf("unable to create sample file: %w", err)
return
}
defer sampleFile.Close()
sampleWriter := bufio.NewWriter(sampleFile)
envFile, err := os.Open(envFilePath)
if err != nil {
err = fmt.Errorf("unable to read env file: %w", err)
return
}
defer envFile.Close()
scanner := bufio.NewScanner(envFile)
// interate through the env file line-by-line, searching for environment variables
// any line that starts with a key from `sampleFileContents` followed by an equal sign `=` is considered
// a line that contains a secret
for scanner.Scan() {
envFileLine := scanner.Text()
scrubbedLine := replaceSecrets(envFileLine, sampleFileContent)
sampleWriter.WriteString(fmt.Sprintf("%s\n", scrubbedLine))
}
sampleWriter.Flush()
return
}
func scrubEnvFile(envFile map[string]string, examples map[string]string) {
for envFileKey := range envFile {
exampleVal, ok := examples[envFileKey]
if ok {
envFile[envFileKey] = exampleVal
} else {
envFile[envFileKey] = fmt.Sprintf("<%s>", envFileKey)
}
}
}
// replaceSecrets replaces the content of env file entries (lines) with a new line that is scrubbed of secrets
//
// any line containing a variable name followed by and equal sign is considered to contain a secret
func replaceSecrets(envFileEntry string, sampleFileContent map[string]string) (newLine string) {
var r *regexp.Regexp
newLine = envFileEntry
for secretKey, secretPlaceholder := range sampleFileContent {
r = regexp.MustCompile(fmt.Sprintf("(%s.*=.*)", secretKey))
if r.MatchString(envFileEntry) {
newLine = r.ReplaceAllString(envFileEntry, fmt.Sprintf("%s=%s", secretKey, secretPlaceholder))
}
}
return
}
// gitDirPath returns the path the the GIT_DIR
func gitDirPaths() (projectPath, gitDir string) {
var outb, errb bytes.Buffer
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
cmd.Stdout = &outb
cmd.Stderr = &errb
err := cmd.Run()
if err != nil {
fmt.Println("unable to find project directory:", err)
os.Exit(1)
}
projectPath = strings.TrimRight(outb.String(), "\r\n")
outb.Reset()
errb.Reset()
cmd = exec.Command("git", "rev-parse", "--git-dir")
cmd.Stdout = &outb
cmd.Stderr = &errb
err = cmd.Run()
if err != nil {
fmt.Println("unable to find GIT_DIR:", err)
os.Exit(1)
}
gitDir = strings.TrimRight(outb.String(), "\r\n")
gitDir = filepath.Join(projectPath, gitDir)
return
}
var preCommitScriptTemplate = `#!/usr/bin/env bash
# File generated by env-sample-sync https://github.com/acaloiaro/env-sample-sync
ARGS=({{ .Args }})
for SCRIPT in $(dirname -- "${BASH_SOURCE[0]}")/{{ .PreCommitHooksDir }}/*; do
exec "$SCRIPT" "${ARGS[@]}"
done
`
// installHook installs the env-sample-sync git hook to 'dir'
func installHook(gitDirPath string) (err error) {
hooksScriptDirName := "pre-commit-hooks.d"
hooksDirName := filepath.Join(gitDirPath, "hooks")
hooksScriptDirPath := filepath.Join(hooksDirName, hooksScriptDirName)
preCommitHookScriptPath := filepath.Join(hooksDirName, "pre-commit")
_, err = os.Stat(preCommitHookScriptPath)
if !os.IsNotExist(err) {
fmt.Printf("a pre-commit hook is already installed. Overwrite? [y/n]: ")
var response string
fmt.Scanln(&response)
if response != "y" {
os.Exit(0)
}
}
// Create a directory to place pre-commit-hook executable within
if _, err = os.Stat(hooksScriptDirPath); os.IsNotExist(err) {
if err = os.Mkdir(hooksScriptDirPath, os.ModePerm); err != nil {
return
}
}
preCommitHookPath := filepath.Join(hooksScriptDirPath, "0-env-sample-sync")
preCommitHook, err := os.OpenFile(preCommitHookPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755)
if err != nil {
fmt.Println("unable to install pre-commit hook", err)
os.Exit(1)
}
executablePath, err := exec.LookPath(os.Args[0])
if err != nil {
fmt.Println("unable to find 'env-sample-sync' in $PATH:", err)
os.Exit(1)
}
executable, err := os.Open(executablePath)
if err != nil {
return
}
// copy the currently running executable wholsale into the pre commit hooks directory
io.Copy(preCommitHook, executable)
tmpl, err := template.New("pre-commit-script").Parse(preCommitScriptTemplate)
if err != nil {
log.Println("unable to parse pre-commit script template:", err)
os.Exit(1)
}
args := fmt.Sprintf("--env-file=%s --sample-file=%s %s", envFileFlag, sampleFileFlag, examplesFlag)
var buff = bytes.NewBufferString("")
err = tmpl.Execute(buff, map[string]string{
"Args": args,
"PreCommitHooksDir": hooksScriptDirName,
})
if err != nil {
return
}
preCommitHookScript, err := os.OpenFile(preCommitHookScriptPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755)
if err != nil {
return
}
_, err = buff.WriteTo(preCommitHookScript)
if err != nil {
return
}
fmt.Println("env-sample-sync pre-commit hook installed!")
return
}