mirror of
https://github.com/acaloiaro/ess
synced 2026-07-21 10:12:19 +00:00
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:
parent
9132970933
commit
af99fc849a
7 changed files with 369 additions and 78 deletions
35
.github/workflows/goreleaser.yml
vendored
Normal file
35
.github/workflows/goreleaser.yml
vendored
Normal 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
33
.github/workflows/post-release.yml
vendored
Normal 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'
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
5
go.mod
Normal 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
2
go.sum
Normal 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
291
main.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue