X Tutup
Skip to content

Commit e82958b

Browse files
joshmgrossmislav
andauthored
Support setting user Codespaces secrets (cli#4699)
Co-authored-by: Mislav Marohnić <mislav@github.com>
1 parent 577f29a commit e82958b

File tree

5 files changed

+208
-88
lines changed

5 files changed

+208
-88
lines changed

internal/ghrepo/repo.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ func SetDefaultHost(host string) {
5353
// FromFullName extracts the GitHub repository information from the following
5454
// formats: "OWNER/REPO", "HOST/OWNER/REPO", and a full URL.
5555
func FromFullName(nwo string) (Interface, error) {
56+
return FromFullNameWithHost(nwo, defaultHost())
57+
}
58+
59+
// FromFullNameWithHost is like FromFullName that defaults to a specific host for values that don't
60+
// explicitly include a hostname.
61+
func FromFullNameWithHost(nwo, fallbackHost string) (Interface, error) {
5662
if git.IsURL(nwo) {
5763
u, err := git.ParseURL(nwo)
5864
if err != nil {
@@ -71,7 +77,7 @@ func FromFullName(nwo string) (Interface, error) {
7177
case 3:
7278
return NewWithHost(parts[1], parts[2], parts[0]), nil
7379
case 2:
74-
return NewWithHost(parts[0], parts[1], defaultHost()), nil
80+
return NewWithHost(parts[0], parts[1], fallbackHost), nil
7581
default:
7682
return nil, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, nwo)
7783
}

pkg/cmd/secret/secret.go

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,9 @@ func NewCmdSecret(f *cmdutil.Factory) *cobra.Command {
1515
Short: "Manage GitHub secrets",
1616
Long: heredoc.Doc(`
1717
Secrets can be set at the repository, environment, or organization level for use in
18-
GitHub Actions. Run "gh help secret set" to learn how to get started.
19-
`),
20-
Annotations: map[string]string{
21-
"IsActions": "true",
22-
},
18+
GitHub Actions. User secrets can be set for use in GitHub Codespaces.
19+
Run "gh help secret set" to learn how to get started.
20+
`),
2321
}
2422

2523
cmdutil.EnableRepoOverride(cmd, f)

pkg/cmd/secret/set/http.go

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"fmt"
88
"sort"
9+
"strconv"
910
"strings"
1011

1112
"github.com/cli/cli/v2/api"
@@ -20,6 +21,13 @@ type SecretPayload struct {
2021
KeyID string `json:"key_id"`
2122
}
2223

24+
// The Codespaces Secret API currently expects repositories IDs as strings
25+
type CodespacesSecretPayload struct {
26+
EncryptedValue string `json:"encrypted_value"`
27+
Repositories []string `json:"selected_repository_ids,omitempty"`
28+
KeyID string `json:"key_id"`
29+
}
30+
2331
type PubKey struct {
2432
Raw [32]byte
2533
ID string `json:"key_id"`
@@ -50,6 +58,10 @@ func getOrgPublicKey(client *api.Client, host, orgName string) (*PubKey, error)
5058
return getPubKey(client, host, fmt.Sprintf("orgs/%s/actions/secrets/public-key", orgName))
5159
}
5260

61+
func getUserPublicKey(client *api.Client, host string) (*PubKey, error) {
62+
return getPubKey(client, host, "user/codespaces/secrets/public-key")
63+
}
64+
5365
func getRepoPubKey(client *api.Client, repo ghrepo.Interface) (*PubKey, error) {
5466
return getPubKey(client, repo.RepoHost(), fmt.Sprintf("repos/%s/actions/secrets/public-key",
5567
ghrepo.FullName(repo)))
@@ -60,7 +72,7 @@ func getEnvPubKey(client *api.Client, repo ghrepo.Interface, envName string) (*P
6072
ghrepo.FullName(repo), envName))
6173
}
6274

63-
func putSecret(client *api.Client, host, path string, payload SecretPayload) error {
75+
func putSecret(client *api.Client, host, path string, payload interface{}) error {
6476
payloadBytes, err := json.Marshal(payload)
6577
if err != nil {
6678
return fmt.Errorf("failed to serialize: %w", err)
@@ -78,7 +90,20 @@ func putOrgSecret(client *api.Client, host string, pk *PubKey, opts SetOptions,
7890
var repositoryIDs []int
7991
var err error
8092
if orgName != "" && visibility == shared.Selected {
81-
repositoryIDs, err = mapRepoNameToID(client, host, orgName, opts.RepositoryNames)
93+
repos := make([]ghrepo.Interface, 0, len(opts.RepositoryNames))
94+
for _, repositoryName := range opts.RepositoryNames {
95+
var repo ghrepo.Interface
96+
if strings.Contains(repositoryName, "/") {
97+
repo, err = ghrepo.FromFullNameWithHost(repositoryName, host)
98+
if err != nil {
99+
return fmt.Errorf("invalid repository name: %w", err)
100+
}
101+
} else {
102+
repo = ghrepo.NewWithHost(opts.OrgName, repositoryName, host)
103+
}
104+
repos = append(repos, repo)
105+
}
106+
repositoryIDs, err = mapRepoToID(client, host, repos)
82107
if err != nil {
83108
return fmt.Errorf("failed to look up IDs for repositories %v: %w", opts.RepositoryNames, err)
84109
}
@@ -95,6 +120,38 @@ func putOrgSecret(client *api.Client, host string, pk *PubKey, opts SetOptions,
95120
return putSecret(client, host, path, payload)
96121
}
97122

123+
func putUserSecret(client *api.Client, host string, pk *PubKey, opts SetOptions, eValue string) error {
124+
payload := CodespacesSecretPayload{
125+
EncryptedValue: eValue,
126+
KeyID: pk.ID,
127+
}
128+
129+
if len(opts.RepositoryNames) > 0 {
130+
repos := make([]ghrepo.Interface, len(opts.RepositoryNames))
131+
for i, repo := range opts.RepositoryNames {
132+
// For user secrets, repository names should be fully qualifed (e.g. "owner/repo")
133+
repoNWO, err := ghrepo.FromFullNameWithHost(repo, host)
134+
if err != nil {
135+
return err
136+
}
137+
repos[i] = repoNWO
138+
}
139+
140+
repositoryIDs, err := mapRepoToID(client, host, repos)
141+
if err != nil {
142+
return fmt.Errorf("failed to look up repository IDs: %w", err)
143+
}
144+
repositoryStringIDs := make([]string, len(repositoryIDs))
145+
for i, id := range repositoryIDs {
146+
repositoryStringIDs[i] = strconv.Itoa(id)
147+
}
148+
payload.Repositories = repositoryStringIDs
149+
}
150+
151+
path := fmt.Sprintf("user/codespaces/secrets/%s", opts.SecretName)
152+
return putSecret(client, host, path, payload)
153+
}
154+
98155
func putEnvSecret(client *api.Client, pk *PubKey, repo ghrepo.Interface, envName string, secretName, eValue string) error {
99156
payload := SecretPayload{
100157
EncryptedValue: eValue,
@@ -114,14 +171,14 @@ func putRepoSecret(client *api.Client, pk *PubKey, repo ghrepo.Interface, secret
114171
}
115172

116173
// This does similar logic to `api.RepoNetwork`, but without the overfetching.
117-
func mapRepoNameToID(client *api.Client, host, orgName string, repositoryNames []string) ([]int, error) {
118-
queries := make([]string, 0, len(repositoryNames))
119-
for i, repoName := range repositoryNames {
174+
func mapRepoToID(client *api.Client, host string, repositories []ghrepo.Interface) ([]int, error) {
175+
queries := make([]string, 0, len(repositories))
176+
for i, repo := range repositories {
120177
queries = append(queries, fmt.Sprintf(`
121178
repo_%03d: repository(owner: %q, name: %q) {
122179
databaseId
123180
}
124-
`, i, orgName, repoName))
181+
`, i, repo.RepoOwner(), repo.RepoName()))
125182
}
126183

127184
query := fmt.Sprintf(`query MapRepositoryNames { %s }`, strings.Join(queries, ""))
@@ -134,13 +191,13 @@ func mapRepoNameToID(client *api.Client, host, orgName string, repositoryNames [
134191
return nil, fmt.Errorf("failed to look up repositories: %w", err)
135192
}
136193

137-
repoKeys := make([]string, 0, len(repositoryNames))
194+
repoKeys := make([]string, 0, len(repositories))
138195
for k := range graphqlResult {
139196
repoKeys = append(repoKeys, k)
140197
}
141198
sort.Strings(repoKeys)
142199

143-
result := make([]int, len(repositoryNames))
200+
result := make([]int, len(repositories))
144201
for i, k := range repoKeys {
145202
result[i] = graphqlResult[k].DatabaseID
146203
}

pkg/cmd/secret/set/set.go

Lines changed: 50 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,10 @@ package set
22

33
import (
44
"encoding/base64"
5-
"errors"
65
"fmt"
76
"io"
87
"io/ioutil"
98
"net/http"
10-
"regexp"
11-
"strings"
129

1310
"github.com/AlecAivazis/survey/v2"
1411
"github.com/MakeNowJust/heredoc"
@@ -34,6 +31,7 @@ type SetOptions struct {
3431
SecretName string
3532
OrgName string
3633
EnvName string
34+
UserSecrets bool
3735
Body string
3836
Visibility string
3937
RepositoryNames []string
@@ -49,25 +47,39 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
4947
cmd := &cobra.Command{
5048
Use: "set <secret-name>",
5149
Short: "Create or update secrets",
52-
Long: "Locally encrypt a new or updated secret at either the repository, environment, or organization level and send it to GitHub for storage.",
50+
Long: heredoc.Doc(`
51+
Set a value for a secret on one of the following levels:
52+
- repository (default): available to Actions runs in a repository
53+
- environment: available to Actions runs for a deployment environment in a repository
54+
- organization: available to Actions runs within an organization
55+
- user: available to Codespaces for your user
56+
57+
Organization and user secrets can optionally be restricted to only be available to
58+
specific repositories.
59+
60+
Secret values are locally encrypted before being sent to GitHub.
61+
`),
5362
Example: heredoc.Doc(`
54-
Paste secret in prompt
63+
# Paste secret value for the current repository in an interactive prompt
5564
$ gh secret set MYSECRET
5665
57-
Use environment variable as secret value
58-
$ gh secret set MYSECRET -b"${ENV_VALUE}"
66+
# Read secret value from an environment variable
67+
$ gh secret set MYSECRET --body "$ENV_VALUE"
5968
60-
Use file as secret value
61-
$ gh secret set MYSECRET < file.json
69+
# Read secret value from a file
70+
$ gh secret set MYSECRET < myfile.txt
6271
63-
Set environment level secret
64-
$ gh secret set MYSECRET -bval --env=anEnv
72+
# Set secret for a deployment environment in the current repository
73+
$ gh secret set MYSECRET --env myenvironment
6574
66-
Set organization level secret visible to entire organization
67-
$ gh secret set MYSECRET -bval --org=anOrg --visibility=all
75+
# Set organization-level secret visible to both public and private repositories
76+
$ gh secret set MYSECRET --org myOrg --visibility all
6877
69-
Set organization level secret visible only to certain repositories
70-
$ gh secret set MYSECRET -bval --org=anOrg --repos="repo1,repo2,repo3"
78+
# Set organization-level secret visible to specific repositories
79+
$ gh secret set MYSECRET --org myOrg --repos repo1,repo2,repo3
80+
81+
# Set user-level secret for Codespaces
82+
$ gh secret set MYSECRET --user
7183
`),
7284
Args: func(cmd *cobra.Command, args []string) error {
7385
if len(args) != 1 {
@@ -79,35 +91,30 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
7991
// support `-R, --repo` override
8092
opts.BaseRepo = f.BaseRepo
8193

82-
if err := cmdutil.MutuallyExclusive("specify only one of `--org` or `--env`", opts.OrgName != "", opts.EnvName != ""); err != nil {
94+
if err := cmdutil.MutuallyExclusive("specify only one of `--org`, `--env`, or `--user`", opts.OrgName != "", opts.EnvName != "", opts.UserSecrets); err != nil {
8395
return err
8496
}
8597

8698
opts.SecretName = args[0]
8799

88-
err := validSecretName(opts.SecretName)
89-
if err != nil {
90-
return err
91-
}
92-
93100
if cmd.Flags().Changed("visibility") {
94101
if opts.OrgName == "" {
95-
return cmdutil.FlagErrorf("--visibility not supported for repository secrets; did you mean to pass --org?")
102+
return cmdutil.FlagErrorf("`--visibility` is only supported with `--org`")
96103
}
97104

98105
if opts.Visibility != shared.All && opts.Visibility != shared.Private && opts.Visibility != shared.Selected {
99-
return cmdutil.FlagErrorf("--visibility must be one of `all`, `private`, or `selected`")
106+
return cmdutil.FlagErrorf("`--visibility` must be one of \"all\", \"private\", or \"selected\"")
100107
}
101108

102-
if opts.Visibility != shared.Selected && cmd.Flags().Changed("repos") {
103-
return cmdutil.FlagErrorf("--repos only supported when --visibility='selected'")
109+
if opts.Visibility != shared.Selected && len(opts.RepositoryNames) > 0 {
110+
return cmdutil.FlagErrorf("`--repos` is only supported with `--visibility=selected`")
104111
}
105112

106-
if opts.Visibility == shared.Selected && !cmd.Flags().Changed("repos") {
107-
return cmdutil.FlagErrorf("--repos flag required when --visibility='selected'")
113+
if opts.Visibility == shared.Selected && len(opts.RepositoryNames) == 0 {
114+
return cmdutil.FlagErrorf("`--repos` list required with `--visibility=selected`")
108115
}
109116
} else {
110-
if cmd.Flags().Changed("repos") {
117+
if len(opts.RepositoryNames) > 0 {
111118
opts.Visibility = shared.Selected
112119
}
113120
}
@@ -119,11 +126,13 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
119126
return setRun(opts)
120127
},
121128
}
122-
cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "Set a secret for an organization")
123-
cmd.Flags().StringVarP(&opts.EnvName, "env", "e", "", "Set a secret for an environment")
124-
cmd.Flags().StringVarP(&opts.Visibility, "visibility", "v", "private", "Set visibility for an organization secret: `all`, `private`, or `selected`")
125-
cmd.Flags().StringSliceVarP(&opts.RepositoryNames, "repos", "r", []string{}, "List of repository names for `selected` visibility")
126-
cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "A value for the secret. Reads from STDIN if not specified.")
129+
130+
cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "Set `organization` secret")
131+
cmd.Flags().StringVarP(&opts.EnvName, "env", "e", "", "Set deployment `environment` secret")
132+
cmd.Flags().BoolVarP(&opts.UserSecrets, "user", "u", false, "Set a secret for your user")
133+
cmd.Flags().StringVarP(&opts.Visibility, "visibility", "v", "private", "Set visibility for an organization secret: `{all|private|selected}`")
134+
cmd.Flags().StringSliceVarP(&opts.RepositoryNames, "repos", "r", []string{}, "List of `repositories` that can access an organization or user secret")
135+
cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "The value for the secret (reads from standard input if not specified)")
127136

128137
return cmd
129138
}
@@ -144,7 +153,7 @@ func setRun(opts *SetOptions) error {
144153
envName := opts.EnvName
145154

146155
var baseRepo ghrepo.Interface
147-
if orgName == "" {
156+
if orgName == "" && !opts.UserSecrets {
148157
baseRepo, err = opts.BaseRepo()
149158
if err != nil {
150159
return fmt.Errorf("could not determine base repo: %w", err)
@@ -166,6 +175,8 @@ func setRun(opts *SetOptions) error {
166175
pk, err = getOrgPublicKey(client, host, orgName)
167176
} else if envName != "" {
168177
pk, err = getEnvPubKey(client, baseRepo, envName)
178+
} else if opts.UserSecrets {
179+
pk, err = getUserPublicKey(client, host)
169180
} else {
170181
pk, err = getRepoPubKey(client, baseRepo)
171182
}
@@ -184,6 +195,8 @@ func setRun(opts *SetOptions) error {
184195
err = putOrgSecret(client, host, pk, *opts, encoded)
185196
} else if envName != "" {
186197
err = putEnvSecret(client, pk, baseRepo, envName, opts.SecretName, encoded)
198+
} else if opts.UserSecrets {
199+
err = putUserSecret(client, host, pk, *opts, encoded)
187200
} else {
188201
err = putRepoSecret(client, pk, baseRepo, opts.SecretName, encoded)
189202
}
@@ -193,7 +206,9 @@ func setRun(opts *SetOptions) error {
193206

194207
if opts.IO.IsStdoutTTY() {
195208
target := orgName
196-
if orgName == "" {
209+
if opts.UserSecrets {
210+
target = "your user"
211+
} else if orgName == "" {
197212
target = ghrepo.FullName(baseRepo)
198213
}
199214
cs := opts.IO.ColorScheme()
@@ -203,28 +218,6 @@ func setRun(opts *SetOptions) error {
203218
return nil
204219
}
205220

206-
func validSecretName(name string) error {
207-
if name == "" {
208-
return errors.New("secret name cannot be blank")
209-
}
210-
211-
if strings.HasPrefix(name, "GITHUB_") {
212-
return errors.New("secret name cannot begin with GITHUB_")
213-
}
214-
215-
leadingNumber := regexp.MustCompile(`^[0-9]`)
216-
if leadingNumber.MatchString(name) {
217-
return errors.New("secret name cannot start with a number")
218-
}
219-
220-
validChars := regexp.MustCompile(`^([0-9]|[a-z]|[A-Z]|_)+$`)
221-
if !validChars.MatchString(name) {
222-
return errors.New("secret name can only contain letters, numbers, and _")
223-
}
224-
225-
return nil
226-
}
227-
228221
func getBody(opts *SetOptions) ([]byte, error) {
229222
if opts.Body == "" {
230223
if opts.IO.CanPrompt() {

0 commit comments

Comments
 (0)
X Tutup