Support setting user Codespaces secrets (#4699)

Co-authored-by: Mislav Marohnić <mislav@github.com>
This commit is contained in:
Josh Gross 2021-11-18 06:55:24 -05:00 committed by GitHub
parent 577f29ae0d
commit e82958b4e0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 208 additions and 88 deletions

View file

@ -53,6 +53,12 @@ func SetDefaultHost(host string) {
// FromFullName extracts the GitHub repository information from the following
// formats: "OWNER/REPO", "HOST/OWNER/REPO", and a full URL.
func FromFullName(nwo string) (Interface, error) {
return FromFullNameWithHost(nwo, defaultHost())
}
// FromFullNameWithHost is like FromFullName that defaults to a specific host for values that don't
// explicitly include a hostname.
func FromFullNameWithHost(nwo, fallbackHost string) (Interface, error) {
if git.IsURL(nwo) {
u, err := git.ParseURL(nwo)
if err != nil {
@ -71,7 +77,7 @@ func FromFullName(nwo string) (Interface, error) {
case 3:
return NewWithHost(parts[1], parts[2], parts[0]), nil
case 2:
return NewWithHost(parts[0], parts[1], defaultHost()), nil
return NewWithHost(parts[0], parts[1], fallbackHost), nil
default:
return nil, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, nwo)
}

View file

@ -15,11 +15,9 @@ func NewCmdSecret(f *cmdutil.Factory) *cobra.Command {
Short: "Manage GitHub secrets",
Long: heredoc.Doc(`
Secrets can be set at the repository, environment, or organization level for use in
GitHub Actions. Run "gh help secret set" to learn how to get started.
`),
Annotations: map[string]string{
"IsActions": "true",
},
GitHub Actions. User secrets can be set for use in GitHub Codespaces.
Run "gh help secret set" to learn how to get started.
`),
}
cmdutil.EnableRepoOverride(cmd, f)

View file

@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"github.com/cli/cli/v2/api"
@ -20,6 +21,13 @@ type SecretPayload struct {
KeyID string `json:"key_id"`
}
// The Codespaces Secret API currently expects repositories IDs as strings
type CodespacesSecretPayload struct {
EncryptedValue string `json:"encrypted_value"`
Repositories []string `json:"selected_repository_ids,omitempty"`
KeyID string `json:"key_id"`
}
type PubKey struct {
Raw [32]byte
ID string `json:"key_id"`
@ -50,6 +58,10 @@ func getOrgPublicKey(client *api.Client, host, orgName string) (*PubKey, error)
return getPubKey(client, host, fmt.Sprintf("orgs/%s/actions/secrets/public-key", orgName))
}
func getUserPublicKey(client *api.Client, host string) (*PubKey, error) {
return getPubKey(client, host, "user/codespaces/secrets/public-key")
}
func getRepoPubKey(client *api.Client, repo ghrepo.Interface) (*PubKey, error) {
return getPubKey(client, repo.RepoHost(), fmt.Sprintf("repos/%s/actions/secrets/public-key",
ghrepo.FullName(repo)))
@ -60,7 +72,7 @@ func getEnvPubKey(client *api.Client, repo ghrepo.Interface, envName string) (*P
ghrepo.FullName(repo), envName))
}
func putSecret(client *api.Client, host, path string, payload SecretPayload) error {
func putSecret(client *api.Client, host, path string, payload interface{}) error {
payloadBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to serialize: %w", err)
@ -78,7 +90,20 @@ func putOrgSecret(client *api.Client, host string, pk *PubKey, opts SetOptions,
var repositoryIDs []int
var err error
if orgName != "" && visibility == shared.Selected {
repositoryIDs, err = mapRepoNameToID(client, host, orgName, opts.RepositoryNames)
repos := make([]ghrepo.Interface, 0, len(opts.RepositoryNames))
for _, repositoryName := range opts.RepositoryNames {
var repo ghrepo.Interface
if strings.Contains(repositoryName, "/") {
repo, err = ghrepo.FromFullNameWithHost(repositoryName, host)
if err != nil {
return fmt.Errorf("invalid repository name: %w", err)
}
} else {
repo = ghrepo.NewWithHost(opts.OrgName, repositoryName, host)
}
repos = append(repos, repo)
}
repositoryIDs, err = mapRepoToID(client, host, repos)
if err != nil {
return fmt.Errorf("failed to look up IDs for repositories %v: %w", opts.RepositoryNames, err)
}
@ -95,6 +120,38 @@ func putOrgSecret(client *api.Client, host string, pk *PubKey, opts SetOptions,
return putSecret(client, host, path, payload)
}
func putUserSecret(client *api.Client, host string, pk *PubKey, opts SetOptions, eValue string) error {
payload := CodespacesSecretPayload{
EncryptedValue: eValue,
KeyID: pk.ID,
}
if len(opts.RepositoryNames) > 0 {
repos := make([]ghrepo.Interface, len(opts.RepositoryNames))
for i, repo := range opts.RepositoryNames {
// For user secrets, repository names should be fully qualifed (e.g. "owner/repo")
repoNWO, err := ghrepo.FromFullNameWithHost(repo, host)
if err != nil {
return err
}
repos[i] = repoNWO
}
repositoryIDs, err := mapRepoToID(client, host, repos)
if err != nil {
return fmt.Errorf("failed to look up repository IDs: %w", err)
}
repositoryStringIDs := make([]string, len(repositoryIDs))
for i, id := range repositoryIDs {
repositoryStringIDs[i] = strconv.Itoa(id)
}
payload.Repositories = repositoryStringIDs
}
path := fmt.Sprintf("user/codespaces/secrets/%s", opts.SecretName)
return putSecret(client, host, path, payload)
}
func putEnvSecret(client *api.Client, pk *PubKey, repo ghrepo.Interface, envName string, secretName, eValue string) error {
payload := SecretPayload{
EncryptedValue: eValue,
@ -114,14 +171,14 @@ func putRepoSecret(client *api.Client, pk *PubKey, repo ghrepo.Interface, secret
}
// This does similar logic to `api.RepoNetwork`, but without the overfetching.
func mapRepoNameToID(client *api.Client, host, orgName string, repositoryNames []string) ([]int, error) {
queries := make([]string, 0, len(repositoryNames))
for i, repoName := range repositoryNames {
func mapRepoToID(client *api.Client, host string, repositories []ghrepo.Interface) ([]int, error) {
queries := make([]string, 0, len(repositories))
for i, repo := range repositories {
queries = append(queries, fmt.Sprintf(`
repo_%03d: repository(owner: %q, name: %q) {
databaseId
}
`, i, orgName, repoName))
`, i, repo.RepoOwner(), repo.RepoName()))
}
query := fmt.Sprintf(`query MapRepositoryNames { %s }`, strings.Join(queries, ""))
@ -134,13 +191,13 @@ func mapRepoNameToID(client *api.Client, host, orgName string, repositoryNames [
return nil, fmt.Errorf("failed to look up repositories: %w", err)
}
repoKeys := make([]string, 0, len(repositoryNames))
repoKeys := make([]string, 0, len(repositories))
for k := range graphqlResult {
repoKeys = append(repoKeys, k)
}
sort.Strings(repoKeys)
result := make([]int, len(repositoryNames))
result := make([]int, len(repositories))
for i, k := range repoKeys {
result[i] = graphqlResult[k].DatabaseID
}

View file

@ -2,13 +2,10 @@ package set
import (
"encoding/base64"
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
"regexp"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/MakeNowJust/heredoc"
@ -34,6 +31,7 @@ type SetOptions struct {
SecretName string
OrgName string
EnvName string
UserSecrets bool
Body string
Visibility string
RepositoryNames []string
@ -49,25 +47,39 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
cmd := &cobra.Command{
Use: "set <secret-name>",
Short: "Create or update secrets",
Long: "Locally encrypt a new or updated secret at either the repository, environment, or organization level and send it to GitHub for storage.",
Long: heredoc.Doc(`
Set a value for a secret on one of the following levels:
- repository (default): available to Actions runs in a repository
- environment: available to Actions runs for a deployment environment in a repository
- organization: available to Actions runs within an organization
- user: available to Codespaces for your user
Organization and user secrets can optionally be restricted to only be available to
specific repositories.
Secret values are locally encrypted before being sent to GitHub.
`),
Example: heredoc.Doc(`
Paste secret in prompt
# Paste secret value for the current repository in an interactive prompt
$ gh secret set MYSECRET
Use environment variable as secret value
$ gh secret set MYSECRET -b"${ENV_VALUE}"
# Read secret value from an environment variable
$ gh secret set MYSECRET --body "$ENV_VALUE"
Use file as secret value
$ gh secret set MYSECRET < file.json
# Read secret value from a file
$ gh secret set MYSECRET < myfile.txt
Set environment level secret
$ gh secret set MYSECRET -bval --env=anEnv
# Set secret for a deployment environment in the current repository
$ gh secret set MYSECRET --env myenvironment
Set organization level secret visible to entire organization
$ gh secret set MYSECRET -bval --org=anOrg --visibility=all
# Set organization-level secret visible to both public and private repositories
$ gh secret set MYSECRET --org myOrg --visibility all
Set organization level secret visible only to certain repositories
$ gh secret set MYSECRET -bval --org=anOrg --repos="repo1,repo2,repo3"
# Set organization-level secret visible to specific repositories
$ gh secret set MYSECRET --org myOrg --repos repo1,repo2,repo3
# Set user-level secret for Codespaces
$ gh secret set MYSECRET --user
`),
Args: func(cmd *cobra.Command, args []string) error {
if len(args) != 1 {
@ -79,35 +91,30 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
if err := cmdutil.MutuallyExclusive("specify only one of `--org` or `--env`", opts.OrgName != "", opts.EnvName != ""); err != nil {
if err := cmdutil.MutuallyExclusive("specify only one of `--org`, `--env`, or `--user`", opts.OrgName != "", opts.EnvName != "", opts.UserSecrets); err != nil {
return err
}
opts.SecretName = args[0]
err := validSecretName(opts.SecretName)
if err != nil {
return err
}
if cmd.Flags().Changed("visibility") {
if opts.OrgName == "" {
return cmdutil.FlagErrorf("--visibility not supported for repository secrets; did you mean to pass --org?")
return cmdutil.FlagErrorf("`--visibility` is only supported with `--org`")
}
if opts.Visibility != shared.All && opts.Visibility != shared.Private && opts.Visibility != shared.Selected {
return cmdutil.FlagErrorf("--visibility must be one of `all`, `private`, or `selected`")
return cmdutil.FlagErrorf("`--visibility` must be one of \"all\", \"private\", or \"selected\"")
}
if opts.Visibility != shared.Selected && cmd.Flags().Changed("repos") {
return cmdutil.FlagErrorf("--repos only supported when --visibility='selected'")
if opts.Visibility != shared.Selected && len(opts.RepositoryNames) > 0 {
return cmdutil.FlagErrorf("`--repos` is only supported with `--visibility=selected`")
}
if opts.Visibility == shared.Selected && !cmd.Flags().Changed("repos") {
return cmdutil.FlagErrorf("--repos flag required when --visibility='selected'")
if opts.Visibility == shared.Selected && len(opts.RepositoryNames) == 0 {
return cmdutil.FlagErrorf("`--repos` list required with `--visibility=selected`")
}
} else {
if cmd.Flags().Changed("repos") {
if len(opts.RepositoryNames) > 0 {
opts.Visibility = shared.Selected
}
}
@ -119,11 +126,13 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
return setRun(opts)
},
}
cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "Set a secret for an organization")
cmd.Flags().StringVarP(&opts.EnvName, "env", "e", "", "Set a secret for an environment")
cmd.Flags().StringVarP(&opts.Visibility, "visibility", "v", "private", "Set visibility for an organization secret: `all`, `private`, or `selected`")
cmd.Flags().StringSliceVarP(&opts.RepositoryNames, "repos", "r", []string{}, "List of repository names for `selected` visibility")
cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "A value for the secret. Reads from STDIN if not specified.")
cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "Set `organization` secret")
cmd.Flags().StringVarP(&opts.EnvName, "env", "e", "", "Set deployment `environment` secret")
cmd.Flags().BoolVarP(&opts.UserSecrets, "user", "u", false, "Set a secret for your user")
cmd.Flags().StringVarP(&opts.Visibility, "visibility", "v", "private", "Set visibility for an organization secret: `{all|private|selected}`")
cmd.Flags().StringSliceVarP(&opts.RepositoryNames, "repos", "r", []string{}, "List of `repositories` that can access an organization or user secret")
cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "The value for the secret (reads from standard input if not specified)")
return cmd
}
@ -144,7 +153,7 @@ func setRun(opts *SetOptions) error {
envName := opts.EnvName
var baseRepo ghrepo.Interface
if orgName == "" {
if orgName == "" && !opts.UserSecrets {
baseRepo, err = opts.BaseRepo()
if err != nil {
return fmt.Errorf("could not determine base repo: %w", err)
@ -166,6 +175,8 @@ func setRun(opts *SetOptions) error {
pk, err = getOrgPublicKey(client, host, orgName)
} else if envName != "" {
pk, err = getEnvPubKey(client, baseRepo, envName)
} else if opts.UserSecrets {
pk, err = getUserPublicKey(client, host)
} else {
pk, err = getRepoPubKey(client, baseRepo)
}
@ -184,6 +195,8 @@ func setRun(opts *SetOptions) error {
err = putOrgSecret(client, host, pk, *opts, encoded)
} else if envName != "" {
err = putEnvSecret(client, pk, baseRepo, envName, opts.SecretName, encoded)
} else if opts.UserSecrets {
err = putUserSecret(client, host, pk, *opts, encoded)
} else {
err = putRepoSecret(client, pk, baseRepo, opts.SecretName, encoded)
}
@ -193,7 +206,9 @@ func setRun(opts *SetOptions) error {
if opts.IO.IsStdoutTTY() {
target := orgName
if orgName == "" {
if opts.UserSecrets {
target = "your user"
} else if orgName == "" {
target = ghrepo.FullName(baseRepo)
}
cs := opts.IO.ColorScheme()
@ -203,28 +218,6 @@ func setRun(opts *SetOptions) error {
return nil
}
func validSecretName(name string) error {
if name == "" {
return errors.New("secret name cannot be blank")
}
if strings.HasPrefix(name, "GITHUB_") {
return errors.New("secret name cannot begin with GITHUB_")
}
leadingNumber := regexp.MustCompile(`^[0-9]`)
if leadingNumber.MatchString(name) {
return errors.New("secret name cannot start with a number")
}
validChars := regexp.MustCompile(`^([0-9]|[a-z]|[A-Z]|_)+$`)
if !validChars.MatchString(name) {
return errors.New("secret name can only contain letters, numbers, and _")
}
return nil
}
func getBody(opts *SetOptions) ([]byte, error) {
if opts.Body == "" {
if opts.IO.CanPrompt() {

View file

@ -90,6 +90,16 @@ func TestNewCmdSet(t *testing.T) {
OrgName: "coolOrg",
},
},
{
name: "user with selected repos",
cli: `-u -bs -r"monalisa/coolRepo,cli/cli,github/hub" cool_secret`,
wants: SetOptions{
SecretName: "cool_secret",
Visibility: shared.Selected,
RepositoryNames: []string{"monalisa/coolRepo", "cli/cli", "github/hub"},
Body: "s",
},
},
{
name: "repo",
cli: `cool_secret -b"a secret"`,
@ -121,21 +131,6 @@ func TestNewCmdSet(t *testing.T) {
OrgName: "coolOrg",
},
},
{
name: "bad name prefix",
cli: `GITHUB_SECRET -b"cool"`,
wantsErr: true,
},
{
name: "leading numbers in name",
cli: `123_SECRET -b"cool"`,
wantsErr: true,
},
{
name: "invalid characters in name",
cli: `BAD-SECRET -b"cool"`,
wantsErr: true,
},
}
for _, tt := range tests {
@ -275,7 +270,7 @@ func Test_setRun_org(t *testing.T) {
opts: &SetOptions{
OrgName: "UmbrellaCorporation",
Visibility: shared.Selected,
RepositoryNames: []string{"birkin", "wesker"},
RepositoryNames: []string{"birkin", "UmbrellaCorporation/wesker"},
},
wantRepositories: []int{1, 2},
},
@ -297,7 +292,7 @@ func Test_setRun_org(t *testing.T) {
if len(tt.opts.RepositoryNames) > 0 {
reg.Register(httpmock.GraphQL(`query MapRepositoryNames\b`),
httpmock.StringResponse(`{"data":{"birkin":{"databaseId":1},"wesker":{"databaseId":2}}}`))
httpmock.StringResponse(`{"data":{"repo_0001":{"databaseId":1},"repo_0002":{"databaseId":2}}}`))
}
io, _, _, _ := iostreams.Test()
@ -335,6 +330,77 @@ func Test_setRun_org(t *testing.T) {
}
}
func Test_setRun_user(t *testing.T) {
tests := []struct {
name string
opts *SetOptions
wantVisibility shared.Visibility
wantRepositories []string
}{
{
name: "all vis",
opts: &SetOptions{
UserSecrets: true,
Visibility: shared.All,
},
},
{
name: "selected visibility",
opts: &SetOptions{
UserSecrets: true,
Visibility: shared.Selected,
RepositoryNames: []string{"cli/cli", "github/hub"},
},
wantRepositories: []string{"212613049", "401025"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
reg.Register(httpmock.REST("GET", "user/codespaces/secrets/public-key"),
httpmock.JSONResponse(PubKey{ID: "123", Key: "CDjXqf7AJBXWhMczcy+Fs7JlACEptgceysutztHaFQI="}))
reg.Register(httpmock.REST("PUT", "user/codespaces/secrets/cool_secret"),
httpmock.StatusStringResponse(201, `{}`))
if len(tt.opts.RepositoryNames) > 0 {
reg.Register(httpmock.GraphQL(`query MapRepositoryNames\b`),
httpmock.StringResponse(`{"data":{"repo_0001":{"databaseId":212613049},"repo_0002":{"databaseId":401025}}}`))
}
io, _, _, _ := iostreams.Test()
tt.opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
tt.opts.Config = func() (config.Config, error) {
return config.NewBlankConfig(), nil
}
tt.opts.IO = io
tt.opts.SecretName = "cool_secret"
tt.opts.Body = "a secret"
// Cribbed from https://github.com/golang/crypto/commit/becbf705a91575484002d598f87d74f0002801e7
tt.opts.RandomOverride = bytes.NewReader([]byte{5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5})
err := setRun(tt.opts)
assert.NoError(t, err)
reg.Verify(t)
data, err := ioutil.ReadAll(reg.Requests[len(reg.Requests)-1].Body)
assert.NoError(t, err)
var payload CodespacesSecretPayload
err = json.Unmarshal(data, &payload)
assert.NoError(t, err)
assert.Equal(t, payload.KeyID, "123")
assert.Equal(t, payload.EncryptedValue, "UKYUCbHd0DJemxa3AOcZ6XcsBwALG9d4bpB8ZT0gSV39vl3BHiGSgj8zJapDxgB2BwqNqRhpjC4=")
assert.ElementsMatch(t, payload.Repositories, tt.wantRepositories)
})
}
}
func Test_getBody(t *testing.T) {
tests := []struct {
name string