feat(secret,variable): read values like gh and report stored names

secret set and variable set read the value from -b/--body, -F/--body-file
or standard input when it is not a terminal (one trailing line break
removed), and otherwise prompt for it; --env-file sets every NAME=value
of a dotenv file. --value and --value-file keep working. The scope flags
are -o/--org and --account on each command, and secret list no longer
offers --account, which Gitea cannot list.

Commands report what they did, naming the scope and the name Gitea
stores: "Set Actions secret LOWER_NAME for alice/x (names are
upper-cased)"; --json returns the stored name and omits an unknown
creation time. Deleting asks "Delete secret X from alice/x?". Lists show
NAME, CREATED and DESCRIPTION for secrets and NAME, VALUE and DESCRIPTION
for variables; variable view shows a record and the new variable get
prints the raw value. A missing secret or variable is reported by name
and scope.

The global --user selects the account tea acts as, unlike gh's --user,
so secret and variable add a note on a terminal pointing to --account
when it is given, as run list does for --actor. Help explains the scopes.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 23:46:57 +02:00
1 parent 4edb836416
commit 0dfe7def9b
5 files changed
+622 -166

No files matched your search

+409 -118
View File
@@ -4,8 +4,11 @@
package app
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"regexp"
"strings"
@@ -17,138 +20,426 @@ import (
var actionValueName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
func (a *application) actionValueCommand(secret bool) *cobra.Command {
kind := "variable"
if secret {
kind = "secret"
}
group := &cobra.Command{Use: kind, Short: "Manage scoped Actions " + kind + "s"}
var org string
var account bool
group.PersistentFlags().StringVar(&org, "org", "", "Organization scope")
group.PersistentFlags().BoolVar(&account, "account", false, "Current account scope across repositories")
scope := func(cmd *cobra.Command) (services.ActionScope, target, error) {
if (org != "" && account) || ((org != "" || account) && cmd.Flags().Changed("repo")) {
return services.ActionScope{}, target{}, clierr.Wrap(clierr.Usage, errors.New("--org, --account, and --repo select mutually exclusive scopes"))
}
target, err := a.resolve(cmd.Context(), org == "" && !account)
return services.ActionScope{Repository: target.Repo, Organization: org, Account: account}, target, err
}
var limit, page int
list := &cobra.Command{Use: "list", Short: "List " + kind + "s in the selected scope", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
selected, target, err := scope(cmd)
if err != nil {
return err
}
if secret {
items, err := (services.ActionSecrets{Client: target.client, Scope: selected}).List(cmd.Context(), limit, page)
if err != nil {
return err
}
return writeList(a, cmd, items, secretRow)
}
items, err := (services.ActionVariables{Client: target.client, Scope: selected}).List(cmd.Context(), limit, page)
if err != nil {
return err
}
return writeList(a, cmd, items, variableRow)
}}
pageFlags(list, &limit, &page)
group.AddCommand(list)
verbs := []string{"set", "delete"}
if !secret {
verbs = append(verbs, "view")
}
for _, verb := range verbs {
var value, file, description string
command := &cobra.Command{Use: verb + " <name>", Short: titleCase(verb) + " an Actions " + kind, Args: argsExact(1)}
if verb == "set" {
command.Flags().StringVar(&value, "value", "", "Value to store (use a file for secrets)")
command.Flags().StringVar(&file, "value-file", "", "Read value from file, or - for stdin")
command.Flags().StringVar(&description, "description", "", "Description")
}
if verb == "delete" {
command.Annotations = textOutput(kind + " deletion does not return a resource")
}
command.RunE = func(cmd *cobra.Command, args []string) error {
name := args[0]
// checkActionValueName rejects names that Gitea refuses for secrets and variables.
func checkActionValueName(name string) error {
if !actionValueName.MatchString(name) {
return clierr.Wrap(clierr.Usage, errors.New("name must contain letters, numbers, and underscores, and cannot start with a number"))
return clierr.Wrap(clierr.Usage, fmt.Errorf("invalid name %q: use letters, numbers and underscores, not starting with a number", name))
}
if strings.EqualFold(name, "CI") || strings.HasPrefix(strings.ToUpper(name), "GITEA_") || strings.HasPrefix(strings.ToUpper(name), "GITHUB_") {
return clierr.Wrap(clierr.Usage, errors.New("CI and names beginning GITEA_ or GITHUB_ are reserved"))
if upper := strings.ToUpper(name); upper == "CI" || strings.HasPrefix(upper, "GITEA_") || strings.HasPrefix(upper, "GITHUB_") {
return clierr.Wrap(clierr.Usage, fmt.Errorf("invalid name %q: CI and names starting with GITEA_ or GITHUB_ are reserved", name))
}
if verb == "set" {
var err error
if value, err = a.readSecret(cmd, value, file, "value", "value-file"); err != nil {
return err
return nil
}
// actionScopeFlags select where secrets and variables live: a repository by default, an
// organization with --org, or the user's account with --account.
type actionScopeFlags struct {
org string
account bool
}
// register adds the scope flags to cmd; account adds --account, which secret list lacks.
func (f *actionScopeFlags) register(a *application, cmd *cobra.Command, kind string, account bool) {
cmd.Flags().StringVarP(&f.org, "org", "o", "", "Use the "+kind+"s of an organization instead of a repository")
_ = cmd.RegisterFlagCompletionFunc("org", a.completeOrgs())
if account {
cmd.Flags().BoolVar(&f.account, "account", false, "Use the "+kind+"s of your user account instead of a repository or organization")
}
if !cmd.Flags().Changed("value") && !cmd.Flags().Changed("value-file") {
snapshot, err := a.deps.Settings.Load()
}
func (f *actionScopeFlags) scope() services.ActionScope {
return services.ActionScope{Organization: f.org, Account: f.account}
}
// resolve selects the scope and the account that manages it.
func (f *actionScopeFlags) resolve(a *application, cmd *cobra.Command) (services.ActionScope, target, error) {
if (f.org != "" && f.account) || ((f.org != "" || f.account) && a.options.repo != "") {
return services.ActionScope{}, target{}, clierr.Wrap(clierr.Usage, errors.New("--org, --account and --repo select different scopes; pass one"))
}
t, err := a.resolve(cmd.Context(), f.org == "" && !f.account)
if err != nil {
return err
return services.ActionScope{}, t, err
}
if !a.policy(snapshot).CanPrompt(a.deps.Streams) {
return clierr.Wrap(clierr.Usage, errors.New("missing value: supply --value or --value-file"))
scope := f.scope()
if f.org == "" && !f.account {
scope.Repository = t.Repo
}
return scope, t, nil
}
// actionValueLong explains the scopes of secrets or variables.
func actionValueLong(kind, use string) string {
return fmt.Sprintf(`Manage the Actions %[1]ss that workflows %[2]s.
A %[1]s belongs to a repository, to an organization (--org), whose repositories all see it, or
to your user account (--account), which all your repositories see. Without --org or --account,
tea uses the repository of the working directory or --repo. Names are stored upper-cased.
Note that the global --user flag selects the account tea acts as, not a scope.`, kind, use)
}
func (a *application) actionValueCommand(secret bool) *cobra.Command {
kind, use := "variable", "read with ${{ vars.NAME }}"
if secret {
value, err = a.deps.Prompter.SecretInput(cmd.Context(), "Secret value")
} else {
value, err = a.deps.Prompter.Input(cmd.Context(), "Variable value")
kind, use = "secret", "read with ${{ secrets.NAME }}"
}
if err != nil {
return err
group := &cobra.Command{
Use: kind,
Short: fmt.Sprintf("Manage Actions %ss for a repository, organization, or your account", kind),
Long: actionValueLong(kind, use),
}
}
if value == "" {
return clierr.Wrap(clierr.Usage, fmt.Errorf("%s value cannot be empty", kind))
}
if !secret && len(value) > 65536 {
return clierr.Wrap(clierr.Usage, errors.New("value cannot exceed 64 KiB"))
}
}
selected, target, err := scope(cmd)
if err != nil {
return err
}
if verb == "delete" {
if err := a.confirm(cmd, verb+" Actions "+kind+" "+name); err != nil {
return err
}
}
if secret {
service := services.ActionSecrets{Client: target.client, Scope: selected}
if verb == "delete" {
return service.Delete(cmd.Context(), name)
}
item, err := service.Set(cmd.Context(), name, value, description)
if err != nil {
return err
}
return writeItem(a, cmd, item, secretRow)
}
service := services.ActionVariables{Client: target.client, Scope: selected}
if verb == "delete" {
return service.Delete(cmd.Context(), name)
}
var item services.ActionVariable
if verb == "view" {
item, err = service.View(cmd.Context(), name)
} else {
item, err = service.Set(cmd.Context(), name, value, description)
}
if err != nil {
return err
}
return writeItem(a, cmd, item, variableRow)
}
group.AddCommand(command)
group.AddCommand(a.actionValueListCommand(secret, kind), a.actionValueSetCommand(secret, kind), a.actionValueDeleteCommand(secret, kind))
if !secret {
group.AddCommand(a.variableViewCommand(), a.variableGetCommand())
}
return group
}
func secretRow(item services.ActionSecret) []terminal.Cell { return terminal.Row(item.Name) }
func (a *application) actionValueListCommand(secret bool, kind string) *cobra.Command {
var scope actionScopeFlags
var limit, page int
example := `# list the variables of the repository
$ tea variable list
# list the variables of an organization
$ tea variable list --org acme`
if secret {
example = `# list the secrets of the repository
$ tea secret list
# list the secrets of an organization
$ tea secret list --org acme`
}
cmd := &cobra.Command{
Use: "list",
Short: "List " + kind + "s",
Example: example,
Args: argsExact(0),
RunE: func(cmd *cobra.Command, _ []string) error {
selected, t, err := scope.resolve(a, cmd)
if err != nil {
return err
}
if secret {
a.accountNote(cmd, t, "Gitea cannot list your account's secrets; set or delete them with --account")
} else {
a.accountNote(cmd, t, "for your account's variables, use --account")
}
where := " of " + selected.String()
if selected.Repository != "" {
where = " in " + selected.Repository
}
pages := listPage{Number: page}
if secret {
items, err := (services.ActionSecrets{Client: t.client, Scope: selected}).ListPage(cmd.Context(), limit, page)
if err != nil {
return err
}
pages.More, pages.Total, pages.Counted = items.More, items.Total, items.Counted
spec := listSpec[services.ActionSecret]{Columns: secretColumns, Row: secretRow, Empty: "no secrets" + where, Context: "secrets" + where}
return writeTable(a, cmd, items.Items, spec, pages)
}
items, err := (services.ActionVariables{Client: t.client, Scope: selected}).ListPage(cmd.Context(), limit, page)
if err != nil {
return err
}
pages.More, pages.Total, pages.Counted = items.More, items.Total, items.Counted
spec := listSpec[services.ActionVariable]{Columns: variableColumns, Row: variableRow, Empty: "no variables" + where, Context: "variables" + where}
return writeTable(a, cmd, items.Items, spec, pages)
},
}
pageFlags(cmd, &limit, &page)
scope.register(a, cmd, kind, !secret)
return cmd
}
var (
secretColumns = []terminal.Column{{Header: "Name"}, {Header: "Created"}, {Header: "Description", Flex: true}}
variableColumns = []terminal.Column{{Header: "Name"}, {Header: "Value", Flex: true}, {Header: "Description", Flex: true}}
)
func secretRow(item services.ActionSecret) []terminal.Cell {
return []terminal.Cell{{Text: item.Name}, {Time: terminal.ParseTime(item.CreatedAt)}, {Text: item.Description}}
}
func variableRow(item services.ActionVariable) []terminal.Cell {
return terminal.Row(item.Name, item.Value)
return []terminal.Cell{{Text: item.Name}, {Text: item.Value}, {Text: item.Description}}
}
func (a *application) actionValueSetCommand(secret bool, kind string) *cobra.Command {
var scope actionScopeFlags
var body, bodyFile, envFile, description string
example := `# set a repository variable
$ tea variable set ENVIRONMENT --body staging
# set an organization variable from a file
$ tea variable set CONFIG --org acme < config.json
# set the variables of a dotenv file
$ tea variable set --env-file .env`
if secret {
example = `# paste the value of a repository secret at a prompt
$ tea secret set DEPLOY_TOKEN
# set an organization secret from standard input
$ echo -n "$TOKEN" | tea secret set NPM_TOKEN --org acme
# set a secret of your account from a file
$ tea secret set SSH_KEY --account --body-file key.pem
# set the secrets of a dotenv file
$ tea secret set --env-file .env`
}
cmd := &cobra.Command{
Use: "set [<name>]",
Short: "Create or update a " + kind,
Long: fmt.Sprintf(`Create or update a %[1]s.
The value comes from --body, --body-file or standard input when it is not a terminal (one
trailing line break is removed), or else from a prompt. --env-file sets every NAME=value of a
dotenv file instead.`, kind),
Example: example,
Args: usageArgs(cobra.MaximumNArgs(1)),
RunE: func(cmd *cobra.Command, args []string) error {
if (len(args) == 1) == (envFile != "") {
return clierr.Wrap(clierr.Usage, &argumentError{problem: "pass a " + kind + " name or --env-file", cmd: cmd})
}
if envFile != "" && (cmd.Flags().Changed("body") || cmd.Flags().Changed("body-file")) {
return clierr.Wrap(clierr.Usage, errors.New("--env-file cannot be combined with --body or --body-file"))
}
var entries []envEntry
if envFile != "" {
data, err := readFileOrStdin(a.deps.Streams.In, envFile)
if err != nil {
return fmt.Errorf("read --env-file: %w", err)
}
if entries, err = parseEnvFile(data); err != nil {
return clierr.Wrap(clierr.Usage, fmt.Errorf("%s: %w", envFile, err))
}
} else {
value, err := a.actionValue(cmd, secret, args[0], body, bodyFile)
if err != nil {
return err
}
entries = []envEntry{{args[0], value}}
}
for _, entry := range entries {
if err := checkActionValueName(entry.name); err != nil {
return err
}
if entry.value == "" {
return clierr.Wrap(clierr.Usage, fmt.Errorf("the value of %s is empty", entry.name))
}
if !secret && len(entry.value) > 65536 {
return clierr.Wrap(clierr.Usage, fmt.Errorf("the value of %s exceeds 64 KiB", entry.name))
}
}
selected, t, err := scope.resolve(a, cmd)
if err != nil {
return err
}
a.accountNote(cmd, t, "for a "+kind+" of your account, use --account")
given := map[string]string{}
for _, entry := range entries {
given[services.ActionValueName(entry.name)] = entry.name
}
label := func(entry envEntry) string { return entry.name }
report := func(name string) string {
text := fmt.Sprintf("Set Actions %s %s for %s", kind, name, selected)
if given[name] != name {
text += " (names are upper-cased)"
}
return text
}
if secret {
service := services.ActionSecrets{Client: t.client, Scope: selected}
return batchDone(a, cmd, entries, label, func(entry envEntry) (services.ActionSecret, error) {
return service.Set(cmd.Context(), entry.name, entry.value, description)
}, func(item services.ActionSecret) string { return report(item.Name) })
}
service := services.ActionVariables{Client: t.client, Scope: selected}
return batchDone(a, cmd, entries, label, func(entry envEntry) (services.ActionVariable, error) {
return service.Set(cmd.Context(), entry.name, entry.value, description)
}, func(item services.ActionVariable) string { return report(item.Name) })
},
}
cmd.Flags().StringVarP(&body, "body", "b", "", "The value")
cmd.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read the value from a file, or - for standard input")
cmd.Flags().StringVarP(&envFile, "env-file", "f", "", "Set every NAME=value of a dotenv file, or - for standard input")
cmd.Flags().StringVarP(&description, "description", "d", "", "Description")
flagAliases(cmd, map[string]string{"value": "body", "value-file": "body-file"})
scope.register(a, cmd, kind, true)
cmd.ValidArgsFunction = a.completeActionValues(secret, &scope)
return cmd
}
// actionValue returns the value to set: --body, --body-file, standard input when it is not a
// terminal, or else what the user types at a prompt.
func (a *application) actionValue(cmd *cobra.Command, secret bool, name, body, bodyFile string) (string, error) {
if cmd.Flags().Changed("body") || cmd.Flags().Changed("body-file") {
return a.readSecret(cmd, body, bodyFile, "body", "body-file")
}
missing := clierr.Wrap(clierr.Usage, errors.New("missing value: pass --body, --body-file or standard input"))
if !a.deps.Streams.InTTY {
data, err := io.ReadAll(a.deps.Streams.In)
if err != nil {
return "", fmt.Errorf("read standard input: %w", err)
}
if len(data) == 0 {
return "", missing
}
return strings.TrimSuffix(strings.TrimSuffix(string(data), "\n"), "\r"), nil
}
s, err := a.deps.Settings.Load()
if err != nil {
return "", err
}
if !a.policy(s).CanPrompt(a.deps.Streams) {
return "", missing
}
if secret {
return a.deps.Prompter.SecretInput(cmd.Context(), "Paste the value of "+strings.ToUpper(name)+":")
}
return a.deps.Prompter.Input(cmd.Context(), "Value of "+strings.ToUpper(name)+":")
}
// envEntry is one NAME=value line of a dotenv file.
type envEntry struct{ name, value string }
// parseEnvFile reads NAME=value lines, skipping blank lines and # comments. An optional export
// prefix is ignored; values may be quoted, and double-quoted ones understand \n.
func parseEnvFile(data []byte) ([]envEntry, error) {
var entries []envEntry
scanner := bufio.NewScanner(bytes.NewReader(data))
scanner.Buffer(nil, 1<<20)
for number := 1; scanner.Scan(); number++ {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
name, value, ok := strings.Cut(strings.TrimPrefix(line, "export "), "=")
name, value = strings.TrimSpace(name), strings.TrimSpace(value)
if !ok || name == "" {
return nil, fmt.Errorf("line %d: use NAME=value", number)
}
switch {
case len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"':
value = strings.ReplaceAll(value[1:len(value)-1], `\n`, "\n")
case len(value) >= 2 && value[0] == '\'' && value[len(value)-1] == '\'':
value = value[1 : len(value)-1]
default:
value, _, _ = strings.Cut(value, " #")
}
entries = append(entries, envEntry{name, value})
}
if len(entries) == 0 {
return nil, errors.New("no NAME=value lines")
}
return entries, scanner.Err()
}
func (a *application) actionValueDeleteCommand(secret bool, kind string) *cobra.Command {
var scope actionScopeFlags
cmd := &cobra.Command{
Use: "delete <name>",
Short: "Delete a " + kind,
Example: fmt.Sprintf(`# delete a repository %[1]s
$ tea %[1]s delete DEPLOY_TOKEN
# delete an organization %[1]s without confirmation
$ tea %[1]s delete DEPLOY_TOKEN --org acme --yes`, kind),
Args: argsExact(1),
Annotations: map[string]string{"tea.confirm": "true", "tea.text": kind + " deletion does not return a resource"},
RunE: func(cmd *cobra.Command, args []string) error {
name := services.ActionValueName(args[0])
selected, t, err := scope.resolve(a, cmd)
if err != nil {
return err
}
if err := a.confirmAction(cmd, terminal.Confirmation{
Prompt: fmt.Sprintf("Delete %s %s from %s?", kind, name, selected),
NonTTY: fmt.Sprintf("delete %s %s from %s", kind, name, selected),
}); err != nil {
return err
}
if secret {
err = services.ActionSecrets{Client: t.client, Scope: selected}.Delete(cmd.Context(), args[0])
} else {
err = services.ActionVariables{Client: t.client, Scope: selected}.Delete(cmd.Context(), args[0])
}
if err != nil {
return err
}
a.done(cmd, "Deleted Actions %s %s from %s", kind, name, selected)
return nil
},
}
scope.register(a, cmd, kind, true)
cmd.ValidArgsFunction = a.completeActionValues(secret, &scope)
return cmd
}
func (a *application) variableViewCommand() *cobra.Command {
var scope actionScopeFlags
cmd := &cobra.Command{
Use: "view <name>",
Short: "View a variable",
Example: `# view a variable with its description
$ tea variable view ENVIRONMENT`,
Args: argsExact(1),
RunE: func(cmd *cobra.Command, args []string) error {
selected, t, err := scope.resolve(a, cmd)
if err != nil {
return err
}
item, err := services.ActionVariables{Client: t.client, Scope: selected}.View(cmd.Context(), args[0])
if err != nil {
return err
}
if a.options.json != "" {
return a.writeJSON(cmd, item)
}
d := a.display()
d.Markdown = false
view := terminal.View{Title: item.Name, Fields: []terminal.Field{{Label: "Scope", Value: selected.String()}, {Label: "Description", Value: item.Description}}, Body: item.Value}
return a.writeHuman(cmd, view.Render(d))
},
}
scope.register(a, cmd, "variable", true)
cmd.ValidArgsFunction = a.completeActionValues(false, &scope)
return cmd
}
func (a *application) variableGetCommand() *cobra.Command {
var scope actionScopeFlags
cmd := &cobra.Command{
Use: "get <name>",
Short: "Print the value of a variable",
Example: `# use the value of a variable in a script
$ region=$(tea variable get REGION --org acme)`,
Args: argsExact(1),
RunE: func(cmd *cobra.Command, args []string) error {
selected, t, err := scope.resolve(a, cmd)
if err != nil {
return err
}
item, err := services.ActionVariables{Client: t.client, Scope: selected}.View(cmd.Context(), args[0])
if err != nil {
return err
}
if a.options.json != "" {
return a.writeJSON(cmd, item)
}
return writeTerminalSafe(cmd.OutOrStdout(), a.deps.Streams.OutTTY, strings.NewReader(item.Value+"\n"))
},
}
scope.register(a, cmd, "variable", true)
cmd.ValidArgsFunction = a.completeActionValues(false, &scope)
return cmd
}
// completeActionValues completes the secret or variable names of the scope the flags select.
func (a *application) completeActionValues(secret bool, scope *actionScopeFlags) cobra.CompletionFunc {
names := a.completeVariables(scope.scope)
if secret {
names = a.completeSecrets(scope.scope)
}
return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return names(cmd, args, toComplete)
}
}
+144
View File
@@ -7,9 +7,12 @@ import (
"context"
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dev/tea/internal/clierr"
"github.com/stretchr/testify/require"
)
@@ -32,3 +35,144 @@ func TestSecretValueFileDropsTrailingLineEnding(t *testing.T) {
require.NoError(t, Execute(context.Background(), deps, []string{"secret", "set", "BUILD_TOKEN", "-R", "alice/project", "--value-file", "-", "--yes"}))
require.Equal(t, "sensitive", stored)
}
func TestActionValueSetReadsValuesAndReportsStoredNames(t *testing.T) {
envFile := filepath.Join(t.TempDir(), ".env")
require.NoError(t, os.WriteFile(envFile, []byte("# deploy\nAPI_KEY=abc\nexport region=\"eu\\nwest\"\nMODE=fast # inline\n"), 0o600))
for _, tc := range []struct {
name string
args []string
stdin string
requests []string
out, err string
}{
{
"lower-case name from --body",
[]string{"secret", "set", "lower_name", "-b", "x"},
"",
[]string{`PUT actions/secrets/lower_name {"data":"x","description":""}`},
"",
"✓ Set Actions secret LOWER_NAME for alice/project (names are upper-cased)\n",
},
{
"standard input",
[]string{"secret", "set", "TOKEN"},
"piped\n",
[]string{`PUT actions/secrets/TOKEN {"data":"piped","description":""}`},
"", "✓ Set Actions secret TOKEN for alice/project\n",
},
{
"JSON names the stored secret",
[]string{"secret", "set", "token", "--body", "x", "--json"},
"",
[]string{`PUT actions/secrets/token {"data":"x","description":""}`},
`{"name":"TOKEN","description":""}`, "",
},
{
"organization",
[]string{"secret", "set", "TOKEN", "-o", "acme", "-b", "x"},
"",
[]string{`PUT api/v1/orgs/acme/actions/secrets/TOKEN {"data":"x","description":""}`},
"", "✓ Set Actions secret TOKEN for organization acme\n",
},
{
"account",
[]string{"secret", "set", "TOKEN", "--account", "-b", "x"},
"",
[]string{`PUT api/v1/user/actions/secrets/TOKEN {"data":"x","description":""}`},
"", "✓ Set Actions secret TOKEN for your account\n",
},
{
"dotenv file",
[]string{"variable", "set", "--env-file", envFile, "-o", "acme"},
"",
[]string{
"GET api/v1/orgs/acme/actions/variables/API_KEY", `POST api/v1/orgs/acme/actions/variables/API_KEY {"value":"abc","description":""}`,
"GET api/v1/orgs/acme/actions/variables/region", `POST api/v1/orgs/acme/actions/variables/region {"value":"eu\nwest","description":""}`,
"GET api/v1/orgs/acme/actions/variables/MODE", `POST api/v1/orgs/acme/actions/variables/MODE {"value":"fast","description":""}`,
},
"",
"✓ Set Actions variable API_KEY for organization acme\n✓ Set Actions variable REGION for organization acme (names are upper-cased)\n✓ Set Actions variable MODE for organization acme\n",
},
} {
t.Run(tc.name, func(t *testing.T) {
server, requests := actionsAPI(t, map[string]string{
"PUT actions/secrets/lower_name": "204 ", "PUT actions/secrets/TOKEN": "204 ", "PUT actions/secrets/token": "204 ",
"PUT api/v1/orgs/acme/actions/secrets/TOKEN": "204 ", "PUT api/v1/user/actions/secrets/TOKEN": "204 ",
"GET api/v1/orgs/acme/actions/variables/API_KEY": `404 {}`, "POST api/v1/orgs/acme/actions/variables/API_KEY": "201 ",
"GET api/v1/orgs/acme/actions/variables/region": `404 {}`, "POST api/v1/orgs/acme/actions/variables/region": "201 ",
"GET api/v1/orgs/acme/actions/variables/MODE": `404 {}`, "POST api/v1/orgs/acme/actions/variables/MODE": "201 ",
})
deps, out, diagnostics := actionsDeps(t, server.URL, true)
deps.Streams.In = strings.NewReader(tc.stdin)
args := tc.args
if !strings.Contains(strings.Join(args, " "), "-o acme") && !strings.Contains(strings.Join(args, " "), "--account") {
args = append(args, "-R", "alice/project")
}
require.NoError(t, Execute(context.Background(), deps, args))
require.Equal(t, tc.requests, *requests)
if tc.out != "" {
require.JSONEq(t, tc.out, out.String())
} else {
require.Empty(t, out.String())
}
require.Equal(t, tc.err, diagnostics.String())
})
}
}
func TestActionValueListsShowScopeAndExplainTheAccountFlags(t *testing.T) {
server, _ := actionsAPI(t, map[string]string{
"GET actions/secrets": `[{"name":"DEPLOY_TOKEN","description":"Deploy token","created_at":"2028-01-01T10:00:00Z"}]`,
"GET actions/variables": `[{"name":"ENVIRONMENT","data":"staging","description":"Target environment"}]`,
})
deps, out, diagnostics := actionsDeps(t, server.URL, true)
deps.Streams.ErrTTY = true
require.NoError(t, Execute(context.Background(), deps, []string{"secret", "list", "-R", "alice/project", "--user", "alice"}))
require.Equal(t, "Showing 1 of 1 secrets in alice/project\n\nNAME CREATED DESCRIPTION\nDEPLOY_TOKEN about 2 hours ago Deploy token\n", out.String())
require.Equal(t, "note: --user selects the account tea acts as (alice); Gitea cannot list your account's secrets; set or delete them with --account\n", diagnostics.String())
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"variable", "list", "-R", "alice/project"}))
require.Equal(t, "Showing 1 of 1 variables in alice/project\n\nNAME VALUE DESCRIPTION\nENVIRONMENT staging Target environment\n", out.String())
err := Execute(context.Background(), deps, []string{"secret", "list", "--account"})
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.ErrorContains(t, err, "unknown flag: --account")
}
func TestVariableGetAndView(t *testing.T) {
server, _ := actionsAPI(t, map[string]string{"GET actions/variables/region": `{"name":"REGION","data":"eu\nwest","description":"Where to deploy"}`})
for _, tc := range []struct {
args []string
tty bool
want string
}{
{[]string{"variable", "get", "region"}, false, "eu\nwest\n"},
{[]string{"variable", "view", "region"}, true, "REGION\nScope: alice/project\nDescription: Where to deploy\n\neu\nwest\n"},
{[]string{"variable", "view", "region"}, false, "title:\tREGION\nscope:\talice/project\ndescription:\tWhere to deploy\n--\neu\nwest\n"},
} {
deps, out, _ := actionsDeps(t, server.URL, tc.tty)
require.NoError(t, Execute(context.Background(), deps, append(tc.args, "-R", "alice/project")))
require.Equal(t, tc.want, out.String(), "%v tty=%t", tc.args, tc.tty)
}
}
func TestActionValueDeleteConfirmsWithTheScope(t *testing.T) {
server, requests := actionsAPI(t, map[string]string{"DELETE actions/secrets/deploy_token": "204 "})
for _, accept := range []bool{false, true} {
*requests = nil
deps, _, diagnostics := actionsDeps(t, server.URL, true)
prompter := &scriptedPrompter{accept: accept}
deps.Prompter = prompter
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
err := Execute(context.Background(), deps, []string{"secret", "delete", "deploy_token", "-R", "alice/project"})
require.Equal(t, []string{"Delete secret DEPLOY_TOKEN from alice/project?"}, prompter.confirmations)
if !accept {
require.Equal(t, clierr.Canceled, clierr.ExitCode(err))
require.Empty(t, *requests)
continue
}
require.NoError(t, err)
require.Equal(t, []string{"DELETE actions/secrets/deploy_token"}, *requests)
require.Equal(t, "✓ Deleted Actions secret DEPLOY_TOKEN from alice/project\n", diagnostics.String())
}
}
-9
View File
@@ -544,18 +544,11 @@ const (
search issue
search pr
search repo
secret delete
secret list
secret set
status
time create
time delete
time list
time reset
variable delete
variable list
variable set
variable view
wiki create
wiki delete
wiki edit
@@ -581,9 +574,7 @@ const (
release asset
repo
search
secret
time
variable
wiki
`
)
+8 -8
View File
@@ -198,10 +198,10 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
{[]string{"search", "issue", "--since="}, "invalid --since: use a date"},
{[]string{"search", "repo", "query", "--sort", "invalid"}, "--sort must be alpha, created, updated, size, or id"},
{[]string{"search", "repo", "query", "--order", "invalid"}, "--order must be asc or desc"},
{[]string{"secret", "set", "BUILD_TOKEN", "--value", "text", "--value-file", "-"}, "--value and --value-file are mutually exclusive"},
{[]string{"secret", "set", "BUILD_TOKEN", "--value", "text", "--value-file", "-"}, "--body and --body-file are mutually exclusive"},
{[]string{"secret", "set", "BUILD_TOKEN", "--value= ", "--json=value"}, "unknown JSON field \"value\""},
{[]string{"secret", "set", "BUILD_TOKEN", "--no-input"}, "missing value: supply --value or --value-file"},
{[]string{"secret", "set", "CI", "--value", "text"}, "CI and names beginning GITEA_ or GITHUB_ are reserved"},
{[]string{"secret", "set", "BUILD_TOKEN", "--no-input"}, "missing value: pass --body, --body-file or standard input"},
{[]string{"secret", "set", "CI", "--value", "text"}, "CI and names starting with GITEA_ or GITHUB_ are reserved"},
{[]string{"status", "--limit=0"}, `"--limit" flag: must be 1..100`},
{[]string{"status", "--json=token"}, "unknown JSON field \"token\""},
{[]string{"team", "create", "org", "--name", "Developers", "--permission", "owner"}, "--permission must be read, write, or admin"},
@@ -221,10 +221,10 @@ func TestUsageErrorsPrecedeRequests(t *testing.T) {
{[]string{"time", "list", "--from", "2026-10-02", "--until", "2026-10-01"}, "--from must not be later than --until"},
{[]string{"time", "list", "--from", "1h", "--until", "2h"}, "--from must not be later than --until"},
{[]string{"time", "list", "--total", "--json"}, "--total is a human-output summary"},
{[]string{"variable", "set", "BUILD_MODE", "--value="}, "variable value cannot be empty"},
{[]string{"variable", "set", "GITEA_RESERVED", "--value", "text"}, "CI and names beginning GITEA_ or GITHUB_ are reserved"},
{[]string{"variable", "list", "--org", "team", "--account"}, "--org, --account, and --repo select mutually exclusive scopes"},
{[]string{"variable", "list", "--org", "team", "--repo", "alice/project"}, "--org, --account, and --repo select mutually exclusive scopes"},
{[]string{"variable", "set", "BUILD_MODE", "--value="}, "the value of BUILD_MODE is empty"},
{[]string{"variable", "set", "GITEA_RESERVED", "--value", "text"}, "CI and names starting with GITEA_ or GITHUB_ are reserved"},
{[]string{"variable", "list", "--org", "team", "--account"}, "--org, --account and --repo select different scopes; pass one"},
{[]string{"variable", "list", "--org", "team", "--repo", "alice/project"}, "--org, --account and --repo select different scopes; pass one"},
{[]string{"version", "--json"}, "version prints text"},
{[]string{"webhook", "edit", "1", "--secret", "private", "--clear-authorization"}, "webhook secret editing is unsupported"},
{[]string{"webhook", "create", "ftp://host/path"}, "webhook URL must be an absolute HTTP(S) URL without user information or fragment"},
@@ -289,7 +289,7 @@ func TestPromptedCreateSubmitsWithoutConfirmation(t *testing.T) {
secret, write string
}{
{[]string{"secret", "set", "TOKEN"}, nil, "private", "PUT /api/v1/repos/alice/project/actions/secrets/TOKEN"},
{[]string{"variable", "set", "MODE"}, map[string]string{"Variable value": "fast"}, "", "POST /api/v1/repos/alice/project/actions/variables/MODE"},
{[]string{"variable", "set", "MODE"}, map[string]string{"Value of MODE:": "fast"}, "", "POST /api/v1/repos/alice/project/actions/variables/MODE"},
{[]string{"webhook", "create"}, map[string]string{"Payload URL": "https://hooks.test/path"}, "", "POST /api/v1/repos/alice/project/hooks"},
{[]string{"team", "create", "acme"}, map[string]string{"Team name": "Developers"}, "", "POST /api/v1/orgs/acme/teams"},
} {
+61 -31
View File
@@ -6,9 +6,9 @@ package services
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
gitea "gitea.dev/sdk"
"gitea.dev/tea/internal/transport"
@@ -20,13 +20,28 @@ type ActionScope struct {
Account bool
}
// ActionSecret describes a secret without containing its value.
// String names the scope for people: "alice/project", "organization acme" or "your account".
func (s ActionScope) String() string {
switch {
case s.Account:
return "your account"
case s.Organization != "":
return "organization " + s.Organization
}
return s.Repository
}
// ActionSecret describes a secret without containing its value. CreatedAt is empty for a secret
// that was just set, as Gitea does not return it.
type ActionSecret struct {
Name string `json:"name"`
Description string `json:"description"`
CreatedAt string `json:"created_at"`
CreatedAt string `json:"created_at,omitempty"`
}
// ActionValueName is the name Gitea stores a secret or variable under: names are upper-cased.
func ActionValueName(name string) string { return strings.ToUpper(name) }
func secretFrom(secret *gitea.Secret) ActionSecret {
return ActionSecret{Name: secret.Name, Description: secret.Description, CreatedAt: timestamp(secret.Created)}
}
@@ -50,11 +65,18 @@ type ActionSecrets struct {
// List returns secret metadata only.
func (s ActionSecrets) List(ctx context.Context, limit, page int) ([]ActionSecret, error) {
window, err := s.ListPage(ctx, limit, page)
return window.Items, err
}
// ListPage is List with what the server reports about the other pages.
func (s ActionSecrets) ListPage(ctx context.Context, limit, page int) (Page[ActionSecret], error) {
if s.Scope.Account {
return nil, &CapabilityError{Feature: "account secret listing", Reason: "Gitea exposes account secret set/delete operations only"}
return Page[ActionSecret]{}, &CapabilityError{Feature: "account secret listing", Reason: "Gitea exposes account secret set/delete operations only"}
}
window, err := listPage(limit, page, func(limit, page int) ([]*gitea.Secret, http.Header, error) {
options := listOptions(limit, page)
secrets, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Secret, *gitea.Response, error) {
secrets, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.Secret, *gitea.Response, error) {
if s.Scope.Organization != "" {
return c.Actions.ListOrgSecrets(ctx, s.Scope.Organization, gitea.ListOrgActionsSecretOption{ListOptions: options})
}
@@ -64,13 +86,13 @@ func (s ActionSecrets) List(ctx context.Context, limit, page int) ([]ActionSecre
}
return c.Actions.ListRepoSecrets(ctx, owner, repo, gitea.ListRepoActionsSecretOption{ListOptions: options})
})
if err != nil {
return nil, err
}
return convertAll(secrets, secretFrom), nil
return secrets, headerOf(resp), err
})
return Page[ActionSecret]{Items: convertAll(window.Items, secretFrom), More: window.More, Total: window.Total, Counted: window.Counted}, err
}
// Set creates or replaces a secret; the API's PUT operation defines upsert semantics.
// Set creates or replaces a secret; the API's PUT operation defines upsert semantics. The result
// names the secret as Gitea stores it.
func (s ActionSecrets) Set(ctx context.Context, name, value, description string) (ActionSecret, error) {
option := gitea.CreateOrUpdateSecretOption{Data: value, Description: description}
err := do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
@@ -86,12 +108,12 @@ func (s ActionSecrets) Set(ctx context.Context, name, value, description string)
}
return c.Actions.CreateRepoSecret(ctx, owner, repo, name, option)
})
return ActionSecret{Name: name, Description: description}, err
return ActionSecret{Name: ActionValueName(name), Description: description}, err
}
// Delete removes only the named secret in the selected scope.
func (s ActionSecrets) Delete(ctx context.Context, name string) error {
return do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
err := do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
switch {
case s.Scope.Account:
return c.Actions.DeleteUserSecret(ctx, name)
@@ -105,6 +127,7 @@ func (s ActionSecrets) Delete(ctx context.Context, name string) error {
// TODO(sdk): DeleteRepoSecret does not escape the secret name.
return c.Actions.DeleteRepoSecret(ctx, owner, repo, url.PathEscape(name))
})
return Classify(err, "secret", ActionValueName(name), s.Scope.String())
}
// ActionVariables manages scoped non-secret Actions variables.
@@ -126,24 +149,28 @@ func (s ActionVariables) repoVariables(name string) string {
// List returns a non-nil page of variables.
func (s ActionVariables) List(ctx context.Context, limit, page int) ([]ActionVariable, error) {
var variables []*gitea.ActionsVariable
window, err := s.ListPage(ctx, limit, page)
return window.Items, err
}
// ListPage is List with what the server reports about the other pages.
func (s ActionVariables) ListPage(ctx context.Context, limit, page int) (Page[ActionVariable], error) {
var window Page[*gitea.ActionsVariable]
var err error
switch {
case s.Scope.Account:
variables, _, err = call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.ActionsVariable, *gitea.Response, error) {
if s.Scope.Account || s.Scope.Organization != "" {
window, err = listPage(limit, page, func(limit, page int) ([]*gitea.ActionsVariable, http.Header, error) {
variables, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.ActionsVariable, *gitea.Response, error) {
if s.Scope.Account {
return c.Actions.ListUserVariables(ctx, listOptions(limit, page))
})
case s.Scope.Organization != "":
variables, _, err = call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.ActionsVariable, *gitea.Response, error) {
}
return c.Actions.ListOrgVariables(ctx, s.Scope.Organization, gitea.ListOrgActionsVariableOption{ListOptions: listOptions(limit, page)})
})
default:
err = getJSON(ctx, s.Client, fmt.Sprintf("%s?limit=%d&page=%d", s.repoVariables(""), limit, page), &variables)
return variables, headerOf(resp), err
})
} else {
window, err = rawPage[*gitea.ActionsVariable](ctx, s.Client, s.repoVariables(""), nil, limit, page)
}
if err != nil {
return nil, err
}
return convertAll(variables, variableFrom), nil
return Page[ActionVariable]{Items: convertAll(window.Items, variableFrom), More: window.More, Total: window.Total, Counted: window.Counted}, err
}
// View retrieves one variable.
@@ -163,7 +190,7 @@ func (s ActionVariables) View(ctx context.Context, name string) (ActionVariable,
err = getJSON(ctx, s.Client, s.repoVariables(name), variable)
}
if err != nil {
return ActionVariable{}, err
return ActionVariable{}, Classify(err, "variable", ActionValueName(name), s.Scope.String())
}
return variableFrom(variable), nil
}
@@ -172,7 +199,7 @@ func (s ActionVariables) View(ctx context.Context, name string) (ActionVariable,
func (s ActionVariables) Set(ctx context.Context, name, value, description string) (ActionVariable, error) {
exists := true
if _, err := s.View(ctx, name); err != nil {
if apiError, ok := errors.AsType[*transport.APIError](err); !ok || apiError.Status != http.StatusNotFound {
if !errors.As(err, new(*NotFoundError)) {
return ActionVariable{}, err
}
exists = false
@@ -202,20 +229,23 @@ func (s ActionVariables) Set(ctx context.Context, name, value, description strin
}
err = sendJSON(ctx, s.Client, method, s.repoVariables(name), update, nil)
}
return ActionVariable{Name: name, Value: value, Description: description}, err
return ActionVariable{Name: ActionValueName(name), Value: value, Description: description}, err
}
// Delete removes only the named variable in the selected scope.
func (s ActionVariables) Delete(ctx context.Context, name string) error {
var err error
switch {
case s.Scope.Account:
return do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
err = do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
return c.Actions.DeleteUserVariable(ctx, name)
})
case s.Scope.Organization != "":
return do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
err = do(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.Response, error) {
return c.Actions.DeleteOrgVariable(ctx, s.Scope.Organization, name)
})
default:
err = sendJSON(ctx, s.Client, http.MethodDelete, s.repoVariables(name), nil, nil)
}
return sendJSON(ctx, s.Client, http.MethodDelete, s.repoVariables(name), nil, nil)
return Classify(err, "variable", ActionValueName(name), s.Scope.String())
}