Skip to content

feat(ENG-14007): add cloudsmith repos gpg command group - #391

Open
BartoszBlizniak wants to merge 8 commits into
cloudsmith-io:masterfrom
BartoszBlizniak:eng-14007-cli-add-gpg-key-command
Open

feat(ENG-14007): add cloudsmith repos gpg command group#391
BartoszBlizniak wants to merge 8 commits into
cloudsmith-io:masterfrom
BartoszBlizniak:eng-14007-cli-add-gpg-key-command

Conversation

@BartoszBlizniak

@BartoszBlizniak BartoszBlizniak commented Aug 25, 2026

Copy link
Copy Markdown
Member

Description

Adds cloudsmith repos gpg for managing the GPG key a repository signs its package indexes with: get reads the active key, upload installs one you supply, regenerate replaces it with a fresh Cloudsmith key. There's no delete - the API has no way to remove a repository's key.

Three judgement calls worth a look; the rest is plumbing.

regenerate needs a typed word, not a y/N. It's irreversible and breaks every consumer pinned to the old fingerprint, so a reflexive y is too cheap. With no terminal attached it fails instead of blocking; -y is still how a pipeline runs it.

$ cloudsmith repos gpg regenerate your-org/your-repo

Regenerating a repository's GPG key is irrevocable. The old key is discarded
and every consumer verifying against its fingerprint will need to fetch and
trust the new one before their next install succeeds.

Type 'regenerate' to confirm: regenerate
Regenerating GPG key ... OK

upload and regenerate take -n/--dry-run. It validates the inputs, reads the key currently in place and names the fingerprint that would be replaced, then stops - so a mistyped repository or a dead credential fails in the rehearsal rather than on the attempt. It never prompts for a passphrase, since nothing is sent.

Actionable API failures read as one sentence. handle_api_exceptions gained an optional per-status summary map, used for the three statuses a person can act on: Could not set GPG key for your-repo: custom GPG keys require a paid plan. Every other status, and all JSON output, keeps the existing rendering - no other command's output changes.

Secrets only ever come from a file, stdin or a hidden prompt, never a command-line value. --debug is refused on upload, because it would log the request body.

Type of Change

  • New feature
  • Bug fix (test isolation - see below)
  • Breaking change
  • Documentation update
  • Refactoring
  • Other (please describe)

Additional Notes

Two changes here aren't about GPG. Both were found while building this, and both explain files outside repos.py in the diff:

  1. cli/tests/conftest.py - config.get_or_create_options caches Options in a thread-local, so sticky state (--debug) leaked between CLI invocations in the same test process. Three tests this branch doesn't touch fail without the new autouse fixture. Test-only: a real invocation builds Options once.
  2. cli/exceptions.py - the standard error block moved into a helper so the summary map could sit beside it. Behaviour-identical for every other caller.

Verified against a live organisation on a throwaway repository (since deleted): the typed confirmation accepted and declined, a real regenerate, a real key upload, both dry runs, the non-terminal refusal, and the 400/404 error voices. The prompt was driven over a pty - CliRunner has no terminal, so the tests that exercise it patch the terminal check.

N/A: no screenshots - CLI change, and the output above covers it.

Adds CLI support for managing a repository's GPG signing key, closing the
gap noted in cloudsmith-io#83: the API has supported this
since repos_gpg_list/create/regenerate were added to the SDK, but the CLI
never exposed it.

- core/api/repos.py: list_repo_gpg_key, create_repo_gpg_key and
  regenerate_repo_gpg_key wrappers around the SDK's repos_gpg_* endpoints,
  translating SDK exceptions to ApiException per existing convention.
- cli/commands/repos.py: `cloudsmith repos gpg get|upload|regenerate
  OWNER/REPO`. Key/passphrase material is only ever read from a file (or
  stdin via '-') or an interactive hide_input prompt, never a bare
  command-line flag, so it can't leak into shell history or the process
  list. `regenerate` asks for confirmation first (like `repos delete`),
  since it invalidates the repository's current key.
