feat(ENG-14007): add cloudsmith repos gpg command group - #391
Merged
cloudsmith-iduffy merged 13 commits intoAug 26, 2026
Merged
Conversation
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.
6 tasks
Contributor
There was a problem hiding this comment.
Pull request overview
Adds repository GPG key management to the CLI.
Changes:
- Adds
get,upload, andregenerateGPG 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
marked this pull request as ready for review
August 25, 2026 16:28
Binary (non-armored) --private-key-file now fails with a friendly message instead of a raw UnicodeDecodeError. Error summaries for GPG failures now name owner/repo instead of just repo, since a 404 can be caused by either. The 400 "provided key is not valid" mapping is scoped to the real upload request only - regenerate and both dry-run pre-flights never send a key, so they fall through to the standard rendering on a 400 instead of misattributing it. The dry-run pre-flight now prints progress text before its read, so a failure appends ERROR to that line instead of printing bare.
cloudsmith-iduffy
approved these changes
Aug 26, 2026
Member
Author
|
@copilot resolve the merge conflicts in this pull request |
…pg-key-command # Conflicts: # CHANGELOG.md # cloudsmith_cli/cli/exceptions.py # cloudsmith_cli/core/api/repos.py
… into eng-14007-cli-add-gpg-key-command # Conflicts: # CHANGELOG.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Adds
cloudsmith repos gpgfor managing the GPG key a repository signs its package indexes with:getreads the active key,uploadinstalls one you supply,regeneratereplaces it with a fresh Cloudsmith key. There's nodelete- the API has no way to remove a repository's key.Three judgement calls worth a look; the rest is plumbing.
regenerateneeds a typed word, not ay/N. It's irreversible and breaks every consumer pinned to the old fingerprint, so a reflexiveyis too cheap. With no terminal attached it fails instead of blocking;-yis still how a pipeline runs it.uploadandregeneratetake-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_exceptionsgained 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.
--debugis refused onupload, because it would log the request body.Type of Change
Additional Notes
Two changes here aren't about GPG. Both were found while building this, and both explain files outside
repos.pyin the diff:cli/tests/conftest.py-config.get_or_create_optionscaches 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.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 -CliRunnerhas no terminal, so the tests that exercise it patch the terminal check.N/A: no screenshots - CLI change, and the output above covers it.