Merge branch 'trunk' into fetch-artifact-attestation-bundles-with-sas-url
This commit is contained in:
commit
7160f7ef50
104 changed files with 3977 additions and 1292 deletions
2
.github/workflows/deployment.yml
vendored
2
.github/workflows/deployment.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/triage.yml
vendored
4
.github/workflows/triage.yml
vendored
|
|
@ -35,6 +35,8 @@ jobs:
|
|||
|
||||
---
|
||||
|
||||
cc: @github/cli
|
||||
|
||||
> $BODY
|
||||
EOF
|
||||
|
||||
|
|
@ -63,5 +65,7 @@ jobs:
|
|||
|
||||
---
|
||||
|
||||
cc: @github/cli
|
||||
|
||||
> $BODY
|
||||
EOF
|
||||
|
|
|
|||
34
README.md
34
README.md
|
|
@ -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].
|
||||
|
|
|
|||
33
acceptance/testdata/pr/pr-checkout-by-number.txtar
vendored
Normal file
33
acceptance/testdata/pr/pr-checkout-by-number.txtar
vendored
Normal 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'''
|
||||
37
acceptance/testdata/pr/pr-checkout-with-url-from-fork.txtar
vendored
Normal file
37
acceptance/testdata/pr/pr-checkout-with-url-from-fork.txtar
vendored
Normal 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'''
|
||||
37
acceptance/testdata/pr/pr-create-from-issue-develop-base.txtar
vendored
Normal file
37
acceptance/testdata/pr/pr-create-from-issue-develop-base.txtar
vendored
Normal 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'
|
||||
34
acceptance/testdata/pr/pr-create-from-manual-merge-base.txtar
vendored
Normal file
34
acceptance/testdata/pr/pr-create-from-manual-merge-base.txtar
vendored
Normal 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'
|
||||
39
acceptance/testdata/pr/pr-view-same-org-fork.txtar
vendored
Normal file
39
acceptance/testdata/pr/pr-view-same-org-fork.txtar
vendored
Normal 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'
|
||||
35
acceptance/testdata/repo/repo-create-bare.txtar
vendored
Normal file
35
acceptance/testdata/repo/repo-create-bare.txtar
vendored
Normal 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'
|
||||
36
acceptance/testdata/workflow/cache-list-empty.txtar
vendored
Normal file
36
acceptance/testdata/workflow/cache-list-empty.txtar
vendored
Normal 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 '\[\]'
|
||||
|
|
@ -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
|
||||
|
|
|
|||
71
acceptance/testdata/workflow/run-download-traversal.txtar
vendored
Normal file
71
acceptance/testdata/workflow/run-download-traversal.txtar
vendored
Normal 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
|
||||
42
acceptance/testdata/workflow/run-download.txtar
vendored
42
acceptance/testdata/workflow/run-download.txtar
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ type PullRequest struct {
|
|||
Closed bool
|
||||
URL string
|
||||
BaseRefName string
|
||||
BaseRefOid string
|
||||
HeadRefName string
|
||||
HeadRefOid string
|
||||
Body string
|
||||
|
|
|
|||
|
|
@ -285,6 +285,7 @@ var PullRequestFields = append(sharedIssuePRFields,
|
|||
"additions",
|
||||
"autoMergeRequest",
|
||||
"baseRefName",
|
||||
"baseRefOid",
|
||||
"changedFiles",
|
||||
"commits",
|
||||
"deletions",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
20
go.mod
|
|
@ -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
36
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
74
internal/safepaths/absolute.go
Normal file
74
internal/safepaths/absolute.go
Normal 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...))
|
||||
}
|
||||
137
internal/safepaths/absolute_test.go
Normal file
137
internal/safepaths/absolute_test.go
Normal 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 "/"
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
73
pkg/cmd/attestation/verify/attestation.go
Normal file
73
pkg/cmd/attestation/verify/attestation.go
Normal 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
|
||||
}
|
||||
117
pkg/cmd/attestation/verify/attestation_integration_test.go
Normal file
117
pkg/cmd/attestation/verify/attestation_integration_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
24
pkg/cmd/cache/delete/delete.go
vendored
24
pkg/cmd/cache/delete/delete.go
vendored
|
|
@ -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 {
|
||||
|
|
|
|||
2
pkg/cmd/cache/list/list.go
vendored
2
pkg/cmd/cache/list/list.go
vendored
|
|
@ -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)))
|
||||
}
|
||||
|
||||
|
|
|
|||
62
pkg/cmd/cache/list/list_test.go
vendored
62
pkg/cmd/cache/list/list_test.go
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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, "")
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ type IssueMetadataState struct {
|
|||
Body string
|
||||
Title string
|
||||
|
||||
Template string
|
||||
|
||||
Metadata []string
|
||||
Reviewers []string
|
||||
Assignees []string
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ func TestJSONFields(t *testing.T) {
|
|||
"author",
|
||||
"autoMergeRequest",
|
||||
"baseRefName",
|
||||
"baseRefOid",
|
||||
"body",
|
||||
"changedFiles",
|
||||
"closed",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
159
pkg/cmd/root/extension_test.go
Normal file
159
pkg/cmd/root/extension_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue