Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
091c24d
Record attachment operation counts
BagToad Sep 4, 2026
5e078ed
finalize telemetry at invocation completion
williammartin Sep 8, 2026
46e09a5
separate event recording from invocation policy
williammartin Sep 8, 2026
7f3b491
send telemetry when invocation finishes
williammartin Sep 8, 2026
c41dd03
restore telemetry service naming
williammartin Sep 8, 2026
570183b
rename telemetry BeginEvent to Begin
williammartin Sep 8, 2026
0710596
move service back into telemetry.go
williammartin Sep 8, 2026
3c517fa
limit telemetry disabling to the service
williammartin Sep 8, 2026
03acc10
name pending telemetry updates as upserts
williammartin Sep 8, 2026
2ac2543
simplify attachment telemetry initialization
williammartin Sep 8, 2026
14fc1ff
minimize incidental telemetry service changes
williammartin Sep 8, 2026
d2fe6e9
start attachment telemetry before asset validation
williammartin Sep 8, 2026
3870413
use typed attachment telemetry events
williammartin Sep 8, 2026
b54469f
simplify telemetry test coverage
williammartin Sep 9, 2026
81780e7
simplify telemetry recording and tests
williammartin Sep 9, 2026
1ba640d
clarify attachment tests and telemetry state
williammartin Sep 9, 2026
6dc60bf
Filter acceptance tests by token capability
williammartin Sep 4, 2026
7901e7e
Address acceptance capability review
williammartin Sep 9, 2026
9174ffb
Validate repository names during interactive creation (#14313)
sergiou87 Sep 9, 2026
e18cc33
Merge pull request #14354 from cli/williammartin-acceptance-token-cap…
williammartin Sep 9, 2026
3041965
Propagate gh path to extensions
williammartin Aug 28, 2026
c708a53
Remove GH_EXTENSION help text per review feedback
Copilot Sep 9, 2026
983071a
Restore GH_EXTENSION and GH_PATH help entries
Copilot Sep 9, 2026
ae1ba32
Merge pull request #14390 from cli/williammartin-wm-invocation-telemetry
BagToad Sep 9, 2026
093c348
Merge pull request #14282 from cli/williammartin-propagate-gh-path-to…
williammartin Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion .github/skills/writing-acceptance-tests/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@ creation and cloning without allowing concurrent scripts to interfere.
Read `acceptance/README.md` and nearby scripts before editing. Test groups are
discovered from `acceptance/testdata/<group>/`; do not register them manually.

## Declare token capability

Every `.txtar` script must start with exactly one capability declaration:

```txtar
# requires-user-capability: false
```

Set the declaration to `true` only when the script requires a user principal,
such as account SSH/GPG keys, personal-account forks, or user membership APIs.
Repository and organization operations supported by an installation token
should use `false`.

## Choose one repository mode

Every script must contain exactly one declaration:
Expand Down Expand Up @@ -120,7 +133,7 @@ Run metadata checks without live credentials:

```sh
go test -tags=acceptance \
-run '^(TestSelectAcceptanceTestGroups|TestAcceptanceScriptsDeclareFixtureRepository|TestValidateFixtureRepositoryDeclaration|TestFixtureRepositoryManager)$' \
-run '^(TestSelectAcceptanceTestGroups|TestFilterAcceptanceScripts|TestTokenHasUserCapability|TestAcceptanceScriptsDeclareUserCapabilityRequirement|TestAcceptanceScriptsDeclareFixtureRepository|TestRequiresUserCapabilityForScript|TestValidateFixtureRepositoryDeclaration|TestFixtureRepositoryManager)$' \
./acceptance
```

Expand Down
11 changes: 10 additions & 1 deletion acceptance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ The token to use for authenticating with the `GH_ACCEPTANCE_HOST`. This must alr

It's recommended to create and use a Legacy PAT for this; Fine-Grained PATs do not offer all the necessary privileges required. You can use an OAuth token provided via `gh auth login --web` and can provide it to the acceptance tests via `GH_ACCEPTANCE_TOKEN=$(gh auth token --hostname <host>)` but this can be a bit confusing and annoying if you `gh auth login` again without `-s` and lose the required scopes.

The test harness infers whether a token authenticates a user from GitHub's documented token prefixes. OAuth (`gho_`), classic PAT (`ghp_`), fine-grained PAT (`github_pat_`), and GitHub App user (`ghu_`) tokens provide user capabilities. GitHub App installation (`ghs_`) tokens do not, so scripts marked `requires-user-capability: true` are omitted from unfiltered runs. Explicitly selecting an incompatible script with `GH_ACCEPTANCE_SCRIPT` fails with an error instead.

Managed fixture repositories reduce repository creation by sharing state where
tests can safely coexist.

Expand Down Expand Up @@ -76,7 +78,14 @@ The following custom environment variables are made available to the scripts:

#### Script Metadata

Every script must declare exactly one repository fixture mode:
Every script must begin with a structured header comment declaring whether it
needs a token that authenticates a user:

```txtar
# requires-user-capability: false
```

Every script must also declare exactly one repository fixture mode:

```txtar
fixture-repo shared REPO
Expand Down
133 changes: 125 additions & 8 deletions acceptance/acceptance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,8 @@ func validateAcceptanceScripts(t *testing.T, tsEnv testScriptEnv, groups []strin
require.NoError(t, err)
for _, file := range candidates {
require.NoError(t, validateFixtureRepositoryDeclaration(file))
_, err := requiresUserCapabilityForScript(file)
require.NoError(t, err)
}
}
}
Expand Down Expand Up @@ -713,19 +715,128 @@ func TestSelectAcceptanceTestGroups(t *testing.T) {
}
}

type acceptanceScript struct {
file string
requiresUserCapability bool
}

func filterAcceptanceScripts(candidates []acceptanceScript, filtered, hasUserCapability bool) ([]string, string, error) {
if filtered && len(candidates) == 0 {
return nil, "no selected script belongs to this command directory", nil
}

files := make([]string, 0, len(candidates))
for _, candidate := range candidates {
if candidate.requiresUserCapability && !hasUserCapability {
if filtered {
return nil, "", fmt.Errorf("%s requires a token that authenticates a user", candidate.file)
}
continue
}
files = append(files, candidate.file)
}
if len(files) == 0 {
return nil, "all scripts require a token that authenticates a user", nil
}

return files, "", nil
}

func TestFilterAcceptanceScripts(t *testing.T) {
userOnly := acceptanceScript{file: "user.txtar", requiresUserCapability: true}
compatible := acceptanceScript{file: "installation.txtar"}

tests := []struct {
name string
candidates []acceptanceScript
filtered bool
hasUserCapability bool
wantFiles []string
wantSkip string
wantErr string
}{
{
name: "user token keeps all scripts",
candidates: []acceptanceScript{userOnly, compatible},
hasUserCapability: true,
wantFiles: []string{"user.txtar", "installation.txtar"},
},
{
name: "installation token omits user-only scripts",
candidates: []acceptanceScript{userOnly, compatible},
wantFiles: []string{"installation.txtar"},
},
{
name: "all incompatible scripts skip",
candidates: []acceptanceScript{userOnly},
wantSkip: "all scripts require a token that authenticates a user",
},
{
name: "empty explicit selection skips",
filtered: true,
wantSkip: "no selected script belongs to this command directory",
},
{
name: "explicit compatible selection remains",
candidates: []acceptanceScript{compatible},
filtered: true,
wantFiles: []string{"installation.txtar"},
},
{
name: "explicit incompatible selection errors",
candidates: []acceptanceScript{userOnly},
filtered: true,
wantErr: "user.txtar requires a token that authenticates a user",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
files, skipReason, err := filterAcceptanceScripts(tt.candidates, tt.filtered, tt.hasUserCapability)
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantFiles, files)
assert.Equal(t, tt.wantSkip, skipReason)
})
}
}

func testScriptParamsFor(t *testing.T, tsEnv testScriptEnv, fixtureRepositories *fixtureRepositoryManager, command string) testscript.Params {
t.Helper()

candidates, filtered, err := acceptanceScriptCandidates(tsEnv, command)
scriptFiles, filtered, err := acceptanceScriptCandidates(tsEnv, command)
if err != nil {
t.Fatal(err)
}
if filtered && len(candidates) == 0 {
t.Skipf("testdata/%s: no selected script belongs to this command directory", command)

candidates := make([]acceptanceScript, 0, len(scriptFiles))
for _, file := range scriptFiles {
requiresUserCapability, err := requiresUserCapabilityForScript(file)
if err != nil {
t.Fatal(err)
}
candidates = append(candidates, acceptanceScript{
file: file,
requiresUserCapability: requiresUserCapability,
})
}

files, skipReason, err := filterAcceptanceScripts(candidates, filtered, tsEnv.hasUserCapability)
if err != nil {
t.Fatal(err)
}
if skipReason != "" {
if filtered {
t.Skipf("testdata/%s: %s", command, skipReason)
}
t.Skip(skipReason)
}

return testscript.Params{
Files: candidates,
Files: files,
Setup: sharedSetup(tsEnv),
Cmds: sharedCmds(tsEnv, fixtureRepositories),
RequireExplicitExec: true,
Expand Down Expand Up @@ -1174,10 +1285,11 @@ func (e missingEnvError) Error() string {
}

type testScriptEnv struct {
host string
org string
token string
user string
host string
org string
token string
user string
hasUserCapability bool

// scripts optionally narrows a run to named scripts within the command
// directory being run. Empty means run every script in the directory.
Expand Down Expand Up @@ -1223,6 +1335,11 @@ func (e *testScriptEnv) fromEnv() error {
e.host = envMap["GH_ACCEPTANCE_HOST"]
e.org = envMap["GH_ACCEPTANCE_ORG"]
e.token = envMap["GH_ACCEPTANCE_TOKEN"]
var err error
e.hasUserCapability, err = tokenHasUserCapability(e.token)
if err != nil {
return err
}

e.scripts = parseScriptFilter(os.Getenv("GH_ACCEPTANCE_SCRIPT"))
e.preserveWorkDir = os.Getenv("GH_ACCEPTANCE_PRESERVE_WORK_DIR") == "true"
Expand Down
1 change: 1 addition & 0 deletions acceptance/testdata/api/basic-graphql.txtar
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# requires-user-capability: false

fixture-repo none

Expand Down
1 change: 1 addition & 0 deletions acceptance/testdata/api/basic-rest.txtar
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# requires-user-capability: false

fixture-repo none

Expand Down
1 change: 1 addition & 0 deletions acceptance/testdata/auth/auth-login-logout.txtar
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# requires-user-capability: false

fixture-repo none

Expand Down
1 change: 1 addition & 0 deletions acceptance/testdata/auth/auth-setup-git.txtar
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# requires-user-capability: false

fixture-repo none

Expand Down
1 change: 1 addition & 0 deletions acceptance/testdata/auth/auth-status.txtar
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# requires-user-capability: false

fixture-repo none

Expand Down
1 change: 1 addition & 0 deletions acceptance/testdata/auth/auth-token.txtar
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# requires-user-capability: false

fixture-repo none

Expand Down
1 change: 1 addition & 0 deletions acceptance/testdata/discussion/discussion-comment.txtar
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# requires-user-capability: false

# Create a repository with a file so it has a default branch
fixture-repo shared REPO
Expand Down
1 change: 1 addition & 0 deletions acceptance/testdata/discussion/discussion-create.txtar
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# requires-user-capability: false

# Create a repository with a file so it has a default branch
fixture-repo isolated REPO
Expand Down
1 change: 1 addition & 0 deletions acceptance/testdata/discussion/discussion-edit.txtar
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# requires-user-capability: false

# Create a repository with a file so it has a default branch
fixture-repo shared REPO
Expand Down
1 change: 1 addition & 0 deletions acceptance/testdata/discussion/discussion-list.txtar
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# requires-user-capability: false

# Create a repository with a file so it has a default branch
fixture-repo isolated REPO
Expand Down
1 change: 1 addition & 0 deletions acceptance/testdata/discussion/discussion-view.txtar
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# requires-user-capability: false

# Create a repository with a file so it has a default branch
fixture-repo isolated REPO
Expand Down
20 changes: 18 additions & 2 deletions acceptance/testdata/extension/extension-env.txtar
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# requires-user-capability: false

# Verify that gh tells an extension when it is being run as an extension

Expand All @@ -10,7 +11,8 @@ fixture-repo none
env EXT_NAME=printenv-${RANDOM_STRING}
env EXT_DIR=gh-${EXT_NAME}

# Setup a local extension that reports the value of GH_EXTENSION
# Setup a local extension that reports the extension environment and verifies
# it can invoke the current gh executable without relying on PATH
mkdir $EXT_DIR
mv print-env.sh $EXT_DIR/$EXT_DIR
chmod 777 $EXT_DIR/$EXT_DIR
Expand All @@ -23,19 +25,33 @@ defer gh extension remove $EXT_NAME
# Verify GH_EXTENSION is set when the extension is run as gh <extension>
exec gh $EXT_NAME
stdout 'GH_EXTENSION=1'
stdout 'GH_PATH was set correctly'

# Verify GH_EXTENSION is set when the extension is run via gh extension exec
exec gh extension exec $EXT_NAME
stdout 'GH_EXTENSION=1'
stdout 'GH_PATH was set correctly'

# Verify GH_EXTENSION is absent when the extension is run standalone
exec ./$EXT_DIR
stdout 'GH_EXTENSION=0'

# Verify GH_EXTENSION is documented
# Verify GH_EXTENSION and GH_PATH are documented
exec gh help environment
stdout 'GH_EXTENSION`: set to `1` by gh when it invokes an extension'
stdout 'GH_PATH`: set the path to the gh executable, useful for when gh can not properly determine'

-- print-env.sh --
#!/usr/bin/env bash
set -e

echo "GH_EXTENSION=${GH_EXTENSION:-0}"

if [[ "${GH_EXTENSION:-0}" == "1" ]]; then
expected_token=$GH_TOKEN
mkdir -p empty-path
PATH=$PWD/empty-path
actual_token=$("$GH_PATH" auth token --hostname "$GH_HOST")
[[ "$actual_token" == "$expected_token" ]]
echo "GH_PATH was set correctly"
fi
1 change: 1 addition & 0 deletions acceptance/testdata/extension/extension.txtar
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# requires-user-capability: false

# Skip if Bash is not available given script extension
[!exec:bash] skip
Expand Down
1 change: 1 addition & 0 deletions acceptance/testdata/gist/gist-create-view-delete.txtar
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# requires-user-capability: true

fixture-repo none

Expand Down
1 change: 1 addition & 0 deletions acceptance/testdata/gist/gist-edit-rename-list.txtar
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# requires-user-capability: true

fixture-repo none

Expand Down
1 change: 1 addition & 0 deletions acceptance/testdata/gpg-key/gpg-key.txtar
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# requires-user-capability: true

skip 'it modifies the user''s personal GitHub account GPG keys'

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# requires-user-capability: false

# Setup environment variables used for testscript

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# requires-user-capability: false

# Setup environment variables used for testscript

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# requires-user-capability: false

# Setup environment variables used for testscript

Expand Down
1 change: 1 addition & 0 deletions acceptance/testdata/issue/issue-comment-new.txtar
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# requires-user-capability: false

# Setup environment variables used for testscript

Expand Down
1 change: 1 addition & 0 deletions acceptance/testdata/issue/issue-create-basic.txtar
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# requires-user-capability: false

# Create a repository with a file so it has a default branch
fixture-repo shared REPO
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# requires-user-capability: false

# Create a repository with a file so it has a default branch
fixture-repo shared REPO
Expand Down
1 change: 1 addition & 0 deletions acceptance/testdata/issue/issue-create-with-metadata.txtar
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# requires-user-capability: true

# Create a repository with a file so it has a default branch
fixture-repo shared REPO
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# requires-user-capability: false

fixture-repo none

Expand Down
1 change: 1 addition & 0 deletions acceptance/testdata/issue/issue-develop-worktree.txtar
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# requires-user-capability: false

# Develop an issue in a fresh worktree, reuse that worktree, and add another
# worktree after the local branch exists.
Expand Down
1 change: 1 addition & 0 deletions acceptance/testdata/issue/issue-list.txtar
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# requires-user-capability: false

# Create a repository with a file so it has a default branch
fixture-repo shared REPO
Expand Down
Loading
Loading