diff --git a/.circleci/config.yml b/.circleci/config.yml index a9948b9..95f98f8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,43 +1,67 @@ +--- version: 2.1 +parameters: + publish-orb-name: + type: string + default: cloudsmith/cloudsmith + orbs: - cli: circleci/circleci-cli@0.1.5 - orb-tools: circleci/orb-tools@8.27.3 + orb-tools: circleci/orb-tools@12.4.0 + +# Run jobs on all branches and tags +filters: &filters + tags: + only: /.*/ -jobs: - publish-orb: - description: Publishes an orb to CircleCI - executor: - cli/default - steps: - - attach_workspace: - at: workspace - - run: - name: > - Publish orb to CircleCI - command: | - if [[ -n $CIRCLE_TAG ]]; then - ORB_REF=$CIRCLE_TAG - else - ORB_REF="dev:$CIRCLE_BRANCH" - fi - circleci orb publish workspace/orb.yml cloudsmith/cloudsmith@$ORB_REF +# Run jobs only on semver release tags (e.g. v2.0.0) +release-filters: &release-filters + branches: + ignore: /.*/ + tags: + only: /^v[0-9]+\.[0-9]+\.[0-9]+$/ workflows: - verify-and-publish: + lint-pack-publish: jobs: + # Lint all YAML files in src/ - orb-tools/lint: - filters: - tags: - only: /.*/ + filters: *filters + # Pack src/ into a single orb.yml and validate it - orb-tools/pack: + filters: *filters + # Review orb source against CircleCI best practices + - orb-tools/review: + filters: *filters + orb_name: cloudsmith + # RC009: long inline commands (we use inline bash intentionally) + # RC010: snake_case naming (our params use kebab-case by convention) + exclude: RC009,RC010 + # Publish a dev version on every push (except release tags and PRs) + - orb-tools/publish: + name: publish-dev + orb_name: << pipeline.parameters.publish-orb-name >> + vcs_type: << pipeline.project.type >> + pub_type: dev + context: orb-publishing + requires: + - orb-tools/lint + - orb-tools/pack + - orb-tools/review filters: + branches: + ignore: /^pull\/[0-9]+/ tags: - only: /.*/ - - publish-orb: - filters: - tags: - only: /.*/ + ignore: /^v[0-9]+\.[0-9]+\.[0-9]+$/ + # Publish a production version when a semver tag is pushed + - orb-tools/publish: + name: publish-production + orb_name: << pipeline.parameters.publish-orb-name >> + vcs_type: << pipeline.project.type >> + pub_type: production + context: orb-publishing requires: - orb-tools/lint - orb-tools/pack + - orb-tools/review + filters: *release-filters diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000..0e7e28c --- /dev/null +++ b/.yamllint @@ -0,0 +1,11 @@ +--- +extends: default + +rules: + line-length: + max: 200 + truthy: + check-keys: true + comments: + min-spaces-from-content: 1 + document-start: enable diff --git a/README.md b/README.md index 01314d8..d273528 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,104 @@ CircleCI orb for publishing packages to (and interacting with) Cloudsmith reposi See [onsite documentation](https://circleci.com/orbs/registry/orb/cloudsmith/cloudsmith) for further details. +## Commands + +### `authenticate-with-oidc` + +Authenticate with Cloudsmith using OpenID Connect (OIDC) to obtain a short-lived API token. The token is exported as the `CLOUDSMITH_API_KEY` environment variable for use by the Cloudsmith CLI or any subsequent steps. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `organization` | string | *required* | Cloudsmith organization name | +| `service-account` | string | *required* | Cloudsmith service account name | +| `oidc-audience` | string | `""` | Custom audience for the OIDC token exchange (omitted when empty) | +| `oidc-auth-retry` | integer | `3` | Number of token exchange attempts (5 s delay between retries) | + + +### `install-cli` + +Installs the Cloudsmith CLI by downloading the zipapp from Cloudsmith. Set `pip-install: true` to install via pip instead. Optional parameters configure the CLI via `~/.cloudsmith/config.ini`. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `cli-version` | string | `""` | Pin a specific CLI version (e.g. `"1.2.0"`). Empty installs the latest | +| `pip-install` | boolean | `false` | Install via pip instead of the default zipapp | +| `install-path` | string | `$HOME/bin` | Directory where the zipapp binary is installed and added to `PATH` (ignored when using pip) | +| `api-host` | string | `""` | Override `api_host` in config.ini (default: `api.cloudsmith.io`) | +| `api-proxy` | string | `""` | HTTP/HTTPS proxy (`api_proxy` in config.ini) | +| `api-ssl-verify` | boolean | `true` | Enable/disable SSL verification (`api_ssl_verify` in config.ini) | +| `api-user-agent` | string | `""` | Custom user-agent (`api_user_agent` in config.ini) | + +### `ensure-api-key` + +Validates that the `CLOUDSMITH_API_KEY` environment variable is set. Fails the build immediately if it is missing. + +### `publish` *(deprecated)* + +Wraps individual `cloudsmith push` calls. This command will be removed in a future major version. The recommended approach is to call `install-cli` and `authenticate-with-oidc` (or `ensure-api-key`), then invoke the Cloudsmith CLI directly in your run steps. + +## Executor + +The `default` executor uses the `cimg/python` convenience image (default tag `3.10`), which has the prerequisites for installing the Cloudsmith CLI. + +## Usage + +### Recommended — OIDC authentication with direct CLI usage + +```yaml +version: 2.1 + +orbs: + cloudsmith: cloudsmith/cloudsmith@2.0.0 + +workflows: + publish: + jobs: + - publish + +jobs: + publish: + executor: cloudsmith/default + steps: + - checkout + - cloudsmith/authenticate-with-oidc: + organization: my-org + service-account: my-service-account + - cloudsmith/install-cli + - run: + name: Build and publish Python package + command: | + pip install build + python -m build --wheel + cloudsmith push python my-org/my-repo dist/*.whl +``` + +### API key authentication + +```yaml +version: 2.1 + +orbs: + cloudsmith: cloudsmith/cloudsmith@2.0.0 + +jobs: + publish: + executor: cloudsmith/default + steps: + - checkout + - cloudsmith/ensure-api-key + - cloudsmith/install-cli + - run: + name: Build and publish + command: | + pip install build + python -m build --wheel + cloudsmith push python cloudsmith/examples dist/*.whl +``` + ## Development -We use the [CircleCI CLI](https://circleci.com/docs/2.0/local-cli/) to perform common development and release tasks for this orb. Please first ensure you have it installed and configured with appropriate credentials. +We use the [CircleCI CLI](https://circleci.com/docs/guides/toolkit/local-cli/) to perform common development and release tasks for this orb. Please first ensure you have it installed and configured with appropriate credentials. ### Generating the orb @@ -26,14 +121,12 @@ $ circleci orb validate orb.yml ## Release Management -Releasing the orb happens automatically from CI. The orb is linted (`yamllint`) and validated (`circleci orb validate`) as part of the CI process. +Releasing the orb happens automatically from CI using the [`circleci/orb-tools`](https://circleci.com/developer/orbs/orb/circleci/orb-tools) orb. The orb source is linted, reviewed for best practices, packed, and validated as part of the pipeline. ### Dev/Alpha releases - -To make an development (or alpha) release, simply push your changes to a branch on Github. CircleCI will automatically build the orb and push a development release to the version `cloudsmith/cloudsmith@dev:$BRANCH_NAME`. +To make a development (or alpha) release, simply push your changes to a branch on GitHub. CircleCI will automatically build the orb and push a development release to the version `cloudsmith/cloudsmith@dev:$BRANCH_NAME`. ### Production releases +Once happy with your changes, merge to master as normal via a PR and then tag a new release (either via CI or the GitHub UI) with an appropriate `v`-prefixed semver version. -Once happy with your changes, merge to master as normal via a PR and then tag a new release (either via CI or the Github UI) with an appropriate version number (must be semver compatible). - -For example, if you create a tag named `2.0.0` it'll result in a public release to `cloudsmith/cloudsmith@2.0.0`. +For example, if you create a tag named `v2.0.0` it'll result in a public release to `cloudsmith/cloudsmith@2.0.0`. diff --git a/src/@orb.yaml b/src/@orb.yaml deleted file mode 100644 index f7243b5..0000000 --- a/src/@orb.yaml +++ /dev/null @@ -1,8 +0,0 @@ -version: 2.1 - -description: | - Install the Cloudsmith CLI and publish packages to Cloudsmith. - -display: - home_url: https://cloudsmith.io/ - source_url: https://github.com/cloudsmith-io/orb diff --git a/src/@orb.yml b/src/@orb.yml new file mode 100644 index 0000000..56d8ecf --- /dev/null +++ b/src/@orb.yml @@ -0,0 +1,10 @@ +--- +version: 2.1 + +description: | + Install the Cloudsmith CLI and publish packages to Cloudsmith repositories. + Supports OIDC and API key authentication with configurable CLI options. + +display: + home_url: https://cloudsmith.io/ + source_url: https://github.com/cloudsmith-io/orb diff --git a/src/commands/authenticate-with-oidc.yml b/src/commands/authenticate-with-oidc.yml index 08906ab..99c997b 100644 --- a/src/commands/authenticate-with-oidc.yml +++ b/src/commands/authenticate-with-oidc.yml @@ -1,39 +1,102 @@ +--- description: > Authenticate with Cloudsmith using OpenID Connect (OIDC) to generate a - short-lived OIDC token for use in subsequent Cloudsmith requests. This + short-lived API token for use in subsequent Cloudsmith requests. This sets the CLOUDSMITH_API_KEY environment variable to the retrieved token - for use with the Cloudsmith CLI. + for use with the Cloudsmith CLI. Retries the token exchange up to + oidc-auth-retry times (default 3) with a 5-second delay between attempts. + To use OIDC authentication without installing the CLI, simply call this + command on its own and omit install-cli from your workflow steps. parameters: organization: type: string - description: The name of the Cloudsmith organization to use for authentication + description: > + The name of the Cloudsmith organization to use for authentication. service-account: type: string - description: The name of the Cloudsmith service account to use for authentication + description: > + The name of the Cloudsmith service account to use for authentication. + oidc-audience: + type: string + default: "" + description: > + Custom audience value sent in the OIDC token exchange request body. + Leave empty to omit the field and use Cloudsmith's default audience. + oidc-auth-retry: + type: integer + default: 3 + description: > + Total number of attempts for the OIDC token exchange. Each retry waits + 5 seconds before the next attempt. The step fails if all attempts are + exhausted without a valid token. steps: - run: name: Authenticate to Cloudsmith with OIDC command: | organization="<>" service_account="<>" + oidc_audience="<>" + max_retries=<> oidc_endpoint="https://api.cloudsmith.io/openid/$organization/" - payload=$(jq -n \ + # Verify required tools are available + for cmd in curl jq; do + if ! command -v "$cmd" > /dev/null; then + echo "$cmd is required but not found. Ensure it is installed." + exit 1 + fi + done + + if [[ -z "$CIRCLE_OIDC_TOKEN_V2" ]]; then + echo "CIRCLE_OIDC_TOKEN_V2 is not set." + echo "Enable OIDC: Project Settings -> Advanced -> Enable OIDC." + exit 1 + fi + + attempt=0 + while [[ $attempt -lt $max_retries ]]; do + attempt=$((attempt + 1)) + echo "OIDC authentication attempt $attempt of $max_retries" + + payload=$(jq -n \ --arg oidc_token "$CIRCLE_OIDC_TOKEN_V2" \ --arg service_slug "$service_account" \ '{ - oidc_token: $oidc_token, - service_slug: $service_slug - }' - ) + oidc_token: $oidc_token, + service_slug: $service_slug + }') + + if [[ -n "$oidc_audience" ]]; then + payload=$(echo "$payload" | jq \ + --arg audience "$oidc_audience" \ + '. + {audience: $audience}') + fi - response=$(curl -X POST \ + response=$(curl -s -X POST \ -H "Content-Type: application/json" \ -d "$payload" \ - --silent \ - "$oidc_endpoint" - ) + --write-out '\n%{http_code}' \ + "$oidc_endpoint") || true + + http_code=$(echo "$response" | tail -n1) + body=$(echo "$response" | sed '$d') + + if [[ "$http_code" -ge 200 && "$http_code" -lt 300 ]]; then + token=$(echo "$body" | jq -r '.token // empty') + + if [[ -n "$token" ]]; then + echo "export CLOUDSMITH_API_KEY=\"$token\"" >> "$BASH_ENV" + echo "Successfully authenticated with Cloudsmith via OIDC." + exit 0 + fi + fi + + echo "OIDC token exchange failed (attempt $attempt, HTTP $http_code)." - token=$(echo "$response" | jq -r '.token') - echo "export CLOUDSMITH_API_KEY=$token" >> "$BASH_ENV" - source "$BASH_ENV" + if [[ $attempt -lt $max_retries ]]; then + echo "Retrying in 5 seconds..." + sleep 5 + fi + done + echo "OIDC authentication failed after $max_retries attempt(s)." + exit 1 diff --git a/src/commands/ensure-api-key.yml b/src/commands/ensure-api-key.yml index 91051d3..378c71b 100644 --- a/src/commands/ensure-api-key.yml +++ b/src/commands/ensure-api-key.yml @@ -1,3 +1,4 @@ +--- description: > This command checks and ensures that a Cloudsmith API key has been configured correctly in the CI environment such that the Cloudsmith CLI will be able to @@ -6,5 +7,6 @@ steps: - run: name: Check for CLOUDSMITH_API_KEY environment variable command: | - : "${CLOUDSMITH_API_KEY?CLOUDSMITH_API_KEY must be set in the build environment}" - echo "Checking for the Cloudsmith API key" + : "${CLOUDSMITH_API_KEY?CLOUDSMITH_API_KEY must be set in the build \ + environment}" + echo "Cloudsmith API key found." diff --git a/src/commands/install-cli.yml b/src/commands/install-cli.yml index dbf4e39..13b9ccf 100644 --- a/src/commands/install-cli.yml +++ b/src/commands/install-cli.yml @@ -1,25 +1,196 @@ +--- description: > - This command uses pip to install the Cloudsmith CLI from PyPI into the CI - environment, or falls back to downloading the latest zipapp if pip is not - available. The Cloudsmith CLI is used for publishing packages to Cloudsmith - from your build jobs. + Installs the Cloudsmith CLI by downloading the zipapp from Cloudsmith. + Set pip-install to true to install via pip instead. Pass cli-version to + pin a specific release. Optional API configuration parameters are written + to the CLI config file (~/.cloudsmith/config.ini) for use in subsequent steps. +parameters: + cli-version: + type: string + default: "" + description: > + Pin a specific Cloudsmith CLI version (e.g. "1.2.0"). Leave empty to + install the latest available release. + pip-install: + type: boolean + default: false + description: > + Install via pip instead of the default zipapp. When true pip must be + available — the step fails if pip is not found. + install-path: + type: string + default: "$HOME/bin" + description: > + Directory where the Cloudsmith CLI zipapp binary is installed. Ignored + when installing via pip. + api-host: + type: string + default: "" + description: > + Override the Cloudsmith API host (api_host in config.ini). Leave empty + to use the default (api.cloudsmith.io). + api-proxy: + type: string + default: "" + description: > + HTTP/HTTPS proxy URL for Cloudsmith API calls (api_proxy in config.ini). + Leave empty for no proxy. + api-ssl-verify: + type: boolean + default: true + description: > + Enable or disable SSL certificate verification for Cloudsmith API calls + (api_ssl_verify in config.ini). + api-user-agent: + type: string + default: "" + description: > + Custom user-agent string sent with Cloudsmith API requests (api_user_agent + in config.ini). Leave empty to use the default. steps: - run: name: Install Cloudsmith CLI command: | - if [[ $(command -v cloudsmith) == "" ]]; then - export PIP=$(which pip pip3 | head -1) - if [[ -n $PIP ]]; then - if which sudo > /dev/null; then - sudo $PIP install cloudsmith-cli --upgrade - else - $PIP install cloudsmith-cli --upgrade --user + cli_version="<>" + force_pip="<>" + install_path="<>" + api_host="<>" + api_proxy="<>" + api_ssl_verify="<>" + api_user_agent="<>" + + write_cli_config() { + if [[ -z "$api_host" && -z "$api_proxy" && "$api_ssl_verify" != "false" && -z "$api_user_agent" ]]; then + return + fi + + local config_dir="${HOME}/.cloudsmith" + local config_file="${config_dir}/config.ini" + + mkdir -p "$config_dir" + { + echo "[default]" + echo "api_host=${api_host}" + echo "api_proxy=${api_proxy}" + echo "api_ssl_verify=${api_ssl_verify}" + echo "api_user_agent=${api_user_agent}" + } > "$config_file" + + echo "Cloudsmith CLI config written to $config_file" + } + + add_install_path() { + case ":$PATH:" in + *":$install_path:"*) ;; + *) + export PATH="$install_path:$PATH" + echo "export PATH=\"$install_path:\$PATH\"" >> "$BASH_ENV" + ;; + esac + } + + install_with_pip() { + local pip_cmd + local install_args=("install") + + pip_cmd=$(command -v pip 2>/dev/null || command -v pip3 2>/dev/null) + if [[ -z "$pip_cmd" ]]; then + echo "pip-install is true but pip was not found. Ensure pip is" \ + "available in this executor." + exit 1 + fi + + if [[ -n "$cli_version" ]]; then + install_args+=("cloudsmith-cli==$cli_version") + else + install_args+=("cloudsmith-cli" "--upgrade") + fi + + if command -v sudo > /dev/null; then + sudo "$pip_cmd" "${install_args[@]}" + else + "$pip_cmd" "${install_args[@]}" --user + fi + } + + zipapp_url() { + if [[ -n "$cli_version" ]]; then + echo "https://dl.cloudsmith.io/public/cloudsmith/cli-zipapp/raw/names/cloudsmith-cli/versions/${cli_version}/cloudsmith.pyz" + else + echo "https://dl.cloudsmith.io/public/cloudsmith/cli-zipapp/raw/names/cloudsmith-cli/versions/latest/cloudsmith.pyz" + fi + } + + download_zipapp() { + local pyz_url="$1" + local destination="$2" + + if ! curl -sSfL "$pyz_url" -o "$destination"; then + echo "Failed to download Cloudsmith CLI zipapp from:" + echo " $pyz_url" + exit 1 + fi + } + + install_zipapp() { + local pyz_url + local target + local probe_file + local tmpfile + + pyz_url="$(zipapp_url)" + target="${install_path}/cloudsmith" + probe_file="${install_path}/.cloudsmith-write-test-$$" + + if mkdir -p "$install_path" 2>/dev/null && touch "$probe_file" 2>/dev/null; then + rm -f "$probe_file" + download_zipapp "$pyz_url" "$target" + chmod +x "$target" + elif command -v sudo > /dev/null; then + tmpfile="$(mktemp)" + download_zipapp "$pyz_url" "$tmpfile" + + if ! sudo mkdir -p "$install_path"; then + echo "Failed to create install path '$install_path'." + rm -f "$tmpfile" + exit 1 + fi + + if ! sudo mv "$tmpfile" "$target"; then + echo "Failed to move Cloudsmith CLI zipapp into '$install_path'." + rm -f "$tmpfile" + exit 1 fi + + sudo chmod +x "$target" + else + echo "Install path '$install_path' is not writable and sudo is not available." + echo "Set 'install-path' to a user-writable directory such as \$HOME/bin." + exit 1 + fi + + add_install_path + echo "Cloudsmith CLI installed to $target" + } + + if [[ "$install_path" == ~* ]]; then + install_path="${install_path/#\~/$HOME}" + fi + + write_cli_config + + # Install the Cloudsmith CLI + if command -v cloudsmith > /dev/null && [[ "$force_pip" != "true" ]]; then + if [[ -n "$cli_version" ]]; then + echo "Warning: Cloudsmith CLI is already installed." \ + "cli-version='$cli_version' was ignored. Set pip-install: true" \ + "to enforce the version." else - latest_pyz=$(curl -s https://api.github.com/repos/cloudsmith-io/cloudsmith-cli/releases/latest | jq -r '.assets[] | select(.name | endswith(".pyz")) | .browser_download_url') - curl -L $latest_pyz -o /home/circleci/bin/cloudsmith - chmod +x /home/circleci/bin/cloudsmith + echo "Cloudsmith CLI is already installed." fi + + elif [[ "$force_pip" == "true" ]]; then + install_with_pip else - echo "Cloudsmith CLI is already installed." + install_zipapp fi diff --git a/src/commands/publish.yml b/src/commands/publish.yml index 9a0d64c..5d13edc 100644 --- a/src/commands/publish.yml +++ b/src/commands/publish.yml @@ -1,9 +1,11 @@ +--- description: > - This command uses the Cloudsmith CLI to publish packages to Cloudsmith. - Packages are uploaded and then verified with the CLI. If the upload (or - subsequent server-side processing) fails then this command will also exit - with a failing status. Most common configuration parameters are exposed via - the orb to allow seamless integration with your existing CircleCI workflows. + DEPRECATED: This command wraps individual cloudsmith push calls and will be + removed in a future major version. The recommended approach is to call + install-cli and authenticate-with-oidc (or ensure-api-key), then invoke the + Cloudsmith CLI directly in your run steps — e.g. "cloudsmith push python + my-org/my-repo dist/package-*.whl". All existing parameters continue to work + as before. parameters: cloudsmith-repository: type: string diff --git a/src/examples/authenticate-with-oidc.yml b/src/examples/authenticate-with-oidc.yml new file mode 100644 index 0000000..5768444 --- /dev/null +++ b/src/examples/authenticate-with-oidc.yml @@ -0,0 +1,25 @@ +--- +description: > + Authenticate with Cloudsmith using OpenID Connect (OIDC) to obtain a + short-lived API token. The token is exported as CLOUDSMITH_API_KEY and can + be used in subsequent steps without installing the Cloudsmith CLI. +usage: + version: 2.1 + + orbs: + cloudsmith: cloudsmith/cloudsmith@2.0.0 + + workflows: + cloudsmith_oidc_auth: + jobs: + - authenticate + + jobs: + authenticate: + executor: + cloudsmith/default + steps: + - checkout + - cloudsmith/authenticate-with-oidc: + organization: my-org + service-account: my-service-account diff --git a/src/examples/cli-with-oidc.yml b/src/examples/cli-with-oidc.yml new file mode 100644 index 0000000..27e4a74 --- /dev/null +++ b/src/examples/cli-with-oidc.yml @@ -0,0 +1,32 @@ +--- +description: > + Recommended workflow — authenticate with Cloudsmith via OIDC and install the + CLI, then invoke the CLI directly in your run steps. This pattern gives you + full access to all Cloudsmith CLI commands. +usage: + version: 2.1 + + orbs: + cloudsmith: cloudsmith/cloudsmith@2.0.0 + + workflows: + cloudsmith_oidc_publish: + jobs: + - publish + + jobs: + publish: + executor: + cloudsmith/default + steps: + - checkout + - cloudsmith/authenticate-with-oidc: + organization: my-org + service-account: my-service-account + - cloudsmith/install-cli + - run: + name: Build and publish Python package + command: | + pip install build + python -m build --wheel + cloudsmith push python my-org/my-repo dist/*.whl diff --git a/src/examples/command-publish.yml b/src/examples/command-publish.yml index 581987b..cdfd752 100644 --- a/src/examples/command-publish.yml +++ b/src/examples/command-publish.yml @@ -1,9 +1,10 @@ +--- description: Customize your Cloudsmith workflow using the commands from this orb usage: version: 2.1 orbs: - cloudsmith: cloudsmith/cloudsmith@1.0.3 + cloudsmith: cloudsmith/cloudsmith@2.0.0 workflows: cloudsmith_publish: diff --git a/src/executors/default.yml b/src/executors/default.yml index a52f0e8..89e10b8 100644 --- a/src/executors/default.yml +++ b/src/executors/default.yml @@ -1,8 +1,12 @@ +--- description: | - Uses the basic circleci/python image, which has the prerequisites for installing Cloudsmith's CLI. + Uses the cimg/python convenience image, which has the prerequisites for installing Cloudsmith's CLI. parameters: tag: type: string - default: "3.7.4" + default: "3.10" + description: >- + Pick a specific cimg/python image version tag: + https://circleci.com/developer/images/image/cimg/python docker: - - image: circleci/python:<> + - image: cimg/python:<>