- Tests: httpretty-mocked API tests in core/tests/test_repos.py, and
  mock-patched CLI tests in cli/tests/commands/test_repos.py.

There is no delete/rotate-off endpoint on the backend, so there's no
`delete` subcommand - `get`/`upload`/`regenerate` is the full surface.
`config.get_or_create_options` caches the Options object in a
thread-local, so state that is sticky by design - `--debug` in
particular - leaked from one test's CLI invocation into every later one
in the same process. Two GPG tests already failed because of it, and any
test that runs after one passing `--debug` was at risk.

The two tests that needed a clean Options object cleared the
thread-local themselves; do it for every CLI test instead.
Applies the command-design review of `cloudsmith repos gpg`:

- `regenerate` now requires the word "regenerate" to be typed, instead
  of a y/N answer, and states what is irrevocable about it before
  asking. Anything else typed declines with "Not confirmed. No changes
  made." and sends nothing. With no terminal attached the command fails
  with a usage error rather than blocking on a question nobody can
  answer, so `-y/--yes` stays the way to run it unattended.
- Both mutating subcommands accept `-n/--dry-run`, which resolves and
  validates the inputs (so an empty key file still errors) and reports
  what would change without calling the API.
- The GPG failures a person can act on now read as one sentence, e.g.
  "Could not set GPG key for your-repo: custom GPG keys require a paid
  plan." `handle_api_exceptions` takes an optional per-status summary
  map for this; unmapped statuses and JSON output keep the existing
  rendering, so nothing else changes shape.
- `upload` only prompts for a passphrase when a terminal is attached;
  without one it takes the key to be unencrypted instead of aborting on
  EOF, which is what the documented behaviour always claimed. The
  prompt also goes to stderr under `-F json` so stdout stays a single
  parseable document.
The `gpg upload` guards that stop the private key and the passphrase
both being read from stdin compared the streams click returned against
`click.get_text_stream("stdin")`. `click.File` builds a fresh
`_NonClosingTextIOWrapper` for `-` on every conversion, so that identity
only ever holds under `CliRunner`, where the runner's own stdin object is
handed back unchanged. In a real process both guards were inert:
`--private-key-file - --passphrase-file -` read the key and then took the
passphrase from an already-drained stdin, and `--private-key-file -` on
its own silently assumed the key was unencrypted.

Record the literal value each secret file option was given on the click
context instead, and key the guards off that. This is exact in both a
real process and under `CliRunner`, so the existing tests now prove the
behaviour they claim to.

Also sort the conftest imports, which the new `OPTIONS` import left
out of order.
A dry run that only echoed its own arguments could not catch the two
mistakes it is there to catch: a mistyped repository and a credential
that no longer works. Both mutating subcommands now read the key
currently in place first, report the fingerprint the real run would
replace, and stop before the mutating request - so those failures land
in the rehearsal, in the same voice the real command would use.

`upload --dry-run` also no longer prompts for the passphrase. Nothing is
being sent, so there is no reason to make anyone type a real secret; the
line says which source the real run would use instead.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds repository GPG key management to the CLI.

Changes:

  • Adds get, upload, and regenerate GPG commands with secure secret handling.
  • Adds dry runs, typed confirmation, and actionable API errors.
  • Adds comprehensive CLI and API tests plus test-state isolation.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.

Show a summary per file
File Description
CHANGELOG.md Documents the new commands.
cloudsmith_cli/cli/commands/repos.py Implements GPG CLI workflows.
cloudsmith_cli/cli/exceptions.py Supports status-specific error summaries.
cloudsmith_cli/cli/tests/commands/test_repos.py Tests GPG command behavior.
cloudsmith_cli/cli/tests/conftest.py Resets cached CLI options between tests.
cloudsmith_cli/core/api/repos.py Adds GPG API wrappers.
cloudsmith_cli/core/tests/test_repos.py Tests GPG API requests and errors.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@BartoszBlizniak
BartoszBlizniak marked this pull request as ready for review August 25, 2026 16:28
@BartoszBlizniak
BartoszBlizniak requested a review from a team as a code owner August 25, 2026 16:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants