Skip to content

Commit 903048e

Browse files
Initial implementation
1 parent db6b8ef commit 903048e

13 files changed

+715
-0
lines changed

.github/workflows/release.yml

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: Release with goreleaser
2+
on:
3+
push:
4+
tags:
5+
- v* # Push events to matching v*, i.e. v1.0, v20.15.10
6+
jobs:
7+
build:
8+
runs-on: ubuntu-latest
9+
name: goreleaser
10+
steps:
11+
- uses: actions/checkout@v2
12+
- uses: actions/setup-go@v1
13+
with:
14+
go-version: 1.13
15+
- name: Release via goreleaser
16+
uses: goreleaser/goreleaser-action@master
17+
with:
18+
args: release
19+
env:
20+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.github/workflows/unit-tests.yml

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
name: Unit Tests
2+
on: [push]
3+
4+
jobs:
5+
test:
6+
name: Run Unit Tests
7+
runs-on: ubuntu-latest
8+
9+
steps:
10+
- uses: actions/checkout@v2
11+
- uses: actions/setup-go@v1
12+
with:
13+
go-version: 1.13
14+
- name: Run Unit Tests
15+
run: go test -v -count=1 ./...

.goreleaser.yml

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
before:
2+
hooks:
3+
- go mod download
4+
builds:
5+
- env:
6+
- CGO_ENABLED=0
7+
goos:
8+
- linux
9+
- darwin
10+
- windows
11+
archives:
12+
- name_template: '{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}'
13+
files:
14+
- none*
15+
checksum:
16+
name_template: 'checksums.txt'
17+
snapshot:
18+
name_template: "{{ .Tag }}-next"

LICENSE

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
The MIT License (MIT)
2+
3+
Copyright © 2020 Kumbirai Tanekha
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in
13+
all copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
THE SOFTWARE.

README.md

+107
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# github-secrets-writer
2+
3+
**github-secrets-writer** is a command-line tool that simplifies the process of creating or updating [Github secrets](https://help.github.com/en/actions/configuring-and-managing-workflows/creating-and-storing-encrypted-secrets) by carrying out the encryption for you, and writing the secrets to Github via the API.
4+
> Secrets are encrypted environment variables created in a repository and can only be used by GitHub Actions
5+
6+
**Table of Contents**
7+
* [What's all the fuss?](#whats-all-the-fuss)
8+
* [Installation](#installation)
9+
+ [Binaries](#binaries)
10+
+ [Via Go](#via-go)
11+
* [Usage](#usage)
12+
+ [Write secrets](#write-secrets)
13+
14+
## What's all the fuss?
15+
16+
GitHub secrets are encrypted using public-key authenticated encryption and the Poly1305 cipher algorithm. The [Github developer documentation](https://developer.github.com/v3/actions/secrets/#create-or-update-a-secret-for-a-repository) suggests carrying out the encryption using [LibSodium](https://libsodium.gitbook.io/doc/bindings_for_other_languages).
17+
18+
😞 Without **github-secrets-writer**, a user would have to piece together bits of code to:
19+
20+
1. Encrypt a secret using LibSodium (requires installing dependencies)
21+
2. Write the secret to Github using the API
22+
3. Repeat (1) and (2) for multiple secrets
23+
24+
🚀 **github-secrets-writer** offers the user some convenience by doing all the above for you, without the need to install additional dependencies. It comes as a binary and it uses Go's supplementary [cryptography libraries](https://go.googlesource.com/crypto) (that are interoperable with LibSodium) to carry out the encryption, then writes the secrets to Github using the [Go library for accessing the GitHub API](https://github.com/google/go-github).
25+
26+
## Installation
27+
### Binaries
28+
For installation instructions from binaries please visit the [Releases Page](https://github.com/doodlesbykumbi/github-secrets-writer/releases).
29+
30+
As an example, if you wanted to install v0.1.0 on OSX you would run the following commands in your terminal:
31+
```
32+
# Download the release archive with the binary
33+
wget https://github.com/doodlesbykumbi/github-secrets-writer/releases/download/v0.1.0/github-secrets-writer_darwin_amd64.tar.gz
34+
35+
# Uncompress the release archive to /usr/local/bin. You might need to run this with `sudo`
36+
tar -C /usr/local/bin -zxvf github-secrets-writer_darwin_amd64.tar.gz
37+
```
38+
39+
### Via Go
40+
```console
41+
$ go get -u github.com/doodlesbykumbi/github-secrets-writer
42+
```
43+
44+
## Usage
45+
46+
**github-secrets-writer** uses flags to specify the Github repository and the secrets to write to it.
47+
48+
NOTE: An OAuth token **must** be provided via the `GITHUB_TOKEN` environment variable, this is used to authenticate to the Github API. Access tokens require [`repo` scope](https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/#available-scopes) for private repos and [`public_repo` scope](https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/#available-scopes) for public repos. GitHub Apps must have the `secrets` permission to use the API. Authenticated users must have collaborator access to a repository to create, update, or read secrets.
49+
50+
```console
51+
$ github-secrets-writer -h
52+
Create or update multiple Github secrets sourced from literal values or files.
53+
54+
Key/value pairs representing a secret name and the source of the secret value are provided via the flags --from-file and --from-literal. Depending on the key/value pairs specified a single invocation may carry out zero or more writes to the Github secrets of the repository.
55+
56+
NOTE: An OAuth token **must** be provided via the 'GITHUB_TOKEN' environment variable, this is used to authenticate to the Github API. Access tokens require 'repo' scope for private repos and 'public_repo' scope for public repos. GitHub Apps must have the 'secrets' permission to use the API. Authenticated users must have collaborator access to a repository to create, update, or read secrets.
57+
58+
Usage:
59+
github-secrets-writer --owner=owner --repo=repo [--from-literal=secretName1=secretValue1] [--from-file=secretName2=/path/to/secretValue2]
60+
61+
Examples:
62+
# Write a single secret from a literal value
63+
github-secrets-writer --owner=owner --repo=repo --from-literal=secretName1=secretValue1
64+
65+
# Write a single secret from a file
66+
github-secrets-writer --owner=owner --repo=repo --from-file=secretName1=secretFilePath
67+
68+
# Write multiple secrets, one from a literal value and one from a file
69+
github-secrets-writer --owner=owner --repo=repo --from-literal=secretName1=secretValue1 --from-file=secretName2=/path/to/secretValue2
70+
71+
Flags:
72+
--from-file stringArray specify secret name and literal value pairs e.g. secretname=somevalue (zero or more)
73+
--from-literal stringArray specify secret name and source file pairs e.g. secretname=somefile (zero or more)
74+
-h, --help help for github-secrets-writer
75+
--owner string owner of the repository e.g. an organisation or user (required)
76+
--repo string name of the repository (required)
77+
```
78+
79+
### Write secrets
80+
81+
To write secrets to a repository you must invoke **github-secrets-writer** with the relevant flags. Below is an example of writing 3 secrets (2 from literal values and 1 from a source file) to the `example-owner/example-repo` repository.
82+
83+
```console
84+
$ GITHUB_TOKEN=... github-secrets-writer \
85+
--owner example-owner \
86+
--repo example-repo \
87+
--from-literal secretName1=secretValue1 \
88+
--from-literal secretName2=secretValue2 \
89+
--from-file secretName3=/path/to/secretValue3
90+
Write results:
91+
92+
secretName1: 204 No Content
93+
secretName2: 204 No Content
94+
secretName3: 204 No Content
95+
```
96+
97+
Following the successful writes, you can [use the encrypted secrets in a workflow](https://help.github.com/en/actions/configuring-and-managing-workflows/creating-and-storing-encrypted-secrets#using-encrypted-secrets-in-a-workflow) as shown below.
98+
99+
```yaml
100+
steps:
101+
- name: Hello world action
102+
with: # Set the secret as an input
103+
secretName1: ${{ secrets.secretName1 }}
104+
env: # Or as an environment variable
105+
secretName2: ${{ secrets.secretName2 }}
106+
secretName3: ${{ secrets.secretName3 }}
107+
```

cmd/root.go

+110
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/spf13/cobra"
8+
9+
"github.com/spf13/viper"
10+
11+
gsw "github.com/doodlesbykumbi/github-secrets-writer/pkg"
12+
)
13+
14+
var owner string
15+
var repo string
16+
var fromFile []string
17+
var fromLiteral []string
18+
var secrets []Secret
19+
20+
func fatal(err error) {
21+
_, _ = os.Stderr.Write([]byte(fmt.Sprintf("ERROR: %s\n", err)))
22+
os.Exit(1)
23+
}
24+
25+
var rootCmd = &cobra.Command{
26+
Use: "github-secrets-writer --owner=owner --repo=repo [--from-literal=secretName1=secretValue1] [--from-file=secretName2=/path/to/secretValue2]",
27+
DisableFlagsInUseLine: true,
28+
Short: "Create or update multiple Github secrets sourced from literal values or files.",
29+
Long: `Create or update multiple Github secrets sourced from literal values or files.
30+
31+
Key/value pairs representing a secret name and the source of the secret value are provided via the flags --from-file and --from-literal. Depending on the key/value pairs specified a single invocation may carry out zero or more writes to the Github secrets of the repository.
32+
33+
NOTE: An OAuth token **must** be provided via the 'GITHUB_TOKEN' environment variable, this is used to authenticate to the Github API. Access tokens require 'repo' scope for private repos and 'public_repo' scope for public repos. GitHub Apps must have the 'secrets' permission to use the API. Authenticated users must have collaborator access to a repository to create, update, or read secrets.
34+
`,
35+
Example: `# Write a single secret from a literal value
36+
github-secrets-writer --owner=owner --repo=repo --from-literal=secretName1=secretValue1
37+
38+
# Write a single secret from a file
39+
github-secrets-writer --owner=owner --repo=repo --from-file=secretName1=/path/to/secretValue1
40+
41+
# Write multiple secrets, one from a literal value and one from a file
42+
github-secrets-writer --owner=owner --repo=repo --from-literal=secretName1=secretValue1 --from-file=secretName2=/path/to/secretValue2`,
43+
Run: func(cmd *cobra.Command, args []string) {
44+
var err error
45+
defer func() {
46+
if err != nil {
47+
fatal(err)
48+
}
49+
}()
50+
secretWriter := gsw.NewSecretWriter(viper.GetString("token"))
51+
var hasFailures bool
52+
53+
fmt.Printf("Write results:\n\n")
54+
for _, secret := range secrets {
55+
result, wErr := secretWriter.Write(owner, repo, secret.Name, secret.Value)
56+
if wErr != nil {
57+
hasFailures = true
58+
result = wErr.Error()
59+
}
60+
61+
fmt.Printf("%s: %s\n", secret.Name, result)
62+
}
63+
64+
if hasFailures {
65+
err = fmt.Errorf("encountered some failures, see above")
66+
return
67+
}
68+
},
69+
PreRunE: func(cmd *cobra.Command, args []string) error {
70+
if !viper.IsSet("token") {
71+
return fmt.Errorf("envvar not set: GITHUB_TOKEN")
72+
}
73+
74+
var err error
75+
secrets, err = CollectSecrets(fromLiteral, fromFile)
76+
if err != nil {
77+
return err
78+
}
79+
80+
if len(secrets) == 0 {
81+
return fmt.Errorf("no secret name-source pairs provided, you must specify at least one of --from-literal or --from-file")
82+
}
83+
84+
return nil
85+
},
86+
}
87+
88+
func Execute() {
89+
if err := rootCmd.Execute(); err != nil {
90+
fmt.Println(err)
91+
os.Exit(1)
92+
}
93+
}
94+
95+
func init() {
96+
cobra.OnInitialize(initConfig)
97+
98+
rootCmd.Flags().StringVar(&owner, "owner", "", "owner of the repository e.g. an organisation or user (required)")
99+
rootCmd.Flags().StringVar(&repo, "repo", "", "name of the repository (required)")
100+
rootCmd.Flags().StringArrayVar(&fromFile, "from-file", []string{}, "specify secret name and literal value pairs e.g. secretname=somevalue (zero or more)")
101+
rootCmd.Flags().StringArrayVar(&fromLiteral, "from-literal", []string{}, "specify secret name and source file pairs e.g. secretname=somefile (zero or more)")
102+
103+
_ = rootCmd.MarkFlagRequired("owner")
104+
_ = rootCmd.MarkFlagRequired("repo")
105+
}
106+
107+
// initConfig reads in ENV variables.
108+
func initConfig() {
109+
_ = viper.BindEnv("token", "GITHUB_TOKEN")
110+
}

cmd/secret.go

+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"io/ioutil"
6+
"strings"
7+
8+
"github.com/pkg/errors"
9+
)
10+
11+
type Secret struct {
12+
Name string
13+
Value []byte
14+
}
15+
16+
func CollectSecrets(literalSources, fileSources []string) ([]Secret, error) {
17+
var allSecrets []Secret
18+
19+
var err error
20+
21+
secrets, err := secretsFromLiteralSources(literalSources)
22+
if err != nil {
23+
return nil, errors.Wrap(err, fmt.Sprintf(
24+
"literal sources %v", literalSources))
25+
}
26+
allSecrets = append(allSecrets, secrets...)
27+
28+
secrets, err = secretsFromFileSources(fileSources)
29+
if err != nil {
30+
return nil, errors.Wrap(err, fmt.Sprintf(
31+
"file sources: %v", fileSources))
32+
}
33+
allSecrets = append(allSecrets, secrets...)
34+
35+
var secretNames = map[string]bool{}
36+
for _, secret := range allSecrets {
37+
if secretNames[secret.Name] {
38+
return nil, fmt.Errorf(
39+
"multiple sources provided for secret name: %s",
40+
secret.Name,
41+
)
42+
}
43+
44+
secretNames[secret.Name] = true
45+
}
46+
47+
return allSecrets, nil
48+
}
49+
50+
func secretsFromLiteralSources(sources []string) ([]Secret, error) {
51+
var secrets []Secret
52+
for _, s := range sources {
53+
secretName, secretValue, err := parseSecretSource(s)
54+
if err != nil {
55+
return nil, err
56+
}
57+
secrets = append(secrets, Secret{Name: secretName, Value: []byte(secretValue)})
58+
}
59+
return secrets, nil
60+
}
61+
62+
func secretsFromFileSources(sources []string) ([]Secret, error) {
63+
var secrets []Secret
64+
for _, s := range sources {
65+
secretName, fPath, err := parseSecretSource(s)
66+
if err != nil {
67+
return nil, err
68+
}
69+
content, err := ioutil.ReadFile(fPath)
70+
if err != nil {
71+
return nil, err
72+
}
73+
secrets = append(secrets, Secret{Name: secretName, Value: content})
74+
}
75+
return secrets, nil
76+
}
77+
78+
// parseSecretSource parses the source key=val pair into its component pieces.
79+
// This functionality is distinguished from strings.SplitN(source, "=", 2) since
80+
// it returns an error in the case of empty keys, values, or a missing equals sign.
81+
func parseSecretSource(source string) (secretName, value string, err error) {
82+
// leading equal is invalid
83+
if strings.Index(source, "=") == 0 {
84+
return "", "", fmt.Errorf("invalid secret source %v, expected key=value", source)
85+
}
86+
// split after the first equal (so values can have the = character)
87+
items := strings.SplitN(source, "=", 2)
88+
if len(items) != 2 {
89+
return "", "", fmt.Errorf("invalid secret source %v, expected key=value", source)
90+
}
91+
return items[0], strings.Trim(items[1], "\"'"), nil
92+
}

0 commit comments

Comments
 (0)