Merge branch 'trunk' into fetch-artifact-attestation-bundles-with-sas-url

This commit is contained in:
Meredith Lancaster 2024-12-13 15:25:00 -07:00
commit 7160f7ef50
104 changed files with 3977 additions and 1292 deletions

View file

@ -299,7 +299,7 @@ jobs:
rpmsign --addsign dist/*.rpm
- name: Attest release artifacts
if: inputs.environment == 'production'
uses: actions/attest-build-provenance@1c608d11d69870c2092266b3f9a6f3abbf17002c # v1.4.3
uses: actions/attest-build-provenance@7668571508540a607bdfd90a87a560489fe372eb # v2.1.0
with:
subject-path: "dist/gh_*"
- name: Run createrepo

View file

@ -35,6 +35,8 @@ jobs:
---
cc: @github/cli
> $BODY
EOF
@ -63,5 +65,7 @@ jobs:
---
cc: @github/cli
> $BODY
EOF

View file

@ -88,7 +88,7 @@ For more information, see [Linux & BSD installation](./docs/install_linux.md).
| ------------------- | --------------------|
| `winget install --id GitHub.cli` | `winget upgrade --id GitHub.cli` |
> **Note**
> [!NOTE]
> The Windows installer modifies your PATH. When using Windows Terminal, you will need to **open a new window** for the changes to take effect. (Simply opening a new tab will _not_ be sufficient.)
#### scoop
@ -125,6 +125,38 @@ GitHub CLI comes pre-installed in all [GitHub-Hosted Runners](https://docs.githu
Download packaged binaries from the [releases page][].
#### Verification of binaries
Since version 2.50.0 `gh` has been producing [Build Provenance Attestation](https://github.blog/changelog/2024-06-25-artifact-attestations-is-generally-available/) enabling a cryptographically verifiable paper-trail back to the origin GitHub repository, git revision and build instructions used. The build provenance attestations are signed and relies on Public Good [Sigstore](https://www.sigstore.dev/) for PKI.
There are two common ways to verify a downloaded release, depending if `gh` is aready installed or not. If `gh` is installed, it's trivial to verify a new release:
- **Option 1: Using `gh` if already installed:**
```shell
$ % gh at verify -R cli/cli gh_2.62.0_macOS_arm64.zip
Loaded digest sha256:fdb77f31b8a6dd23c3fd858758d692a45f7fc76383e37d475bdcae038df92afc for file://gh_2.62.0_macOS_arm64.zip
Loaded 1 attestation from GitHub API
✓ Verification succeeded!
sha256:fdb77f31b8a6dd23c3fd858758d692a45f7fc76383e37d475bdcae038df92afc was attested by:
REPO PREDICATE_TYPE WORKFLOW
cli/cli https://slsa.dev/provenance/v1 .github/workflows/deployment.yml@refs/heads/trunk
```
- **Option 2: Using Sigstore [`cosign`](https://github.com/sigstore/cosign):**
To perform this, download the [attestation](https://github.com/cli/cli/attestations) for the downloaded release and use cosign to verify the authenticity of the downloaded release:
```shell
$ cosign verify-blob-attestation --bundle cli-cli-attestation-3120304.sigstore.json \
--new-bundle-format \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
--certificate-identity-regexp="^https://github.com/cli/cli/.github/workflows/deployment.yml@refs/heads/trunk$" \
gh_2.62.0_macOS_arm64.zip
Verified OK
```
### Build from source
See here on how to [build GitHub CLI from source][build from source].

View file

@ -0,0 +1,33 @@
# Set up env vars
env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
# Use gh as a credential helper
exec gh auth setup-git
# Create a repository with a file so it has a default branch
exec gh repo create ${ORG}/${REPO} --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes ${ORG}/${REPO}
# Clone the repo
exec gh repo clone ${ORG}/${REPO}
# Prepare a branch to PR
cd ${REPO}
exec git checkout -b feature-branch
exec git commit --allow-empty -m 'Empty Commit'
exec git push -u origin feature-branch
# Create the PR
exec gh pr create --title 'Feature Title' --body 'Feature Body'
stdout2env PR_URL
# Remove the local branch
exec git checkout main
exec git branch -D feature-branch
stdout 'Deleted branch feature-branch'
# Checkout the PR
exec gh pr checkout 1
stderr 'Switched to a new branch ''feature-branch'''

View file

@ -0,0 +1,37 @@
# Set up env vars
env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
# Use gh as a credential helper
exec gh auth setup-git
# Create a repository with a file so it has a default branch
exec gh repo create ${ORG}/${REPO} --add-readme --private
# Defer upstream cleanup
defer gh repo delete --yes ${ORG}/${REPO}
# Create a fork
exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${REPO}-fork
# Defer fork cleanup
defer gh repo delete --yes ${ORG}/${REPO}-fork
# Clone both repos
exec gh repo clone ${ORG}/${REPO}
exec gh repo clone ${ORG}/${REPO}-fork
# Prepare a branch to PR in the fork itself
cd ${REPO}-fork
exec git checkout -b feature-branch
exec git commit --allow-empty -m 'Empty Commit'
exec git push -u origin feature-branch
# Create the PR inside the fork
exec gh repo set-default ${ORG}/${REPO}-fork
exec gh pr create --title 'Feature Title' --body 'Feature Body'
stdout2env PR_URL
# Checkout the PR by full URL in the upstream repo
cd ${WORK}/${REPO}
exec gh pr checkout ${PR_URL}
stderr 'Switched to branch ''feature-branch'''

View file

@ -0,0 +1,37 @@
# Set up env vars
env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
# Use gh as a credential helper
exec gh auth setup-git
# Create a repository with a file so it has a default branch
exec gh repo create ${ORG}/${REPO} --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes ${ORG}/${REPO}
# Clone the repo
exec gh repo clone ${ORG}/${REPO}
# Create a branch to act as the merge base branch
cd ${REPO}
exec git checkout -b long-lived-feature-branch
exec git push -u origin long-lived-feature-branch
# Create an issue to develop against
exec gh issue create --title 'Feature Request' --body 'Request Body'
stdout2env ISSUE_URL
# Create a new branch using issue develop with the long lived branch as the base
exec gh issue develop --name 'feature-branch' --base 'long-lived-feature-branch' --checkout ${ISSUE_URL}
# Prepare a PR on the develop branch
exec git commit --allow-empty -m 'Empty Commit'
exec git push -u origin feature-branch
# Create the PR
exec gh pr create --title 'Feature Title' --body 'Feature Body'
# Check the PR is created against the base branch we specified
exec gh pr view --json 'baseRefName' --jq '.baseRefName'
stdout 'long-lived-feature-branch'

View file

@ -0,0 +1,34 @@
# Set up env vars
env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
# Use gh as a credential helper
exec gh auth setup-git
# Create a repository with a file so it has a default branch
exec gh repo create ${ORG}/${REPO} --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes ${ORG}/${REPO}
# Clone the repo
exec gh repo clone ${ORG}/${REPO}
# Create a branch to act as the merge base branch
cd ${REPO}
exec git checkout -b long-lived-feature-branch
exec git push -u origin long-lived-feature-branch
# Prepare a branch from the merge base to PR
exec git checkout -b feature-branch
exec git commit --allow-empty -m 'Empty Commit'
exec git push -u origin feature-branch
# Set the merge-base branch config
exec git config 'branch.feature-branch.gh-merge-base' 'long-lived-feature-branch'
# Create the PR
exec gh pr create --title 'Feature Title' --body 'Feature Body'
# Check the PR is created against the merge base branch
exec gh pr view --json 'baseRefName' --jq '.baseRefName'
stdout 'long-lived-feature-branch'

View file

@ -0,0 +1,39 @@
# Setup environment variables used for testscript
env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
env FORK=${REPO}-fork
# Use gh as a credential helper
exec gh auth setup-git
# Create a repository to act as upstream with a file so it has a default branch
exec gh repo create ${ORG}/${REPO} --add-readme --private
# Defer repo cleanup of upstream
defer gh repo delete --yes ${ORG}/${REPO}
exec gh repo view ${ORG}/${REPO} --json id --jq '.id'
stdout2env REPO_ID
# Create a fork in the same org
exec gh repo fork ${ORG}/${REPO} --org ${ORG} --fork-name ${FORK}
# Defer repo cleanup of fork
defer gh repo delete --yes ${ORG}/${FORK}
sleep 1
exec gh repo view ${ORG}/${FORK} --json id --jq '.id'
stdout2env FORK_ID
# Clone the fork
exec gh repo clone ${ORG}/${FORK}
cd ${FORK}
# Prepare a branch
exec git checkout -b feature-branch
exec git commit --allow-empty -m 'Empty Commit'
exec git push -u origin feature-branch
# Create the PR spanning upstream and fork repositories, gh pr create does not support headRepositoryId needed for private forks
exec gh api graphql -F repositoryId="${REPO_ID}" -F headRepositoryId="${FORK_ID}" -F query='mutation CreatePullRequest($headRepositoryId: ID!, $repositoryId: ID!) { createPullRequest(input:{ baseRefName: "main", body: "Feature Body", draft: false, headRefName: "feature-branch", headRepositoryId: $headRepositoryId, repositoryId: $repositoryId, title:"Feature Title" }){ pullRequest{ id url } } }'
# View the PR
exec gh pr view
stdout 'Feature Title'

View file

@ -0,0 +1,35 @@
# It's unclear what we want to do with these acceptance tests beyond our GHEC discovery, so skip new ones by default
skip
# Set up env var
env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
# Use gh as a credential helper
exec gh auth setup-git
# Initialise a local repository with two branches
# We expect a bare repo to have all refs pushed with --mirror
mkdir ${REPO}
cd ${REPO}
exec git init
exec git checkout -b feature-1
exec git commit --allow-empty -m 'Empty Commit 1'
exec git checkout -b feature-2
exec git commit --allow-empty -m 'Empty Commit 2'
# Clone a bare repo from that local repo
cd ..
exec git clone --bare ${REPO} ${REPO}-bare
cd ${REPO}-bare
# Create a GitHub repository from that bare repo
exec gh repo create ${ORG}/${REPO} --private --source . --push --remote bare
# Defer repo cleanup
defer gh repo delete --yes ${ORG}/${REPO}
# Check the remote repo has both branches
exec gh api /repos/${ORG}/${REPO}/branches
stdout 'feature-1'
stdout 'feature-2'

View file

@ -0,0 +1,36 @@
# It's unclear what we want to do with these acceptance tests beyond our GHEC discovery, so skip new ones by default
skip
# Set up env vars
env REPO=${ORG}/${SCRIPT_NAME}-${RANDOM_STRING}
# Create a repository with a file so it has a default branch
exec gh repo create ${REPO} --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes ${REPO}
# Set the repo to be targeted by all following commands
env GH_REPO=${REPO}
# Listing the cache non-interactively shows nothing
exec gh cache list
! stdout '.'
# Listing the cache non-interactively with --json shows an empty array
exec gh cache list --json id
stdout '\[\]'
# Now set an env var so the commands run interactively and without colour for stdout matching
# Unfortunately testscript provides no way to turn them off again, and since this
# script is for discovery, we're not adding a new command.
env GH_FORCE_TTY=true
env CLICOLOR=0
# Listing the cache interactively shows an informative message on stderr
exec gh cache list
stderr 'No caches found in'
# Listing the cache interactively with --json shows an empty array
exec gh cache list --json id
stdout '\[\]'

View file

@ -40,7 +40,7 @@ exec gh run watch $RUN_ID --exit-status
# Delete the workflow run
exec gh run delete $RUN_ID
stdout '✓ Request to delete workflow submitted.'
stdout '✓ Request to delete workflow run submitted.'
# It takes some time for a workflow run to be deleted
sleep 5

View file

@ -0,0 +1,71 @@
# Set up env
env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
# Use gh as a credential helper
exec gh auth setup-git
# Create a repository with a file so it has a default branch
exec gh repo create ${ORG}/${REPO} --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes ${ORG}/${REPO}
# Clone the repo
exec gh repo clone ${ORG}/${REPO}
# commit the workflow file
cd ${REPO}
mkdir .github/workflows
mv ../workflow.yml .github/workflows/workflow.yml
exec git add .github/workflows/workflow.yml
exec git commit -m 'Create workflow file'
exec git push -u origin main
# Sleep because it takes a second for the workflow to register
sleep 1
# Check the workflow is indeed created
exec gh workflow list
stdout 'Test Workflow Name'
# Run the workflow
exec gh workflow run 'Test Workflow Name'
# It takes some time for a workflow run to register
sleep 10
# Get the run ID we want to watch
exec gh run list --json databaseId --jq '.[0].databaseId'
stdout2env RUN_ID
# Wait for workflow to complete
exec gh run watch ${RUN_ID} --exit-status
# Download the artifact and see there is an error
! exec gh run download ${RUN_ID}
stderr 'would result in path traversal'
-- workflow.yml --
# This is a basic workflow to help you get started with Actions
name: Test Workflow Name
# Controls when the workflow will run
on:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
build:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
- run: echo hello > world.txt
- uses: actions/upload-artifact@v4
with:
name: ..
path: world.txt

View file

@ -1,17 +1,20 @@
# Set up env
env REPO=${SCRIPT_NAME}-${RANDOM_STRING}
# Use gh as a credential helper
exec gh auth setup-git
# Create a repository with a file so it has a default branch
exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
exec gh repo create ${ORG}/${REPO} --add-readme --private
# Defer repo cleanup
defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
defer gh repo delete --yes ${ORG}/${REPO}
# Clone the repo
exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
exec gh repo clone ${ORG}/${REPO}
# commit the workflow file
cd $SCRIPT_NAME-$RANDOM_STRING
cd ${REPO}
mkdir .github/workflows
mv ../workflow.yml .github/workflows/workflow.yml
exec git add .github/workflows/workflow.yml
@ -36,13 +39,28 @@ exec gh run list --json databaseId --jq '.[0].databaseId'
stdout2env RUN_ID
# Wait for workflow to complete
exec gh run watch $RUN_ID --exit-status
exec gh run watch ${RUN_ID} --exit-status
# Download the artifact
exec gh run download $RUN_ID
# Download the artifact to current dir
exec gh run download ${RUN_ID}
# Check if we downloaded the artifact
exists ./my-artifact/world.txt
# Check that we downloaded the artifact and extracted into a dir with the name of the artifact
exists ./my-artifact/child/world.txt
# Remove the artifact
rm ./my-artifact
# Download the artifact via name to current dir
exec gh run download -n 'my-artifact' ${RUN_ID}
# Check that we downloaded the artifact and extracted into the current dir
exists ./child/world.txt
# Download the artifact via name to a specific dir
exec gh run download -n 'my-artifact' ${RUN_ID} --dir '..'
# Check that we downloaded the artifact and extracted into the specified dir
exists ../child/world.txt
-- workflow.yml --
# This is a basic workflow to help you get started with Actions
@ -63,8 +81,10 @@ jobs:
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
- run: echo hello > world.txt
- run: |
mkdir -p ./parent/child
echo hello > ./parent/child/world.txt
- uses: actions/upload-artifact@v4
with:
name: my-artifact
path: world.txt
path: ./parent

View file

@ -33,6 +33,7 @@ type PullRequest struct {
Closed bool
URL string
BaseRefName string
BaseRefOid string
HeadRefName string
HeadRefOid string
Body string

View file

@ -285,6 +285,7 @@ var PullRequestFields = append(sharedIssuePRFields,
"additions",
"autoMergeRequest",
"baseRefName",
"baseRefOid",
"changedFiles",
"commits",
"deletions",

View file

@ -33,31 +33,39 @@ sudo apt install gh
> [!NOTE]
> If errors regarding GPG signatures occur, see [cli/cli#9569](https://github.com/cli/cli/issues/9569) for steps to fix this.
### Fedora, CentOS, Red Hat Enterprise Linux (dnf5)
### Fedora, CentOS, Red Hat Enterprise Linux (DNF4 & DNF5)
Install from our package repository for immediate access to latest releases:
Install from our package repository for immediate access to latest releases.
#### DNF5
> [!IMPORTANT]
> **These commands apply to DNF5 only**. If you're using DNF4, please use [the DNF4 instructions](#dnf4).
```bash
# DNF5 installation commands
sudo dnf install dnf5-plugins
sudo dnf config-manager addrepo --from-repofile=https://cli.github.com/packages/rpm/gh-cli.repo
sudo dnf install gh --repo gh-cli
```
These commands apply for `dnf5`. If you're using `dnf4`, commands will vary slightly.
#### DNF4
<details>
<summary>Show dnf4 commands</summary>
> [!IMPORTANT]
> **These commands apply to DNF4 only**. If you're using DNF5, please use [the DNF5 instructions](#dnf5).
```bash
sudo dnf4 install 'dnf-command(config-manager)'
sudo dnf4 config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo
sudo dnf4 install gh --repo gh-cli
# DNF4 installation commands
sudo dnf install 'dnf-command(config-manager)'
sudo dnf config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo
sudo dnf install gh --repo gh-cli
```
</details>
> [!NOTE]
> If errors regarding GPG signatures occur, see [cli/cli#9569](https://github.com/cli/cli/issues/9569) for steps to fix this.
### Fedora, CentOS, Red Hat Enterprise Linux - Community repository
Alternatively, install from the [community repository](https://packages.fedoraproject.org/pkgs/gh/gh/):
```bash

View file

@ -16,9 +16,13 @@ import (
"strings"
"sync"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/safeexec"
)
// MergeBaseConfig is the configuration setting to keep track of the PR target branch.
const MergeBaseConfig = "gh-merge-base"
var remoteRE = regexp.MustCompile(`(.+)\s+(.+)\s+\((push|fetch)\)`)
// This regexp exists to match lines of the following form:
@ -94,16 +98,65 @@ func (c *Client) Command(ctx context.Context, args ...string) (*Command, error)
return &Command{cmd}, nil
}
// CredentialPattern is used to inform AuthenticatedCommand which patterns Git should match
// against when trying to find credentials. It is a little over-engineered as a type because we
// want AuthenticatedCommand to have a clear compilation error when this is not provided,
// as opposed to using a string which might compile with `client.AuthenticatedCommand(ctx, "fetch")`.
//
// It is only usable when constructed by another function in the package because the empty pattern,
// without allMatching set to true, will result in an error in AuthenticatedCommand.
//
// Callers can currently opt-in to an slightly less secure mode for backwards compatibility by using
// AllMatchingCredentialsPattern.
type CredentialPattern struct {
allMatching bool // should only be constructable via AllMatchingCredentialsPattern
pattern string
}
// AllMatchingCredentialsPattern allows for setting gh as credential helper for all hosts.
// However, we should endeavour to remove it as it's less secure.
var AllMatchingCredentialsPattern = CredentialPattern{allMatching: true, pattern: ""}
var disallowedCredentialPattern = CredentialPattern{allMatching: false, pattern: ""}
// CredentialPatternFromGitURL takes a git remote URL e.g. "https://github.com/cli/cli.git" or
// "git@github.com:cli/cli.git" and returns the credential pattern that should be used for it.
func CredentialPatternFromGitURL(gitURL string) (CredentialPattern, error) {
normalizedURL, err := ParseURL(gitURL)
if err != nil {
return CredentialPattern{}, fmt.Errorf("failed to parse remote URL: %w", err)
}
return CredentialPatternFromHost(normalizedURL.Host), nil
}
// CredentialPatternFromHost expects host to be in the form "github.com" and returns
// the credential pattern that should be used for it.
// It does not perform any canonicalisation e.g. "api.github.com" will not work as expected.
func CredentialPatternFromHost(host string) CredentialPattern {
return CredentialPattern{
pattern: strings.TrimSuffix(ghinstance.HostPrefix(host), "/"),
}
}
// AuthenticatedCommand is a wrapper around Command that included configuration to use gh
// as the credential helper for git.
func (c *Client) AuthenticatedCommand(ctx context.Context, args ...string) (*Command, error) {
preArgs := []string{"-c", "credential.helper="}
func (c *Client) AuthenticatedCommand(ctx context.Context, credentialPattern CredentialPattern, args ...string) (*Command, error) {
if c.GhPath == "" {
// Assumes that gh is in PATH.
c.GhPath = "gh"
}
credHelper := fmt.Sprintf("!%q auth git-credential", c.GhPath)
preArgs = append(preArgs, "-c", fmt.Sprintf("credential.helper=%s", credHelper))
var preArgs []string
if credentialPattern == disallowedCredentialPattern {
return nil, fmt.Errorf("empty credential pattern is not allowed unless provided explicitly")
} else if credentialPattern == AllMatchingCredentialsPattern {
preArgs = []string{"-c", "credential.helper="}
preArgs = append(preArgs, "-c", fmt.Sprintf("credential.helper=%s", credHelper))
} else {
preArgs = []string{"-c", fmt.Sprintf("credential.%s.helper=", credentialPattern.pattern)}
preArgs = append(preArgs, "-c", fmt.Sprintf("credential.%s.helper=%s", credentialPattern.pattern, credHelper))
}
args = append(preArgs, args...)
return c.Command(ctx, args...)
}
@ -323,10 +376,10 @@ func (c *Client) lookupCommit(ctx context.Context, sha, format string) ([]byte,
return out, nil
}
// ReadBranchConfig parses the `branch.BRANCH.(remote|merge)` part of git config.
// ReadBranchConfig parses the `branch.BRANCH.(remote|merge|gh-merge-base)` part of git config.
func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (cfg BranchConfig) {
prefix := regexp.QuoteMeta(fmt.Sprintf("branch.%s.", branch))
args := []string{"config", "--get-regexp", fmt.Sprintf("^%s(remote|merge)$", prefix)}
args := []string{"config", "--get-regexp", fmt.Sprintf("^%s(remote|merge|%s)$", prefix, MergeBaseConfig)}
cmd, err := c.Command(ctx, args...)
if err != nil {
return
@ -335,6 +388,8 @@ func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (cfg Branc
if err != nil {
return
}
cfg.LocalName = branch
for _, line := range outputLines(out) {
parts := strings.SplitN(line, " ", 2)
if len(parts) < 2 {
@ -354,11 +409,26 @@ func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (cfg Branc
}
case "merge":
cfg.MergeRef = parts[1]
case MergeBaseConfig:
cfg.MergeBase = parts[1]
}
}
return
}
// SetBranchConfig sets the named value on the given branch.
func (c *Client) SetBranchConfig(ctx context.Context, branch, name, value string) error {
name = fmt.Sprintf("branch.%s.%s", branch, name)
args := []string{"config", name, value}
cmd, err := c.Command(ctx, args...)
if err != nil {
return err
}
// No output expected but check for any printed git error.
_, err = cmd.Output()
return err
}
func (c *Client) DeleteLocalTag(ctx context.Context, tag string) error {
args := []string{"tag", "-d", tag}
cmd, err := c.Command(ctx, args...)
@ -549,7 +619,7 @@ func (c *Client) Fetch(ctx context.Context, remote string, refspec string, mods
if refspec != "" {
args = append(args, refspec)
}
cmd, err := c.AuthenticatedCommand(ctx, args...)
cmd, err := c.AuthenticatedCommand(ctx, AllMatchingCredentialsPattern, args...)
if err != nil {
return err
}
@ -564,7 +634,7 @@ func (c *Client) Pull(ctx context.Context, remote, branch string, mods ...Comman
if remote != "" && branch != "" {
args = append(args, remote, branch)
}
cmd, err := c.AuthenticatedCommand(ctx, args...)
cmd, err := c.AuthenticatedCommand(ctx, AllMatchingCredentialsPattern, args...)
if err != nil {
return err
}
@ -576,7 +646,7 @@ func (c *Client) Pull(ctx context.Context, remote, branch string, mods ...Comman
func (c *Client) Push(ctx context.Context, remote string, ref string, mods ...CommandModifier) error {
args := []string{"push", "--set-upstream", remote, ref}
cmd, err := c.AuthenticatedCommand(ctx, args...)
cmd, err := c.AuthenticatedCommand(ctx, AllMatchingCredentialsPattern, args...)
if err != nil {
return err
}
@ -587,6 +657,13 @@ func (c *Client) Push(ctx context.Context, remote string, ref string, mods ...Co
}
func (c *Client) Clone(ctx context.Context, cloneURL string, args []string, mods ...CommandModifier) (string, error) {
// Note that even if this is an SSH clone URL, we are setting the pattern anyway.
// We could write some code to prevent this, but it also doesn't seem harmful.
pattern, err := CredentialPatternFromGitURL(cloneURL)
if err != nil {
return "", err
}
cloneArgs, target := parseCloneArgs(args)
cloneArgs = append(cloneArgs, cloneURL)
// If the args contain an explicit target, pass it to clone otherwise,
@ -601,7 +678,7 @@ func (c *Client) Clone(ctx context.Context, cloneURL string, args []string, mods
}
}
cloneArgs = append([]string{"clone"}, cloneArgs...)
cmd, err := c.AuthenticatedCommand(ctx, cloneArgs...)
cmd, err := c.AuthenticatedCommand(ctx, pattern, cloneArgs...)
if err != nil {
return "", err
}

View file

@ -64,17 +64,32 @@ func TestClientAuthenticatedCommand(t *testing.T) {
tests := []struct {
name string
path string
pattern CredentialPattern
wantArgs []string
wantErr error
}{
{
name: "adds credential helper config options",
name: "when credential pattern allows for anything, credential helper matches everything",
path: "path/to/gh",
pattern: AllMatchingCredentialsPattern,
wantArgs: []string{"path/to/git", "-c", "credential.helper=", "-c", `credential.helper=!"path/to/gh" auth git-credential`, "fetch"},
},
{
name: "when credential pattern is set, credential helper only matches that pattern",
path: "path/to/gh",
pattern: CredentialPattern{pattern: "https://github.com"},
wantArgs: []string{"path/to/git", "-c", "credential.https://github.com.helper=", "-c", `credential.https://github.com.helper=!"path/to/gh" auth git-credential`, "fetch"},
},
{
name: "fallback when GhPath is not set",
pattern: AllMatchingCredentialsPattern,
wantArgs: []string{"path/to/git", "-c", "credential.helper=", "-c", `credential.helper=!"gh" auth git-credential`, "fetch"},
},
{
name: "errors when attempting to use an empty pattern that isn't marked all matching",
pattern: CredentialPattern{allMatching: false, pattern: ""},
wantErr: fmt.Errorf("empty credential pattern is not allowed unless provided explicitly"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -82,9 +97,12 @@ func TestClientAuthenticatedCommand(t *testing.T) {
GhPath: tt.path,
GitPath: "path/to/git",
}
cmd, err := client.AuthenticatedCommand(context.Background(), "fetch")
assert.NoError(t, err)
assert.Equal(t, tt.wantArgs, cmd.Args)
cmd, err := client.AuthenticatedCommand(context.Background(), tt.pattern, "fetch")
if tt.wantErr != nil {
require.Equal(t, tt.wantErr, err)
return
}
require.Equal(t, tt.wantArgs, cmd.Args)
})
}
}
@ -717,9 +735,9 @@ func TestClientReadBranchConfig(t *testing.T) {
}{
{
name: "read branch config",
cmdStdout: "branch.trunk.remote origin\nbranch.trunk.merge refs/heads/trunk",
wantCmdArgs: `path/to/git config --get-regexp ^branch\.trunk\.(remote|merge)$`,
wantBranchConfig: BranchConfig{RemoteName: "origin", MergeRef: "refs/heads/trunk"},
cmdStdout: "branch.trunk.remote origin\nbranch.trunk.merge refs/heads/trunk\nbranch.trunk.gh-merge-base trunk",
wantCmdArgs: `path/to/git config --get-regexp ^branch\.trunk\.(remote|merge|gh-merge-base)$`,
wantBranchConfig: BranchConfig{LocalName: "trunk", RemoteName: "origin", MergeRef: "refs/heads/trunk", MergeBase: "trunk"},
},
}
for _, tt := range tests {
@ -1131,44 +1149,52 @@ func TestClientSetRemoteBranches(t *testing.T) {
func TestClientFetch(t *testing.T) {
tests := []struct {
name string
mods []CommandModifier
cmdExitStatus int
cmdStdout string
cmdStderr string
wantCmdArgs string
wantErrorMsg string
name string
mods []CommandModifier
commands mockedCommands
wantErrorMsg string
}{
{
name: "fetch",
wantCmdArgs: `path/to/git -c credential.helper= -c credential.helper=!"gh" auth git-credential fetch origin trunk`,
name: "fetch",
commands: map[args]commandResult{
`path/to/git -c credential.helper= -c credential.helper=!"gh" auth git-credential fetch origin trunk`: {
ExitStatus: 0,
},
},
},
{
name: "accepts command modifiers",
mods: []CommandModifier{WithRepoDir("/path/to/repo")},
wantCmdArgs: `path/to/git -C /path/to/repo -c credential.helper= -c credential.helper=!"gh" auth git-credential fetch origin trunk`,
name: "accepts command modifiers",
mods: []CommandModifier{WithRepoDir("/path/to/repo")},
commands: map[args]commandResult{
`path/to/git -C /path/to/repo -c credential.helper= -c credential.helper=!"gh" auth git-credential fetch origin trunk`: {
ExitStatus: 0,
},
},
},
{
name: "git error",
cmdExitStatus: 1,
cmdStderr: "git error message",
wantCmdArgs: `path/to/git -c credential.helper= -c credential.helper=!"gh" auth git-credential fetch origin trunk`,
wantErrorMsg: "failed to run git: git error message",
name: "git error on fetch",
commands: map[args]commandResult{
`path/to/git -c credential.helper= -c credential.helper=!"gh" auth git-credential fetch origin trunk`: {
ExitStatus: 1,
Stderr: "fetch error message",
},
},
wantErrorMsg: "failed to run git: fetch error message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr)
cmdCtx := createMockedCommandContext(t, tt.commands)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
err := client.Fetch(context.Background(), "origin", "trunk", tt.mods...)
assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
if tt.wantErrorMsg == "" {
assert.NoError(t, err)
require.NoError(t, err)
} else {
assert.EqualError(t, err, tt.wantErrorMsg)
require.EqualError(t, err, tt.wantErrorMsg)
}
})
}
@ -1176,44 +1202,52 @@ func TestClientFetch(t *testing.T) {
func TestClientPull(t *testing.T) {
tests := []struct {
name string
mods []CommandModifier
cmdExitStatus int
cmdStdout string
cmdStderr string
wantCmdArgs string
wantErrorMsg string
name string
mods []CommandModifier
commands mockedCommands
wantErrorMsg string
}{
{
name: "pull",
wantCmdArgs: `path/to/git -c credential.helper= -c credential.helper=!"gh" auth git-credential pull --ff-only origin trunk`,
name: "pull",
commands: map[args]commandResult{
`path/to/git -c credential.helper= -c credential.helper=!"gh" auth git-credential pull --ff-only origin trunk`: {
ExitStatus: 0,
},
},
},
{
name: "accepts command modifiers",
mods: []CommandModifier{WithRepoDir("/path/to/repo")},
wantCmdArgs: `path/to/git -C /path/to/repo -c credential.helper= -c credential.helper=!"gh" auth git-credential pull --ff-only origin trunk`,
name: "accepts command modifiers",
mods: []CommandModifier{WithRepoDir("/path/to/repo")},
commands: map[args]commandResult{
`path/to/git -C /path/to/repo -c credential.helper= -c credential.helper=!"gh" auth git-credential pull --ff-only origin trunk`: {
ExitStatus: 0,
},
},
},
{
name: "git error",
cmdExitStatus: 1,
cmdStderr: "git error message",
wantCmdArgs: `path/to/git -c credential.helper= -c credential.helper=!"gh" auth git-credential pull --ff-only origin trunk`,
wantErrorMsg: "failed to run git: git error message",
name: "git error on pull",
commands: map[args]commandResult{
`path/to/git -c credential.helper= -c credential.helper=!"gh" auth git-credential pull --ff-only origin trunk`: {
ExitStatus: 1,
Stderr: "pull error message",
},
},
wantErrorMsg: "failed to run git: pull error message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr)
cmdCtx := createMockedCommandContext(t, tt.commands)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
err := client.Pull(context.Background(), "origin", "trunk", tt.mods...)
assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
if tt.wantErrorMsg == "" {
assert.NoError(t, err)
require.NoError(t, err)
} else {
assert.EqualError(t, err, tt.wantErrorMsg)
require.EqualError(t, err, tt.wantErrorMsg)
}
})
}
@ -1221,44 +1255,52 @@ func TestClientPull(t *testing.T) {
func TestClientPush(t *testing.T) {
tests := []struct {
name string
mods []CommandModifier
cmdExitStatus int
cmdStdout string
cmdStderr string
wantCmdArgs string
wantErrorMsg string
name string
mods []CommandModifier
commands mockedCommands
wantErrorMsg string
}{
{
name: "push",
wantCmdArgs: `path/to/git -c credential.helper= -c credential.helper=!"gh" auth git-credential push --set-upstream origin trunk`,
name: "push",
commands: map[args]commandResult{
`path/to/git -c credential.helper= -c credential.helper=!"gh" auth git-credential push --set-upstream origin trunk`: {
ExitStatus: 0,
},
},
},
{
name: "accepts command modifiers",
mods: []CommandModifier{WithRepoDir("/path/to/repo")},
wantCmdArgs: `path/to/git -C /path/to/repo -c credential.helper= -c credential.helper=!"gh" auth git-credential push --set-upstream origin trunk`,
name: "accepts command modifiers",
mods: []CommandModifier{WithRepoDir("/path/to/repo")},
commands: map[args]commandResult{
`path/to/git -C /path/to/repo -c credential.helper= -c credential.helper=!"gh" auth git-credential push --set-upstream origin trunk`: {
ExitStatus: 0,
},
},
},
{
name: "git error",
cmdExitStatus: 1,
cmdStderr: "git error message",
wantCmdArgs: `path/to/git -c credential.helper= -c credential.helper=!"gh" auth git-credential push --set-upstream origin trunk`,
wantErrorMsg: "failed to run git: git error message",
name: "git error on push",
commands: map[args]commandResult{
`path/to/git -c credential.helper= -c credential.helper=!"gh" auth git-credential push --set-upstream origin trunk`: {
ExitStatus: 1,
Stderr: "push error message",
},
},
wantErrorMsg: "failed to run git: push error message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr)
cmdCtx := createMockedCommandContext(t, tt.commands)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
err := client.Push(context.Background(), "origin", "trunk", tt.mods...)
assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
if tt.wantErrorMsg == "" {
assert.NoError(t, err)
require.NoError(t, err)
} else {
assert.EqualError(t, err, tt.wantErrorMsg)
require.EqualError(t, err, tt.wantErrorMsg)
}
})
}
@ -1279,14 +1321,14 @@ func TestClientClone(t *testing.T) {
{
name: "clone",
args: []string{},
wantCmdArgs: `path/to/git -c credential.helper= -c credential.helper=!"gh" auth git-credential clone github.com/cli/cli`,
wantCmdArgs: `path/to/git -c credential.https://github.com.helper= -c credential.https://github.com.helper=!"gh" auth git-credential clone https://github.com/cli/cli`,
wantTarget: "cli",
},
{
name: "accepts command modifiers",
args: []string{},
mods: []CommandModifier{WithRepoDir("/path/to/repo")},
wantCmdArgs: `path/to/git -C /path/to/repo -c credential.helper= -c credential.helper=!"gh" auth git-credential clone github.com/cli/cli`,
wantCmdArgs: `path/to/git -C /path/to/repo -c credential.https://github.com.helper= -c credential.https://github.com.helper=!"gh" auth git-credential clone https://github.com/cli/cli`,
wantTarget: "cli",
},
{
@ -1294,19 +1336,19 @@ func TestClientClone(t *testing.T) {
args: []string{},
cmdExitStatus: 1,
cmdStderr: "git error message",
wantCmdArgs: `path/to/git -c credential.helper= -c credential.helper=!"gh" auth git-credential clone github.com/cli/cli`,
wantCmdArgs: `path/to/git -c credential.https://github.com.helper= -c credential.https://github.com.helper=!"gh" auth git-credential clone https://github.com/cli/cli`,
wantErrorMsg: "failed to run git: git error message",
},
{
name: "bare clone",
args: []string{"--bare"},
wantCmdArgs: `path/to/git -c credential.helper= -c credential.helper=!"gh" auth git-credential clone --bare github.com/cli/cli`,
wantCmdArgs: `path/to/git -c credential.https://github.com.helper= -c credential.https://github.com.helper=!"gh" auth git-credential clone --bare https://github.com/cli/cli`,
wantTarget: "cli.git",
},
{
name: "bare clone with explicit target",
args: []string{"cli-bare", "--bare"},
wantCmdArgs: `path/to/git -c credential.helper= -c credential.helper=!"gh" auth git-credential clone --bare github.com/cli/cli cli-bare`,
wantCmdArgs: `path/to/git -c credential.https://github.com.helper= -c credential.https://github.com.helper=!"gh" auth git-credential clone --bare https://github.com/cli/cli cli-bare`,
wantTarget: "cli-bare",
},
}
@ -1317,7 +1359,7 @@ func TestClientClone(t *testing.T) {
GitPath: "path/to/git",
commandContext: cmdCtx,
}
target, err := client.Clone(context.Background(), "github.com/cli/cli", tt.args, tt.mods...)
target, err := client.Clone(context.Background(), "https://github.com/cli/cli", tt.args, tt.mods...)
assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
if tt.wantErrorMsg == "" {
assert.NoError(t, err)
@ -1442,6 +1484,54 @@ func initRepo(t *testing.T, dir string) {
assert.NoError(t, err)
}
type args string
type commandResult struct {
ExitStatus int `json:"exitStatus"`
Stdout string `json:"out"`
Stderr string `json:"err"`
}
type mockedCommands map[args]commandResult
// TestCommandMocking is an invoked test helper that emulates expected behavior for predefined shell commands, erroring when unexpected conditions are encountered.
func TestCommandMocking(t *testing.T) {
if os.Getenv("GH_WANT_HELPER_PROCESS_RICH") != "1" {
return
}
jsonVar, ok := os.LookupEnv("GH_HELPER_PROCESS_RICH_COMMANDS")
if !ok {
fmt.Fprint(os.Stderr, "missing GH_HELPER_PROCESS_RICH_COMMANDS")
os.Exit(1)
}
var commands mockedCommands
if err := json.Unmarshal([]byte(jsonVar), &commands); err != nil {
fmt.Fprint(os.Stderr, "failed to unmarshal GH_HELPER_PROCESS_RICH_COMMANDS")
os.Exit(1)
}
// The discarded args are those for the go test binary itself, e.g. `-test.run=TestHelperProcessRich`
realArgs := os.Args[3:]
commandResult, ok := commands[args(strings.Join(realArgs, " "))]
if !ok {
fmt.Fprintf(os.Stderr, "unexpected command: %s\n", strings.Join(realArgs, " "))
os.Exit(1)
}
if commandResult.Stdout != "" {
fmt.Fprint(os.Stdout, commandResult.Stdout)
}
if commandResult.Stderr != "" {
fmt.Fprint(os.Stderr, commandResult.Stderr)
}
os.Exit(commandResult.ExitStatus)
}
func TestHelperProcess(t *testing.T) {
if os.Getenv("GH_WANT_HELPER_PROCESS") != "1" {
return
@ -1465,6 +1555,65 @@ func TestHelperProcess(t *testing.T) {
os.Exit(0)
}
func TestCredentialPatternFromGitURL(t *testing.T) {
tests := []struct {
name string
gitURL string
wantErr bool
wantCredentialPattern CredentialPattern
}{
{
name: "Given a well formed gitURL, it returns the corresponding CredentialPattern",
gitURL: "https://github.com/OWNER/REPO.git",
wantCredentialPattern: CredentialPattern{
pattern: "https://github.com",
allMatching: false,
},
},
{
name: "Given a malformed gitURL, it returns an error",
// This pattern is copied from the tests in ParseURL
// Unexpectedly, a non URL-like string did not error in ParseURL
gitURL: "ssh://git@[/tmp/git-repo",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
credentialPattern, err := CredentialPatternFromGitURL(tt.gitURL)
if tt.wantErr {
assert.ErrorContains(t, err, "failed to parse remote URL")
} else {
assert.NoError(t, err)
assert.Equal(t, tt.wantCredentialPattern, credentialPattern)
}
})
}
}
func TestCredentialPatternFromHost(t *testing.T) {
tests := []struct {
name string
host string
wantCredentialPattern CredentialPattern
}{
{
name: "Given a well formed host, it returns the corresponding CredentialPattern",
host: "github.com",
wantCredentialPattern: CredentialPattern{
pattern: "https://github.com",
allMatching: false,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
credentialPattern := CredentialPatternFromHost(tt.host)
require.Equal(t, tt.wantCredentialPattern, credentialPattern)
})
}
}
func createCommandContext(t *testing.T, exitStatus int, stdout, stderr string) (*exec.Cmd, commandCtx) {
cmd := exec.CommandContext(context.Background(), os.Args[0], "-test.run=TestHelperProcess", "--")
cmd.Env = []string{
@ -1479,3 +1628,21 @@ func createCommandContext(t *testing.T, exitStatus int, stdout, stderr string) (
return cmd
}
}
func createMockedCommandContext(t *testing.T, commands mockedCommands) commandCtx {
marshaledCommands, err := json.Marshal(commands)
require.NoError(t, err)
// invokes helper within current test binary, emulating desired behavior
return func(ctx context.Context, exe string, args ...string) *exec.Cmd {
cmd := exec.CommandContext(context.Background(), os.Args[0], "-test.run=TestCommandMocking", "--")
cmd.Env = []string{
"GH_WANT_HELPER_PROCESS_RICH=1",
fmt.Sprintf("GH_HELPER_PROCESS_RICH_COMMANDS=%s", string(marshaledCommands)),
}
cmd.Args = append(cmd.Args, exe)
cmd.Args = append(cmd.Args, args...)
return cmd
}
}

View file

@ -71,7 +71,11 @@ type Commit struct {
}
type BranchConfig struct {
// LocalName of the branch.
LocalName string
RemoteName string
RemoteURL *url.URL
MergeRef string
// MergeBase is the optional base branch to target in a new PR if `--base` is not specified.
MergeBase string
MergeRef string
}

20
go.mod
View file

@ -11,14 +11,15 @@ require (
github.com/cenkalti/backoff/v4 v4.3.0
github.com/charmbracelet/glamour v0.7.0
github.com/charmbracelet/lipgloss v0.10.1-0.20240413172830-d0be07ea6b9c
github.com/cli/go-gh/v2 v2.11.0
github.com/cli/go-gh/v2 v2.11.1
github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24
github.com/cli/oauth v1.1.1
github.com/cli/safeexec v1.0.1
github.com/cpuguy83/go-md2man/v2 v2.0.5
github.com/creack/pty v1.1.23
github.com/creack/pty v1.1.24
github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7
github.com/distribution/reference v0.5.0
github.com/gabriel-vasile/mimetype v1.4.6
github.com/gabriel-vasile/mimetype v1.4.7
github.com/gdamore/tcell/v2 v2.5.4
github.com/google/go-cmp v0.6.0
github.com/google/go-containerregistry v0.20.2
@ -44,10 +45,10 @@ require (
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.9.0
github.com/zalando/go-keyring v0.2.5
golang.org/x/crypto v0.28.0
golang.org/x/sync v0.8.0
golang.org/x/term v0.25.0
golang.org/x/text v0.19.0
golang.org/x/crypto v0.31.0
golang.org/x/sync v0.10.0
golang.org/x/term v0.27.0
golang.org/x/text v0.21.0
google.golang.org/grpc v1.64.1
google.golang.org/protobuf v1.34.2
gopkg.in/h2non/gock.v1 v1.1.2
@ -69,7 +70,6 @@ require (
github.com/danieljoos/wincred v1.2.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect
github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect
github.com/dlclark/regexp2 v1.4.0 // indirect
github.com/docker/cli v27.1.1+incompatible // indirect
github.com/docker/distribution v2.8.2+incompatible // indirect
@ -160,8 +160,8 @@ require (
go.uber.org/zap v1.27.0 // indirect
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 // indirect
golang.org/x/mod v0.21.0 // indirect
golang.org/x/net v0.30.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/net v0.31.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/tools v0.26.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240520151616-dc85e6b867a5 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240520151616-dc85e6b867a5 // indirect

36
go.sum
View file

@ -95,8 +95,8 @@ github.com/charmbracelet/x/exp/term v0.0.0-20240425164147-ba2a9512b05f/go.mod h1
github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q=
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
github.com/cli/go-gh/v2 v2.11.0 h1:TERLYMMWderKBO3lBff/JIu2+eSly2oFRgN2WvO+3eA=
github.com/cli/go-gh/v2 v2.11.0/go.mod h1:MeRoKzXff3ygHu7zP+NVTT+imcHW6p3tpuxHAzRM2xE=
github.com/cli/go-gh/v2 v2.11.1 h1:amAyfqMWQTBdue8iTmDUegGZK7c8kk6WCxD9l/wLtGI=
github.com/cli/go-gh/v2 v2.11.1/go.mod h1:MeRoKzXff3ygHu7zP+NVTT+imcHW6p3tpuxHAzRM2xE=
github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24 h1:QDrhR4JA2n3ij9YQN0u5ZeuvRIIvsUGmf5yPlTS0w8E=
github.com/cli/go-internal v0.0.0-20241025142207-6c48bcd5ce24/go.mod h1:rr9GNING0onuVw8MnracQHn7PcchnFlP882Y0II2KZk=
github.com/cli/oauth v1.1.1 h1:459gD3hSjlKX9B1uXBuiAMdpXBUQ9QGf/NDcCpoQxPs=
@ -115,8 +115,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/cyberphone/json-canonicalization v0.0.0-20220623050100-57a0ce2678a7 h1:vU+EP9ZuFUCYE0NYLwTSob+3LNEJATzNfP/DC7SWGWI=
github.com/cyberphone/json-canonicalization v0.0.0-20220623050100-57a0ce2678a7/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw=
github.com/danieljoos/wincred v1.2.1 h1:dl9cBrupW8+r5250DYkYxocLeZ1Y4vB1kxgtjxw8GQs=
@ -149,8 +149,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc=
github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc=
github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/tcell/v2 v2.5.4 h1:TGU4tSjD3sCL788vFNeJnTdzpNKIw1H5dgLnJRQVv/k=
@ -490,8 +490,8 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 h1:hNQpMuAJe5CtcUqCXaWga3FHu+kQvCqcsoVaQgSV60o=
golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@ -500,14 +500,14 @@ golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA=
golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -519,19 +519,19 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220906165534-d0df966e6959/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24=
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View file

@ -8,6 +8,7 @@ import (
"fmt"
"net"
"os"
"regexp"
"strconv"
"strings"
"time"
@ -241,6 +242,9 @@ func (i *invoker) StartSSHServerWithOptions(ctx context.Context, options StartSS
return 0, "", fmt.Errorf("failed to parse SSH server port: %w", err)
}
if !isUsernameValid(response.User) {
return 0, "", fmt.Errorf("invalid username: %s", response.User)
}
return port, response.User, nil
}
@ -300,3 +304,10 @@ func (i *invoker) notifyCodespaceOfClientActivity(ctx context.Context, activity
return nil
}
func isUsernameValid(username string) bool {
// assuming valid usernames are alphanumeric, with these special characters allowed: . _ -
var validUsernamePattern = `^[a-zA-Z0-9_][-.a-zA-Z0-9_]*$`
re := regexp.MustCompile(validUsernamePattern)
return re.MatchString(username)
}

View file

@ -9,7 +9,7 @@ import (
)
const (
gitAuthRE = `-c credential.helper= -c credential.helper=!"[^"]+" auth git-credential `
gitAuthRE = `-c credential(?:\..+)?\.helper= -c credential(?:\..+)?\.helper=!"[^"]+" auth git-credential `
)
type T interface {

View file

@ -0,0 +1,74 @@
package safepaths
import (
"fmt"
"path/filepath"
"strings"
)
// Absolute must be constructed via ParseAbsolute, or other methods in this package.
// The zero value of Absolute will panic when String is called.
type Absolute struct {
path string
}
// ParseAbsolute takes a string path that may be relative and returns
// an Absolute that is guaranteed to be absolute, or an error.
func ParseAbsolute(path string) (Absolute, error) {
path, err := filepath.Abs(path)
if err != nil {
return Absolute{}, fmt.Errorf("failed to get absolute path: %w", err)
}
return Absolute{path: path}, nil
}
// String returns a string representation of the absolute path, or panics
// if the absolute path is empty. This guards against programmer error.
func (a Absolute) String() string {
if a.path == "" {
panic("empty absolute path")
}
return a.path
}
// Join an absolute path with elements to create a new Absolute path, or error.
// A PathTraversalError will be returned if the joined path would traverse outside of
// the base Absolute path. Note that this does not handle symlinks.
func (a Absolute) Join(elem ...string) (Absolute, error) {
joinedAbsolutePath, err := ParseAbsolute(filepath.Join(append([]string{a.path}, elem...)...))
if err != nil {
return Absolute{}, fmt.Errorf("failed to parse joined path: %w", err)
}
isSubpath, err := joinedAbsolutePath.isSubpathOf(a)
if err != nil {
return Absolute{}, err
}
if !isSubpath {
return Absolute{}, PathTraversalError{
Base: a,
Elems: elem,
}
}
return joinedAbsolutePath, nil
}
func (a Absolute) isSubpathOf(dir Absolute) (bool, error) {
relativePath, err := filepath.Rel(dir.path, a.path)
if err != nil {
return false, err
}
return !strings.HasPrefix(relativePath, ".."), nil
}
type PathTraversalError struct {
Base Absolute
Elems []string
}
func (e PathTraversalError) Error() string {
return fmt.Sprintf("joining %s and %s would be a traversal", e.Base, filepath.Join(e.Elems...))
}

View file

@ -0,0 +1,137 @@
package safepaths_test
import (
"fmt"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/cli/cli/v2/internal/safepaths"
"github.com/stretchr/testify/require"
)
func TestParseAbsolutePath(t *testing.T) {
t.Parallel()
absolutePath, err := safepaths.ParseAbsolute("/base")
require.NoError(t, err)
require.Equal(t, filepath.Join(rootDir(), "base"), absolutePath.String())
}
func TestAbsoluteEmptyPathStringPanic(t *testing.T) {
t.Parallel()
absolutePath := safepaths.Absolute{}
require.Panics(t, func() {
_ = absolutePath.String()
})
}
func TestJoin(t *testing.T) {
t.Parallel()
tests := []struct {
name string
base safepaths.Absolute
elems []string
want safepaths.Absolute
wantPathTraversalError bool
}{
{
name: "child of base",
base: mustParseAbsolute("/base"),
elems: []string{"child"},
want: mustParseAbsolute("/base/child"),
},
{
name: "grandchild of base",
base: mustParseAbsolute("/base"),
elems: []string{"child", "grandchild"},
want: mustParseAbsolute("/base/child/grandchild"),
},
{
name: "relative parent of base",
base: mustParseAbsolute("/base"),
elems: []string{".."},
wantPathTraversalError: true,
},
{
name: "relative grandparent of base",
base: mustParseAbsolute("/base"),
elems: []string{"..", ".."},
wantPathTraversalError: true,
},
{
name: "relative current dir",
base: mustParseAbsolute("/base"),
elems: []string{"."},
want: mustParseAbsolute("/base"),
},
{
name: "subpath via relative parent",
base: mustParseAbsolute("/child"),
elems: []string{"..", "child"},
want: mustParseAbsolute("/child"),
},
{
name: "empty string",
base: mustParseAbsolute("/base"),
elems: []string{""},
want: mustParseAbsolute("/base"),
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
joinedPath, err := tt.base.Join(tt.elems...)
if tt.wantPathTraversalError {
var pathTraversalError safepaths.PathTraversalError
require.ErrorAs(t, err, &pathTraversalError)
require.Equal(t, tt.base, pathTraversalError.Base)
require.Equal(t, tt.elems, pathTraversalError.Elems)
return
}
require.NoError(t, err)
require.Equal(t, tt.want, joinedPath)
})
}
}
func TestPathTraversalErrorMessage(t *testing.T) {
t.Parallel()
pathTraversalError := safepaths.PathTraversalError{
Base: mustParseAbsolute("/base"),
Elems: []string{".."},
}
expectedMsg := fmt.Sprintf("joining %s and %s would be a traversal", filepath.Join(rootDir(), "base"), "..")
require.EqualError(t, pathTraversalError, expectedMsg)
}
func mustParseAbsolute(s string) safepaths.Absolute {
t, err := safepaths.ParseAbsolute(s)
if err != nil {
panic(err)
}
return t
}
func rootDir() string {
// Get the current working directory
cwd, err := os.Getwd()
if err != nil {
panic(err)
}
// For Windows, extract the volume and add back the root
if runtime.GOOS == "windows" {
volume := filepath.VolumeName(cwd)
return volume + "\\"
}
// For Unix-based systems, the root is always "/"
return "/"
}

View file

@ -122,14 +122,13 @@ func runDownload(opts *Options) error {
opts.Logger.VerbosePrintf("Downloading trusted metadata for artifact %s\n\n", opts.ArtifactPath)
c := verification.FetchAttestationsConfig{
APIClient: opts.APIClient,
Digest: artifact.DigestWithAlg(),
Limit: opts.Limit,
Owner: opts.Owner,
Repo: opts.Repo,
params := verification.FetchRemoteAttestationsParams{
Digest: artifact.DigestWithAlg(),
Limit: opts.Limit,
Owner: opts.Owner,
Repo: opts.Repo,
}
attestations, err := verification.GetRemoteAttestations(c)
attestations, err := verification.GetRemoteAttestations(opts.APIClient, params)
if err != nil {
if errors.Is(err, api.ErrNoAttestations{}) {
fmt.Fprintf(opts.Logger.IO.Out, "No attestations found for %s\n", opts.ArtifactPath)

View file

@ -6,7 +6,6 @@ import (
"strings"
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
"github.com/cli/cli/v2/pkg/cmd/attestation/verification"
)
type workflow struct {
@ -110,29 +109,3 @@ func getAttestationDetail(tenant string, attr api.Attestation) (AttestationDetai
WorkflowID: predicate.RunDetails.Metadata.InvocationID,
}, nil
}
func getDetailsAsSlice(tenant string, results []*verification.AttestationProcessingResult) ([][]string, error) {
details := make([][]string, len(results))
for i, result := range results {
detail, err := getAttestationDetail(tenant, *result.Attestation)
if err != nil {
return nil, fmt.Errorf("failed to get attestation detail: %v", err)
}
details[i] = []string{detail.RepositoryName, detail.RepositoryID, detail.OrgName, detail.OrgID, detail.WorkflowID}
}
return details, nil
}
func getAttestationDetails(tenant string, results []*verification.AttestationProcessingResult) ([]AttestationDetail, error) {
details := make([]AttestationDetail, len(results))
for i, result := range results {
detail, err := getAttestationDetail(tenant, *result.Attestation)
if err != nil {
return nil, fmt.Errorf("failed to get attestation detail: %v", err)
}
details[i] = detail
}
return details, nil
}

View file

@ -2,17 +2,24 @@ package inspect
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/internal/tableprinter"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
"github.com/cli/cli/v2/pkg/cmd/attestation/artifact"
"github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci"
"github.com/cli/cli/v2/pkg/cmd/attestation/auth"
"github.com/cli/cli/v2/pkg/cmd/attestation/io"
"github.com/cli/cli/v2/pkg/cmd/attestation/verification"
"github.com/cli/cli/v2/pkg/cmdutil"
ghauth "github.com/cli/go-gh/v2/pkg/auth"
"github.com/digitorus/timestamp"
in_toto "github.com/in-toto/attestation/go/v1"
"github.com/sigstore/sigstore-go/pkg/bundle"
"github.com/sigstore/sigstore-go/pkg/fulcio/certificate"
"github.com/sigstore/sigstore-go/pkg/verify"
"github.com/MakeNowJust/heredoc"
"github.com/spf13/cobra"
@ -21,70 +28,64 @@ import (
func NewInspectCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command {
opts := &Options{}
inspectCmd := &cobra.Command{
Use: "inspect [<file path> | oci://<OCI image URI>] --bundle <path-to-bundle>",
Args: cmdutil.ExactArgs(1, "must specify file path or container image URI, as well --bundle"),
Use: "inspect <path-to-sigstore-bundle>",
Args: cmdutil.ExactArgs(1, "must specify bundle file path"),
Hidden: true,
Short: "Inspect a sigstore bundle",
Short: "Inspect a Sigstore bundle",
Long: heredoc.Docf(`
### NOTE: This feature is currently in public preview, and subject to change.
Inspect a Sigstore bundle that has been downloaded to disk. To download bundles
associated with your artifact(s), see the %[1]sgh at download%[1]s command.
Inspect a downloaded Sigstore bundle for a given artifact.
Given a .json or .jsonl file, this command will:
- Extract the bundle's statement and predicate
- Provide a certificate summary, if present, and indicate whether the cert
was issued by GitHub or by Sigstore's Public Good Instance (PGI)
- Check the bundles' "authenticity"
The command requires either:
* a relative path to a local artifact, or
* a container image URI (e.g. %[1]soci://<my-OCI-image-URI>%[1]s)
For our purposes, a bundle is authentic if we have the trusted materials to
verify the included certificate(s), transparency log entries, and signed
timestamps, and if the included signatures match the provided public key.
Note that if you provide an OCI URI for the artifact you must already
be authenticated with a container registry.
This command cannot be used to verify a bundle. To verify a bundle, see the
%[1]sgh at verify%[1]s command.
The command also requires the %[1]s--bundle%[1]s flag, which provides a file
path to a previously downloaded Sigstore bundle. (See also the %[1]sdownload%[1]s
command).
By default, the command will print information about the bundle in a table format.
If the %[1]s--json-result%[1]s flag is provided, the command will print the
information in JSON format.
By default, this command prints a condensed table. To see full results, provide the
%[1]s--format=json%[1]s flag.
`, "`"),
Example: heredoc.Doc(`
# Inspect a Sigstore bundle and print the results in table format
$ gh attestation inspect <my-artifact> --bundle <path-to-bundle>
$ gh attestation inspect <path-to-bundle>
# Inspect a Sigstore bundle and print the results in JSON format
$ gh attestation inspect <my-artifact> --bundle <path-to-bundle> --json-result
# Inspect a Sigsore bundle for an OCI artifact, and print the results in table format
$ gh attestation inspect oci://<my-OCI-image> --bundle <path-to-bundle>
$ gh attestation inspect <path-to-bundle> --format=json
`),
PreRunE: func(cmd *cobra.Command, args []string) error {
// Create a logger for use throughout the inspect command
opts.Logger = io.NewHandler(f.IOStreams)
// set the artifact path
opts.ArtifactPath = args[0]
// set the bundle path
opts.BundlePath = args[0]
// Clean file path options
// opts.Clean()
opts.Clean()
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
opts.OCIClient = oci.NewLiveClient()
// handle tenancy
if opts.Hostname == "" {
opts.Hostname, _ = ghauth.DefaultHost()
}
if err := auth.IsHostSupported(opts.Hostname); err != nil {
err := auth.IsHostSupported(opts.Hostname)
if err != nil {
return err
}
if runF != nil {
return runF(opts)
}
config := verification.SigstoreConfig{
Logger: opts.Logger,
}
// Prepare for tenancy if detected
if ghauth.IsTenancy(opts.Hostname) {
hc, err := f.HttpClient()
if err != nil {
@ -93,20 +94,23 @@ func NewInspectCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
apiClient := api.NewLiveClient(hc, opts.Hostname, opts.Logger)
td, err := apiClient.GetTrustDomain()
if err != nil {
return err
return fmt.Errorf("error getting trust domain, make sure you are authenticated against the host: %w", err)
}
tenant, found := ghinstance.TenantName(opts.Hostname)
_, found := ghinstance.TenantName(opts.Hostname)
if !found {
return fmt.Errorf("Invalid hostname provided: '%s'",
return fmt.Errorf("invalid hostname provided: '%s'",
opts.Hostname)
}
config.TrustDomain = td
opts.Tenant = tenant
}
opts.SigstoreVerifier = verification.NewLiveSigstoreVerifier(config)
if runF != nil {
return runF(opts)
}
if err := runInspect(opts); err != nil {
return fmt.Errorf("Failed to inspect the artifact and bundle: %w", err)
}
@ -114,75 +118,217 @@ func NewInspectCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
},
}
inspectCmd.Flags().StringVarP(&opts.BundlePath, "bundle", "b", "", "Path to bundle on disk, either a single bundle in a JSON file or a JSON lines file with multiple bundles")
inspectCmd.MarkFlagRequired("bundle") //nolint:errcheck
inspectCmd.Flags().StringVarP(&opts.Hostname, "hostname", "", "", "Configure host to use")
cmdutil.StringEnumFlag(inspectCmd, &opts.DigestAlgorithm, "digest-alg", "d", "sha256", []string{"sha256", "sha512"}, "The algorithm used to compute a digest of the artifact")
cmdutil.AddFormatFlags(inspectCmd, &opts.exporter)
return inspectCmd
}
type BundleInspectResult struct {
InspectedBundles []BundleInspection `json:"inspectedBundles"`
}
type BundleInspection struct {
Authentic bool `json:"authentic"`
Certificate CertificateInspection `json:"certificate"`
TransparencyLogEntries []TlogEntryInspection `json:"transparencyLogEntries"`
SignedTimestamps []time.Time `json:"signedTimestamps"`
Statement *in_toto.Statement `json:"statement"`
}
type CertificateInspection struct {
certificate.Summary
NotBefore time.Time `json:"notBefore"`
NotAfter time.Time `json:"notAfter"`
}
type TlogEntryInspection struct {
IntegratedTime time.Time
LogID string
}
func runInspect(opts *Options) error {
artifact, err := artifact.NewDigestedArtifact(opts.OCIClient, opts.ArtifactPath, opts.DigestAlgorithm)
if err != nil {
return fmt.Errorf("failed to digest artifact: %s", err)
}
opts.Logger.Printf("Verifying attestations for the artifact found at %s\n\n", artifact.URL)
attestations, err := verification.GetLocalAttestations(opts.BundlePath)
if err != nil {
return fmt.Errorf("failed to read attestations for subject: %s", artifact.DigestWithAlg())
return fmt.Errorf("failed to read attestations")
}
policy, err := buildPolicy(*artifact)
if err != nil {
return fmt.Errorf("failed to build policy: %v", err)
inspectedBundles := []BundleInspection{}
unsafeSigstorePolicy := verify.NewPolicy(verify.WithoutArtifactUnsafe(), verify.WithoutIdentitiesUnsafe())
for _, a := range attestations {
inspectedBundle := BundleInspection{}
// we ditch the verificationResult to avoid even implying that it is "verified"
// you can't meaningfully "verify" a bundle with such an Unsafe policy!
_, err := opts.SigstoreVerifier.Verify([]*api.Attestation{a}, unsafeSigstorePolicy)
// food for thought for later iterations:
// if the err is present, we keep on going because we want to be able to
// inspect bundles we might not have trusted materials for.
// but maybe we should print the error?
if err == nil {
inspectedBundle.Authentic = true
}
entity := a.Bundle
verificationContent, err := entity.VerificationContent()
if err != nil {
return fmt.Errorf("failed to fetch verification content: %w", err)
}
// summarize cert if present
if leafCert := verificationContent.GetCertificate(); leafCert != nil {
certSummary, err := certificate.SummarizeCertificate(leafCert)
if err != nil {
return fmt.Errorf("failed to summarize certificate: %w", err)
}
inspectedBundle.Certificate = CertificateInspection{
Summary: certSummary,
NotBefore: leafCert.NotBefore,
NotAfter: leafCert.NotAfter,
}
}
// parse the sig content and pop the statement
sigContent, err := entity.SignatureContent()
if err != nil {
return fmt.Errorf("failed to fetch signature content: %w", err)
}
if envelope := sigContent.EnvelopeContent(); envelope != nil {
stmt, err := envelope.Statement()
if err != nil {
return fmt.Errorf("failed to fetch envelope statement: %w", err)
}
inspectedBundle.Statement = stmt
}
// fetch the observer timestamps
tlogTimestamps, err := dumpTlogs(entity)
if err != nil {
return fmt.Errorf("failed to dump tlog: %w", err)
}
inspectedBundle.TransparencyLogEntries = tlogTimestamps
signedTimestamps, err := dumpSignedTimestamps(entity)
if err != nil {
return fmt.Errorf("failed to dump tsa: %w", err)
}
inspectedBundle.SignedTimestamps = signedTimestamps
inspectedBundles = append(inspectedBundles, inspectedBundle)
}
res := opts.SigstoreVerifier.Verify(attestations, policy)
if res.Error != nil {
return fmt.Errorf("at least one attestation failed to verify against Sigstore: %v", res.Error)
}
opts.Logger.VerbosePrint(opts.Logger.ColorScheme.Green(
"Successfully verified all attestations against Sigstore!\n\n",
))
inspectionResult := BundleInspectResult{InspectedBundles: inspectedBundles}
// If the user provides the --format=json flag, print the results in JSON format
if opts.exporter != nil {
details, err := getAttestationDetails(opts.Tenant, res.VerifyResults)
if err != nil {
return fmt.Errorf("failed to get attestation detail: %v", err)
}
// print the results to the terminal as an array of JSON objects
if err = opts.exporter.Write(opts.Logger.IO, details); err != nil {
if err = opts.exporter.Write(opts.Logger.IO, inspectionResult); err != nil {
return fmt.Errorf("failed to write JSON output")
}
return nil
}
// otherwise, print results in a table
details, err := getDetailsAsSlice(opts.Tenant, res.VerifyResults)
if err != nil {
return fmt.Errorf("failed to parse attestation details: %v", err)
}
headers := []string{"Repo Name", "Repo ID", "Org Name", "Org ID", "Workflow ID"}
t := tableprinter.New(opts.Logger.IO, tableprinter.WithHeader(headers...))
for _, row := range details {
for _, field := range row {
t.AddField(field, tableprinter.WithTruncate(nil))
}
t.EndRow()
}
if err = t.Render(); err != nil {
return fmt.Errorf("failed to print output: %v", err)
}
printInspectionSummary(opts.Logger, inspectionResult.InspectedBundles)
return nil
}
func printInspectionSummary(logger *io.Handler, bundles []BundleInspection) {
logger.Printf("Inspecting bundles…\n")
logger.Printf("Found %s:\n---\n", text.Pluralize(len(bundles), "attestation"))
bundleSummaries := make([][][]string, len(bundles))
for i, iB := range bundles {
bundleSummaries[i] = [][]string{
{"Authentic", formatAuthentic(iB.Authentic, iB.Certificate.CertificateIssuer)},
{"Source Repo", formatNwo(iB.Certificate.SourceRepositoryURI)},
{"PredicateType", iB.Statement.GetPredicateType()},
{"SubjectAlternativeName", iB.Certificate.SubjectAlternativeName},
{"RunInvocationURI", iB.Certificate.RunInvocationURI},
{"CertificateNotBefore", iB.Certificate.NotBefore.Format(time.RFC3339)},
}
}
// "SubjectAlternativeName" has 22 chars
maxNameLength := 22
scheme := logger.ColorScheme
for i, bundle := range bundleSummaries {
for _, pair := range bundle {
colName := pair[0]
dots := maxNameLength - len(colName)
logger.OutPrintf("%s:%s %s\n", scheme.Bold(colName), strings.Repeat(".", dots), pair[1])
}
if i < len(bundleSummaries)-1 {
logger.OutPrintln("---")
}
}
}
func formatNwo(longUrl string) string {
repo, err := ghrepo.FromFullName(longUrl)
if err != nil {
return longUrl
}
return ghrepo.FullName(repo)
}
func formatAuthentic(authentic bool, certIssuer string) string {
if strings.HasSuffix(certIssuer, "O=GitHub\\, Inc.") {
certIssuer = "(GitHub)"
} else if strings.HasSuffix(certIssuer, "O=sigstore.dev") {
certIssuer = "(Sigstore PGI)"
} else {
certIssuer = "(Unknown)"
}
return strconv.FormatBool(authentic) + " " + certIssuer
}
func dumpTlogs(entity *bundle.Bundle) ([]TlogEntryInspection, error) {
inspectedTlogEntries := []TlogEntryInspection{}
entries, err := entity.TlogEntries()
if err != nil {
return nil, err
}
for _, entry := range entries {
inspectedEntry := TlogEntryInspection{
IntegratedTime: entry.IntegratedTime(),
LogID: entry.LogKeyID(),
}
inspectedTlogEntries = append(inspectedTlogEntries, inspectedEntry)
}
return inspectedTlogEntries, nil
}
func dumpSignedTimestamps(entity *bundle.Bundle) ([]time.Time, error) {
timestamps := []time.Time{}
signedTimestamps, err := entity.Timestamps()
if err != nil {
return nil, err
}
for _, signedTsBytes := range signedTimestamps {
tsaTime, err := timestamp.ParseResponse(signedTsBytes)
if err != nil {
return nil, err
}
timestamps = append(timestamps, tsaTime.Time)
}
return timestamps, nil
}

View file

@ -27,8 +27,7 @@ const (
)
var (
artifactPath = test.NormalizeRelativePath("../test/data/sigstore-js-2.1.0.tgz")
bundlePath = test.NormalizeRelativePath("../test/data/sigstore-js-2.1.0-bundle.json")
bundlePath = test.NormalizeRelativePath("../test/data/sigstore-js-2.1.0-bundle.json")
)
func TestNewInspectCmd(t *testing.T) {
@ -50,61 +49,11 @@ func TestNewInspectCmd(t *testing.T) {
wantsErr bool
wantsExporter bool
}{
{
name: "Invalid digest-alg flag",
cli: fmt.Sprintf("%s --bundle %s --digest-alg sha384", artifactPath, bundlePath),
wants: Options{
ArtifactPath: artifactPath,
BundlePath: bundlePath,
DigestAlgorithm: "sha384",
OCIClient: oci.MockClient{},
SigstoreVerifier: verification.NewMockSigstoreVerifier(t),
},
wantsErr: true,
},
{
name: "Use default digest-alg value",
cli: fmt.Sprintf("%s --bundle %s", artifactPath, bundlePath),
wants: Options{
ArtifactPath: artifactPath,
BundlePath: bundlePath,
DigestAlgorithm: "sha256",
OCIClient: oci.MockClient{},
SigstoreVerifier: verification.NewMockSigstoreVerifier(t),
},
wantsErr: false,
},
{
name: "Use custom digest-alg value",
cli: fmt.Sprintf("%s --bundle %s --digest-alg sha512", artifactPath, bundlePath),
wants: Options{
ArtifactPath: artifactPath,
BundlePath: bundlePath,
DigestAlgorithm: "sha512",
OCIClient: oci.MockClient{},
SigstoreVerifier: verification.NewMockSigstoreVerifier(t),
},
wantsErr: false,
},
{
name: "Missing bundle flag",
cli: artifactPath,
wants: Options{
ArtifactPath: artifactPath,
DigestAlgorithm: "sha256",
OCIClient: oci.MockClient{},
SigstoreVerifier: verification.NewMockSigstoreVerifier(t),
},
wantsErr: true,
},
{
name: "Prints output in JSON format",
cli: fmt.Sprintf("%s --bundle %s --format json", artifactPath, bundlePath),
cli: fmt.Sprintf("%s --format json", bundlePath),
wants: Options{
ArtifactPath: artifactPath,
BundlePath: bundlePath,
DigestAlgorithm: "sha256",
OCIClient: oci.MockClient{},
SigstoreVerifier: verification.NewMockSigstoreVerifier(t),
},
wantsExporter: true,
@ -131,11 +80,8 @@ func TestNewInspectCmd(t *testing.T) {
}
assert.NoError(t, err)
assert.Equal(t, tc.wants.ArtifactPath, opts.ArtifactPath)
assert.Equal(t, tc.wants.BundlePath, opts.BundlePath)
assert.Equal(t, tc.wants.DigestAlgorithm, opts.DigestAlgorithm)
assert.NotNil(t, opts.Logger)
assert.NotNil(t, opts.OCIClient)
assert.Equal(t, tc.wantsExporter, opts.exporter != nil)
})
}
@ -143,22 +89,20 @@ func TestNewInspectCmd(t *testing.T) {
func TestRunInspect(t *testing.T) {
opts := Options{
ArtifactPath: artifactPath,
BundlePath: bundlePath,
DigestAlgorithm: "sha512",
Logger: io.NewTestHandler(),
OCIClient: oci.MockClient{},
SigstoreVerifier: verification.NewMockSigstoreVerifier(t),
}
t.Run("with valid artifact and bundle", func(t *testing.T) {
require.Nil(t, runInspect(&opts))
})
t.Run("with valid bundle and default output", func(t *testing.T) {
testIO, _, out, _ := iostreams.Test()
opts.Logger = io.NewHandler(testIO)
t.Run("with missing artifact path", func(t *testing.T) {
customOpts := opts
customOpts.ArtifactPath = test.NormalizeRelativePath("../test/data/non-existent-artifact.zip")
require.Error(t, runInspect(&customOpts))
require.Nil(t, runInspect(&opts))
outputStr := string(out.Bytes()[:])
assert.Regexp(t, "PredicateType:......... https://slsa.dev/provenance/v1", outputStr)
})
t.Run("with missing bundle path", func(t *testing.T) {
@ -171,17 +115,17 @@ func TestRunInspect(t *testing.T) {
func TestJSONOutput(t *testing.T) {
testIO, _, out, _ := iostreams.Test()
opts := Options{
ArtifactPath: artifactPath,
BundlePath: bundlePath,
DigestAlgorithm: "sha512",
Logger: io.NewHandler(testIO),
OCIClient: oci.MockClient{},
SigstoreVerifier: verification.NewMockSigstoreVerifier(t),
exporter: cmdutil.NewJSONExporter(),
}
require.Nil(t, runInspect(&opts))
var target []AttestationDetail
var target BundleInspectResult
err := json.Unmarshal(out.Bytes(), &target)
assert.Equal(t, "https://github.com/sigstore/sigstore-js", target.InspectedBundles[0].Certificate.SourceRepositoryURI)
assert.Equal(t, "https://slsa.dev/provenance/v1", target.InspectedBundles[0].Statement.PredicateType)
require.NoError(t, err)
}

View file

@ -1,18 +0,0 @@
package inspect
import (
"github.com/cli/cli/v2/pkg/cmd/attestation/artifact"
"github.com/cli/cli/v2/pkg/cmd/attestation/verification"
sigstoreVerify "github.com/sigstore/sigstore-go/pkg/verify"
)
func buildPolicy(a artifact.DigestedArtifact) (sigstoreVerify.PolicyBuilder, error) {
artifactDigestPolicyOption, err := verification.BuildDigestPolicyOption(a)
if err != nil {
return sigstoreVerify.PolicyBuilder{}, err
}
policy := sigstoreVerify.NewPolicy(artifactDigestPolicyOption, sigstoreVerify.WithoutIdentitiesUnsafe())
return policy, nil
}

View file

@ -37,6 +37,10 @@ func (h *Handler) Printf(f string, v ...interface{}) (int, error) {
return fmt.Fprintf(h.IO.ErrOut, f, v...)
}
func (h *Handler) OutPrintf(f string, v ...interface{}) (int, error) {
return fmt.Fprintf(h.IO.Out, f, v...)
}
// Println writes the arguments to the stderr writer with a newline at the end.
func (h *Handler) Println(v ...interface{}) (int, error) {
if !h.IO.IsStdoutTTY() {
@ -45,6 +49,10 @@ func (h *Handler) Println(v ...interface{}) (int, error) {
return fmt.Fprintln(h.IO.ErrOut, v...)
}
func (h *Handler) OutPrintln(v ...interface{}) (int, error) {
return fmt.Fprintln(h.IO.Out, v...)
}
func (h *Handler) VerbosePrint(msg string) (int, error) {
if !h.debugEnabled || !h.IO.IsStdoutTTY() {
return 0, nil

View file

@ -9,8 +9,8 @@ import (
"path/filepath"
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
"github.com/cli/cli/v2/pkg/cmd/attestation/artifact"
"github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci"
"github.com/google/go-containerregistry/pkg/name"
protobundle "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1"
"github.com/sigstore/sigstore-go/pkg/bundle"
)
@ -20,32 +20,11 @@ const SLSAPredicateV1 = "https://slsa.dev/provenance/v1"
var ErrUnrecognisedBundleExtension = errors.New("bundle file extension not supported, must be json or jsonl")
var ErrEmptyBundleFile = errors.New("provided bundle file is empty")
type FetchAttestationsConfig struct {
APIClient api.Client
BundlePath string
Digest string
Limit int
Owner string
Repo string
OCIClient oci.Client
UseBundleFromRegistry bool
NameRef name.Reference
}
func (c *FetchAttestationsConfig) IsBundleProvided() bool {
return c.BundlePath != ""
}
func GetAttestations(c FetchAttestationsConfig) ([]*api.Attestation, error) {
if c.IsBundleProvided() {
return GetLocalAttestations(c.BundlePath)
}
if c.UseBundleFromRegistry {
return GetOCIAttestations(c)
}
return GetRemoteAttestations(c)
type FetchRemoteAttestationsParams struct {
Digest string
Limit int
Owner string
Repo string
}
// GetLocalAttestations returns a slice of attestations read from a local bundle file.
@ -116,31 +95,31 @@ func loadBundlesFromJSONLinesFile(path string) ([]*api.Attestation, error) {
return attestations, nil
}
func GetRemoteAttestations(c FetchAttestationsConfig) ([]*api.Attestation, error) {
if c.APIClient == nil {
func GetRemoteAttestations(client api.Client, params FetchRemoteAttestationsParams) ([]*api.Attestation, error) {
if client == nil {
return nil, fmt.Errorf("api client must be provided")
}
// check if Repo is set first because if Repo has been set, Owner will be set using the value of Repo.
// If Repo is not set, the field will remain empty. It will not be populated using the value of Owner.
if c.Repo != "" {
attestations, err := c.APIClient.GetByRepoAndDigest(c.Repo, c.Digest, c.Limit)
if params.Repo != "" {
attestations, err := client.GetByRepoAndDigest(params.Repo, params.Digest, params.Limit)
if err != nil {
return nil, fmt.Errorf("failed to fetch attestations from %s: %w", c.Repo, err)
return nil, fmt.Errorf("failed to fetch attestations from %s: %w", params.Repo, err)
}
fetched, err := c.APIClient.FetchAttestationsWithSASURL(attestations)
fetched, err := client.FetchAttestationsWithSASURL(attestations)
if err != nil {
return nil, fmt.Errorf("failed to fetch bundles from SAS URL: %w", err)
}
return fetched, nil
} else if c.Owner != "" {
attestations, err := c.APIClient.GetByOwnerAndDigest(c.Owner, c.Digest, c.Limit)
} else if params.Owner != "" {
attestations, err := client.GetByOwnerAndDigest(params.Owner, params.Digest, params.Limit)
if err != nil {
return nil, fmt.Errorf("failed to fetch attestations from %s: %w", c.Owner, err)
return nil, fmt.Errorf("failed to fetch attestations from %s: %w", params.Owner, err)
}
fetched, err := c.APIClient.FetchAttestationsWithSASURL(attestations)
fetched, err := client.FetchAttestationsWithSASURL(attestations)
if err != nil {
return nil, fmt.Errorf("failed to fetch bundles from SAS URL: %w", err)
}
@ -150,8 +129,8 @@ func GetRemoteAttestations(c FetchAttestationsConfig) ([]*api.Attestation, error
return nil, fmt.Errorf("owner or repo must be provided")
}
func GetOCIAttestations(c FetchAttestationsConfig) ([]*api.Attestation, error) {
attestations, err := c.OCIClient.GetAttestations(c.NameRef, c.Digest)
func GetOCIAttestations(client oci.Client, artifact artifact.DigestedArtifact) ([]*api.Attestation, error) {
attestations, err := client.GetAttestations(artifact.NameRef(), artifact.DigestWithAlg())
if err != nil {
return nil, fmt.Errorf("failed to fetch OCI attestations: %w", err)
}

View file

@ -4,6 +4,8 @@ import (
"errors"
"fmt"
"strings"
"github.com/sigstore/sigstore-go/pkg/fulcio/certificate"
)
var (
@ -11,72 +13,50 @@ var (
GitHubTenantOIDCIssuer = "https://token.actions.%s.ghe.com"
)
func VerifyCertExtensions(results []*AttestationProcessingResult, tenant, owner, repo, issuer string) error {
// VerifyCertExtensions allows us to perform case insensitive comparisons of certificate extensions
func VerifyCertExtensions(results []*AttestationProcessingResult, ec EnforcementCriteria) ([]*AttestationProcessingResult, error) {
if len(results) == 0 {
return errors.New("no attestations proccessing results")
return nil, errors.New("no attestations processing results")
}
var atLeastOneVerified bool
verified := make([]*AttestationProcessingResult, 0, len(results))
var lastErr error
for _, attestation := range results {
if err := verifyCertExtensions(attestation, tenant, owner, repo, issuer); err != nil {
return err
if err := verifyCertExtensions(*attestation.VerificationResult.Signature.Certificate, ec.Certificate); err != nil {
lastErr = err
// move onto the next attestation in the for loop if verification fails
continue
}
atLeastOneVerified = true
// otherwise, add the result to the results slice and increment verifyCount
verified = append(verified, attestation)
}
if atLeastOneVerified {
return nil
} else {
return ErrNoAttestationsVerified
// if we have exited the for loop without verifying any attestations,
// return the last error found
if len(verified) == 0 {
return nil, lastErr
}
return verified, nil
}
func verifyCertExtensions(attestation *AttestationProcessingResult, tenant, owner, repo, issuer string) error {
var want string
if tenant == "" {
want = fmt.Sprintf("https://github.com/%s", owner)
} else {
want = fmt.Sprintf("https://%s.ghe.com/%s", tenant, owner)
}
sourceRepositoryOwnerURI := attestation.VerificationResult.Signature.Certificate.Extensions.SourceRepositoryOwnerURI
if !strings.EqualFold(want, sourceRepositoryOwnerURI) {
return fmt.Errorf("expected SourceRepositoryOwnerURI to be %s, got %s", want, sourceRepositoryOwnerURI)
func verifyCertExtensions(given, expected certificate.Summary) error {
if !strings.EqualFold(expected.SourceRepositoryOwnerURI, given.SourceRepositoryOwnerURI) {
return fmt.Errorf("expected SourceRepositoryOwnerURI to be %s, got %s", expected.SourceRepositoryOwnerURI, given.SourceRepositoryOwnerURI)
}
// if repo is set, check the SourceRepositoryURI field
if repo != "" {
if tenant == "" {
want = fmt.Sprintf("https://github.com/%s", repo)
} else {
want = fmt.Sprintf("https://%s.ghe.com/%s", tenant, repo)
}
sourceRepositoryURI := attestation.VerificationResult.Signature.Certificate.Extensions.SourceRepositoryURI
if !strings.EqualFold(want, sourceRepositoryURI) {
return fmt.Errorf("expected SourceRepositoryURI to be %s, got %s", want, sourceRepositoryURI)
}
// if repo is set, compare the SourceRepositoryURI fields
if expected.SourceRepositoryURI != "" && !strings.EqualFold(expected.SourceRepositoryURI, given.SourceRepositoryURI) {
return fmt.Errorf("expected SourceRepositoryURI to be %s, got %s", expected.SourceRepositoryURI, given.SourceRepositoryURI)
}
// if issuer is anything other than the default, use the user-provided value;
// otherwise, select the appropriate default based on the tenant
if issuer != GitHubOIDCIssuer {
want = issuer
} else {
if tenant != "" {
want = fmt.Sprintf(GitHubTenantOIDCIssuer, tenant)
} else {
want = GitHubOIDCIssuer
}
}
certIssuer := attestation.VerificationResult.Signature.Certificate.Extensions.Issuer
if !strings.EqualFold(want, certIssuer) {
if strings.Index(certIssuer, want+"/") == 0 {
return fmt.Errorf("expected Issuer to be %s, got %s -- if you have a custom OIDC issuer policy for your enterprise, use the --cert-oidc-issuer flag with your expected issuer", want, certIssuer)
} else {
return fmt.Errorf("expected Issuer to be %s, got %s", want, certIssuer)
// compare the OIDC issuers. If not equal, return an error depending
// on if there is a partial match
if !strings.EqualFold(expected.Issuer, given.Issuer) {
if strings.Index(given.Issuer, expected.Issuer+"/") == 0 {
return fmt.Errorf("expected Issuer to be %s, got %s -- if you have a custom OIDC issuer policy for your enterprise, use the --cert-oidc-issuer flag with your expected issuer", expected.Issuer, given.Issuer)
}
return fmt.Errorf("expected Issuer to be %s, got %s", expected.Issuer, given.Issuer)
}
return nil

View file

@ -8,143 +8,90 @@ import (
"github.com/stretchr/testify/require"
)
func createSampleResult() *AttestationProcessingResult {
return &AttestationProcessingResult{
VerificationResult: &verify.VerificationResult{
Signature: &verify.SignatureVerificationResult{
Certificate: &certificate.Summary{
Extensions: certificate.Extensions{
SourceRepositoryOwnerURI: "https://github.com/owner",
SourceRepositoryURI: "https://github.com/owner/repo",
Issuer: "https://token.actions.githubusercontent.com",
},
},
},
},
}
}
func TestVerifyCertExtensions(t *testing.T) {
results := []*AttestationProcessingResult{
{
VerificationResult: &verify.VerificationResult{
Signature: &verify.SignatureVerificationResult{
Certificate: &certificate.Summary{
Extensions: certificate.Extensions{
SourceRepositoryOwnerURI: "https://github.com/owner",
SourceRepositoryURI: "https://github.com/owner/repo",
Issuer: "https://token.actions.githubusercontent.com",
},
},
},
},
},
results := []*AttestationProcessingResult{createSampleResult()}
certSummary := certificate.Summary{}
certSummary.SourceRepositoryOwnerURI = "https://github.com/owner"
certSummary.SourceRepositoryURI = "https://github.com/owner/repo"
certSummary.Issuer = GitHubOIDCIssuer
c := EnforcementCriteria{
Certificate: certSummary,
}
t.Run("VerifyCertExtensions with owner and repo", func(t *testing.T) {
err := VerifyCertExtensions(results, "", "owner", "owner/repo", GitHubOIDCIssuer)
t.Run("passes with one result", func(t *testing.T) {
verified, err := VerifyCertExtensions(results, c)
require.NoError(t, err)
require.Len(t, verified, 1)
})
t.Run("VerifyCertExtensions with owner and repo, but wrong tenant", func(t *testing.T) {
err := VerifyCertExtensions(results, "foo", "owner", "owner/repo", GitHubOIDCIssuer)
require.ErrorContains(t, err, "expected SourceRepositoryOwnerURI to be https://foo.ghe.com/owner, got https://github.com/owner")
})
t.Run("passes with 1/2 valid results", func(t *testing.T) {
twoResults := []*AttestationProcessingResult{createSampleResult(), createSampleResult()}
require.Len(t, twoResults, 2)
twoResults[1].VerificationResult.Signature.Certificate.Extensions.SourceRepositoryOwnerURI = "https://github.com/wrong"
t.Run("VerifyCertExtensions with owner", func(t *testing.T) {
err := VerifyCertExtensions(results, "", "owner", "", GitHubOIDCIssuer)
verified, err := VerifyCertExtensions(twoResults, c)
require.NoError(t, err)
require.Len(t, verified, 1)
})
t.Run("VerifyCertExtensions with wrong owner", func(t *testing.T) {
err := VerifyCertExtensions(results, "", "wrong", "", GitHubOIDCIssuer)
t.Run("fails when all results fail verification", func(t *testing.T) {
twoResults := []*AttestationProcessingResult{createSampleResult(), createSampleResult()}
require.Len(t, twoResults, 2)
twoResults[0].VerificationResult.Signature.Certificate.Extensions.SourceRepositoryOwnerURI = "https://github.com/wrong"
twoResults[1].VerificationResult.Signature.Certificate.Extensions.SourceRepositoryOwnerURI = "https://github.com/wrong"
verified, err := VerifyCertExtensions(twoResults, c)
require.Error(t, err)
require.Nil(t, verified)
})
t.Run("with wrong SourceRepositoryOwnerURI", func(t *testing.T) {
expectedCriteria := c
expectedCriteria.Certificate.SourceRepositoryOwnerURI = "https://github.com/wrong"
verified, err := VerifyCertExtensions(results, expectedCriteria)
require.ErrorContains(t, err, "expected SourceRepositoryOwnerURI to be https://github.com/wrong, got https://github.com/owner")
require.Nil(t, verified)
})
t.Run("VerifyCertExtensions with wrong repo", func(t *testing.T) {
err := VerifyCertExtensions(results, "", "owner", "wrong", GitHubOIDCIssuer)
require.ErrorContains(t, err, "expected SourceRepositoryURI to be https://github.com/wrong, got https://github.com/owner/repo")
t.Run("with wrong SourceRepositoryURI", func(t *testing.T) {
expectedCriteria := c
expectedCriteria.Certificate.SourceRepositoryURI = "https://github.com/foo/wrong"
verified, err := VerifyCertExtensions(results, expectedCriteria)
require.ErrorContains(t, err, "expected SourceRepositoryURI to be https://github.com/foo/wrong, got https://github.com/owner/repo")
require.Nil(t, verified)
})
t.Run("VerifyCertExtensions with wrong issuer", func(t *testing.T) {
err := VerifyCertExtensions(results, "", "owner", "", "wrong")
t.Run("with wrong OIDCIssuer", func(t *testing.T) {
expectedCriteria := c
expectedCriteria.Certificate.Issuer = "wrong"
verified, err := VerifyCertExtensions(results, expectedCriteria)
require.ErrorContains(t, err, "expected Issuer to be wrong, got https://token.actions.githubusercontent.com")
})
}
func TestVerifyCertExtensionsCustomizedIssuer(t *testing.T) {
results := []*AttestationProcessingResult{
{
VerificationResult: &verify.VerificationResult{
Signature: &verify.SignatureVerificationResult{
Certificate: &certificate.Summary{
Extensions: certificate.Extensions{
SourceRepositoryOwnerURI: "https://github.com/owner",
SourceRepositoryURI: "https://github.com/owner/repo",
Issuer: "https://token.actions.githubusercontent.com/foo-bar",
},
},
},
},
},
}
t.Run("VerifyCertExtensions with exact issuer match", func(t *testing.T) {
err := VerifyCertExtensions(results, "", "owner", "owner/repo", "https://token.actions.githubusercontent.com/foo-bar")
require.NoError(t, err)
require.Nil(t, verified)
})
t.Run("VerifyCertExtensions with partial issuer match", func(t *testing.T) {
err := VerifyCertExtensions(results, "", "owner", "owner/repo", "https://token.actions.githubusercontent.com")
t.Run("with partial OIDCIssuer match", func(t *testing.T) {
expectedResults := results
expectedResults[0].VerificationResult.Signature.Certificate.Extensions.Issuer = "https://token.actions.githubusercontent.com/foo-bar"
verified, err := VerifyCertExtensions(expectedResults, c)
require.ErrorContains(t, err, "expected Issuer to be https://token.actions.githubusercontent.com, got https://token.actions.githubusercontent.com/foo-bar -- if you have a custom OIDC issuer")
})
t.Run("VerifyCertExtensions with wrong issuer", func(t *testing.T) {
err := VerifyCertExtensions(results, "", "owner", "", "wrong")
require.ErrorContains(t, err, "expected Issuer to be wrong, got https://token.actions.githubusercontent.com/foo-bar")
})
}
func TestVerifyTenancyCertExtensions(t *testing.T) {
defaultIssuer := GitHubOIDCIssuer
results := []*AttestationProcessingResult{
{
VerificationResult: &verify.VerificationResult{
Signature: &verify.SignatureVerificationResult{
Certificate: &certificate.Summary{
Extensions: certificate.Extensions{
SourceRepositoryOwnerURI: "https://foo.ghe.com/owner",
SourceRepositoryURI: "https://foo.ghe.com/owner/repo",
Issuer: "https://token.actions.foo.ghe.com",
},
},
},
},
},
}
t.Run("VerifyCertExtensions with owner and repo", func(t *testing.T) {
err := VerifyCertExtensions(results, "foo", "owner", "owner/repo", defaultIssuer)
require.NoError(t, err)
})
t.Run("VerifyCertExtensions with owner and repo, no tenant", func(t *testing.T) {
err := VerifyCertExtensions(results, "", "owner", "owner/repo", defaultIssuer)
require.ErrorContains(t, err, "expected SourceRepositoryOwnerURI to be https://github.com/owner, got https://foo.ghe.com/owner")
})
t.Run("VerifyCertExtensions with owner and repo, wrong tenant", func(t *testing.T) {
err := VerifyCertExtensions(results, "bar", "owner", "owner/repo", defaultIssuer)
require.ErrorContains(t, err, "expected SourceRepositoryOwnerURI to be https://bar.ghe.com/owner, got https://foo.ghe.com/owner")
})
t.Run("VerifyCertExtensions with owner", func(t *testing.T) {
err := VerifyCertExtensions(results, "foo", "owner", "", defaultIssuer)
require.NoError(t, err)
})
t.Run("VerifyCertExtensions with wrong owner", func(t *testing.T) {
err := VerifyCertExtensions(results, "foo", "wrong", "", defaultIssuer)
require.ErrorContains(t, err, "expected SourceRepositoryOwnerURI to be https://foo.ghe.com/wrong, got https://foo.ghe.com/owner")
})
t.Run("VerifyCertExtensions with wrong repo", func(t *testing.T) {
err := VerifyCertExtensions(results, "foo", "owner", "wrong", defaultIssuer)
require.ErrorContains(t, err, "expected SourceRepositoryURI to be https://foo.ghe.com/wrong, got https://foo.ghe.com/owner/repo")
})
t.Run("VerifyCertExtensions with correct, non-default issuer", func(t *testing.T) {
err := VerifyCertExtensions(results, "foo", "owner", "owner/repo", "https://token.actions.foo.ghe.com")
require.NoError(t, err)
})
t.Run("VerifyCertExtensions with wrong issuer", func(t *testing.T) {
err := VerifyCertExtensions(results, "foo", "owner", "owner/repo", "wrong")
require.ErrorContains(t, err, "expected Issuer to be wrong, got https://token.actions.foo.ghe.com")
require.Nil(t, verified)
})
}

View file

@ -6,6 +6,7 @@ import (
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
"github.com/cli/cli/v2/pkg/cmd/attestation/test/data"
"github.com/sigstore/sigstore-go/pkg/bundle"
"github.com/sigstore/sigstore-go/pkg/fulcio/certificate"
in_toto "github.com/in-toto/attestation/go/v1"
@ -13,10 +14,15 @@ import (
)
type MockSigstoreVerifier struct {
t *testing.T
t *testing.T
mockResults []*AttestationProcessingResult
}
func (v *MockSigstoreVerifier) Verify(attestations []*api.Attestation, policy verify.PolicyBuilder) *SigstoreResults {
func (v *MockSigstoreVerifier) Verify([]*api.Attestation, verify.PolicyBuilder) ([]*AttestationProcessingResult, error) {
if v.mockResults != nil {
return v.mockResults, nil
}
statement := &in_toto.Statement{}
statement.PredicateType = SLSAPredicateV1
@ -41,19 +47,55 @@ func (v *MockSigstoreVerifier) Verify(attestations []*api.Attestation, policy ve
results := []*AttestationProcessingResult{&result}
return &SigstoreResults{
VerifyResults: results,
}
return results, nil
}
func NewMockSigstoreVerifier(t *testing.T) *MockSigstoreVerifier {
return &MockSigstoreVerifier{t}
result := BuildSigstoreJsMockResult(t)
results := []*AttestationProcessingResult{&result}
return &MockSigstoreVerifier{t, results}
}
func NewMockSigstoreVerifierWithMockResults(t *testing.T, mockResults []*AttestationProcessingResult) *MockSigstoreVerifier {
return &MockSigstoreVerifier{t, mockResults}
}
type FailSigstoreVerifier struct{}
func (v *FailSigstoreVerifier) Verify(attestations []*api.Attestation, policy verify.PolicyBuilder) *SigstoreResults {
return &SigstoreResults{
Error: fmt.Errorf("failed to verify attestations"),
func (v *FailSigstoreVerifier) Verify([]*api.Attestation, verify.PolicyBuilder) ([]*AttestationProcessingResult, error) {
return nil, fmt.Errorf("failed to verify attestations")
}
func BuildMockResult(b *bundle.Bundle, buildSignerURI, sourceRepoOwnerURI, sourceRepoURI, issuer string) AttestationProcessingResult {
statement := &in_toto.Statement{}
statement.PredicateType = SLSAPredicateV1
return AttestationProcessingResult{
Attestation: &api.Attestation{
Bundle: b,
},
VerificationResult: &verify.VerificationResult{
Statement: statement,
Signature: &verify.SignatureVerificationResult{
Certificate: &certificate.Summary{
Extensions: certificate.Extensions{
BuildSignerURI: buildSignerURI,
SourceRepositoryOwnerURI: sourceRepoOwnerURI,
SourceRepositoryURI: sourceRepoURI,
Issuer: issuer,
},
},
},
},
}
}
func BuildSigstoreJsMockResult(t *testing.T) AttestationProcessingResult {
bundle := data.SigstoreBundle(t)
buildSignerURI := "https://github.com/github/example/.github/workflows/release.yml@refs/heads/main"
sourceRepoOwnerURI := "https://github.com/sigstore"
sourceRepoURI := "https://github.com/sigstore/sigstore-js"
issuer := "https://token.actions.githubusercontent.com"
return BuildMockResult(bundle, buildSignerURI, sourceRepoOwnerURI, sourceRepoURI, issuer)
}

View file

@ -2,12 +2,18 @@ package verification
import (
"encoding/hex"
"fmt"
"strings"
"github.com/cli/cli/v2/pkg/cmd/attestation/artifact"
"github.com/sigstore/sigstore-go/pkg/fulcio/certificate"
"github.com/sigstore/sigstore-go/pkg/verify"
)
// represents the GitHub hosted runner in the certificate RunnerEnvironment extension
const GitHubRunner = "github-hosted"
// BuildDigestPolicyOption builds a verify.ArtifactPolicyOption
// from the given artifact digest and digest algorithm
func BuildDigestPolicyOption(a artifact.DigestedArtifact) (verify.ArtifactPolicyOption, error) {
@ -18,3 +24,71 @@ func BuildDigestPolicyOption(a artifact.DigestedArtifact) (verify.ArtifactPolicy
}
return verify.WithArtifactDigest(a.Algorithm(), decoded), nil
}
type EnforcementCriteria struct {
Certificate certificate.Summary
PredicateType string
SANRegex string
SAN string
}
func (c EnforcementCriteria) Valid() error {
if c.Certificate.Issuer == "" {
return fmt.Errorf("Issuer must be set")
}
if c.Certificate.RunnerEnvironment != "" && c.Certificate.RunnerEnvironment != GitHubRunner {
return fmt.Errorf("RunnerEnvironment must be set to either \"\" or %s", GitHubRunner)
}
if c.Certificate.SourceRepositoryOwnerURI == "" {
return fmt.Errorf("SourceRepositoryOwnerURI must be set")
}
if c.PredicateType == "" {
return fmt.Errorf("PredicateType must be set")
}
if c.SANRegex == "" && c.SAN == "" {
return fmt.Errorf("SANRegex or SAN must be set")
}
return nil
}
func (c EnforcementCriteria) BuildPolicyInformation() string {
policyAttr := make([][]string, 0, 6)
policyAttr = appendStr(policyAttr, "- OIDC Issuer must match", c.Certificate.Issuer)
if c.Certificate.RunnerEnvironment == GitHubRunner {
policyAttr = appendStr(policyAttr, "- Action workflow Runner Environment must match ", GitHubRunner)
}
policyAttr = appendStr(policyAttr, "- Source Repository Owner URI must match", c.Certificate.SourceRepositoryOwnerURI)
if c.Certificate.SourceRepositoryURI != "" {
policyAttr = appendStr(policyAttr, "- Source Repository URI must match", c.Certificate.SourceRepositoryURI)
}
policyAttr = appendStr(policyAttr, "- Predicate type must match", c.PredicateType)
if c.SAN != "" {
policyAttr = appendStr(policyAttr, "- Subject Alternative Name must match", c.SAN)
} else if c.SANRegex != "" {
policyAttr = appendStr(policyAttr, "- Subject Alternative Name must match regex", c.SANRegex)
}
maxColLen := 0
for _, attr := range policyAttr {
if len(attr[0]) > maxColLen {
maxColLen = len(attr[0])
}
}
policyInfo := ""
for _, attr := range policyAttr {
dots := strings.Repeat(".", maxColLen-len(attr[0]))
policyInfo += fmt.Sprintf("%s:%s %s\n", attr[0], dots, attr[1])
}
return policyInfo
}
func appendStr(arr [][]string, a, b string) [][]string {
return append(arr, []string{a, b})
}

View file

@ -28,11 +28,6 @@ type AttestationProcessingResult struct {
VerificationResult *verify.VerificationResult `json:"verificationResult"`
}
type SigstoreResults struct {
VerifyResults []*AttestationProcessingResult
Error error
}
type SigstoreConfig struct {
TrustedRoot string
Logger *io.Handler
@ -42,11 +37,15 @@ type SigstoreConfig struct {
}
type SigstoreVerifier interface {
Verify(attestations []*api.Attestation, policy verify.PolicyBuilder) *SigstoreResults
Verify(attestations []*api.Attestation, policy verify.PolicyBuilder) ([]*AttestationProcessingResult, error)
}
type LiveSigstoreVerifier struct {
config SigstoreConfig
TrustedRoot string
Logger *io.Handler
NoPublicGood bool
// If tenancy mode is not used, trust domain is empty
TrustDomain string
}
var ErrNoAttestationsVerified = errors.New("no attestations were verified")
@ -56,108 +55,98 @@ var ErrNoAttestationsVerified = errors.New("no attestations were verified")
// Public Good, GitHub, or a custom trusted root.
func NewLiveSigstoreVerifier(config SigstoreConfig) *LiveSigstoreVerifier {
return &LiveSigstoreVerifier{
config: config,
TrustedRoot: config.TrustedRoot,
Logger: config.Logger,
NoPublicGood: config.NoPublicGood,
TrustDomain: config.TrustDomain,
}
}
func (v *LiveSigstoreVerifier) chooseVerifier(b *bundle.Bundle) (*verify.SignedEntityVerifier, string, error) {
func getBundleIssuer(b *bundle.Bundle) (string, error) {
if !b.MinVersion("0.2") {
return nil, "", fmt.Errorf("unsupported bundle version: %s", b.MediaType)
return "", fmt.Errorf("unsupported bundle version: %s", b.MediaType)
}
verifyContent, err := b.VerificationContent()
if err != nil {
return nil, "", fmt.Errorf("failed to get bundle verification content: %v", err)
return "", fmt.Errorf("failed to get bundle verification content: %v", err)
}
leafCert := verifyContent.GetCertificate()
if leafCert == nil {
return nil, "", fmt.Errorf("leaf cert not found")
return "", fmt.Errorf("leaf cert not found")
}
if len(leafCert.Issuer.Organization) != 1 {
return nil, "", fmt.Errorf("expected the leaf certificate issuer to only have one organization")
return "", fmt.Errorf("expected the leaf certificate issuer to only have one organization")
}
issuer := leafCert.Issuer.Organization[0]
return leafCert.Issuer.Organization[0], nil
}
if v.config.TrustedRoot != "" {
customTrustRoots, err := os.ReadFile(v.config.TrustedRoot)
func (v *LiveSigstoreVerifier) chooseVerifier(issuer string) (*verify.SignedEntityVerifier, error) {
// if no custom trusted root is set, attempt to create a Public Good or
// GitHub Sigstore verifier
if v.TrustedRoot == "" {
switch issuer {
case PublicGoodIssuerOrg:
if v.NoPublicGood {
return nil, fmt.Errorf("detected public good instance but requested verification without public good instance")
}
return newPublicGoodVerifier()
case GitHubIssuerOrg:
return newGitHubVerifier(v.TrustDomain)
default:
return nil, fmt.Errorf("leaf certificate issuer is not recognized")
}
}
customTrustRoots, err := os.ReadFile(v.TrustedRoot)
if err != nil {
return nil, fmt.Errorf("unable to read file %s: %v", v.TrustedRoot, err)
}
reader := bufio.NewReader(bytes.NewReader(customTrustRoots))
var line []byte
var readError error
line, readError = reader.ReadBytes('\n')
for readError == nil {
// Load each trusted root
trustedRoot, err := root.NewTrustedRootFromJSON(line)
if err != nil {
return nil, "", fmt.Errorf("unable to read file %s: %v", v.config.TrustedRoot, err)
return nil, fmt.Errorf("failed to create custom verifier: %v", err)
}
reader := bufio.NewReader(bytes.NewReader(customTrustRoots))
var line []byte
var readError error
line, readError = reader.ReadBytes('\n')
for readError == nil {
// Load each trusted root
trustedRoot, err := root.NewTrustedRootFromJSON(line)
// Compare bundle leafCert issuer with trusted root cert authority
certAuthorities := trustedRoot.FulcioCertificateAuthorities()
for _, certAuthority := range certAuthorities {
lowestCert, err := getLowestCertInChain(&certAuthority)
if err != nil {
return nil, "", fmt.Errorf("failed to create custom verifier: %v", err)
return nil, err
}
// Compare bundle leafCert issuer with trusted root cert authority
certAuthorities := trustedRoot.FulcioCertificateAuthorities()
for _, certAuthority := range certAuthorities {
lowestCert, err := getLowestCertInChain(&certAuthority)
if err != nil {
return nil, "", err
}
if len(lowestCert.Issuer.Organization) == 0 {
continue
}
if lowestCert.Issuer.Organization[0] == issuer {
// Determine what policy to use with this trusted root.
//
// Note that we are *only* inferring the policy with the
// issuer. We *must* use the trusted root provided.
if issuer == PublicGoodIssuerOrg {
if v.config.NoPublicGood {
return nil, "", fmt.Errorf("detected public good instance but requested verification without public good instance")
}
verifier, err := newPublicGoodVerifierWithTrustedRoot(trustedRoot)
if err != nil {
return nil, "", err
}
return verifier, issuer, nil
} else if issuer == GitHubIssuerOrg {
verifier, err := newGitHubVerifierWithTrustedRoot(trustedRoot)
if err != nil {
return nil, "", err
}
return verifier, issuer, nil
} else {
// Make best guess at reasonable policy
customVerifier, err := newCustomVerifier(trustedRoot)
if err != nil {
return nil, "", fmt.Errorf("failed to create custom verifier: %v", err)
}
return customVerifier, issuer, nil
}
}
// if the custom trusted root issuer is not set or doesn't match the given issuer, skip it
if len(lowestCert.Issuer.Organization) == 0 || lowestCert.Issuer.Organization[0] != issuer {
continue
}
// Determine what policy to use with this trusted root.
//
// Note that we are *only* inferring the policy with the
// issuer. We *must* use the trusted root provided.
switch issuer {
case PublicGoodIssuerOrg:
if v.NoPublicGood {
return nil, fmt.Errorf("detected public good instance but requested verification without public good instance")
}
return newPublicGoodVerifierWithTrustedRoot(trustedRoot)
case GitHubIssuerOrg:
return newGitHubVerifierWithTrustedRoot(trustedRoot)
default:
// Make best guess at reasonable policy
return newCustomVerifier(trustedRoot)
}
line, readError = reader.ReadBytes('\n')
}
return nil, "", fmt.Errorf("unable to use provided trusted roots")
line, readError = reader.ReadBytes('\n')
}
if leafCert.Issuer.Organization[0] == PublicGoodIssuerOrg && !v.config.NoPublicGood {
publicGoodVerifier, err := newPublicGoodVerifier()
if err != nil {
return nil, "", fmt.Errorf("failed to create Public Good Sigstore verifier: %v", err)
}
return publicGoodVerifier, issuer, nil
} else if leafCert.Issuer.Organization[0] == GitHubIssuerOrg || v.config.NoPublicGood {
ghVerifier, err := newGitHubVerifier(v.config.TrustDomain)
if err != nil {
return nil, "", fmt.Errorf("failed to create GitHub Sigstore verifier: %v", err)
}
return ghVerifier, issuer, nil
}
return nil, "", fmt.Errorf("leaf certificate issuer is not recognized")
return nil, fmt.Errorf("unable to use provided trusted roots")
}
func getLowestCertInChain(ca *root.CertificateAuthority) (*x509.Certificate, error) {
@ -172,61 +161,73 @@ func getLowestCertInChain(ca *root.CertificateAuthority) (*x509.Certificate, err
return nil, fmt.Errorf("certificate authority had no certificates")
}
func (v *LiveSigstoreVerifier) Verify(attestations []*api.Attestation, policy verify.PolicyBuilder) *SigstoreResults {
// initialize the processing apResults before attempting to verify
// with multiple verifiers
apResults := make([]*AttestationProcessingResult, len(attestations))
for i, att := range attestations {
apr := &AttestationProcessingResult{
Attestation: att,
}
apResults[i] = apr
func (v *LiveSigstoreVerifier) verify(attestation *api.Attestation, policy verify.PolicyBuilder) (*AttestationProcessingResult, error) {
issuer, err := getBundleIssuer(attestation.Bundle)
if err != nil {
return nil, fmt.Errorf("failed to get bundle issuer: %v", err)
}
var atLeastOneVerified bool
// determine which verifier should attempt verification against the bundle
verifier, err := v.chooseVerifier(issuer)
if err != nil {
return nil, fmt.Errorf("failed to find recognized issuer from bundle content: %v", err)
}
totalAttestations := len(attestations)
for i, apr := range apResults {
v.config.Logger.VerbosePrintf("Verifying attestation %d/%d against the configured Sigstore trust roots\n", i+1, totalAttestations)
// determine which verifier should attempt verification against the bundle
verifier, issuer, err := v.chooseVerifier(apr.Attestation.Bundle)
if err != nil {
return &SigstoreResults{
Error: fmt.Errorf("failed to find recognized issuer from bundle content: %v", err),
}
}
v.config.Logger.VerbosePrintf("Attempting verification against issuer \"%s\"\n", issuer)
// attempt to verify the attestation
result, err := verifier.Verify(apr.Attestation.Bundle, policy)
// if verification fails, create the error and exit verification early
if err != nil {
v.config.Logger.VerbosePrint(v.config.Logger.ColorScheme.Redf(
"Failed to verify against issuer \"%s\" \n\n", issuer,
))
return &SigstoreResults{
Error: fmt.Errorf("verifying with issuer \"%s\"", issuer),
}
}
// if verification is successful, add the result
// to the AttestationProcessingResult entry
v.config.Logger.VerbosePrint(v.config.Logger.ColorScheme.Greenf(
"SUCCESS - attestation signature verified with \"%s\"\n", issuer,
v.Logger.VerbosePrintf("Attempting verification against issuer \"%s\"\n", issuer)
// attempt to verify the attestation
result, err := verifier.Verify(attestation.Bundle, policy)
// if verification fails, create the error and exit verification early
if err != nil {
v.Logger.VerbosePrint(v.Logger.ColorScheme.Redf(
"Failed to verify against issuer \"%s\" \n\n", issuer,
))
apr.VerificationResult = result
atLeastOneVerified = true
return nil, fmt.Errorf("verifying with issuer \"%s\"", issuer)
}
if atLeastOneVerified {
return &SigstoreResults{
VerifyResults: apResults,
}
} else {
return &SigstoreResults{Error: ErrNoAttestationsVerified}
// if verification is successful, add the result
// to the AttestationProcessingResult entry
v.Logger.VerbosePrint(v.Logger.ColorScheme.Greenf(
"SUCCESS - attestation signature verified with \"%s\"\n", issuer,
))
return &AttestationProcessingResult{
Attestation: attestation,
VerificationResult: result,
}, nil
}
func (v *LiveSigstoreVerifier) Verify(attestations []*api.Attestation, policy verify.PolicyBuilder) ([]*AttestationProcessingResult, error) {
if len(attestations) == 0 {
return nil, ErrNoAttestationsVerified
}
results := make([]*AttestationProcessingResult, len(attestations))
var verifyCount int
var lastError error
totalAttestations := len(attestations)
for i, a := range attestations {
v.Logger.VerbosePrintf("Verifying attestation %d/%d against the configured Sigstore trust roots\n", i+1, totalAttestations)
apr, err := v.verify(a, policy)
if err != nil {
lastError = err
// move onto the next attestation in the for loop if verification fails
continue
}
// otherwise, add the result to the results slice and increment verifyCount
results[verifyCount] = apr
verifyCount++
}
if verifyCount == 0 {
return nil, lastError
}
// truncate the results slice to only include verified attestations
results = results[:verifyCount]
return results, nil
}
func newCustomVerifier(trustedRoot *root.TrustedRoot) (*verify.SignedEntityVerifier, error) {

View file

@ -52,18 +52,49 @@ func TestLiveSigstoreVerifier(t *testing.T) {
Logger: io.NewTestHandler(),
})
res := verifier.Verify(tc.attestations, publicGoodPolicy(t))
results, err := verifier.Verify(tc.attestations, publicGoodPolicy(t))
if tc.expectErr {
require.Error(t, res.Error, "test case: %s", tc.name)
require.ErrorContains(t, res.Error, tc.errContains, "test case: %s", tc.name)
require.Nil(t, res.VerifyResults, "test case: %s", tc.name)
require.Error(t, err, "test case: %s", tc.name)
require.ErrorContains(t, err, tc.errContains, "test case: %s", tc.name)
require.Nil(t, results, "test case: %s", tc.name)
} else {
require.Equal(t, len(tc.attestations), len(res.VerifyResults), "test case: %s", tc.name)
require.NoError(t, res.Error, "test case: %s", tc.name)
require.Equal(t, len(tc.attestations), len(results), "test case: %s", tc.name)
require.NoError(t, err, "test case: %s", tc.name)
}
}
t.Run("with 2/3 verified attestations", func(t *testing.T) {
verifier := NewLiveSigstoreVerifier(SigstoreConfig{
Logger: io.NewTestHandler(),
})
invalidBundle := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0-bundle-v0.1.json")
attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl")
attestations = append(attestations, invalidBundle[0])
require.Len(t, attestations, 3)
results, err := verifier.Verify(attestations, publicGoodPolicy(t))
require.Len(t, results, 2)
require.NoError(t, err)
})
t.Run("fail with 0/2 verified attestations", func(t *testing.T) {
verifier := NewLiveSigstoreVerifier(SigstoreConfig{
Logger: io.NewTestHandler(),
})
invalidBundle := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0-bundle-v0.1.json")
attestations := getAttestationsFor(t, "../test/data/sigstoreBundle-invalid-signature.json")
attestations = append(attestations, invalidBundle[0])
require.Len(t, attestations, 2)
results, err := verifier.Verify(attestations, publicGoodPolicy(t))
require.Nil(t, results)
require.Error(t, err)
})
t.Run("with GitHub Sigstore artifact", func(t *testing.T) {
githubArtifactPath := test.NormalizeRelativePath("../test/data/github_provenance_demo-0.0.12-py3-none-any.whl")
githubArtifact, err := artifact.NewDigestedArtifact(nil, githubArtifactPath, "sha256")
@ -77,9 +108,9 @@ func TestLiveSigstoreVerifier(t *testing.T) {
Logger: io.NewTestHandler(),
})
res := verifier.Verify(attestations, githubPolicy)
require.Len(t, res.VerifyResults, 1)
require.NoError(t, res.Error)
results, err := verifier.Verify(attestations, githubPolicy)
require.Len(t, results, 1)
require.NoError(t, err)
})
t.Run("with custom trusted root", func(t *testing.T) {
@ -90,9 +121,9 @@ func TestLiveSigstoreVerifier(t *testing.T) {
TrustedRoot: test.NormalizeRelativePath("../test/data/trusted_root.json"),
})
res := verifier.Verify(attestations, publicGoodPolicy(t))
require.Len(t, res.VerifyResults, 2)
require.NoError(t, res.Error)
results, err := verifier.Verify(attestations, publicGoodPolicy(t))
require.Len(t, results, 2)
require.NoError(t, err)
})
}

View file

@ -0,0 +1,73 @@
package verify
import (
"fmt"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
"github.com/cli/cli/v2/pkg/cmd/attestation/artifact"
"github.com/cli/cli/v2/pkg/cmd/attestation/verification"
)
func getAttestations(o *Options, a artifact.DigestedArtifact) ([]*api.Attestation, string, error) {
if o.BundlePath != "" {
attestations, err := verification.GetLocalAttestations(o.BundlePath)
if err != nil {
msg := fmt.Sprintf("✗ Loading attestations from %s failed", a.URL)
return nil, msg, err
}
pluralAttestation := text.Pluralize(len(attestations), "attestation")
msg := fmt.Sprintf("Loaded %s from %s", pluralAttestation, o.BundlePath)
return attestations, msg, nil
}
if o.UseBundleFromRegistry {
attestations, err := verification.GetOCIAttestations(o.OCIClient, a)
if err != nil {
msg := "✗ Loading attestations from OCI registry failed"
return nil, msg, err
}
pluralAttestation := text.Pluralize(len(attestations), "attestation")
msg := fmt.Sprintf("Loaded %s from %s", pluralAttestation, o.ArtifactPath)
return attestations, msg, nil
}
params := verification.FetchRemoteAttestationsParams{
Digest: a.DigestWithAlg(),
Limit: o.Limit,
Owner: o.Owner,
Repo: o.Repo,
}
attestations, err := verification.GetRemoteAttestations(o.APIClient, params)
if err != nil {
msg := "✗ Loading attestations from GitHub API failed"
return nil, msg, err
}
pluralAttestation := text.Pluralize(len(attestations), "attestation")
msg := fmt.Sprintf("Loaded %s from GitHub API", pluralAttestation)
return attestations, msg, nil
}
func verifyAttestations(art artifact.DigestedArtifact, att []*api.Attestation, sgVerifier verification.SigstoreVerifier, ec verification.EnforcementCriteria) ([]*verification.AttestationProcessingResult, string, error) {
sgPolicy, err := buildSigstoreVerifyPolicy(ec, art)
if err != nil {
logMsg := "✗ Failed to build Sigstore verification policy"
return nil, logMsg, err
}
sigstoreVerified, err := sgVerifier.Verify(att, sgPolicy)
if err != nil {
logMsg := "✗ Sigstore verification failed"
return nil, logMsg, err
}
// Verify extensions
certExtVerified, err := verification.VerifyCertExtensions(sigstoreVerified, ec)
if err != nil {
logMsg := "✗ Policy verification failed"
return nil, logMsg, err
}
return certExtVerified, "", nil
}

View file

@ -0,0 +1,117 @@
//go:build integration
package verify
import (
"testing"
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
"github.com/cli/cli/v2/pkg/cmd/attestation/artifact"
"github.com/cli/cli/v2/pkg/cmd/attestation/io"
"github.com/cli/cli/v2/pkg/cmd/attestation/test"
"github.com/cli/cli/v2/pkg/cmd/attestation/verification"
"github.com/sigstore/sigstore-go/pkg/fulcio/certificate"
"github.com/stretchr/testify/require"
)
func getAttestationsFor(t *testing.T, bundlePath string) []*api.Attestation {
t.Helper()
attestations, err := verification.GetLocalAttestations(bundlePath)
require.NoError(t, err)
return attestations
}
func TestVerifyAttestations(t *testing.T) {
sgVerifier := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{
Logger: io.NewTestHandler(),
})
certSummary := certificate.Summary{}
certSummary.SourceRepositoryOwnerURI = "https://github.com/sigstore"
certSummary.SourceRepositoryURI = "https://github.com/sigstore/sigstore-js"
certSummary.Issuer = verification.GitHubOIDCIssuer
ec := verification.EnforcementCriteria{
Certificate: certSummary,
PredicateType: verification.SLSAPredicateV1,
SANRegex: "^https://github.com/sigstore/",
}
require.NoError(t, ec.Valid())
artifactPath := test.NormalizeRelativePath("../test/data/sigstore-js-2.1.0.tgz")
a, err := artifact.NewDigestedArtifact(nil, artifactPath, "sha512")
require.NoError(t, err)
t.Run("all attestations pass verification", func(t *testing.T) {
attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl")
require.Len(t, attestations, 2)
results, errMsg, err := verifyAttestations(*a, attestations, sgVerifier, ec)
require.NoError(t, err)
require.Zero(t, errMsg)
require.Len(t, results, 2)
})
t.Run("passes verification with 2/3 attestations passing Sigstore verification", func(t *testing.T) {
invalidBundle := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0-bundle-v0.1.json")
attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl")
attestations = append(attestations, invalidBundle[0])
require.Len(t, attestations, 3)
results, errMsg, err := verifyAttestations(*a, attestations, sgVerifier, ec)
require.NoError(t, err)
require.Zero(t, errMsg)
require.Len(t, results, 2)
})
t.Run("fails verification when Sigstore verification fails", func(t *testing.T) {
invalidBundle := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0-bundle-v0.1.json")
invalidBundle2 := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0-bundle-v0.1.json")
attestations := append(invalidBundle, invalidBundle2...)
require.Len(t, attestations, 2)
results, errMsg, err := verifyAttestations(*a, attestations, sgVerifier, ec)
require.Error(t, err)
require.Contains(t, errMsg, "✗ Sigstore verification failed")
require.Nil(t, results)
})
t.Run("attestations fail to verify when cert extensions don't match enforcement criteria", func(t *testing.T) {
sgjAttestation := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl")
reusableWorkflowAttestations := getAttestationsFor(t, "../test/data/reusable-workflow-attestation.sigstore.json")
attestations := []*api.Attestation{sgjAttestation[0], reusableWorkflowAttestations[0], sgjAttestation[1]}
require.Len(t, attestations, 3)
rwfResult := verification.BuildMockResult(reusableWorkflowAttestations[0].Bundle, "", "https://github.com/malancas", "", verification.GitHubOIDCIssuer)
sgjResult := verification.BuildSigstoreJsMockResult(t)
mockResults := []*verification.AttestationProcessingResult{&sgjResult, &rwfResult, &sgjResult}
mockSgVerifier := verification.NewMockSigstoreVerifierWithMockResults(t, mockResults)
// we want to test that attestations that pass Sigstore verification but fail
// cert extension verification are filtered out properly in the second step
// in verifyAttestations. By using a mock Sigstore verifier, we can ensure
// that the call to verification.VerifyCertExtensions in verifyAttestations
// is filtering out attestations as expected
results, errMsg, err := verifyAttestations(*a, attestations, mockSgVerifier, ec)
require.NoError(t, err)
require.Zero(t, errMsg)
require.Len(t, results, 2)
for _, result := range results {
require.NotEqual(t, result.Attestation.Bundle, reusableWorkflowAttestations[0].Bundle)
}
})
t.Run("fails verification when cert extension verification fails", func(t *testing.T) {
attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl")
require.Len(t, attestations, 2)
expectedCriteria := ec
expectedCriteria.Certificate.SourceRepositoryOwnerURI = "https://github.com/wrong"
results, errMsg, err := verifyAttestations(*a, attestations, sgVerifier, expectedCriteria)
require.Error(t, err)
require.Contains(t, errMsg, "✗ Policy verification failed")
require.Nil(t, results)
})
}

View file

@ -12,11 +12,7 @@ import (
"github.com/cli/cli/v2/pkg/cmd/attestation/verification"
)
const (
// represents the GitHub hosted runner in the certificate RunnerEnvironment extension
GitHubRunner = "github-hosted"
hostRegex = `^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+.*$`
)
const hostRegex = `^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+.*$`
func expandToGitHubURL(tenant, ownerOrRepo string) string {
if tenant == "" {
@ -25,26 +21,72 @@ func expandToGitHubURL(tenant, ownerOrRepo string) string {
return fmt.Sprintf("(?i)^https://%s.ghe.com/%s/", tenant, ownerOrRepo)
}
func buildSANMatcher(opts *Options) (verify.SubjectAlternativeNameMatcher, error) {
func newEnforcementCriteria(opts *Options) (verification.EnforcementCriteria, error) {
c := verification.EnforcementCriteria{
PredicateType: opts.PredicateType,
}
// Set SANRegex using either the opts.SignerRepo or opts.SignerWorkflow values
if opts.SignerRepo != "" {
signedRepoRegex := expandToGitHubURL(opts.Tenant, opts.SignerRepo)
return verify.NewSANMatcher("", signedRepoRegex)
c.SANRegex = signedRepoRegex
} else if opts.SignerWorkflow != "" {
validatedWorkflowRegex, err := validateSignerWorkflow(opts)
if err != nil {
return verify.SubjectAlternativeNameMatcher{}, err
return verification.EnforcementCriteria{}, err
}
return verify.NewSANMatcher("", validatedWorkflowRegex)
} else if opts.SAN != "" || opts.SANRegex != "" {
return verify.NewSANMatcher(opts.SAN, opts.SANRegex)
c.SANRegex = validatedWorkflowRegex
} else {
// If neither of those values were set, default to the provided SANRegex and SAN values
c.SANRegex = opts.SANRegex
c.SAN = opts.SAN
}
return verify.SubjectAlternativeNameMatcher{}, nil
// if the DenySelfHostedRunner option is set to true, set the
// RunnerEnvironment extension to the GitHub hosted runner value
if opts.DenySelfHostedRunner {
c.Certificate.RunnerEnvironment = verification.GitHubRunner
} else {
// if Certificate.RunnerEnvironment value is set to the empty string
// through the second function argument,
// no certificate matching will happen on the RunnerEnvironment field
c.Certificate.RunnerEnvironment = ""
}
// If the Repo option is provided, set the SourceRepositoryURI extension
if opts.Repo != "" {
// If the Tenant options is also provided, set the SourceRepositoryURI extension
// using the specific URI format
if opts.Tenant != "" {
c.Certificate.SourceRepositoryURI = fmt.Sprintf("https://%s.ghe.com/%s", opts.Tenant, opts.Repo)
} else {
c.Certificate.SourceRepositoryURI = fmt.Sprintf("https://github.com/%s", opts.Repo)
}
}
// If the tenant option is provided, set the SourceRepositoryOwnerURI extension
// using the specific URI format
if opts.Tenant != "" {
c.Certificate.SourceRepositoryOwnerURI = fmt.Sprintf("https://%s.ghe.com/%s", opts.Tenant, opts.Owner)
} else {
c.Certificate.SourceRepositoryOwnerURI = fmt.Sprintf("https://github.com/%s", opts.Owner)
}
// if the tenant is provided and OIDC issuer provided matches the default
// use the tenant-specific issuer
if opts.Tenant != "" && opts.OIDCIssuer == verification.GitHubOIDCIssuer {
c.Certificate.Issuer = fmt.Sprintf(verification.GitHubTenantOIDCIssuer, opts.Tenant)
} else {
// otherwise use the custom OIDC issuer provided as an option
c.Certificate.Issuer = opts.OIDCIssuer
}
return c, nil
}
func buildCertificateIdentityOption(opts *Options, runnerEnv string) (verify.PolicyOption, error) {
sanMatcher, err := buildSANMatcher(opts)
func buildCertificateIdentityOption(c verification.EnforcementCriteria) (verify.PolicyOption, error) {
sanMatcher, err := verify.NewSANMatcher(c.SAN, c.SANRegex)
if err != nil {
return nil, err
}
@ -56,7 +98,7 @@ func buildCertificateIdentityOption(opts *Options, runnerEnv string) (verify.Pol
}
extensions := certificate.Extensions{
RunnerEnvironment: runnerEnv,
RunnerEnvironment: c.Certificate.RunnerEnvironment,
}
certId, err := verify.NewCertificateIdentity(sanMatcher, issuerMatcher, extensions)
@ -67,34 +109,13 @@ func buildCertificateIdentityOption(opts *Options, runnerEnv string) (verify.Pol
return verify.WithCertificateIdentity(certId), nil
}
func buildVerifyCertIdOption(opts *Options) (verify.PolicyOption, error) {
if opts.DenySelfHostedRunner {
withGHRunner, err := buildCertificateIdentityOption(opts, GitHubRunner)
if err != nil {
return nil, err
}
return withGHRunner, nil
}
// if Extensions.RunnerEnvironment value is set to the empty string
// through the second function argument,
// no certificate matching will happen on the RunnerEnvironment field
withAnyRunner, err := buildCertificateIdentityOption(opts, "")
if err != nil {
return nil, err
}
return withAnyRunner, nil
}
func buildVerifyPolicy(opts *Options, a artifact.DigestedArtifact) (verify.PolicyBuilder, error) {
func buildSigstoreVerifyPolicy(c verification.EnforcementCriteria, a artifact.DigestedArtifact) (verify.PolicyBuilder, error) {
artifactDigestPolicyOption, err := verification.BuildDigestPolicyOption(a)
if err != nil {
return verify.PolicyBuilder{}, err
}
certIdOption, err := buildVerifyCertIdOption(opts)
certIdOption, err := buildCertificateIdentityOption(c)
if err != nil {
return verify.PolicyBuilder{}, err
}
@ -103,10 +124,6 @@ func buildVerifyPolicy(opts *Options, a artifact.DigestedArtifact) (verify.Polic
return policy, nil
}
func addSchemeToRegex(s string) string {
return fmt.Sprintf("^https://%s", s)
}
func validateSignerWorkflow(opts *Options) (string, error) {
// we expect a provided workflow argument be in the format [HOST/]/<OWNER>/<REPO>/path/to/workflow.yml
// if the provided workflow does not contain a host, set the host
@ -116,12 +133,14 @@ func validateSignerWorkflow(opts *Options) (string, error) {
}
if match {
return addSchemeToRegex(opts.SignerWorkflow), nil
return fmt.Sprintf("^https://%s", opts.SignerWorkflow), nil
}
// if the provided workflow did not match the expect format
// we move onto creating a signer workflow using the provided host name
if opts.Hostname == "" {
return "", errors.New("unknown host")
}
return addSchemeToRegex(fmt.Sprintf("%s/%s", opts.Hostname, opts.SignerWorkflow)), nil
return fmt.Sprintf("^https://%s/%s", opts.Hostname, opts.SignerWorkflow), nil
}

View file

@ -3,31 +3,162 @@ package verify
import (
"testing"
"github.com/cli/cli/v2/pkg/cmd/attestation/artifact"
"github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci"
"github.com/cli/cli/v2/pkg/cmd/attestation/verification"
"github.com/cli/cli/v2/pkg/cmd/factory"
"github.com/stretchr/testify/require"
)
// This tests that a policy can be built from a valid artifact
// Note that policy use is tested in verify_test.go in this package
func TestBuildPolicy(t *testing.T) {
ociClient := oci.MockClient{}
func TestNewEnforcementCriteria(t *testing.T) {
artifactPath := "../test/data/sigstore-js-2.1.0.tgz"
digestAlg := "sha256"
artifact, err := artifact.NewDigestedArtifact(ociClient, artifactPath, digestAlg)
require.NoError(t, err)
t.Run("sets SANRegex using SignerRepo", func(t *testing.T) {
opts := &Options{
ArtifactPath: artifactPath,
Owner: "foo",
Repo: "foo/bar",
SignerRepo: "foo/bar",
}
opts := &Options{
ArtifactPath: artifactPath,
Owner: "sigstore",
SANRegex: "^https://github.com/sigstore/",
}
c, err := newEnforcementCriteria(opts)
require.NoError(t, err)
require.Equal(t, "(?i)^https://github.com/foo/bar/", c.SANRegex)
require.Zero(t, c.SAN)
})
_, err = buildVerifyPolicy(opts, *artifact)
require.NoError(t, err)
t.Run("sets SANRegex using SignerWorkflow matching host regex", func(t *testing.T) {
opts := &Options{
ArtifactPath: artifactPath,
Owner: "foo",
Repo: "foo/bar",
SignerWorkflow: "foo/bar/.github/workflows/attest.yml",
Hostname: "github.com",
}
c, err := newEnforcementCriteria(opts)
require.NoError(t, err)
require.Equal(t, "^https://github.com/foo/bar/.github/workflows/attest.yml", c.SANRegex)
require.Zero(t, c.SAN)
})
t.Run("sets SANRegex and SAN using SANRegex and SAN", func(t *testing.T) {
opts := &Options{
ArtifactPath: artifactPath,
Owner: "foo",
Repo: "foo/bar",
SAN: "https://github/foo/bar/.github/workflows/attest.yml",
SANRegex: "(?i)^https://github/foo",
}
c, err := newEnforcementCriteria(opts)
require.NoError(t, err)
require.Equal(t, "https://github/foo/bar/.github/workflows/attest.yml", c.SAN)
require.Equal(t, "(?i)^https://github/foo", c.SANRegex)
})
t.Run("sets Extensions.RunnerEnvironment to GitHubRunner value if opts.DenySelfHostedRunner is true", func(t *testing.T) {
opts := &Options{
ArtifactPath: artifactPath,
Owner: "foo",
Repo: "foo/bar",
DenySelfHostedRunner: true,
}
c, err := newEnforcementCriteria(opts)
require.NoError(t, err)
require.Equal(t, verification.GitHubRunner, c.Certificate.RunnerEnvironment)
})
t.Run("sets Extensions.RunnerEnvironment to * value if opts.DenySelfHostedRunner is false", func(t *testing.T) {
opts := &Options{
ArtifactPath: artifactPath,
Owner: "foo",
Repo: "foo/bar",
DenySelfHostedRunner: false,
}
c, err := newEnforcementCriteria(opts)
require.NoError(t, err)
require.Zero(t, c.Certificate.RunnerEnvironment)
})
t.Run("sets Extensions.SourceRepositoryURI using opts.Repo and opts.Tenant", func(t *testing.T) {
opts := &Options{
ArtifactPath: artifactPath,
Owner: "foo",
Repo: "foo/bar",
Tenant: "baz",
}
c, err := newEnforcementCriteria(opts)
require.NoError(t, err)
require.Equal(t, "https://baz.ghe.com/foo/bar", c.Certificate.SourceRepositoryURI)
})
t.Run("sets Extensions.SourceRepositoryURI using opts.Repo", func(t *testing.T) {
opts := &Options{
ArtifactPath: artifactPath,
Owner: "foo",
Repo: "foo/bar",
}
c, err := newEnforcementCriteria(opts)
require.NoError(t, err)
require.Equal(t, "https://github.com/foo/bar", c.Certificate.SourceRepositoryURI)
})
t.Run("sets Extensions.SourceRepositoryOwnerURI using opts.Owner and opts.Tenant", func(t *testing.T) {
opts := &Options{
ArtifactPath: artifactPath,
Owner: "foo",
Repo: "foo/bar",
Tenant: "baz",
}
c, err := newEnforcementCriteria(opts)
require.NoError(t, err)
require.Equal(t, "https://baz.ghe.com/foo", c.Certificate.SourceRepositoryOwnerURI)
})
t.Run("sets Extensions.SourceRepositoryOwnerURI using opts.Owner", func(t *testing.T) {
opts := &Options{
ArtifactPath: artifactPath,
Owner: "foo",
Repo: "foo/bar",
}
c, err := newEnforcementCriteria(opts)
require.NoError(t, err)
require.Equal(t, "https://github.com/foo", c.Certificate.SourceRepositoryOwnerURI)
})
t.Run("sets OIDCIssuer using opts.Tenant", func(t *testing.T) {
opts := &Options{
ArtifactPath: artifactPath,
Owner: "foo",
Repo: "foo/bar",
Tenant: "baz",
OIDCIssuer: verification.GitHubOIDCIssuer,
}
c, err := newEnforcementCriteria(opts)
require.NoError(t, err)
require.Equal(t, "https://token.actions.baz.ghe.com", c.Certificate.Issuer)
})
t.Run("sets OIDCIssuer using opts.OIDCIssuer", func(t *testing.T) {
opts := &Options{
ArtifactPath: artifactPath,
Owner: "foo",
Repo: "foo/bar",
OIDCIssuer: "https://foo.com",
Tenant: "baz",
}
c, err := newEnforcementCriteria(opts)
require.NoError(t, err)
require.Equal(t, "https://foo.com", c.Certificate.Issuer)
})
}
func TestValidateSignerWorkflow(t *testing.T) {

View file

@ -6,7 +6,6 @@ import (
"regexp"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
"github.com/cli/cli/v2/pkg/cmd/attestation/artifact"
"github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci"
@ -203,6 +202,17 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
}
func runVerify(opts *Options) error {
ec, err := newEnforcementCriteria(opts)
if err != nil {
opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to build verification policy"))
return err
}
if err := ec.Valid(); err != nil {
opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Invalid verification policy"))
return err
}
artifact, err := artifact.NewDigestedArtifact(opts.OCIClient, opts.ArtifactPath, opts.DigestAlgorithm)
if err != nil {
opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ Loading digest for %s failed\n"), opts.ArtifactPath)
@ -211,68 +221,34 @@ func runVerify(opts *Options) error {
opts.Logger.Printf("Loaded digest %s for %s\n", artifact.DigestWithAlg(), artifact.URL)
c := verification.FetchAttestationsConfig{
APIClient: opts.APIClient,
BundlePath: opts.BundlePath,
Digest: artifact.DigestWithAlg(),
Limit: opts.Limit,
Owner: opts.Owner,
Repo: opts.Repo,
OCIClient: opts.OCIClient,
UseBundleFromRegistry: opts.UseBundleFromRegistry,
NameRef: artifact.NameRef(),
}
attestations, err := verification.GetAttestations(c)
attestations, logMsg, err := getAttestations(opts, *artifact)
if err != nil {
if ok := errors.Is(err, api.ErrNoAttestations{}); ok {
opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found for subject %s\n"), artifact.DigestWithAlg())
return err
}
if c.IsBundleProvided() {
opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ Loading attestations from %s failed\n"), artifact.URL)
} else if c.UseBundleFromRegistry {
opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Loading attestations from OCI registry failed"))
} else {
opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Loading attestations from GitHub API failed"))
}
// Print the message signifying failure fetching attestations
opts.Logger.Println(opts.Logger.ColorScheme.Red(logMsg))
return err
}
pluralAttestation := text.Pluralize(len(attestations), "attestation")
if c.IsBundleProvided() {
opts.Logger.Printf("Loaded %s from %s\n", pluralAttestation, opts.BundlePath)
} else if c.UseBundleFromRegistry {
opts.Logger.Printf("Loaded %s from %s\n", pluralAttestation, opts.ArtifactPath)
} else {
opts.Logger.Printf("Loaded %s from GitHub API\n", pluralAttestation)
}
// Print the message signifying success fetching attestations
opts.Logger.Println(logMsg)
// Apply predicate type filter to returned attestations
filteredAttestations := verification.FilterAttestations(opts.PredicateType, attestations)
filteredAttestations := verification.FilterAttestations(ec.PredicateType, attestations)
if len(filteredAttestations) == 0 {
opts.Logger.Printf(opts.Logger.ColorScheme.Red("✗ No attestations found with predicate type: %s\n"), opts.PredicateType)
return err
}
attestations = filteredAttestations
policy, err := buildVerifyPolicy(opts, *artifact)
// print information about the policy that will be enforced against attestations
opts.Logger.Println("\nThe following policy criteria will be enforced:")
opts.Logger.Println(ec.BuildPolicyInformation())
verified, errMsg, err := verifyAttestations(*artifact, attestations, opts.SigstoreVerifier, ec)
if err != nil {
opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to build verification policy"))
return err
}
opts.Logger.VerbosePrintf("Verifying attestations with predicate type: %s\n", opts.PredicateType)
sigstoreRes := opts.SigstoreVerifier.Verify(attestations, policy)
if sigstoreRes.Error != nil {
opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Verification failed"))
return sigstoreRes.Error
}
// Verify extensions
if err := verification.VerifyCertExtensions(sigstoreRes.VerifyResults, opts.Tenant, opts.Owner, opts.Repo, opts.OIDCIssuer); err != nil {
opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Verification failed"))
opts.Logger.Println(opts.Logger.ColorScheme.Red(errMsg))
return err
}
@ -281,7 +257,7 @@ func runVerify(opts *Options) error {
// If an exporter is provided with the --json flag, write the results to the terminal in JSON format
if opts.exporter != nil {
// print the results to the terminal as an array of JSON objects
if err = opts.exporter.Write(opts.Logger.IO, sigstoreRes.VerifyResults); err != nil {
if err = opts.exporter.Write(opts.Logger.IO, verified); err != nil {
opts.Logger.Println(opts.Logger.ColorScheme.Red("✗ Failed to write JSON output"))
return err
}
@ -291,7 +267,7 @@ func runVerify(opts *Options) error {
opts.Logger.Printf("%s was attested by:\n", artifact.DigestWithAlg())
// Otherwise print the results to the terminal in a table
tableContent, err := buildTableVerifyContent(opts.Tenant, sigstoreRes.VerifyResults)
tableContent, err := buildTableVerifyContent(opts.Tenant, verified)
if err != nil {
opts.Logger.Println(opts.Logger.ColorScheme.Red("failed to parse results"))
return err

View file

@ -111,6 +111,25 @@ func TestVerifyIntegration(t *testing.T) {
require.Error(t, err)
require.ErrorContains(t, err, "verifying with issuer \"sigstore.dev\"")
})
t.Run("with bundle from OCI registry", func(t *testing.T) {
opts := Options{
APIClient: api.NewLiveClient(hc, host, logger),
ArtifactPath: "oci://ghcr.io/github/artifact-attestations-helm-charts/policy-controller:v0.10.0-github9",
UseBundleFromRegistry: true,
DigestAlgorithm: "sha256",
Logger: logger,
OCIClient: oci.NewLiveClient(),
OIDCIssuer: verification.GitHubOIDCIssuer,
Owner: "github",
PredicateType: verification.SLSAPredicateV1,
SANRegex: "^https://github.com/github/",
SigstoreVerifier: verification.NewLiveSigstoreVerifier(sigstoreConfig),
}
err := runVerify(&opts)
require.NoError(t, err)
})
}
func TestVerifyIntegrationCustomIssuer(t *testing.T) {

View file

@ -84,6 +84,8 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm
Specifying %[1]sssh%[1]s for the git protocol will detect existing SSH keys to upload,
prompting to create and upload a new key if one is not found. This can be skipped with
%[1]s--skip-ssh-key%[1]s flag.
For more information on OAuth scopes, <https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps/>.
`, "`"),
Example: heredoc.Doc(`
# Start interactive setup

View file

@ -56,7 +56,8 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.
Use: "refresh",
Args: cobra.ExactArgs(0),
Short: "Refresh stored authentication credentials",
Long: heredoc.Docf(`Expand or fix the permission scopes for stored credentials for active account.
Long: heredoc.Docf(`
Expand or fix the permission scopes for stored credentials for active account.
The %[1]s--scopes%[1]s flag accepts a comma separated list of scopes you want
your gh credentials to have. If no scopes are provided, the command
@ -72,6 +73,8 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.
If you have multiple accounts in %[1]sgh auth status%[1]s and want to refresh the credentials for an
inactive account, you will have to use %[1]sgh auth switch%[1]s to that account first before using
this command, and then switch back when you are done.
For more information on OAuth scopes, <https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps/>.
`, "`"),
Example: heredoc.Doc(`
$ gh auth refresh --scopes write:org,read:public_key

View file

@ -101,10 +101,7 @@ func TestLogin(t *testing.T) {
// simulate that the public key file has been generated
_ = os.WriteFile(keyFile+".pub", []byte("PUBKEY asdf"), 0600)
})
opts.sshContext = ssh.Context{
ConfigDir: dir,
KeygenExe: "ssh-keygen",
}
opts.sshContext = ssh.NewContextForTests(dir, "ssh-keygen")
},
wantsConfig: map[string]string{
"example.com:user": "monalisa",
@ -112,6 +109,11 @@ func TestLogin(t *testing.T) {
"example.com:git_protocol": "ssh",
},
stderrAssert: func(t *testing.T, opts *LoginOptions, stderr string) {
sshDir, err := opts.sshContext.SshDir()
if err != nil {
t.Errorf("Could not load ssh config dir: %v", err)
}
assert.Equal(t, heredoc.Docf(`
Tip: you can generate a Personal Access Token here https://example.com/settings/tokens
The minimum required scopes are 'repo', 'read:org', 'admin:public_key'.
@ -119,7 +121,7 @@ func TestLogin(t *testing.T) {
Configured git protocol
Uploaded the SSH key to your GitHub account: %s
Logged in as monalisa
`, filepath.Join(opts.sshContext.ConfigDir, "id_ed25519.pub")), stderr)
`, filepath.Join(sshDir, "id_ed25519.pub")), stderr)
},
},
{
@ -179,10 +181,7 @@ func TestLogin(t *testing.T) {
// simulate that the public key file has been generated
_ = os.WriteFile(keyFile+".pub", []byte("PUBKEY asdf"), 0600)
})
opts.sshContext = ssh.Context{
ConfigDir: dir,
KeygenExe: "ssh-keygen",
}
opts.sshContext = ssh.NewContextForTests(dir, "ssh-keygen")
},
wantsConfig: map[string]string{
"example.com:user": "monalisa",
@ -190,6 +189,11 @@ func TestLogin(t *testing.T) {
"example.com:git_protocol": "ssh",
},
stderrAssert: func(t *testing.T, opts *LoginOptions, stderr string) {
sshDir, err := opts.sshContext.SshDir()
if err != nil {
t.Errorf("Could not load ssh config dir: %v", err)
}
assert.Equal(t, heredoc.Docf(`
Tip: you can generate a Personal Access Token here https://example.com/settings/tokens
The minimum required scopes are 'repo', 'read:org', 'admin:public_key'.
@ -197,7 +201,7 @@ func TestLogin(t *testing.T) {
Configured git protocol
Uploaded the SSH key to your GitHub account: %s
Logged in as monalisa
`, filepath.Join(opts.sshContext.ConfigDir, "id_ed25519.pub")), stderr)
`, filepath.Join(sshDir, "id_ed25519.pub")), stderr)
},
},
{

View file

@ -59,10 +59,18 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co
}
cmd := &cobra.Command{
Long: "Open the GitHub repository in the web browser.",
Short: "Open the repository in the browser",
Use: "browse [<number> | <path> | <commit-SHA>]",
Args: cobra.MaximumNArgs(1),
Short: "Open repositories, issues, pull requests, and more in the browser",
Long: heredoc.Doc(`
Transition from the terminal to the web browser to view and interact with:
- Issues
- Pull requests
- Repository content
- Repository home page
- Repository settings
`),
Use: "browse [<number> | <path> | <commit-SHA>]",
Args: cobra.MaximumNArgs(1),
Example: heredoc.Doc(`
$ gh browse
#=> Open the home page of the current repository

View file

@ -35,23 +35,23 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
cmd := &cobra.Command{
Use: "delete [<cache-id>| <cache-key> | --all]",
Short: "Delete GitHub Actions caches",
Long: `
Delete GitHub Actions caches.
Long: heredoc.Docf(`
Delete GitHub Actions caches.
Deletion requires authorization with the "repo" scope.
`,
Deletion requires authorization with the %[1]srepo%[1]s scope.
`, "`"),
Example: heredoc.Doc(`
# Delete a cache by id
$ gh cache delete 1234
# Delete a cache by id
$ gh cache delete 1234
# Delete a cache by key
$ gh cache delete cache-key
# Delete a cache by key
$ gh cache delete cache-key
# Delete a cache by id in a specific repo
$ gh cache delete 1234 --repo cli/cli
# Delete a cache by id in a specific repo
$ gh cache delete 1234 --repo cli/cli
# Delete all caches
$ gh cache delete --all
# Delete all caches
$ gh cache delete --all
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {

View file

@ -106,7 +106,7 @@ func listRun(opts *ListOptions) error {
return fmt.Errorf("%s Failed to get caches: %w", opts.IO.ColorScheme().FailureIcon(), err)
}
if len(result.ActionsCaches) == 0 {
if len(result.ActionsCaches) == 0 && opts.Exporter == nil {
return cmdutil.NewNoResultsError(fmt.Sprintf("No caches found in %s", ghrepo.FullName(repo)))
}

View file

@ -2,6 +2,7 @@ package list
import (
"bytes"
"fmt"
"net/http"
"testing"
"time"
@ -243,7 +244,8 @@ ID KEY SIZE CREATED ACCESSED
wantErrMsg: "No caches found in OWNER/REPO",
},
{
name: "displays no results",
name: "displays no results when there is a tty",
tty: true,
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"),
@ -267,6 +269,48 @@ ID KEY SIZE CREATED ACCESSED
wantErr: true,
wantErrMsg: "X Failed to get caches: HTTP 404 (https://api.github.com/repos/OWNER/REPO/actions/caches?per_page=100)",
},
{
name: "calls the exporter when requested",
opts: ListOptions{
Exporter: &verboseExporter{},
},
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"),
httpmock.JSONResponse(shared.CachePayload{
ActionsCaches: []shared.Cache{
{
Id: 1,
Key: "foo",
CreatedAt: time.Date(2021, 1, 1, 1, 1, 1, 1, time.UTC),
LastAccessedAt: time.Date(2022, 1, 1, 1, 1, 1, 1, time.UTC),
SizeInBytes: 100,
},
},
TotalCount: 1,
}),
)
},
wantErr: false,
wantStdout: "[{CreatedAt:2021-01-01 01:01:01.000000001 +0000 UTC Id:1 Key:foo LastAccessedAt:2022-01-01 01:01:01.000000001 +0000 UTC Ref: SizeInBytes:100 Version:}]",
},
{
name: "calls the exporter even when there are no results",
opts: ListOptions{
Exporter: &verboseExporter{},
},
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/actions/caches"),
httpmock.JSONResponse(shared.CachePayload{
ActionsCaches: []shared.Cache{},
TotalCount: 0,
}),
)
},
wantErr: false,
wantStdout: "[]",
},
}
for _, tt := range tests {
@ -305,6 +349,22 @@ ID KEY SIZE CREATED ACCESSED
}
}
// The verboseExporter just writes data formatted as %+v to stdout.
// This allows for easy assertion on the data provided to the exporter.
type verboseExporter struct{}
func (e *verboseExporter) Fields() []string {
return nil
}
func (e *verboseExporter) Write(io *iostreams.IOStreams, data interface{}) error {
_, err := io.Out.Write([]byte(fmt.Sprintf("%+v", data)))
if err != nil {
return err
}
return nil
}
func Test_humanFileSize(t *testing.T) {
tests := []struct {
name string

View file

@ -198,7 +198,7 @@ func (c codespace) displayName(includeOwner bool) string {
displayName = c.Name
}
description := fmt.Sprintf("%s (%s): %s", c.Repository.FullName, branch, displayName)
description := fmt.Sprintf("%s [%s]: %s", c.Repository.FullName, branch, displayName)
if includeOwner {
description = fmt.Sprintf("%-15s %s", c.Owner.Login, description)

View file

@ -34,7 +34,7 @@ func Test_codespace_displayName(t *testing.T) {
DisplayName: "scuba steve",
},
},
want: "cli/cli (trunk): scuba steve",
want: "cli/cli [trunk]: scuba steve",
},
{
name: "No included name - included gitstatus - no unsaved changes",
@ -50,7 +50,7 @@ func Test_codespace_displayName(t *testing.T) {
DisplayName: "scuba steve",
},
},
want: "cli/cli (trunk): scuba steve",
want: "cli/cli [trunk]: scuba steve",
},
{
name: "No included name - included gitstatus - unsaved changes",
@ -67,7 +67,7 @@ func Test_codespace_displayName(t *testing.T) {
DisplayName: "scuba steve",
},
},
want: "cli/cli (trunk*): scuba steve",
want: "cli/cli [trunk*]: scuba steve",
},
{
name: "Included name - included gitstatus - unsaved changes",
@ -84,7 +84,7 @@ func Test_codespace_displayName(t *testing.T) {
DisplayName: "scuba steve",
},
},
want: "cli/cli (trunk*): scuba steve",
want: "cli/cli [trunk*]: scuba steve",
},
{
name: "Included name - included gitstatus - no unsaved changes",
@ -101,7 +101,7 @@ func Test_codespace_displayName(t *testing.T) {
DisplayName: "scuba steve",
},
},
want: "cli/cli (trunk): scuba steve",
want: "cli/cli [trunk]: scuba steve",
},
{
name: "with includeOwner true, prefixes the codespace owner",
@ -123,7 +123,7 @@ func Test_codespace_displayName(t *testing.T) {
DisplayName: "scuba steve",
},
},
want: "jimmy cli/cli (trunk): scuba steve",
want: "jimmy cli/cli [trunk]: scuba steve",
},
}
for _, tt := range tests {
@ -163,7 +163,7 @@ func Test_formatCodespacesForSelect(t *testing.T) {
},
},
wantCodespacesNames: []string{
"cli/cli (trunk): scuba steve",
"cli/cli [trunk]: scuba steve",
},
},
{
@ -191,8 +191,8 @@ func Test_formatCodespacesForSelect(t *testing.T) {
},
},
wantCodespacesNames: []string{
"cli/cli (trunk): scuba steve",
"cli/cli (trunk): flappy bird",
"cli/cli [trunk]: scuba steve",
"cli/cli [trunk]: flappy bird",
},
},
{
@ -220,8 +220,8 @@ func Test_formatCodespacesForSelect(t *testing.T) {
},
},
wantCodespacesNames: []string{
"cli/cli (trunk): scuba steve",
"cli/cli (feature): flappy bird",
"cli/cli [trunk]: scuba steve",
"cli/cli [feature]: flappy bird",
},
},
{
@ -249,8 +249,8 @@ func Test_formatCodespacesForSelect(t *testing.T) {
},
},
wantCodespacesNames: []string{
"github/cli (trunk): scuba steve",
"cli/cli (trunk): flappy bird",
"github/cli [trunk]: scuba steve",
"cli/cli [trunk]: flappy bird",
},
},
{
@ -279,8 +279,8 @@ func Test_formatCodespacesForSelect(t *testing.T) {
},
},
wantCodespacesNames: []string{
"cli/cli (trunk): scuba steve",
"cli/cli (trunk*): flappy bird",
"cli/cli [trunk]: scuba steve",
"cli/cli [trunk*]: flappy bird",
},
},
}

View file

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/codespaces"
"github.com/cli/cli/v2/internal/codespaces/api"
"github.com/cli/cli/v2/internal/codespaces/portforwarder"
@ -20,9 +21,12 @@ func newRebuildCmd(app *App) *cobra.Command {
rebuildCmd := &cobra.Command{
Use: "rebuild",
Short: "Rebuild a codespace",
Long: `Rebuilding recreates your codespace. Your code and any current changes will be
preserved. Your codespace will be rebuilt using your working directory's
dev container. A full rebuild also removes cached Docker images.`,
Long: heredoc.Doc(`
Rebuilding recreates your codespace.
Your code and any current changes will be preserved. Your codespace will be rebuilt using
your working directory's dev container. A full rebuild also removes cached Docker images.
`),
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return app.Rebuild(cmd.Context(), selector, fullRebuild)

View file

@ -20,7 +20,6 @@ import (
"github.com/cli/cli/v2/internal/codespaces/api"
"github.com/cli/cli/v2/internal/codespaces/portforwarder"
"github.com/cli/cli/v2/internal/codespaces/rpc"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/ssh"
"github.com/cli/safeexec"
@ -336,10 +335,20 @@ func selectSSHKeys(
return nil, false, errors.New("missing value to -i argument")
}
privateKeyPath := args[i+1]
// The --config setup will set the automatic key with -i, but it might not actually be created, so we need to ensure that here
if automaticPrivateKeyPath, _ := automaticPrivateKeyPath(sshContext); automaticPrivateKeyPath == privateKeyPath {
_, err := generateAutomaticSSHKeys(sshContext)
if err != nil {
return nil, false, fmt.Errorf("generating automatic keypair: %w", err)
}
}
// User manually specified an identity file so just trust it is correct
return &ssh.KeyPair{
PrivateKeyPath: args[i+1],
PublicKeyPath: args[i+1] + ".pub",
PrivateKeyPath: privateKeyPath,
PublicKeyPath: privateKeyPath + ".pub",
}, false, nil
}
@ -636,7 +645,8 @@ func (a *App) printOpenSSHConfig(ctx context.Context, opts sshOptions) (err erro
return fmt.Errorf("error formatting template: %w", err)
}
automaticIdentityFilePath, err := automaticPrivateKeyPath()
sshContext := ssh.Context{}
automaticIdentityFilePath, err := automaticPrivateKeyPath(sshContext)
if err != nil {
return fmt.Errorf("error finding .ssh directory: %w", err)
}
@ -683,8 +693,8 @@ func (a *App) printOpenSSHConfig(ctx context.Context, opts sshOptions) (err erro
return status
}
func automaticPrivateKeyPath() (string, error) {
sshDir, err := config.HomeDirPath(".ssh")
func automaticPrivateKeyPath(sshContext ssh.Context) (string, error) {
sshDir, err := sshContext.SshDir()
if err != nil {
return "", err
}

View file

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"path"
"path/filepath"
"strings"
"testing"
@ -68,9 +69,7 @@ func TestGenerateAutomaticSSHKeys(t *testing.T) {
for _, tt := range tests {
dir := t.TempDir()
sshContext := ssh.Context{
ConfigDir: dir,
}
sshContext := ssh.NewContextForTests(dir, "")
for _, file := range tt.existingFiles {
f, err := os.Create(filepath.Join(dir, file))
@ -125,6 +124,10 @@ func TestGenerateAutomaticSSHKeys(t *testing.T) {
}
func TestSelectSSHKeys(t *testing.T) {
// This string will be subsituted in sshArgs for test cases
// This is to work around the temp test ssh dir not being known until the test is executing
substituteSSHDir := "SUB_SSH_DIR"
tests := []struct {
sshDirFiles []string
sshConfigKeys []string
@ -139,7 +142,7 @@ func TestSelectSSHKeys(t *testing.T) {
wantKeyPair: &ssh.KeyPair{PrivateKeyPath: "custom-private-key", PublicKeyPath: "custom-private-key.pub"},
},
{
sshArgs: []string{"-i", automaticPrivateKeyName},
sshArgs: []string{"-i", path.Join(substituteSSHDir, automaticPrivateKeyName)},
wantKeyPair: &ssh.KeyPair{PrivateKeyPath: automaticPrivateKeyName, PublicKeyPath: automaticPrivateKeyName + ".pub"},
},
{
@ -202,7 +205,7 @@ func TestSelectSSHKeys(t *testing.T) {
for _, tt := range tests {
sshDir := t.TempDir()
sshContext := ssh.Context{ConfigDir: sshDir}
sshContext := ssh.NewContextForTests(sshDir, "")
for _, file := range tt.sshDirFiles {
f, err := os.Create(filepath.Join(sshDir, file))
@ -226,7 +229,12 @@ func TestSelectSSHKeys(t *testing.T) {
t.Fatalf("could not write test config %v", err)
}
tt.sshArgs = append([]string{"-F", configPath}, tt.sshArgs...)
var subbedSSHArgs []string
for _, arg := range tt.sshArgs {
subbedSSHArgs = append(subbedSSHArgs, strings.Replace(arg, substituteSSHDir, sshDir, -1))
}
tt.sshArgs = append([]string{"-F", configPath}, subbedSSHArgs...)
gotKeyPair, gotShouldAddArg, err := selectSSHKeys(context.Background(), sshContext, tt.sshArgs, sshOptions{profile: tt.profileOpt})
@ -254,11 +262,24 @@ func TestSelectSSHKeys(t *testing.T) {
}
// Strip the dir (sshDir) from the gotKeyPair paths so that they match wantKeyPair (which doesn't know the directory)
gotKeyPair.PrivateKeyPath = filepath.Base(gotKeyPair.PrivateKeyPath)
gotKeyPair.PublicKeyPath = filepath.Base(gotKeyPair.PublicKeyPath)
gotKeyPairJustFileNames := &ssh.KeyPair{
PrivateKeyPath: filepath.Base(gotKeyPair.PrivateKeyPath),
PublicKeyPath: filepath.Base(gotKeyPair.PublicKeyPath),
}
if fmt.Sprintf("%v", gotKeyPair) != fmt.Sprintf("%v", tt.wantKeyPair) {
t.Errorf("Want selectSSHKeys result to be %v, got %v", tt.wantKeyPair, gotKeyPair)
if fmt.Sprintf("%v", gotKeyPairJustFileNames) != fmt.Sprintf("%v", tt.wantKeyPair) {
t.Errorf("Want selectSSHKeys result to be %v, got %v", tt.wantKeyPair, gotKeyPairJustFileNames)
}
// If the automatic key pair is selected, it needs to exist no matter what
if strings.Contains(tt.wantKeyPair.PrivateKeyPath, automaticPrivateKeyName) {
if _, err := os.Stat(gotKeyPair.PrivateKeyPath); err != nil {
t.Errorf("Expected automatic key pair private key to exist, but it did not")
}
if _, err := os.Stat(gotKeyPair.PublicKeyPath); err != nil {
t.Errorf("Expected automatic key pair public key to exist, but it did not")
}
}
}
}

View file

@ -293,19 +293,35 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
Use: "install <repository>",
Short: "Install a gh extension from a repository",
Long: heredoc.Docf(`
Install a GitHub repository locally as a GitHub CLI extension.
Install a GitHub CLI extension from a GitHub or local repository.
The repository argument can be specified in %[1]sOWNER/REPO%[1]s format as well as a full URL.
For GitHub repositories, the repository argument can be specified in %[1]sOWNER/REPO%[1]s format or as a full repository URL.
The URL format is useful when the repository is not hosted on github.com.
To install an extension in development from the current directory, use %[1]s.%[1]s as the
value of the repository argument.
For local repositories, often used while developing extensions, use %[1]s.%[1]s as the
value of the repository argument. Note the following:
- After installing an extension from a locally cloned repository, the GitHub CLI will
manage this extension as a symbolic link (or equivalent mechanism on Windows) pointing
to an executable file with the same name as the repository in the repository's root.
For example, if the repository is named %[1]sgh-foobar%[1]s, the symbolic link will point
to %[1]sgh-foobar%[1]s in the extension repository's root.
- When executing the extension, the GitHub CLI will run the executable file found
by following the symbolic link. If no executable file is found, the extension
will fail to execute.
- If the extension is precompiled, the executable file must be built manually and placed
in the repository's root.
For the list of available extensions, see <https://github.com/topics/gh-extension>.
`, "`"),
Example: heredoc.Doc(`
# Install an extension from a remote repository hosted on GitHub
$ gh extension install owner/gh-extension
$ gh extension install https://git.example.com/owner/gh-extension
# Install an extension from a remote repository via full URL
$ gh extension install https://my.ghes.com/owner/gh-extension
# Install an extension from a local repository in the current working directory
$ gh extension install .
`),
Args: cmdutil.MinimumArgs(1, "must specify a repository to install from"),
@ -322,7 +338,17 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
if err != nil {
return err
}
return m.InstallLocal(wd)
err = m.InstallLocal(wd)
var ErrExtensionExecutableNotFound *ErrExtensionExecutableNotFound
if errors.As(err, &ErrExtensionExecutableNotFound) {
cs := io.ColorScheme()
if io.IsStdoutTTY() {
fmt.Fprintf(io.ErrOut, "%s %s", cs.WarningIcon(), ErrExtensionExecutableNotFound.Error())
}
return nil
}
return err
}
repo, err := ghrepo.FromFullName(args[0])

View file

@ -286,6 +286,44 @@ func TestNewCmdExtension(t *testing.T) {
}
},
},
{
name: "installing local extension without executable with TTY shows warning",
args: []string{"install", "."},
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
em.InstallLocalFunc = func(dir string) error {
return &ErrExtensionExecutableNotFound{
Dir: tempDir,
Name: "gh-test",
}
}
em.ListFunc = func() []extensions.Extension {
return []extensions.Extension{}
}
return nil
},
wantStderr: fmt.Sprintf("! an extension has been installed but there is no executable: executable file named \"%s\" in %s is required to run the extension after install. Perhaps you need to build it?\n", "gh-test", tempDir),
wantErr: false,
isTTY: true,
},
{
name: "install local extension without executable with no TTY shows no warning",
args: []string{"install", "."},
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
em.InstallLocalFunc = func(dir string) error {
return &ErrExtensionExecutableNotFound{
Dir: tempDir,
Name: "gh-test",
}
}
em.ListFunc = func() []extensions.Extension {
return []extensions.Extension{}
}
return nil
},
wantStderr: "",
wantErr: false,
isTTY: false,
},
{
name: "error extension not found",
args: []string{"install", "owner/gh-some-ext"},

View file

@ -29,6 +29,15 @@ import (
// ErrInitialCommitFailed indicates the initial commit when making a new extension failed.
var ErrInitialCommitFailed = errors.New("initial commit failed")
type ErrExtensionExecutableNotFound struct {
Dir string
Name string
}
func (e *ErrExtensionExecutableNotFound) Error() string {
return fmt.Sprintf("an extension has been installed but there is no executable: executable file named \"%s\" in %s is required to run the extension after install. Perhaps you need to build it?\n", e.Name, e.Dir)
}
const darwinAmd64 = "darwin-amd64"
type Manager struct {
@ -194,10 +203,28 @@ func (m *Manager) populateLatestVersions(exts []*Extension) {
func (m *Manager) InstallLocal(dir string) error {
name := filepath.Base(dir)
targetLink := filepath.Join(m.installDir(), name)
if err := os.MkdirAll(filepath.Dir(targetLink), 0755); err != nil {
return err
}
return makeSymlink(dir, targetLink)
if err := makeSymlink(dir, targetLink); err != nil {
return err
}
// Check if an executable of the same name exists in the target directory.
// An error here doesn't indicate a failed extension installation, but
// it does indicate that the user will not be able to run the extension until
// the executable file is built or created manually somehow.
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
if os.IsNotExist(err) {
return &ErrExtensionExecutableNotFound{
Dir: dir,
Name: name,
}
}
return err
}
return nil
}
type binManifest struct {

View file

@ -719,6 +719,63 @@ func TestManager_UpgradeExtension_GitExtension_Pinned(t *testing.T) {
gcOne.AssertExpectations(t)
}
func TestManager_Install_local(t *testing.T) {
extManagerDir := t.TempDir()
ios, _, stdout, stderr := iostreams.Test()
m := newTestManager(extManagerDir, nil, nil, ios)
fakeExtensionName := "local-ext"
// Create a temporary directory to simulate the local extension repo
extensionLocalPath := filepath.Join(extManagerDir, fakeExtensionName)
require.NoError(t, os.MkdirAll(extensionLocalPath, 0755))
// Create a fake executable in the local extension directory
fakeExtensionExecutablePath := filepath.Join(extensionLocalPath, fakeExtensionName)
require.NoError(t, stubExtension(fakeExtensionExecutablePath))
err := m.InstallLocal(extensionLocalPath)
require.NoError(t, err)
// This is the path to a file:
// on windows this is a file whose contents is a string describing the path to the local extension dir.
// on other platforms this file is a real symlink to the local extension dir.
extensionLinkFile := filepath.Join(extManagerDir, "extensions", fakeExtensionName)
if runtime.GOOS == "windows" {
// We don't create true symlinks on Windows, so check if we made a
// file with the correct contents to produce the symlink-like behavior
b, err := os.ReadFile(extensionLinkFile)
require.NoError(t, err)
assert.Equal(t, extensionLocalPath, string(b))
} else {
// Verify the created symlink points to the correct directory
linkTarget, err := os.Readlink(extensionLinkFile)
require.NoError(t, err)
assert.Equal(t, extensionLocalPath, linkTarget)
}
assert.Equal(t, "", stdout.String())
assert.Equal(t, "", stderr.String())
}
func TestManager_Install_local_no_executable_found(t *testing.T) {
tempDir := t.TempDir()
ios, _, stdout, stderr := iostreams.Test()
m := newTestManager(tempDir, nil, nil, ios)
fakeExtensionName := "local-ext"
// Create a temporary directory to simulate the local extension repo
localDir := filepath.Join(tempDir, fakeExtensionName)
require.NoError(t, os.MkdirAll(localDir, 0755))
// Intentionally not creating an executable in the local extension repo
// to simulate an attempt to install a local extension without an executable
err := m.InstallLocal(localDir)
require.ErrorAs(t, err, new(*ErrExtensionExecutableNotFound))
assert.Equal(t, "", stdout.String())
assert.Equal(t, "", stderr.String())
}
func TestManager_Install_git(t *testing.T) {
tempDir := t.TempDir()

View file

@ -44,6 +44,13 @@ func NewCmdDevelop(f *cmdutil.Factory, runF func(*DevelopOptions) error) *cobra.
cmd := &cobra.Command{
Use: "develop {<number> | <url>}",
Short: "Manage linked branches for an issue",
Long: heredoc.Docf(`
Manage linked branches for an issue.
When using the %[1]s--base%[1]s flag, the new development branch will be created from the specified
remote branch. The new branch will be configured as the base branch for pull requests created using
%[1]sgh pr create%[1]s.
`, "`"),
Example: heredoc.Doc(`
# List branches for issue 123
$ gh issue develop --list 123
@ -171,6 +178,14 @@ func developRunCreate(opts *DevelopOptions, apiClient *api.Client, issueRepo ghr
return err
}
// Remember which branch to target when creating a PR.
if opts.BaseBranch != "" {
err = opts.GitClient.SetBranchConfig(ctx.Background(), branchName, git.MergeBaseConfig, opts.BaseBranch)
if err != nil {
return err
}
}
fmt.Fprintf(opts.IO.Out, "%s/%s/tree/%s\n", branchRepo.RepoHost(), ghrepo.FullName(branchRepo), branchName)
return checkoutBranch(opts, branchRepo, branchName)

View file

@ -249,7 +249,7 @@ func TestDevelopRun(t *testing.T) {
expectedOut: heredoc.Doc(`
Showing linked branches for OWNER/REPO#42
BRANCH URL
foo https://github.com/OWNER/REPO/tree/foo
bar https://github.com/OWNER/OTHER-REPO/tree/bar
@ -399,6 +399,7 @@ func TestDevelopRun(t *testing.T) {
},
runStubs: func(cs *run.CommandStubber) {
cs.Register(`git fetch origin \+refs/heads/my-branch:refs/remotes/origin/my-branch`, 0, "")
cs.Register(`git config branch\.my-branch\.gh-merge-base main`, 0, "")
},
expectedOut: "github.com/OWNER/REPO/tree/my-branch\n",
},

View file

@ -130,7 +130,9 @@ func checkoutRun(opts *CheckoutOptions) error {
cmdQueue = append(cmdQueue, []string{"submodule", "update", "--init", "--recursive"})
}
err = executeCmds(opts.GitClient, cmdQueue)
// Note that although we will probably be fetching from the head, in practice, PR checkout can only
// ever point to one host, and we know baseRepo must be populated.
err = executeCmds(opts.GitClient, git.CredentialPatternFromHost(baseRepo.RepoHost()), cmdQueue)
if err != nil {
return err
}
@ -240,13 +242,16 @@ func localBranchExists(client *git.Client, b string) bool {
return err == nil
}
func executeCmds(client *git.Client, cmdQueue [][]string) error {
func executeCmds(client *git.Client, credentialPattern git.CredentialPattern, cmdQueue [][]string) error {
for _, args := range cmdQueue {
var err error
var cmd *git.Command
if args[0] == "fetch" || args[0] == "submodule" {
cmd, err = client.AuthenticatedCommand(context.Background(), args...)
} else {
switch args[0] {
case "submodule":
cmd, err = client.AuthenticatedCommand(context.Background(), credentialPattern, args...)
case "fetch":
cmd, err = client.AuthenticatedCommand(context.Background(), git.AllMatchingCredentialsPattern, args...)
default:
cmd, err = client.Command(context.Background(), args...)
}
if err != nil {

View file

@ -72,6 +72,31 @@ func Test_checkoutRun(t *testing.T) {
wantStderr string
wantErr bool
}{
{
name: "checkout with ssh remote URL",
opts: &CheckoutOptions{
SelectorArg: "123",
Finder: func() shared.PRFinder {
baseRepo, pr := stubPR("OWNER/REPO:master", "OWNER/REPO:feature")
finder := shared.NewMockFinder("123", pr, baseRepo)
return finder
}(),
Config: func() (gh.Config, error) {
return config.NewBlankConfig(), nil
},
Branch: func() (string, error) {
return "main", nil
},
},
remotes: map[string]string{
"origin": "OWNER/REPO",
},
runStubs: func(cs *run.CommandStubber) {
cs.Register(`git show-ref --verify -- refs/heads/feature`, 1, "")
cs.Register(`git fetch origin \+refs/heads/feature:refs/remotes/origin/feature`, 0, "")
cs.Register(`git checkout -b feature --track origin/feature`, 0, "")
},
},
{
name: "fork repo was deleted",
opts: &CheckoutOptions{
@ -295,7 +320,6 @@ func TestPRCheckout_existingBranch(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git fetch origin \+refs/heads/feature:refs/remotes/origin/feature`, 0, "")
cs.Register(`git show-ref --verify -- refs/heads/feature`, 0, "")
cs.Register(`git checkout feature`, 0, "")
@ -328,7 +352,6 @@ func TestPRCheckout_differentRepo_remoteExists(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git fetch robot-fork \+refs/heads/feature:refs/remotes/robot-fork/feature`, 0, "")
cs.Register(`git show-ref --verify -- refs/heads/feature`, 1, "")
cs.Register(`git checkout -b feature --track robot-fork/feature`, 0, "")
@ -349,7 +372,6 @@ func TestPRCheckout_differentRepo(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git fetch origin refs/pull/123/head:feature`, 0, "")
cs.Register(`git config branch\.feature\.merge`, 1, "")
cs.Register(`git checkout feature`, 0, "")
@ -372,7 +394,6 @@ func TestPRCheckout_differentRepo_existingBranch(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git fetch origin refs/pull/123/head:feature`, 0, "")
cs.Register(`git config branch\.feature\.merge`, 0, "refs/heads/feature\n")
cs.Register(`git checkout feature`, 0, "")
@ -392,7 +413,6 @@ func TestPRCheckout_detachedHead(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git fetch origin refs/pull/123/head:feature`, 0, "")
cs.Register(`git config branch\.feature\.merge`, 0, "refs/heads/feature\n")
cs.Register(`git checkout feature`, 0, "")
@ -412,7 +432,6 @@ func TestPRCheckout_differentRepo_currentBranch(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git fetch origin refs/pull/123/head`, 0, "")
cs.Register(`git config branch\.feature\.merge`, 0, "refs/heads/feature\n")
cs.Register(`git merge --ff-only FETCH_HEAD`, 0, "")
@ -432,7 +451,6 @@ func TestPRCheckout_differentRepo_invalidBranchName(t *testing.T) {
_, cmdTeardown := run.Stub()
defer cmdTeardown(t)
output, err := runCommand(http, nil, "master", `123`)
assert.EqualError(t, err, `invalid branch name: "-foo"`)
assert.Equal(t, "", output.Stderr())
@ -449,7 +467,6 @@ func TestPRCheckout_maintainerCanModify(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git fetch origin refs/pull/123/head:feature`, 0, "")
cs.Register(`git config branch\.feature\.merge`, 1, "")
cs.Register(`git checkout feature`, 0, "")
@ -471,7 +488,6 @@ func TestPRCheckout_recurseSubmodules(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git fetch origin \+refs/heads/feature:refs/remotes/origin/feature`, 0, "")
cs.Register(`git show-ref --verify -- refs/heads/feature`, 0, "")
cs.Register(`git checkout feature`, 0, "")
@ -493,7 +509,6 @@ func TestPRCheckout_force(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git fetch origin \+refs/heads/feature:refs/remotes/origin/feature`, 0, "")
cs.Register(`git show-ref --verify -- refs/heads/feature`, 0, "")
cs.Register(`git checkout feature`, 0, "")
@ -515,7 +530,6 @@ func TestPRCheckout_detach(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git checkout --detach FETCH_HEAD`, 0, "")
cs.Register(`git fetch origin refs/pull/123/head`, 0, "")

View file

@ -77,6 +77,10 @@ func NewCmdChecks(f *cmdutil.Factory, runF func(*ChecksOptions) error) *cobra.Co
RunE: func(cmd *cobra.Command, args []string) error {
opts.Finder = shared.NewFinder(f)
if opts.Exporter != nil && opts.Watch {
return cmdutil.FlagErrorf("cannot use `--watch` with `--json` flag")
}
if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 {
return cmdutil.FlagErrorf("argument required when using the `--repo` flag")
}

View file

@ -78,6 +78,11 @@ func TestNewCmdChecks(t *testing.T) {
cli: "--fail-fast",
wantsError: "cannot use `--fail-fast` flag without `--watch` flag",
},
{
name: "watch with json flag",
cli: "--watch --json workflow",
wantsError: "cannot use `--watch` with `--json` flag",
},
{
name: "required flag",
cli: "--required",
@ -171,7 +176,7 @@ func Test_checksRun(t *testing.T) {
wantOut: heredoc.Doc(`
Some checks were not successful
0 cancelled, 1 failing, 1 successful, 0 skipped, and 1 pending checks
NAME DESCRIPTION ELAPSED URL
X sad tests 1m26s sweet link
cool tests 1m26s sweet link
@ -191,7 +196,7 @@ func Test_checksRun(t *testing.T) {
wantOut: heredoc.Doc(`
Some checks were cancelled
1 cancelled, 0 failing, 2 successful, 0 skipped, and 0 pending checks
NAME DESCRIPTION ELAPSED URL
cool tests 1m26s sweet link
- sad tests 1m26s sweet link
@ -211,7 +216,7 @@ func Test_checksRun(t *testing.T) {
wantOut: heredoc.Doc(`
Some checks are still pending
1 cancelled, 0 failing, 2 successful, 0 skipped, and 1 pending checks
NAME DESCRIPTION ELAPSED URL
cool tests 1m26s sweet link
rad tests 1m26s sweet link
@ -232,7 +237,7 @@ func Test_checksRun(t *testing.T) {
wantOut: heredoc.Doc(`
All checks were successful
0 cancelled, 0 failing, 3 successful, 0 skipped, and 0 pending checks
NAME DESCRIPTION ELAPSED URL
awesome tests 1m26s sweet link
cool tests 1m26s sweet link
@ -253,7 +258,7 @@ func Test_checksRun(t *testing.T) {
wantOut: heredoc.Docf(`
%[1]s[?1049hAll checks were successful
0 cancelled, 0 failing, 3 successful, 0 skipped, and 0 pending checks
NAME DESCRIPTION ELAPSED URL
awesome tests 1m26s sweet link
cool tests 1m26s sweet link
@ -281,17 +286,17 @@ func Test_checksRun(t *testing.T) {
},
wantOut: heredoc.Docf(`
%[1]s[?1049h%[1]s[0;0H%[1]s[JRefreshing checks status every 0 seconds. Press Ctrl+C to quit.
Some checks were not successful
0 cancelled, 1 failing, 1 successful, 0 skipped, and 1 pending checks
NAME DESCRIPTION ELAPSED URL
X sad tests 1m26s sweet link
cool tests 1m26s sweet link
* slow tests 1m26s sweet link
%[1]s[?1049lSome checks were not successful
0 cancelled, 1 failing, 1 successful, 0 skipped, and 1 pending checks
NAME DESCRIPTION ELAPSED URL
X sad tests 1m26s sweet link
cool tests 1m26s sweet link
@ -311,7 +316,7 @@ func Test_checksRun(t *testing.T) {
wantOut: heredoc.Doc(`
Some checks were not successful
0 cancelled, 1 failing, 2 successful, 0 skipped, and 0 pending checks
NAME DESCRIPTION ELAPSED URL
X a status sweet link
cool tests 1m26s sweet link
@ -397,7 +402,7 @@ func Test_checksRun(t *testing.T) {
wantOut: heredoc.Doc(`
All checks were successful
0 cancelled, 0 failing, 1 successful, 2 skipped, and 0 pending checks
NAME DESCRIPTION ELAPSED URL
cool tests 1m26s sweet link
- rad tests 1m26s sweet link
@ -429,7 +434,7 @@ func Test_checksRun(t *testing.T) {
wantOut: heredoc.Doc(`
All checks were successful
0 cancelled, 0 failing, 1 successful, 0 skipped, and 0 pending checks
NAME DESCRIPTION ELAPSED URL
cool tests 1m26s sweet link
`),
@ -484,7 +489,7 @@ func Test_checksRun(t *testing.T) {
wantOut: heredoc.Doc(`
All checks were successful
0 cancelled, 0 failing, 3 successful, 0 skipped, and 0 pending checks
NAME DESCRIPTION ELAPSED URL
awesome tests awesome description 1m26s sweet link
cool tests cool description 1m26s sweet link
@ -515,7 +520,7 @@ func Test_checksRun(t *testing.T) {
wantOut: heredoc.Doc(`
All checks were successful
0 cancelled, 0 failing, 2 successful, 0 skipped, and 0 pending checks
NAME DESCRIPTION ELAPSED URL
tests/cool tests (pull_request) cool description 1m26s sweet link
tests/cool tests (push) cool description 1m26s sweet link
@ -535,7 +540,7 @@ func Test_checksRun(t *testing.T) {
wantOut: heredoc.Doc(`
All checks were successful
0 cancelled, 0 failing, 1 successful, 0 skipped, and 0 pending checks
NAME DESCRIPTION ELAPSED URL
tests/cool tests cool description 1m26s sweet link
`),

View file

@ -119,6 +119,10 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
alongside %[1]s--fill%[1]s, the values specified by %[1]s--title%[1]s and/or %[1]s--body%[1]s will
take precedence and overwrite any autofilled content.
The base branch for the created PR can be specified using the %[1]s--base%[1]s flag. If not provided,
the value of %[1]sgh-merge-base%[1]s git branch config will be used. If not configured, the repository's
default branch will be used.
Link an issue to the pull request by referencing the issue in the body of the pull
request. If the body text mentions %[1]sFixes #123%[1]s or %[1]sCloses #123%[1]s, the referenced issue
will automatically get closed when the pull request gets merged.
@ -278,6 +282,9 @@ func createRun(opts *CreateOptions) (err error) {
state.Title = opts.Title
state.Body = opts.Body
}
if opts.Template != "" {
state.Template = opts.Template
}
err = handlePush(*opts, *ctx)
if err != nil {
return
@ -510,11 +517,10 @@ func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState, u
return nil
}
func determineTrackingBranch(gitClient *git.Client, remotes ghContext.Remotes, headBranch string) *git.TrackingRef {
func determineTrackingBranch(gitClient *git.Client, remotes ghContext.Remotes, headBranchConfig *git.BranchConfig) *git.TrackingRef {
refsForLookup := []string{"HEAD"}
var trackingRefs []git.TrackingRef
headBranchConfig := gitClient.ReadBranchConfig(context.Background(), headBranch)
if headBranchConfig.RemoteName != "" {
tr := git.TrackingRef{
RemoteName: headBranchConfig.RemoteName,
@ -527,7 +533,7 @@ func determineTrackingBranch(gitClient *git.Client, remotes ghContext.Remotes, h
for _, remote := range remotes {
tr := git.TrackingRef{
RemoteName: remote.Name,
BranchName: headBranch,
BranchName: headBranchConfig.LocalName,
}
trackingRefs = append(trackingRefs, tr)
refsForLookup = append(refsForLookup, tr.String())
@ -637,9 +643,10 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) {
var headRepo ghrepo.Interface
var headRemote *ghContext.Remote
headBranchConfig := gitClient.ReadBranchConfig(context.Background(), headBranch)
if isPushEnabled {
// determine whether the head branch is already pushed to a remote
if pushedTo := determineTrackingBranch(gitClient, remotes, headBranch); pushedTo != nil {
if pushedTo := determineTrackingBranch(gitClient, remotes, &headBranchConfig); pushedTo != nil {
isPushEnabled = false
if r, err := remotes.FindByName(pushedTo.RemoteName); err == nil {
headRepo = r
@ -712,6 +719,9 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) {
}
baseBranch := opts.BaseBranch
if baseBranch == "" {
baseBranch = headBranchConfig.MergeBase
}
if baseBranch == "" {
baseBranch = baseRepo.DefaultBranchRef.Name
}

View file

@ -1,6 +1,7 @@
package create
import (
ctx "context"
"encoding/json"
"errors"
"fmt"
@ -261,6 +262,15 @@ func TestNewCmdCreate(t *testing.T) {
cli: "--editor",
wantsErr: true,
},
{
name: "fill and base",
cli: "--fill --base trunk",
wantsOpts: CreateOptions{
Autofill: true,
BaseBranch: "trunk",
MaintainerCanModify: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -323,17 +333,18 @@ func TestNewCmdCreate(t *testing.T) {
func Test_createRun(t *testing.T) {
tests := []struct {
name string
setup func(*CreateOptions, *testing.T) func()
cmdStubs func(*run.CommandStubber)
promptStubs func(*prompter.PrompterMock)
httpStubs func(*httpmock.Registry, *testing.T)
expectedOutputs []string
expectedOut string
expectedErrOut string
expectedBrowse string
wantErr string
tty bool
name string
setup func(*CreateOptions, *testing.T) func()
cmdStubs func(*run.CommandStubber)
promptStubs func(*prompter.PrompterMock)
httpStubs func(*httpmock.Registry, *testing.T)
expectedOutputs []string
expectedOut string
expectedErrOut string
expectedBrowse string
wantErr string
tty bool
customBranchConfig bool
}{
{
name: "nontty web",
@ -626,7 +637,6 @@ func Test_createRun(t *testing.T) {
}))
},
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git config --get-regexp.+branch\\\.feature\\\.`, 0, "")
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "")
},
@ -690,7 +700,6 @@ func Test_createRun(t *testing.T) {
}))
},
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git config --get-regexp.+branch\\\.feature\\\.`, 0, "")
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "")
},
@ -737,7 +746,6 @@ func Test_createRun(t *testing.T) {
}))
},
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git config --get-regexp.+branch\\\.feature\\\.`, 0, "")
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "")
},
@ -787,7 +795,6 @@ func Test_createRun(t *testing.T) {
}))
},
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git config --get-regexp.+branch\\\.feature\\\.`, 0, "")
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
cs.Register("git remote rename origin upstream", 0, "")
cs.Register(`git remote add origin https://github.com/monalisa/REPO.git`, 0, "")
@ -846,7 +853,6 @@ func Test_createRun(t *testing.T) {
}))
},
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git config --get-regexp \^branch\\\.feature\\\.`, 1, "") // determineTrackingBranch
cs.Register("git show-ref --verify", 0, heredoc.Doc(`
deadbeef HEAD
deadb00f refs/remotes/upstream/feature
@ -878,6 +884,7 @@ func Test_createRun(t *testing.T) {
assert.Equal(t, "my-feat2", input["headRefName"].(string))
}))
},
customBranchConfig: true,
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git config --get-regexp \^branch\\\.feature\\\.`, 0, heredoc.Doc(`
branch.feature.remote origin
@ -1066,7 +1073,6 @@ func Test_createRun(t *testing.T) {
httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`))
},
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git config --get-regexp.+branch\\\.feature\\\.`, 0, "")
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "")
cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "")
@ -1099,7 +1105,6 @@ func Test_createRun(t *testing.T) {
mockRetrieveProjects(t, reg)
},
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git config --get-regexp.+branch\\\.feature\\\.`, 0, "")
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
cs.Register(`git( .+)? log( .+)? origin/master\.\.\.feature`, 0, "")
cs.Register(`git push --set-upstream origin HEAD:refs/heads/feature`, 0, "")
@ -1464,6 +1469,65 @@ func Test_createRun(t *testing.T) {
},
expectedOut: "https://github.com/OWNER/REPO/pull/12\n",
},
{
name: "gh-merge-base",
tty: true,
setup: func(opts *CreateOptions, t *testing.T) func() {
opts.TitleProvided = true
opts.BodyProvided = true
opts.Title = "my title"
opts.Body = "my body"
opts.Branch = func() (string, error) {
return "task1", nil
}
opts.Remotes = func() (context.Remotes, error) {
return context.Remotes{
{
Remote: &git.Remote{
Name: "upstream",
Resolved: "base",
},
Repo: ghrepo.New("OWNER", "REPO"),
},
{
Remote: &git.Remote{
Name: "origin",
},
Repo: ghrepo.New("monalisa", "REPO"),
},
}, nil
}
return func() {}
},
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
reg.Register(
httpmock.GraphQL(`mutation PullRequestCreate\b`),
httpmock.GraphQLMutation(`
{ "data": { "createPullRequest": { "pullRequest": {
"URL": "https://github.com/OWNER/REPO/pull/12"
} } } }
`, func(input map[string]interface{}) {
assert.Equal(t, "REPOID", input["repositoryId"].(string))
assert.Equal(t, "my title", input["title"].(string))
assert.Equal(t, "my body", input["body"].(string))
assert.Equal(t, "feature/feat2", input["baseRefName"].(string))
assert.Equal(t, "monalisa:task1", input["headRefName"].(string))
}))
},
customBranchConfig: true,
cmdStubs: func(cs *run.CommandStubber) {
cs.Register(`git config --get-regexp \^branch\\\.task1\\\.\(remote\|merge\|gh-merge-base\)\$`, 0, heredoc.Doc(`
branch.task1.remote origin
branch.task1.merge refs/heads/task1
branch.task1.gh-merge-base feature/feat2`)) // ReadBranchConfig
cs.Register(`git show-ref --verify`, 0, heredoc.Doc(`
deadbeef HEAD
deadb00f refs/remotes/upstream/feature/feat2
deadbeef refs/remotes/origin/task1`)) // determineTrackingBranch
},
expectedOut: "https://github.com/OWNER/REPO/pull/12\n",
expectedErrOut: "\nCreating pull request for monalisa:task1 into feature/feat2 in OWNER/REPO\n\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -1485,6 +1549,9 @@ func Test_createRun(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git status --porcelain`, 0, "")
if !tt.customBranchConfig {
cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|gh-merge-base\)\$`, 0, "")
}
if tt.cmdStubs != nil {
tt.cmdStubs(cs)
@ -1651,7 +1718,8 @@ func Test_determineTrackingBranch(t *testing.T) {
GhPath: "some/path/gh",
GitPath: "some/path/git",
}
ref := determineTrackingBranch(gitClient, tt.remotes, "feature")
headBranchConfig := gitClient.ReadBranchConfig(ctx.Background(), "feature")
ref := determineTrackingBranch(gitClient, tt.remotes, &headBranchConfig)
tt.assert(ref, t)
})
}
@ -1714,6 +1782,19 @@ func Test_generateCompareURL(t *testing.T) {
want: "https://github.com/OWNER/REPO/compare/main%2Ftrunk...owner:%21$&%27%28%29+%2C%3B=@?body=&expand=1",
wantErr: false,
},
{
name: "with template",
ctx: CreateContext{
BaseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"),
BaseBranch: "main",
HeadBranchLabel: "feature",
},
state: shared.IssueMetadataState{
Template: "story.md",
},
want: "https://github.com/OWNER/REPO/compare/main...feature?body=&expand=1&template=story.md",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -241,7 +241,14 @@ func (m *mergeContext) warnIfDiverged() {
// Check if the current state of the pull request allows for merging
func (m *mergeContext) canMerge() error {
if m.mergeQueueRequired {
// a pull request can always be added to the merge queue
// Requesting branch deletion on a PR with a merge queue
// policy is not allowed. Doing so can unexpectedly
// delete branches before merging, close the PR, and remove
// the PR from the merge queue.
if m.opts.DeleteBranch {
return fmt.Errorf("%s Cannot use `-d` or `--delete-branch` when merge queue enabled", m.cs.FailureIcon())
}
// Otherwise, a pull request can always be added to the merge queue
return nil
}

View file

@ -659,6 +659,29 @@ func TestPrMerge_deleteBranch(t *testing.T) {
`), output.Stderr())
}
func TestPrMerge_deleteBranch_mergeQueue(t *testing.T) {
http := initFakeHTTP()
defer http.Verify(t)
shared.RunCommandFinder(
"",
&api.PullRequest{
ID: "PR_10",
Number: 10,
State: "OPEN",
Title: "Blueberries are a good fruit",
HeadRefName: "blueberries",
BaseRefName: "main",
MergeStateStatus: "CLEAN",
IsMergeQueueEnabled: true,
},
baseRepo("OWNER", "REPO", "main"),
)
_, err := runCommand(http, nil, "blueberries", true, `pr merge --merge --delete-branch`)
assert.Contains(t, err.Error(), "X Cannot use `-d` or `--delete-branch` when merge queue enabled")
}
func TestPrMerge_deleteBranch_nonDefault(t *testing.T) {
http := initFakeHTTP()
defer http.Verify(t)

View file

@ -246,27 +246,36 @@ func (f *finder) parseCurrentBranch() (string, int, error) {
return "", prNumber, nil
}
var branchOwner string
var gitRemoteRepo ghrepo.Interface
if branchConfig.RemoteURL != nil {
// the branch merges from a remote specified by URL
if r, err := ghrepo.FromURL(branchConfig.RemoteURL); err == nil {
branchOwner = r.RepoOwner()
gitRemoteRepo = r
}
} else if branchConfig.RemoteName != "" {
// the branch merges from a remote specified by name
rem, _ := f.remotesFn()
if r, err := rem.FindByName(branchConfig.RemoteName); err == nil {
branchOwner = r.RepoOwner()
gitRemoteRepo = r
}
}
if branchOwner != "" {
if gitRemoteRepo != nil {
if strings.HasPrefix(branchConfig.MergeRef, "refs/heads/") {
prHeadRef = strings.TrimPrefix(branchConfig.MergeRef, "refs/heads/")
}
// prepend `OWNER:` if this branch is pushed to a fork
if !strings.EqualFold(branchOwner, f.repo.RepoOwner()) {
prHeadRef = fmt.Sprintf("%s:%s", branchOwner, prHeadRef)
// This is determined by:
// - The repo having a different owner
// - The repo having the same owner but a different name (private org fork)
// I suspect that the implementation of the second case may be broken in the face
// of a repo rename, where the remote hasn't been updated locally. This is a
// frequent issue in commands that use SmartBaseRepoFunc. It's not any worse than not
// supporting this case at all though.
sameOwner := strings.EqualFold(gitRemoteRepo.RepoOwner(), f.repo.RepoOwner())
sameOwnerDifferentRepoName := sameOwner && !strings.EqualFold(gitRemoteRepo.RepoName(), f.repo.RepoName())
if !sameOwner || sameOwnerDifferentRepoName {
prHeadRef = fmt.Sprintf("%s:%s", gitRemoteRepo.RepoOwner(), prHeadRef)
}
}
@ -355,7 +364,13 @@ func findForBranch(httpClient *http.Client, repo ghrepo.Interface, baseBranch, h
})
for _, pr := range prs {
if pr.HeadLabel() == headBranch && (baseBranch == "" || pr.BaseRefName == baseBranch) && (pr.State == "OPEN" || resp.Repository.DefaultBranchRef.Name != headBranch) {
headBranchMatches := pr.HeadLabel() == headBranch
baseBranchEmptyOrMatches := baseBranch == "" || pr.BaseRefName == baseBranch
// When the head is the default branch, it doesn't really make sense to show merged or closed PRs.
// https://github.com/cli/cli/issues/4263
isNotClosedOrMergedWhenHeadIsDefault := pr.State == "OPEN" || resp.Repository.DefaultBranchRef.Name != headBranch
if headBranchMatches && baseBranchEmptyOrMatches && isNotClosedOrMergedWhenHeadIsDefault {
return &pr, nil
}
}

View file

@ -347,7 +347,7 @@ func TestFind(t *testing.T) {
wantRepo: "https://github.com/OWNER/REPO",
},
{
name: "current branch with upstream configuration",
name: "current branch with upstream RemoteURL configuration",
args: args{
selector: "",
fields: []string{"id", "number"},
@ -384,6 +384,47 @@ func TestFind(t *testing.T) {
wantPR: 13,
wantRepo: "https://github.com/OWNER/REPO",
},
{
name: "current branch with upstream and fork in same org",
args: args{
selector: "",
fields: []string{"id", "number"},
baseRepoFn: func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
},
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: func(branch string) (c git.BranchConfig) {
c.RemoteName = "origin"
return
},
remotesFn: func() (context.Remotes, error) {
return context.Remotes{{
Remote: &git.Remote{Name: "origin"},
Repo: ghrepo.New("OWNER", "REPO-FORK"),
}}, nil
},
},
httpStub: func(r *httpmock.Registry) {
r.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`{"data":{"repository":{
"pullRequests":{"nodes":[
{
"number": 13,
"state": "OPEN",
"baseRefName": "main",
"headRefName": "blueberries",
"isCrossRepository": true,
"headRepositoryOwner": {"login":"OWNER"}
}
]}
}}}`))
},
wantPR: 13,
wantRepo: "https://github.com/OWNER/REPO",
},
{
name: "current branch made by pr checkout",
args: args{

View file

@ -27,6 +27,10 @@ func WithPrAndIssueQueryParams(client *api.Client, baseRepo ghrepo.Interface, ba
if len(state.Assignees) > 0 {
q.Set("assignees", strings.Join(state.Assignees, ","))
}
// Set a template parameter if no body parameter is provided e.g. Web Mode
if len(state.Template) > 0 && len(state.Body) == 0 {
q.Set("template", state.Template)
}
if len(state.Labels) > 0 {
q.Set("labels", strings.Join(state.Labels, ","))
}
@ -40,6 +44,7 @@ func WithPrAndIssueQueryParams(client *api.Client, baseRepo ghrepo.Interface, ba
if len(state.Milestones) > 0 {
q.Set("milestone", state.Milestones[0])
}
u.RawQuery = q.Encode()
return u.String(), nil
}

View file

@ -23,6 +23,8 @@ type IssueMetadataState struct {
Body string
Title string
Template string
Metadata []string
Reviewers []string
Assignees []string

View file

@ -32,6 +32,7 @@ func TestJSONFields(t *testing.T) {
"author",
"autoMergeRequest",
"baseRefName",
"baseRefOid",
"body",
"changedFiles",
"closed",

View file

@ -477,6 +477,21 @@ func createRun(opts *CreateOptions) error {
}
newRelease, err := createRelease(httpClient, baseRepo, params)
var errMissingRequiredWorkflowScope *errMissingRequiredWorkflowScope
if errors.As(err, &errMissingRequiredWorkflowScope) {
host := errMissingRequiredWorkflowScope.Hostname
refreshInstructions := fmt.Sprintf("gh auth refresh -h %[1]s -s workflow", host)
cs := opts.IO.ColorScheme()
var sb strings.Builder
sb.WriteString(fmt.Sprintf("%s Failed to create release, \"workflow\" scope may be required.\n", cs.WarningIcon()))
sb.WriteString(fmt.Sprintf("To request it, run:\n%s\n", cs.Bold(refreshInstructions)))
fmt.Fprint(opts.IO.ErrOut, sb.String())
return cmdutil.SilentError
}
if err != nil {
return err
}

View file

@ -10,6 +10,7 @@ import (
"path/filepath"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/gh"
@ -1082,6 +1083,74 @@ func Test_createRun(t *testing.T) {
runStubs: defaultRunStubs,
wantErr: "cannot generate release notes from tag v1.2.3 as it does not exist locally",
},
{
name: "API returns 404, OAuth token has no workflow scope",
isTTY: false,
opts: CreateOptions{
TagName: "Does not matter",
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(contentCmd, 0, "some tag message")
rs.Register(signatureCmd, 0, "")
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL("RepositoryFindRef"),
httpmock.StringResponse(`{"data":{"repository":{"ref": {"id": "tag id"}}}}`),
)
reg.Register(
httpmock.REST("POST", "repos/OWNER/REPO/releases"),
httpmock.StatusScopesResponder(404, `repo,read:org`))
},
wantStderr: heredoc.Doc(`
! Failed to create release, "workflow" scope may be required.
To request it, run:
gh auth refresh -h github.com -s workflow
`),
wantErr: cmdutil.SilentError.Error(),
},
{
name: "API returns 404, OAuth token has workflow scope",
isTTY: false,
opts: CreateOptions{
TagName: "Does not matter",
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(contentCmd, 0, "some tag message")
rs.Register(signatureCmd, 0, "")
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL("RepositoryFindRef"),
httpmock.StringResponse(`{"data":{"repository":{"ref": {"id": "tag id"}}}}`),
)
reg.Register(
httpmock.REST("POST", "repos/OWNER/REPO/releases"),
httpmock.StatusScopesResponder(404, `repo,read:org,workflow`))
},
wantErr: "HTTP 404 (https://api.github.com/repos/OWNER/REPO/releases)",
},
{
name: "API returns 404, not an OAuth token",
isTTY: false,
opts: CreateOptions{
TagName: "Does not matter",
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(contentCmd, 0, "some tag message")
rs.Register(signatureCmd, 0, "")
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL("RepositoryFindRef"),
httpmock.StringResponse(`{"data":{"repository":{"ref": {"id": "tag id"}}}}`),
)
reg.Register(
httpmock.REST("POST", "repos/OWNER/REPO/releases"),
httpmock.StatusStringResponse(404, `HTTP 404 (https://api.github.com/repos/OWNER/REPO/releases)`))
},
wantErr: "HTTP 404 (https://api.github.com/repos/OWNER/REPO/releases)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -1115,7 +1184,6 @@ func Test_createRun(t *testing.T) {
err := createRun(&tt.opts)
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
return
} else {
require.NoError(t, err)
}

View file

@ -8,12 +8,16 @@ import (
"io"
"net/http"
"net/url"
"slices"
"strings"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmd/release/shared"
"github.com/shurcooL/githubv4"
ghauth "github.com/cli/go-gh/v2/pkg/auth"
)
type tag struct {
@ -27,6 +31,14 @@ type releaseNotes struct {
var notImplementedError = errors.New("not implemented")
type errMissingRequiredWorkflowScope struct {
Hostname string
}
func (e errMissingRequiredWorkflowScope) Error() string {
return "workflow scope may be required"
}
func remoteTagExists(httpClient *http.Client, repo ghrepo.Interface, tagName string) (bool, error) {
gql := api.NewClientFromHTTP(httpClient)
qualifiedTagName := fmt.Sprintf("refs/tags/%s", tagName)
@ -174,6 +186,24 @@ func createRelease(httpClient *http.Client, repo ghrepo.Interface, params map[st
}
defer resp.Body.Close()
// Check if we received a 404 while attempting to create a release without
// the workflow scope, and if so, return an error message that explains a possible
// solution to the user.
//
// If the same file (with both the same path and contents) exists
// on another branch in the repo, releases with workflow file changes can be
// created without the workflow scope. Otherwise, the workflow scope is
// required to create the release, but the API does not indicate this criteria
// beyond returning a 404.
//
// https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps#available-scopes
if resp.StatusCode == http.StatusNotFound && !tokenHasWorkflowScope(resp) {
normalizedHostname := ghauth.NormalizeHostname(resp.Request.URL.Hostname())
return nil, &errMissingRequiredWorkflowScope{
Hostname: normalizedHostname,
}
}
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return nil, api.HandleHTTPError(resp)
@ -254,3 +284,18 @@ func deleteRelease(httpClient *http.Client, release *shared.Release) error {
}
return nil
}
// tokenHasWorkflowScope checks if the given http.Response's token has the workflow scope.
// Tokens that do not have OAuth scopes are assumed to have the workflow scope.
func tokenHasWorkflowScope(resp *http.Response) bool {
scopes := resp.Header.Get("X-Oauth-Scopes")
// Return true when no scopes are present - no scopes in this header
// means that the user is probably authenticating with a token type other
// than an OAuth token, and we don't know what this token's scopes actually are.
if scopes == "" {
return true
}
return slices.Contains(strings.Split(scopes, ","), "workflow")
}

View file

@ -94,7 +94,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
To create a remote repository from an existing local repository, specify the source directory with %[1]s--source%[1]s.
By default, the remote repository name will be the name of the source directory.
Pass %[1]s--push%[1]s to push any local commits to the new repository.
Pass %[1]s--push%[1]s to push any local commits to the new repository. If the repo is bare, this will mirror all refs.
For language or platform .gitignore templates to use with %[1]s--gitignore%[1]s, <https://github.com/github/gitignore>.
@ -556,11 +556,11 @@ func createFromLocal(opts *CreateOptions) error {
return err
}
isRepo, err := isLocalRepo(opts.GitClient)
repoType, err := localRepoType(opts.GitClient)
if err != nil {
return err
}
if !isRepo {
if repoType == unknown {
if repoPath == "." {
return fmt.Errorf("current directory is not a git repository. Run `git init` to initialize it")
}
@ -652,22 +652,43 @@ func createFromLocal(opts *CreateOptions) error {
// don't prompt for push if there are no commits
if opts.Interactive && committed {
msg := fmt.Sprintf("Would you like to push commits from the current branch to %q?", baseRemote)
if repoType == bare {
msg = fmt.Sprintf("Would you like to mirror all refs to %q?", baseRemote)
}
var err error
opts.Push, err = opts.Prompter.Confirm(fmt.Sprintf("Would you like to push commits from the current branch to %q?", baseRemote), true)
opts.Push, err = opts.Prompter.Confirm(msg, true)
if err != nil {
return err
}
}
if opts.Push {
if opts.Push && repoType == working {
err := opts.GitClient.Push(context.Background(), baseRemote, "HEAD")
if err != nil {
return err
}
if isTTY {
fmt.Fprintf(stdout, "%s Pushed commits to %s\n", cs.SuccessIcon(), remoteURL)
}
}
if opts.Push && repoType == bare {
cmd, err := opts.GitClient.AuthenticatedCommand(context.Background(), git.AllMatchingCredentialsPattern, "push", baseRemote, "--mirror")
if err != nil {
return err
}
if err = cmd.Run(); err != nil {
return err
}
if isTTY {
fmt.Fprintf(stdout, "%s Mirrored all refs to %s\n", cs.SuccessIcon(), remoteURL)
}
}
return nil
}
@ -736,22 +757,34 @@ func hasCommits(gitClient *git.Client) (bool, error) {
return false, nil
}
// check if path is the top level directory of a git repo
func isLocalRepo(gitClient *git.Client) (bool, error) {
type repoType int
const (
unknown repoType = iota
working
bare
)
func localRepoType(gitClient *git.Client) (repoType, error) {
projectDir, projectDirErr := gitClient.GitDir(context.Background())
if projectDirErr != nil {
var execError *exec.ExitError
var execError errWithExitCode
if errors.As(projectDirErr, &execError) {
if exitCode := int(execError.ExitCode()); exitCode == 128 {
return false, nil
return unknown, nil
}
return false, projectDirErr
return unknown, projectDirErr
}
}
if projectDir != ".git" {
return false, nil
switch projectDir {
case ".":
return bare, nil
case ".git":
return working, nil
default:
return unknown, nil
}
return true, nil
}
// clone the checkout branch to specified path

View file

@ -443,6 +443,74 @@ func Test_createRun(t *testing.T) {
},
wantStdout: "✓ Created repository OWNER/REPO on GitHub\n https://github.com/OWNER/REPO\n",
},
{
name: "interactive with existing bare repository public and push",
opts: &CreateOptions{Interactive: true},
tty: true,
promptStubs: func(p *prompter.PrompterMock) {
p.ConfirmFunc = func(message string, defaultValue bool) (bool, error) {
switch message {
case "Add a remote?":
return true, nil
case `Would you like to mirror all refs to "origin"?`:
return true, nil
default:
return false, fmt.Errorf("unexpected confirm prompt: %s", message)
}
}
p.InputFunc = func(message, defaultValue string) (string, error) {
switch message {
case "Path to local repository":
return defaultValue, nil
case "Repository name":
return "REPO", nil
case "Description":
return "my new repo", nil
case "What should the new remote be called?":
return defaultValue, nil
default:
return "", fmt.Errorf("unexpected input prompt: %s", message)
}
}
p.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
switch message {
case "What would you like to do?":
return prompter.IndexFor(options, "Push an existing local repository to GitHub")
case "Visibility":
return prompter.IndexFor(options, "Private")
default:
return 0, fmt.Errorf("unexpected select prompt: %s", message)
}
}
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data":{"viewer":{"login":"someuser","organizations":{"nodes": []}}}}`))
reg.Register(
httpmock.GraphQL(`mutation RepositoryCreate\b`),
httpmock.StringResponse(`
{
"data": {
"createRepository": {
"repository": {
"id": "REPOID",
"name": "REPO",
"owner": {"login":"OWNER"},
"url": "https://github.com/OWNER/REPO"
}
}
}
}`))
},
execStubs: func(cs *run.CommandStubber) {
cs.Register(`git -C . rev-parse --git-dir`, 0, ".")
cs.Register(`git -C . rev-parse HEAD`, 0, "commithash")
cs.Register(`git -C . remote add origin https://github.com/OWNER/REPO`, 0, "")
cs.Register(`git -C . push origin --mirror`, 0, "")
},
wantStdout: "✓ Created repository OWNER/REPO on GitHub\n https://github.com/OWNER/REPO\n✓ Added remote https://github.com/OWNER/REPO.git\n✓ Mirrored all refs to https://github.com/OWNER/REPO.git\n",
},
{
name: "interactive with existing repository public add remote and push",
opts: &CreateOptions{Interactive: true},
@ -696,6 +764,71 @@ func Test_createRun(t *testing.T) {
},
wantStdout: "https://github.com/OWNER/REPO\n",
},
{
name: "noninteractive create bare from source and push",
opts: &CreateOptions{
Interactive: false,
Source: ".",
Push: true,
Name: "REPO",
Visibility: "PRIVATE",
},
tty: false,
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`mutation RepositoryCreate\b`),
httpmock.StringResponse(`
{
"data": {
"createRepository": {
"repository": {
"id": "REPOID",
"name": "REPO",
"owner": {"login":"OWNER"},
"url": "https://github.com/OWNER/REPO"
}
}
}
}`))
},
execStubs: func(cs *run.CommandStubber) {
cs.Register(`git -C . rev-parse --git-dir`, 0, ".")
cs.Register(`git -C . rev-parse HEAD`, 0, "commithash")
cs.Register(`git -C . remote add origin https://github.com/OWNER/REPO`, 0, "")
cs.Register(`git -C . push origin --mirror`, 0, "")
},
wantStdout: "https://github.com/OWNER/REPO\n",
},
{
name: "noninteractive create from cwd that isn't a git repo",
opts: &CreateOptions{
Interactive: false,
Source: ".",
Name: "REPO",
Visibility: "PRIVATE",
},
tty: false,
execStubs: func(cs *run.CommandStubber) {
cs.Register(`git -C . rev-parse --git-dir`, 128, "")
},
wantErr: true,
errMsg: "current directory is not a git repository. Run `git init` to initialize it",
},
{
name: "noninteractive create from cwd that isn't a git repo",
opts: &CreateOptions{
Interactive: false,
Source: "some-dir",
Name: "REPO",
Visibility: "PRIVATE",
},
tty: false,
execStubs: func(cs *run.CommandStubber) {
cs.Register(`git -C some-dir rev-parse --git-dir`, 128, "")
},
wantErr: true,
errMsg: "some-dir is not a git repository. Run `git -C \"some-dir\" init` to initialize it",
},
{
name: "noninteractive clone from scratch",
opts: &CreateOptions{
@ -856,11 +989,11 @@ func Test_createRun(t *testing.T) {
defer reg.Verify(t)
err := createRun(tt.opts)
if tt.wantErr {
assert.Error(t, err)
assert.Equal(t, tt.errMsg, err.Error())
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errMsg)
return
}
assert.NoError(t, err)
require.NoError(t, err)
assert.Equal(t, tt.wantStdout, stdout.String())
assert.Equal(t, "", stderr.String())
})

View file

@ -6,6 +6,7 @@ import (
"net/http"
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmdutil"
@ -38,12 +39,14 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
cmd := &cobra.Command{
Use: "delete [<repository>]",
Short: "Delete a repository",
Long: `Delete a GitHub repository.
Long: heredoc.Docf(`
Delete a GitHub repository.
With no argument, deletes the current repository. Otherwise, deletes the specified repository.
With no argument, deletes the current repository. Otherwise, deletes the specified repository.
Deletion requires authorization with the "delete_repo" scope.
To authorize, run "gh auth refresh -s delete_repo"`,
Deletion requires authorization with the %[1]sdelete_repo%[1]s scope.
To authorize, run %[1]sgh auth refresh -s delete_repo%[1]s
`, "`"),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {

View file

@ -306,6 +306,10 @@ func forkRun(opts *ForkOptions) error {
if err != nil {
return err
}
if connectedToTerminal {
fmt.Fprintf(stderr, "%s Renamed remote %s to %s\n", cs.SuccessIcon(), cs.Bold(remoteName), cs.Bold(renameTarget))
}
} else {
return fmt.Errorf("a git remote named '%s' already exists", remoteName)
}

View file

@ -298,7 +298,7 @@ func TestRepoFork(t *testing.T) {
return true, nil
})
},
wantErrOut: "✓ Created fork someone/REPO\n✓ Added remote origin\n",
wantErrOut: "✓ Created fork someone/REPO\n✓ Renamed remote origin to upstream\n✓ Added remote origin\n",
},
{
name: "implicit tty reuse existing remote",
@ -370,7 +370,7 @@ func TestRepoFork(t *testing.T) {
cs.Register("git remote rename origin upstream", 0, "")
cs.Register(`git remote add origin https://github.com/someone/REPO.git`, 0, "")
},
wantErrOut: "✓ Created fork someone/REPO\n✓ Added remote origin\n",
wantErrOut: "✓ Created fork someone/REPO\n✓ Renamed remote origin to upstream\n✓ Added remote origin\n",
},
{
name: "implicit nontty reuse existing remote",

View file

@ -49,9 +49,27 @@ func NewCmdRename(f *cmdutil.Factory, runf func(*RenameOptions) error) *cobra.Co
cmd := &cobra.Command{
Use: "rename [<new-name>]",
Short: "Rename a repository",
Long: heredoc.Doc(`Rename a GitHub repository.
Long: heredoc.Docf(`
Rename a GitHub repository.
%[1]s<new-name>%[1]s is the desired repository name without the owner.
By default, the current repository is renamed. Otherwise, the repository specified
with %[1]s--repo%[1]s is renamed.
To transfer repository ownership to another user account or organization,
you must follow additional steps on GitHub.com
By default, this renames the current repository; otherwise renames the specified repository.`),
For more information on transferring repository ownership, see:
<https://docs.github.com/en/repositories/creating-and-managing-repositories/transferring-a-repository>
`, "`"),
Example: heredoc.Doc(`
# Rename the current repository (foo/bar -> foo/baz)
$ gh repo rename baz
# Rename the specified repository (qux/quux -> qux/baz)
$ gh repo rename -R qux/quux baz
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
opts.BaseRepo = f.BaseRepo

View file

@ -55,7 +55,7 @@ func (g *gitExecuter) CurrentBranch() (string, error) {
func (g *gitExecuter) Fetch(remote, ref string) error {
args := []string{"fetch", "-q", remote, ref}
cmd, err := g.client.AuthenticatedCommand(context.Background(), args...)
cmd, err := g.client.AuthenticatedCommand(context.Background(), git.AllMatchingCredentialsPattern, args...)
if err != nil {
return err
}

View file

@ -4,6 +4,8 @@ import (
"errors"
"fmt"
"os/exec"
"strings"
"time"
"github.com/cli/cli/v2/pkg/extensions"
"github.com/cli/cli/v2/pkg/iostreams"
@ -14,10 +16,33 @@ type ExternalCommandExitError struct {
*exec.ExitError
}
type extensionReleaseInfo struct {
CurrentVersion string
LatestVersion string
Pinned bool
URL string
}
func NewCmdExtension(io *iostreams.IOStreams, em extensions.ExtensionManager, ext extensions.Extension) *cobra.Command {
updateMessageChan := make(chan *extensionReleaseInfo)
cs := io.ColorScheme()
return &cobra.Command{
Use: ext.Name(),
Short: fmt.Sprintf("Extension %s", ext.Name()),
// PreRun handles looking up whether extension has a latest version only when the command is ran.
PreRun: func(c *cobra.Command, args []string) {
go func() {
if ext.UpdateAvailable() {
updateMessageChan <- &extensionReleaseInfo{
CurrentVersion: ext.CurrentVersion(),
LatestVersion: ext.LatestVersion(),
Pinned: ext.IsPinned(),
URL: ext.URL(),
}
}
}()
},
RunE: func(c *cobra.Command, args []string) error {
args = append([]string{ext.Name()}, args...)
if _, err := em.Dispatch(args, io.In, io.Out, io.ErrOut); err != nil {
@ -29,6 +54,28 @@ func NewCmdExtension(io *iostreams.IOStreams, em extensions.ExtensionManager, ex
}
return nil
},
// PostRun handles communicating extension release information if found
PostRun: func(c *cobra.Command, args []string) {
select {
case releaseInfo := <-updateMessageChan:
if releaseInfo != nil {
stderr := io.ErrOut
fmt.Fprintf(stderr, "\n\n%s %s → %s\n",
cs.Yellowf("A new release of %s is available:", ext.Name()),
cs.Cyan(strings.TrimPrefix(releaseInfo.CurrentVersion, "v")),
cs.Cyan(strings.TrimPrefix(releaseInfo.LatestVersion, "v")))
if releaseInfo.Pinned {
fmt.Fprintf(stderr, "To upgrade, run: gh extension upgrade %s --force\n", ext.Name())
} else {
fmt.Fprintf(stderr, "To upgrade, run: gh extension upgrade %s\n", ext.Name())
}
fmt.Fprintf(stderr, "%s\n\n",
cs.Yellow(releaseInfo.URL))
}
case <-time.After(1 * time.Second):
// Bail on checking for new extension update as its taking too long
}
},
GroupID: "extension",
Annotations: map[string]string{
"skipAuthCheck": "true",

View file

@ -0,0 +1,159 @@
package root_test
import (
"io"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/pkg/cmd/root"
"github.com/cli/cli/v2/pkg/extensions"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewCmdExtension_Updates(t *testing.T) {
tests := []struct {
name string
extCurrentVersion string
extIsPinned bool
extLatestVersion string
extName string
extUpdateAvailable bool
extURL string
wantStderr string
}{
{
name: "no update available",
extName: "no-update",
extUpdateAvailable: false,
extCurrentVersion: "1.0.0",
extLatestVersion: "1.0.0",
extURL: "https//github.com/dne/no-update",
},
{
name: "major update",
extName: "major-update",
extUpdateAvailable: true,
extCurrentVersion: "1.0.0",
extLatestVersion: "2.0.0",
extURL: "https//github.com/dne/major-update",
wantStderr: heredoc.Doc(`
A new release of major-update is available: 1.0.0 2.0.0
To upgrade, run: gh extension upgrade major-update
https//github.com/dne/major-update
`),
},
{
name: "major update, pinned",
extName: "major-update",
extUpdateAvailable: true,
extCurrentVersion: "1.0.0",
extLatestVersion: "2.0.0",
extIsPinned: true,
extURL: "https//github.com/dne/major-update",
wantStderr: heredoc.Doc(`
A new release of major-update is available: 1.0.0 2.0.0
To upgrade, run: gh extension upgrade major-update --force
https//github.com/dne/major-update
`),
},
{
name: "minor update",
extName: "minor-update",
extUpdateAvailable: true,
extCurrentVersion: "1.0.0",
extLatestVersion: "1.1.0",
extURL: "https//github.com/dne/minor-update",
wantStderr: heredoc.Doc(`
A new release of minor-update is available: 1.0.0 1.1.0
To upgrade, run: gh extension upgrade minor-update
https//github.com/dne/minor-update
`),
},
{
name: "minor update, pinned",
extName: "minor-update",
extUpdateAvailable: true,
extCurrentVersion: "1.0.0",
extLatestVersion: "1.1.0",
extURL: "https//github.com/dne/minor-update",
extIsPinned: true,
wantStderr: heredoc.Doc(`
A new release of minor-update is available: 1.0.0 1.1.0
To upgrade, run: gh extension upgrade minor-update --force
https//github.com/dne/minor-update
`),
},
{
name: "patch update",
extName: "patch-update",
extUpdateAvailable: true,
extCurrentVersion: "1.0.0",
extLatestVersion: "1.0.1",
extURL: "https//github.com/dne/patch-update",
wantStderr: heredoc.Doc(`
A new release of patch-update is available: 1.0.0 1.0.1
To upgrade, run: gh extension upgrade patch-update
https//github.com/dne/patch-update
`),
},
{
name: "patch update, pinned",
extName: "patch-update",
extUpdateAvailable: true,
extCurrentVersion: "1.0.0",
extLatestVersion: "1.0.1",
extURL: "https//github.com/dne/patch-update",
extIsPinned: true,
wantStderr: heredoc.Doc(`
A new release of patch-update is available: 1.0.0 1.0.1
To upgrade, run: gh extension upgrade patch-update --force
https//github.com/dne/patch-update
`),
},
}
for _, tt := range tests {
ios, _, _, stderr := iostreams.Test()
em := &extensions.ExtensionManagerMock{
DispatchFunc: func(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) (bool, error) {
// Assume extension executed / dispatched without problems as test is focused on upgrade checking.
return true, nil
},
}
ext := &extensions.ExtensionMock{
CurrentVersionFunc: func() string {
return tt.extCurrentVersion
},
IsPinnedFunc: func() bool {
return tt.extIsPinned
},
LatestVersionFunc: func() string {
return tt.extLatestVersion
},
NameFunc: func() string {
return tt.extName
},
UpdateAvailableFunc: func() bool {
return tt.extUpdateAvailable
},
URLFunc: func() string {
return tt.extURL
},
}
cmd := root.NewCmdExtension(ios, em, ext)
_, err := cmd.ExecuteC()
require.NoError(t, err)
if tt.wantStderr == "" {
assert.Emptyf(t, stderr.String(), "executing extension command should output nothing to stderr")
} else {
assert.Containsf(t, stderr.String(), tt.wantStderr, "executing extension command should output message about upgrade to stderr")
}
}
}

View file

@ -41,12 +41,12 @@ var HelpTopics = []helpTopic{
{
name: "environment",
short: "Environment variables that can be used with gh",
long: heredoc.Docf(`
long: heredoc.Docf(`
%[1]sGH_TOKEN%[1]s, %[1]sGITHUB_TOKEN%[1]s (in order of precedence): an authentication token that will be used when
a command targets either github.com or a subdomain of ghe.com. Setting this avoids being prompted to
authenticate and takes precedence over previously stored credentials.
%[1]sGH_ENTERPRISE_TOKEN%[1]s, %[1]sGITHUB_ENTERPRISE_TOKEN%[1]s (in order of precedence): an authentication
%[1]sGH_ENTERPRISE_TOKEN%[1]s, %[1]sGITHUB_ENTERPRISE_TOKEN%[1]s (in order of precedence): an authentication
token that will be used when a command targets a GitHub Enterprise Server host.
%[1]sGH_HOST%[1]s: specify the GitHub hostname for commands where a hostname has not been provided, or
@ -90,7 +90,7 @@ var HelpTopics = []helpTopic{
checks for new releases once every 24 hours and displays an upgrade notice on standard
error if a newer version was found.
%[1]sGH_CONFIG_DIR%[1]s: the directory where gh will store configuration files. If not specified,
%[1]sGH_CONFIG_DIR%[1]s: the directory where gh will store configuration files. If not specified,
the default value will be one of the following paths (in order of precedence):
- %[1]s$XDG_CONFIG_HOME/gh%[1]s (if %[1]s$XDG_CONFIG_HOME%[1]s is set),
- %[1]s$AppData/GitHub CLI%[1]s (on Windows if %[1]s$AppData%[1]s is set), or

View file

@ -196,9 +196,9 @@ func TestRunCancel(t *testing.T) {
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"* cool commit, CI (trunk) Feb 23, 2021"},
[]string{"* cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "* cool commit, CI (trunk) Feb 23, 2021")
return prompter.IndexFor(opts, "* cool commit, CI [trunk] Feb 23, 2021")
})
},
wantOut: "✓ Request to cancel workflow 1234 submitted.\n",

View file

@ -133,7 +133,7 @@ func runDelete(opts *DeleteOptions) error {
return err
}
fmt.Fprintf(opts.IO.Out, "%s Request to delete workflow submitted.\n", cs.SuccessIcon())
fmt.Fprintf(opts.IO.Out, "%s Request to delete workflow run submitted.\n", cs.SuccessIcon())
return nil
}

View file

@ -110,7 +110,7 @@ func TestRunDelete(t *testing.T) {
httpmock.REST("DELETE", fmt.Sprintf("repos/OWNER/REPO/actions/runs/%d", shared.SuccessfulRun.ID)),
httpmock.StatusStringResponse(204, ""))
},
wantOut: "✓ Request to delete workflow submitted.\n",
wantOut: "✓ Request to delete workflow run submitted.\n",
},
{
name: "not found",
@ -132,7 +132,7 @@ func TestRunDelete(t *testing.T) {
},
prompterStubs: func(pm *prompter.PrompterMock) {
pm.SelectFunc = func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "✓ cool commit, CI (trunk) Feb 23, 2021")
return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021")
}
},
httpStubs: func(reg *httpmock.Registry) {
@ -153,7 +153,7 @@ func TestRunDelete(t *testing.T) {
httpmock.REST("DELETE", fmt.Sprintf("repos/OWNER/REPO/actions/runs/%d", shared.SuccessfulRun.ID)),
httpmock.StatusStringResponse(204, ""))
},
wantOut: "✓ Request to delete workflow submitted.\n",
wantOut: "✓ Request to delete workflow run submitted.\n",
},
}

View file

@ -6,6 +6,7 @@ import (
"path/filepath"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/safepaths"
"github.com/cli/cli/v2/pkg/cmd/run/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
@ -27,8 +28,9 @@ type DownloadOptions struct {
type platform interface {
List(runID string) ([]shared.Artifact, error)
Download(url string, dir string) error
Download(url string, dir safepaths.Absolute) error
}
type iprompter interface {
MultiSelect(string, []string, []string) ([]int, error)
}
@ -151,8 +153,15 @@ func runDownload(opts *DownloadOptions) error {
opts.IO.StartProgressIndicator()
defer opts.IO.StopProgressIndicator()
// track downloaded artifacts and avoid re-downloading any of the same name
// track downloaded artifacts and avoid re-downloading any of the same name, isolate if multiple artifacts
downloaded := set.NewStringSet()
isolateArtifacts := isolateArtifacts(wantNames, wantPatterns)
absoluteDestinationDir, err := safepaths.ParseAbsolute(opts.DestinationDir)
if err != nil {
return fmt.Errorf("error parsing destination directory: %w", err)
}
for _, a := range artifacts {
if a.Expired {
continue
@ -165,10 +174,19 @@ func runDownload(opts *DownloadOptions) error {
continue
}
}
destDir := opts.DestinationDir
if len(wantPatterns) != 0 || len(wantNames) != 1 {
destDir = filepath.Join(destDir, a.Name)
destDir := absoluteDestinationDir
if isolateArtifacts {
destDir, err = absoluteDestinationDir.Join(a.Name)
if err != nil {
var pathTraversalError safepaths.PathTraversalError
if errors.As(err, &pathTraversalError) {
return fmt.Errorf("error downloading %s: would result in path traversal", a.Name)
}
return err
}
}
err := opts.Platform.Download(a.DownloadURL, destDir)
if err != nil {
return fmt.Errorf("error downloading %s: %w", a.Name, err)
@ -183,6 +201,25 @@ func runDownload(opts *DownloadOptions) error {
return nil
}
func isolateArtifacts(wantNames []string, wantPatterns []string) bool {
if len(wantPatterns) > 0 {
// Patterns can match multiple artifacts
return true
}
if len(wantNames) == 0 {
// All artifacts wanted regardless what they are named
return true
}
if len(wantNames) > 1 {
// Multiple, specific artifacts wanted
return true
}
return false
}
func matchAnyName(names []string, name string) bool {
for _, n := range names {
if name == n {

View file

@ -2,19 +2,22 @@ package download
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"testing"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/prompter"
"github.com/cli/cli/v2/internal/safepaths"
"github.com/cli/cli/v2/pkg/cmd/run/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
@ -143,159 +146,584 @@ func Test_NewCmdDownload(t *testing.T) {
}
}
type run struct {
id string
testArtifacts []testArtifact
}
type testArtifact struct {
artifact shared.Artifact
files []string
}
type fakePlatform struct {
runs []run
}
func (f *fakePlatform) List(runID string) ([]shared.Artifact, error) {
runIds := map[string]struct{}{}
if runID != "" {
runIds[runID] = struct{}{}
} else {
for _, run := range f.runs {
runIds[run.id] = struct{}{}
}
}
var artifacts []shared.Artifact
for _, run := range f.runs {
// Skip over any runs that we aren't looking for
if _, ok := runIds[run.id]; !ok {
continue
}
// Grab the artifacts of everything else
for _, testArtifact := range run.testArtifacts {
artifacts = append(artifacts, testArtifact.artifact)
}
}
return artifacts, nil
}
func (f *fakePlatform) Download(url string, dir safepaths.Absolute) error {
if err := os.MkdirAll(dir.String(), 0755); err != nil {
return err
}
// Now to be consistent, we find the artifact with the provided URL.
// It's a bit janky to iterate the runs, to find the right artifact
// rather than keying directly to it, but it allows the setup of the
// fake platform to be declarative rather than imperative.
// Think fakePlatform { artifacts: ... } rather than fakePlatform.makeArtifactAvailable()
for _, run := range f.runs {
for _, testArtifact := range run.testArtifacts {
if testArtifact.artifact.DownloadURL == url {
for _, file := range testArtifact.files {
path := filepath.Join(dir.String(), file)
return os.WriteFile(path, []byte{}, 0600)
}
}
}
}
return errors.New("no artifact matches the provided URL")
}
func Test_runDownload(t *testing.T) {
tests := []struct {
name string
opts DownloadOptions
mockAPI func(*mockPlatform)
promptStubs func(*prompter.MockPrompter)
wantErr string
name string
opts DownloadOptions
platform *fakePlatform
promptStubs func(*prompter.MockPrompter)
expectedFiles []string
wantErr string
}{
{
name: "download non-expired",
name: "download non-expired to relative directory",
opts: DownloadOptions{
RunID: "2345",
DestinationDir: "./tmp",
Names: []string(nil),
},
mockAPI: func(p *mockPlatform) {
p.On("List", "2345").Return([]shared.Artifact{
platform: &fakePlatform{
runs: []run{
{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
id: "2345",
testArtifacts: []testArtifact{
{
artifact: shared.Artifact{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
},
files: []string{
"artifact-1-file",
},
},
{
artifact: shared.Artifact{
Name: "expired-artifact",
DownloadURL: "http://download.com/expired.zip",
Expired: true,
},
files: []string{
"expired",
},
},
{
artifact: shared.Artifact{
Name: "artifact-2",
DownloadURL: "http://download.com/artifact2.zip",
Expired: false,
},
files: []string{
"artifact-2-file",
},
},
},
},
{
Name: "expired-artifact",
DownloadURL: "http://download.com/expired.zip",
Expired: true,
},
{
Name: "artifact-2",
DownloadURL: "http://download.com/artifact2.zip",
Expired: false,
},
}, nil)
p.On("Download", "http://download.com/artifact1.zip", filepath.FromSlash("tmp/artifact-1")).Return(nil)
p.On("Download", "http://download.com/artifact2.zip", filepath.FromSlash("tmp/artifact-2")).Return(nil)
},
},
expectedFiles: []string{
filepath.Join("artifact-1", "artifact-1-file"),
filepath.Join("artifact-2", "artifact-2-file"),
},
},
{
name: "no valid artifacts",
name: "download non-expired to absolute directory",
opts: DownloadOptions{
RunID: "2345",
DestinationDir: ".",
Names: []string(nil),
DestinationDir: "/tmp",
},
mockAPI: func(p *mockPlatform) {
p.On("List", "2345").Return([]shared.Artifact{
platform: &fakePlatform{
runs: []run{
{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: true,
id: "2345",
testArtifacts: []testArtifact{
{
artifact: shared.Artifact{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
},
files: []string{
"artifact-1-file",
},
},
{
artifact: shared.Artifact{
Name: "expired-artifact",
DownloadURL: "http://download.com/expired.zip",
Expired: true,
},
files: []string{
"expired",
},
},
{
artifact: shared.Artifact{
Name: "artifact-2",
DownloadURL: "http://download.com/artifact2.zip",
Expired: false,
},
files: []string{
"artifact-2-file",
},
},
},
},
{
Name: "artifact-2",
DownloadURL: "http://download.com/artifact2.zip",
Expired: true,
},
}, nil)
},
},
wantErr: "no valid artifacts found to download",
expectedFiles: []string{
filepath.Join("artifact-1", "artifact-1-file"),
filepath.Join("artifact-2", "artifact-2-file"),
},
},
{
name: "all artifacts are expired",
opts: DownloadOptions{
RunID: "2345",
},
platform: &fakePlatform{
runs: []run{
{
id: "2345",
testArtifacts: []testArtifact{
{
artifact: shared.Artifact{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: true,
},
files: []string{
"artifact-1-file",
},
},
{
artifact: shared.Artifact{
Name: "artifact-2",
DownloadURL: "http://download.com/artifact2.zip",
Expired: true,
},
files: []string{
"artifact-2-file",
},
},
},
},
},
},
expectedFiles: []string{},
wantErr: "no valid artifacts found to download",
},
{
name: "no name matches",
opts: DownloadOptions{
RunID: "2345",
DestinationDir: ".",
Names: []string{"artifact-3"},
RunID: "2345",
Names: []string{"artifact-3"},
},
mockAPI: func(p *mockPlatform) {
p.On("List", "2345").Return([]shared.Artifact{
platform: &fakePlatform{
runs: []run{
{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
id: "2345",
testArtifacts: []testArtifact{
{
artifact: shared.Artifact{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
},
files: []string{
"artifact-1-file",
},
},
{
artifact: shared.Artifact{
Name: "artifact-2",
DownloadURL: "http://download.com/artifact2.zip",
Expired: false,
},
files: []string{
"artifact-2-file",
},
},
},
},
{
Name: "artifact-2",
DownloadURL: "http://download.com/artifact2.zip",
Expired: false,
},
}, nil)
},
},
expectedFiles: []string{},
wantErr: "no artifact matches any of the names or patterns provided",
},
{
name: "pattern matches",
opts: DownloadOptions{
RunID: "2345",
FilePatterns: []string{"artifact-*"},
},
platform: &fakePlatform{
runs: []run{
{
id: "2345",
testArtifacts: []testArtifact{
{
artifact: shared.Artifact{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
},
files: []string{
"artifact-1-file",
},
},
{
artifact: shared.Artifact{
Name: "non-artifact-2",
DownloadURL: "http://download.com/non-artifact-2.zip",
Expired: false,
},
files: []string{
"non-artifact-2-file",
},
},
{
artifact: shared.Artifact{
Name: "artifact-3",
DownloadURL: "http://download.com/artifact3.zip",
Expired: false,
},
files: []string{
"artifact-3-file",
},
},
},
},
},
},
expectedFiles: []string{
filepath.Join("artifact-1", "artifact-1-file"),
filepath.Join("artifact-3", "artifact-3-file"),
},
wantErr: "no artifact matches any of the names or patterns provided",
},
{
name: "no pattern matches",
opts: DownloadOptions{
RunID: "2345",
DestinationDir: ".",
FilePatterns: []string{"artifiction-*"},
RunID: "2345",
FilePatterns: []string{"artifiction-*"},
},
mockAPI: func(p *mockPlatform) {
p.On("List", "2345").Return([]shared.Artifact{
platform: &fakePlatform{
runs: []run{
{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
id: "2345",
testArtifacts: []testArtifact{
{
artifact: shared.Artifact{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
},
files: []string{
"artifact-1-file",
},
},
{
artifact: shared.Artifact{
Name: "artifact-2",
DownloadURL: "http://download.com/artifact2.zip",
Expired: false,
},
files: []string{
"artifact-2-file",
},
},
},
},
{
Name: "artifact-2",
DownloadURL: "http://download.com/artifact2.zip",
Expired: false,
},
}, nil)
},
},
expectedFiles: []string{},
wantErr: "no artifact matches any of the names or patterns provided",
},
{
name: "want specific single artifact",
opts: DownloadOptions{
RunID: "2345",
Names: []string{"non-artifact-2"},
},
platform: &fakePlatform{
runs: []run{
{
id: "2345",
testArtifacts: []testArtifact{
{
artifact: shared.Artifact{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
},
files: []string{
"artifact-1-file",
},
},
{
artifact: shared.Artifact{
Name: "non-artifact-2",
DownloadURL: "http://download.com/non-artifact-2.zip",
Expired: false,
},
files: []string{
"non-artifact-2-file",
},
},
{
artifact: shared.Artifact{
Name: "artifact-3",
DownloadURL: "http://download.com/artifact3.zip",
Expired: false,
},
files: []string{
"artifact-3-file",
},
},
},
},
},
},
expectedFiles: []string{
filepath.Join("non-artifact-2-file"),
},
},
{
name: "want specific multiple artifacts",
opts: DownloadOptions{
RunID: "2345",
Names: []string{"artifact-1", "artifact-3"},
},
platform: &fakePlatform{
runs: []run{
{
id: "2345",
testArtifacts: []testArtifact{
{
artifact: shared.Artifact{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
},
files: []string{
"artifact-1-file",
},
},
{
artifact: shared.Artifact{
Name: "non-artifact-2",
DownloadURL: "http://download.com/non-artifact-2.zip",
Expired: false,
},
files: []string{
"non-artifact-2-file",
},
},
{
artifact: shared.Artifact{
Name: "artifact-3",
DownloadURL: "http://download.com/artifact3.zip",
Expired: false,
},
files: []string{
"artifact-3-file",
},
},
},
},
},
},
expectedFiles: []string{
filepath.Join("artifact-1", "artifact-1-file"),
filepath.Join("artifact-3", "artifact-3-file"),
},
},
{
name: "avoid redownloading files of the same name",
opts: DownloadOptions{
RunID: "2345",
},
platform: &fakePlatform{
runs: []run{
{
id: "2345",
testArtifacts: []testArtifact{
{
artifact: shared.Artifact{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
},
files: []string{
"artifact-1-file",
},
},
{
artifact: shared.Artifact{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact2.zip",
Expired: false,
},
files: []string{
"artifact-2-file",
},
},
},
},
},
},
expectedFiles: []string{
filepath.Join("artifact-1", "artifact-1-file"),
},
wantErr: "no artifact matches any of the names or patterns provided",
},
{
name: "prompt to select artifact",
opts: DownloadOptions{
RunID: "",
DoPrompt: true,
DestinationDir: ".",
Names: []string(nil),
RunID: "",
DoPrompt: true,
Names: []string(nil),
},
mockAPI: func(p *mockPlatform) {
p.On("List", "").Return([]shared.Artifact{
platform: &fakePlatform{
runs: []run{
{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
id: "2345",
testArtifacts: []testArtifact{
{
artifact: shared.Artifact{
Name: "artifact-1",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
},
files: []string{
"artifact-1-file",
},
},
{
artifact: shared.Artifact{
Name: "expired-artifact",
DownloadURL: "http://download.com/expired.zip",
Expired: true,
},
files: []string{
"expired",
},
},
},
},
{
Name: "expired-artifact",
DownloadURL: "http://download.com/expired.zip",
Expired: true,
id: "6789",
testArtifacts: []testArtifact{
{
artifact: shared.Artifact{
Name: "artifact-2",
DownloadURL: "http://download.com/artifact2.zip",
Expired: false,
},
files: []string{
"artifact-2-file",
},
},
},
},
{
Name: "artifact-2",
DownloadURL: "http://download.com/artifact2.zip",
Expired: false,
},
{
Name: "artifact-2",
DownloadURL: "http://download.com/artifact2.also.zip",
Expired: false,
},
}, nil)
p.On("Download", "http://download.com/artifact2.zip", ".").Return(nil)
},
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterMultiSelect("Select artifacts to download:", nil, []string{"artifact-1", "artifact-2"},
func(_ string, _, opts []string) ([]int, error) {
return []int{1}, nil
for i, o := range opts {
if o == "artifact-2" {
return []int{i}, nil
}
}
return nil, fmt.Errorf("no artifact-2 found in %v", opts)
})
},
expectedFiles: []string{
filepath.Join("artifact-2-file"),
},
},
{
name: "handling artifact name with path traversal exploit",
opts: DownloadOptions{
RunID: "2345",
},
platform: &fakePlatform{
runs: []run{
{
id: "2345",
testArtifacts: []testArtifact{
{
artifact: shared.Artifact{
Name: "..",
DownloadURL: "http://download.com/artifact1.zip",
Expired: false,
},
files: []string{
"etc/passwd",
},
},
},
},
},
},
expectedFiles: []string{},
wantErr: "error downloading ..: would result in path traversal",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
opts := &tt.opts
if opts.DestinationDir == "" {
opts.DestinationDir = t.TempDir()
} else {
opts.DestinationDir = filepath.Join(t.TempDir(), opts.DestinationDir)
}
ios, _, stdout, stderr := iostreams.Test()
opts.IO = ios
opts.Platform = newMockPlatform(t, tt.mockAPI)
opts.Platform = tt.platform
pm := prompter.NewMockPrompter(t)
opts.Prompter = pm
@ -310,34 +738,31 @@ func Test_runDownload(t *testing.T) {
require.NoError(t, err)
}
// Check that the exact number of files exist
require.Equal(t, len(tt.expectedFiles), countFilesInDirRecursively(t, opts.DestinationDir))
// Then check that the exact files are correct
for _, name := range tt.expectedFiles {
require.FileExists(t, filepath.Join(opts.DestinationDir, name))
}
assert.Equal(t, "", stdout.String())
assert.Equal(t, "", stderr.String())
})
}
}
type mockPlatform struct {
mock.Mock
}
func countFilesInDirRecursively(t *testing.T, dir string) int {
t.Helper()
func newMockPlatform(t *testing.T, config func(*mockPlatform)) *mockPlatform {
m := &mockPlatform{}
m.Test(t)
t.Cleanup(func() {
m.AssertExpectations(t)
})
if config != nil {
config(m)
}
return m
}
count := 0
require.NoError(t, filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
require.NoError(t, err)
if !info.IsDir() {
count++
}
return nil
}))
func (p *mockPlatform) List(runID string) ([]shared.Artifact, error) {
args := p.Called(runID)
return args.Get(0).([]shared.Artifact), args.Error(1)
}
func (p *mockPlatform) Download(url string, dir string) error {
args := p.Called(url, dir)
return args.Error(0)
return count
}

View file

@ -9,6 +9,7 @@ import (
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/safepaths"
"github.com/cli/cli/v2/pkg/cmd/run/shared"
)
@ -21,11 +22,11 @@ func (p *apiPlatform) List(runID string) ([]shared.Artifact, error) {
return shared.ListArtifacts(p.client, p.repo, runID)
}
func (p *apiPlatform) Download(url string, dir string) error {
func (p *apiPlatform) Download(url string, dir safepaths.Absolute) error {
return downloadArtifact(p.client, url, dir)
}
func downloadArtifact(httpClient *http.Client, url, destDir string) error {
func downloadArtifact(httpClient *http.Client, url string, destDir safepaths.Absolute) error {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err

View file

@ -9,6 +9,7 @@ import (
"testing"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/safepaths"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -58,7 +59,8 @@ func Test_List_perRepository(t *testing.T) {
func Test_Download(t *testing.T) {
tmpDir := t.TempDir()
destDir := filepath.Join(tmpDir, "artifact")
destDir, err := safepaths.ParseAbsolute(filepath.Join(tmpDir, "artifact"))
require.NoError(t, err)
reg := &httpmock.Registry{}
defer reg.Verify(t)
@ -70,8 +72,7 @@ func Test_Download(t *testing.T) {
api := &apiPlatform{
client: &http.Client{Transport: reg},
}
err := api.Download("https://api.github.com/repos/OWNER/REPO/actions/artifacts/12345/zip", destDir)
require.NoError(t, err)
require.NoError(t, api.Download("https://api.github.com/repos/OWNER/REPO/actions/artifacts/12345/zip", destDir))
var paths []string
parentPrefix := tmpDir + string(filepath.Separator)

View file

@ -2,11 +2,13 @@ package download
import (
"archive/zip"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/cli/cli/v2/internal/safepaths"
)
const (
@ -15,12 +17,17 @@ const (
execMode os.FileMode = 0755
)
func extractZip(zr *zip.Reader, destDir string) error {
func extractZip(zr *zip.Reader, destDir safepaths.Absolute) error {
for _, zf := range zr.File {
fpath := filepath.Join(destDir, filepath.FromSlash(zf.Name))
if !filepathDescendsFrom(fpath, destDir) {
continue
fpath, err := destDir.Join(zf.Name)
if err != nil {
var pathTraversalError safepaths.PathTraversalError
if errors.As(err, &pathTraversalError) {
continue
}
return err
}
if err := extractZipFile(zf, fpath); err != nil {
return fmt.Errorf("error extracting %q: %w", zf.Name, err)
}
@ -28,10 +35,10 @@ func extractZip(zr *zip.Reader, destDir string) error {
return nil
}
func extractZipFile(zf *zip.File, dest string) (extractErr error) {
func extractZipFile(zf *zip.File, dest safepaths.Absolute) (extractErr error) {
zm := zf.Mode()
if zm.IsDir() {
extractErr = os.MkdirAll(dest, dirMode)
extractErr = os.MkdirAll(dest.String(), dirMode)
return
}
@ -42,14 +49,12 @@ func extractZipFile(zf *zip.File, dest string) (extractErr error) {
}
defer f.Close()
if dir := filepath.Dir(dest); dir != "." {
if extractErr = os.MkdirAll(dir, dirMode); extractErr != nil {
return
}
if extractErr = os.MkdirAll(filepath.Dir(dest.String()), dirMode); extractErr != nil {
return
}
var df *os.File
if df, extractErr = os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_EXCL, getPerm(zm)); extractErr != nil {
if df, extractErr = os.OpenFile(dest.String(), os.O_WRONLY|os.O_CREATE|os.O_EXCL, getPerm(zm)); extractErr != nil {
return
}
@ -69,15 +74,3 @@ func getPerm(m os.FileMode) os.FileMode {
}
return execMode
}
func filepathDescendsFrom(p, dir string) bool {
p = filepath.Clean(p)
dir = filepath.Clean(dir)
if dir == "." && !filepath.IsAbs(p) {
return !strings.HasPrefix(p, ".."+string(filepath.Separator))
}
if !strings.HasSuffix(dir, string(filepath.Separator)) {
dir += string(filepath.Separator)
}
return strings.HasPrefix(p, dir)
}

View file

@ -6,12 +6,14 @@ import (
"path/filepath"
"testing"
"github.com/cli/cli/v2/internal/safepaths"
"github.com/stretchr/testify/require"
)
func Test_extractZip(t *testing.T) {
tmpDir := t.TempDir()
extractPath := filepath.Join(tmpDir, "artifact")
extractPath, err := safepaths.ParseAbsolute(filepath.Join(tmpDir, "artifact"))
require.NoError(t, err)
zipFile, err := zip.OpenReader("./fixtures/myproject.zip")
require.NoError(t, err)
@ -20,122 +22,6 @@ func Test_extractZip(t *testing.T) {
err = extractZip(&zipFile.Reader, extractPath)
require.NoError(t, err)
_, err = os.Stat(filepath.Join(extractPath, "src", "main.go"))
_, err = os.Stat(filepath.Join(extractPath.String(), "src", "main.go"))
require.NoError(t, err)
}
func Test_filepathDescendsFrom(t *testing.T) {
type args struct {
p string
dir string
}
tests := []struct {
name string
args args
want bool
}{
{
name: "root child",
args: args{
p: filepath.FromSlash("/hoi.txt"),
dir: filepath.FromSlash("/"),
},
want: true,
},
{
name: "abs descendant",
args: args{
p: filepath.FromSlash("/var/logs/hoi.txt"),
dir: filepath.FromSlash("/"),
},
want: true,
},
{
name: "abs trailing slash",
args: args{
p: filepath.FromSlash("/var/logs/hoi.txt"),
dir: filepath.FromSlash("/var/logs/"),
},
want: true,
},
{
name: "abs mismatch",
args: args{
p: filepath.FromSlash("/var/logs/hoi.txt"),
dir: filepath.FromSlash("/var/pids"),
},
want: false,
},
{
name: "abs partial prefix",
args: args{
p: filepath.FromSlash("/var/logs/hoi.txt"),
dir: filepath.FromSlash("/var/log"),
},
want: false,
},
{
name: "rel child",
args: args{
p: filepath.FromSlash("hoi.txt"),
dir: filepath.FromSlash("."),
},
want: true,
},
{
name: "rel descendant",
args: args{
p: filepath.FromSlash("./log/hoi.txt"),
dir: filepath.FromSlash("."),
},
want: true,
},
{
name: "mixed rel styles",
args: args{
p: filepath.FromSlash("./log/hoi.txt"),
dir: filepath.FromSlash("log"),
},
want: true,
},
{
name: "rel clean",
args: args{
p: filepath.FromSlash("cats/../dogs/pug.txt"),
dir: filepath.FromSlash("dogs"),
},
want: true,
},
{
name: "rel mismatch",
args: args{
p: filepath.FromSlash("dogs/pug.txt"),
dir: filepath.FromSlash("dog"),
},
want: false,
},
{
name: "rel breakout",
args: args{
p: filepath.FromSlash("../escape.txt"),
dir: filepath.FromSlash("."),
},
want: false,
},
{
name: "rel sneaky breakout",
args: args{
p: filepath.FromSlash("dogs/../../escape.txt"),
dir: filepath.FromSlash("dogs"),
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := filepathDescendsFrom(tt.args.p, tt.args.dir); got != tt.want {
t.Errorf("filepathDescendsFrom() = %v, want %v", got, tt.want)
}
})
}
}

View file

@ -341,7 +341,7 @@ func TestRerun(t *testing.T) {
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"X cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021"},
[]string{"X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return 2, nil
})

View file

@ -508,7 +508,7 @@ func SelectRun(p Prompter, cs *iostreams.ColorScheme, runs []Run) (string, error
symbol, _ := Symbol(cs, run.Status, run.Conclusion)
candidates = append(candidates,
// TODO truncate commit message, long ones look terrible
fmt.Sprintf("%s %s, %s (%s) %s", symbol, run.Title(), run.WorkflowName(), run.HeadBranch, preciseAgo(now, run.StartedTime())))
fmt.Sprintf("%s %s, %s [%s] %s", symbol, run.Title(), run.WorkflowName(), run.HeadBranch, preciseAgo(now, run.StartedTime())))
}
selected, err := p.Select("Select a workflow run", "", candidates)

View file

@ -543,9 +543,9 @@ func TestViewRun(t *testing.T) {
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"X cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "✓ cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021"},
[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "✓ cool commit, CI (trunk) Feb 23, 2021")
return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021")
})
},
opts: &ViewOptions{
@ -593,9 +593,9 @@ func TestViewRun(t *testing.T) {
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"X cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "✓ cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021"},
[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "✓ cool commit, CI (trunk) Feb 23, 2021")
return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021")
})
pm.RegisterSelect("View a specific job in this run?",
[]string{"View all jobs in this run", "✓ cool job", "X sad job"},
@ -646,9 +646,9 @@ func TestViewRun(t *testing.T) {
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"X cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "✓ cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021"},
[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "✓ cool commit, CI (trunk) Feb 23, 2021")
return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021")
})
pm.RegisterSelect("View a specific job in this run?",
[]string{"View all jobs in this run", "✓ cool job", "X sad job"},
@ -743,9 +743,9 @@ func TestViewRun(t *testing.T) {
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"X cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "✓ cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021"},
[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "✓ cool commit, CI (trunk) Feb 23, 2021")
return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021")
})
pm.RegisterSelect("View a specific job in this run?",
[]string{"View all jobs in this run", "✓ cool job", "X sad job"},
@ -823,7 +823,7 @@ func TestViewRun(t *testing.T) {
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"X cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "✓ cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021"},
[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return 4, nil
})
@ -876,7 +876,7 @@ func TestViewRun(t *testing.T) {
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"X cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "✓ cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021"},
[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return 4, nil
})
@ -950,7 +950,7 @@ func TestViewRun(t *testing.T) {
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"X cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "✓ cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021"},
[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return 4, nil
})
@ -1104,9 +1104,9 @@ func TestViewRun(t *testing.T) {
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"X cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "✓ cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021"},
[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "✓ cool commit, CI (trunk) Feb 23, 2021")
return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021")
})
pm.RegisterSelect("View a specific job in this run?",
[]string{"View all jobs in this run", "✓ cool job", "X sad job"},
@ -1155,9 +1155,9 @@ func TestViewRun(t *testing.T) {
},
promptStubs: func(pm *prompter.MockPrompter) {
pm.RegisterSelect("Select a workflow run",
[]string{"X cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "✓ cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "- cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "* cool commit, CI (trunk) Feb 23, 2021", "X cool commit, CI (trunk) Feb 23, 2021"},
[]string{"X cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "✓ cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "- cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "* cool commit, CI [trunk] Feb 23, 2021", "X cool commit, CI [trunk] Feb 23, 2021"},
func(_, _ string, opts []string) (int, error) {
return prompter.IndexFor(opts, "✓ cool commit, CI (trunk) Feb 23, 2021")
return prompter.IndexFor(opts, "✓ cool commit, CI [trunk] Feb 23, 2021")
})
pm.RegisterSelect("View a specific job in this run?",
[]string{"View all jobs in this run", "✓ cool job", "X sad job"},

Some files were not shown because too many files have changed in this diff Show more