diff --git a/.circleci/config.yml b/.circleci/config.yml index 95f98f8..1e35e82 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,67 +1,65 @@ --- version: 2.1 - -parameters: - publish-orb-name: - type: string - default: cloudsmith/cloudsmith +setup: true orbs: orb-tools: circleci/orb-tools@12.4.0 + shellcheck: circleci/shellcheck@3.4.0 # Run jobs on all branches and tags filters: &filters tags: only: /.*/ -# 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]+$/ +jobs: + # The installer in src/scripts/install.sh is vendored from + # https://github.com/cloudsmith-io/cloudsmith-cli-install-script and must + # only change together with src/scripts/install.sh.version. + installer-drift-check: + docker: + - image: cimg/base:current + steps: + - checkout + - run: + name: Verify vendored installer matches the recorded checksum + command: | + expected="$(awk -F= '$1 == "sha256" {print $2}' src/scripts/install.sh.version)" + actual="$(sha256sum src/scripts/install.sh | awk '{print $1}')" + if [ "$expected" != "$actual" ]; then + echo "src/scripts/install.sh does not match src/scripts/install.sh.version" >&2 + echo "expected sha256: $expected" >&2 + echo "actual sha256: $actual" >&2 + echo "Update both files together when vendoring a new installer release." >&2 + exit 1 + fi + version="$(awk -F= '$1 == "version" {print $2}' src/scripts/install.sh.version)" + echo "Vendored installer (version: $version) matches the recorded checksum." workflows: - lint-pack-publish: + lint-pack: jobs: - # Lint all YAML files in src/ - orb-tools/lint: 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) + # RC009: inline commands are used for trivial steps intentionally + # RC010: parameters 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: - 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 >> + filters: *filters + - shellcheck/check: + filters: *filters + - installer-drift-check: + filters: *filters + - orb-tools/continue: + pipeline_number: << pipeline.number >> vcs_type: << pipeline.project.type >> - pub_type: production - context: orb-publishing + orb_name: cloudsmith requires: - orb-tools/lint - orb-tools/pack - orb-tools/review - filters: *release-filters + - shellcheck/check + - installer-drift-check + filters: *filters diff --git a/.circleci/test-deploy.yml b/.circleci/test-deploy.yml new file mode 100644 index 0000000..f28e975 --- /dev/null +++ b/.circleci/test-deploy.yml @@ -0,0 +1,166 @@ +--- +version: 2.1 + +orbs: + # The orb built from src/ is injected here by orb-tools/continue. + cloudsmith: {} + orb-tools: circleci/orb-tools@12.4.0 + +parameters: + test-organization: + type: string + default: "" + description: Cloudsmith organization used by the OIDC integration test. + test-service-account: + type: string + default: "" + description: Cloudsmith service account used by the OIDC integration test. + +# Run jobs on all branches and tags +filters: &filters + tags: + only: /.*/ + +# Run jobs only on semver release tags (e.g. v3.0.0) +release-filters: &release-filters + branches: + ignore: /.*/ + tags: + only: /^v[0-9]+\.[0-9]+\.[0-9]+$/ + +executors: + linux-docker: + docker: + - image: cimg/base:current + alpine-docker: + docker: + - image: alpine:3.21 + linux-machine: + machine: + image: ubuntu-2404:current + macos: + macos: + xcode: "16.2" + +jobs: + install-test: + parameters: + executor: + type: executor + executor: << parameters.executor >> + steps: + - run: + name: Install job dependencies (Alpine only) + shell: /bin/sh + command: | + if command -v apk > /dev/null 2>&1; then + apk add --no-cache bash curl ca-certificates + fi + - cloudsmith/install-cli + - run: + name: Verify the CLI is on PATH in later steps + command: | + command -v cloudsmith + cloudsmith --version + + install-pinned-version-test: + executor: linux-docker + steps: + - cloudsmith/install-cli: + cli-version: "1.19.2" + - run: + name: Verify the pinned CLI version was installed + command: | + cloudsmith --version | grep -Fx "CLI Package Version: 1.19.2" + + api-key-test: + executor: linux-docker + steps: + - run: + name: Skip unless CLOUDSMITH_API_KEY is available + command: | + if [ -z "${CLOUDSMITH_API_KEY:-}" ]; then + echo "CLOUDSMITH_API_KEY is not set; skipping the API key test." + circleci-agent step halt + fi + - cloudsmith/ensure-api-key + - cloudsmith/install-cli + - run: + name: Verify API key authentication + command: cloudsmith whoami + + oidc-test: + executor: linux-docker + steps: + - run: + name: Skip unless OIDC testing is enabled + command: | + if [ "${CLOUDSMITH_OIDC_TEST:-}" != "true" ]; then + echo "CLOUDSMITH_OIDC_TEST is not 'true'; skipping the OIDC test." + circleci-agent step halt + fi + if [ -z "<< pipeline.parameters.test-organization >>" ] || [ -z "<< pipeline.parameters.test-service-account >>" ]; then + echo "test-organization/test-service-account pipeline parameters are not set; skipping the OIDC test." + circleci-agent step halt + fi + if [ -z "${CIRCLE_OIDC_TOKEN_V2:-}" ] && [ -z "${CIRCLE_OIDC_TOKEN:-}" ]; then + echo "No OIDC token; the job must use a context. Skipping the OIDC test." + circleci-agent step halt + fi + - cloudsmith/install-cli + - cloudsmith/configure-oidc: + organization: << pipeline.parameters.test-organization >> + service-account: << pipeline.parameters.test-service-account >> + verify-auth: true + - run: + name: Verify OIDC configuration persists to later steps + command: cloudsmith whoami + +workflows: + test-deploy: + jobs: + - install-test: + matrix: + parameters: + executor: [linux-docker, alpine-docker, linux-machine, macos] + filters: *filters + - install-pinned-version-test: + filters: *filters + - api-key-test: + context: cloudsmith-api-key-test + filters: *filters + - oidc-test: + context: orb-publishing + filters: *filters + - orb-tools/pack: + filters: *filters + # Publish a dev version on every push (except forked PRs and release tags) + - orb-tools/publish: + name: publish-dev + orb_name: cloudsmith/cloudsmith + vcs_type: << pipeline.project.type >> + pub_type: dev + context: orb-publishing + requires: + - orb-tools/pack + - install-test + - install-pinned-version-test + - api-key-test + - oidc-test + filters: + branches: + ignore: /^pull\/[0-9]+/ + # Publish a production version when a semver tag is pushed + - orb-tools/publish: + name: publish-production + orb_name: cloudsmith/cloudsmith + vcs_type: << pipeline.project.type >> + pub_type: production + context: orb-publishing + requires: + - orb-tools/pack + - install-test + - install-pinned-version-test + - api-key-test + - oidc-test + filters: *release-filters diff --git a/README.md b/README.md index d273528..66c9374 100644 --- a/README.md +++ b/README.md @@ -1,132 +1,252 @@ # Cloudsmith Orb for CircleCI -CircleCI orb for publishing packages to (and interacting with) Cloudsmith repositories. +[![CircleCI build](https://dl.circleci.com/status-badge/img/gh/cloudsmith-io/orb/tree/master.svg?style=shield)](https://dl.circleci.com/status-badge/redirect/gh/cloudsmith-io/orb/tree/master) +[![Orb Registry](https://badges.circleci.com/orbs/cloudsmith/cloudsmith.svg)](https://circleci.com/developer/orbs/orb/cloudsmith/cloudsmith) -See [onsite documentation](https://circleci.com/orbs/registry/orb/cloudsmith/cloudsmith) for further details. +Install the standalone [Cloudsmith CLI](https://github.com/cloudsmith-io/cloudsmith-cli), add it to `PATH`, and configure authentication for the rest of a CircleCI job. The orb does not require Python, pip, or `jq` on the executor image. -## Commands +[Quick start](#quick-start) · [Configuration](#configuration) · [Migration guide](#migrating-from-v2) · [Contributing](#contributing) -### `authenticate-with-oidc` +## At a glance -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. +| Capability | Support | +| --- | --- | +| Authentication | OpenID Connect (OIDC) or API key | +| Executors | Linux and macOS with `bash` and `curl` or `wget` | +| Architectures | x86-64, plus Linux and macOS ARM64 | +| Installation | Standalone CLI binary with SHA-256 download verification | +| Version selection | Latest release or a specific CLI version | -| 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) | +## Quick start +### Authenticate with OIDC -### `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` +OIDC is the recommended option for CI/CD because it uses short-lived credentials instead of a stored API key. Configure a Cloudsmith service account and an OIDC provider by following the [Cloudsmith OIDC documentation](https://docs.cloudsmith.com/authentication/openid-connect) before using this example. -Validates that the `CLOUDSMITH_API_KEY` environment variable is set. Fails the build immediately if it is missing. +> [!IMPORTANT] +> The job must use at least one [CircleCI context](https://circleci.com/docs/contexts/). Without a context, CircleCI does not issue the OIDC token used to authenticate with Cloudsmith. -### `publish` *(deprecated)* +```yaml +version: 2.1 -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. +orbs: + cloudsmith: cloudsmith/cloudsmith@3.0.0 -## Executor +workflows: + verify: + jobs: + - verify: + context: my-context -The `default` executor uses the `cimg/python` convenience image (default tag `3.10`), which has the prerequisites for installing the Cloudsmith CLI. +jobs: + verify: + executor: cloudsmith/default + steps: + - cloudsmith/install-cli + - cloudsmith/configure-oidc: + organization: YOUR-NAMESPACE + service-account: YOUR-SERVICE-ACCOUNT + - run: cloudsmith whoami +``` -## Usage +### Authenticate with an API key -### Recommended — OIDC authentication with direct CLI usage +Provide the API key through the `CLOUDSMITH_API_KEY` environment variable, typically from a [CircleCI context](https://circleci.com/docs/contexts/). For automated pipelines, use a [Cloudsmith service account](https://docs.cloudsmith.com/accounts-and-teams/service-accounts) rather than a personal API key. ```yaml version: 2.1 orbs: - cloudsmith: cloudsmith/cloudsmith@2.0.0 + cloudsmith: cloudsmith/cloudsmith@3.0.0 workflows: - publish: + verify: jobs: - - publish + - verify: + context: my-context jobs: - publish: + verify: executor: cloudsmith/default steps: - - checkout - - cloudsmith/authenticate-with-oidc: - organization: my-org - service-account: my-service-account + - cloudsmith/ensure-api-key - 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 + - run: cloudsmith whoami ``` -### API key authentication +Personal API keys are available from [Cloudsmith API settings](https://cloudsmith.io/user/settings/api/). + +## Authentication + +Choose one of the following authentication methods: + +| Method | Configuration | Credential handling | Best suited to | +| --- | --- | --- | --- | +| OIDC | `organization` and `service-account` on `configure-oidc` | The CLI exchanges a CircleCI OIDC token on its first authenticated command | CI/CD pipelines | +| API key | `CLOUDSMITH_API_KEY`, optionally checked by `ensure-api-key` | CircleCI supplies the key through a context or project environment variable | Pipelines that cannot use OIDC | + +With OIDC, `configure-oidc` exports the service account context needed by the CLI. The Cloudsmith access token is requested only when the CLI first needs to authenticate and is not exposed by the orb. + +```mermaid +flowchart LR + A[configure-oidc command] -->|Exports OIDC settings| B[Cloudsmith CLI command] + B -->|Requests identity token| C[CircleCI OIDC] + C -->|Exchanges identity| D[Cloudsmith] +``` + +Set `verify-auth: true` on `configure-oidc` to run `cloudsmith whoami` during setup and fail early if authentication is not configured correctly. + +## Configuration + +### `install-cli` + +Installs the standalone Cloudsmith CLI on a Linux or macOS executor and adds its binary directory to `PATH` for later steps through `$BASH_ENV`. + +#### Installation parameters + +| Parameter | Description | Required | Default | +| --- | --- | --- | --- | +| `cli-version` | CLI version to install, such as `1.20.0` | No | `latest` | +| `install-path` | Root directory for versioned CLI installations | No | `$XDG_DATA_HOME/cloudsmith-cli` or `$HOME/.local/share/cloudsmith-cli` | + +#### API configuration parameters + +When supplied, these values are written to `~/.cloudsmith/config.ini`. + +| Parameter | Description | Required | Default | +| --- | --- | --- | --- | +| `api-host` | Cloudsmith API host override | No | `api.cloudsmith.io` | +| `api-proxy` | HTTP or HTTPS proxy for Cloudsmith API calls | No | — | +| `api-ssl-verify` | Whether to verify API SSL certificates | No | `true` | +| `api-user-agent` | User agent override for Cloudsmith API requests | No | — | + +### `configure-oidc` + +Configures the CLI for CircleCI-native OIDC. The job must use at least one context so CircleCI issues an OIDC token. + +| Parameter | Description | Required | Default | +| --- | --- | --- | --- | +| `organization` | Cloudsmith organization or namespace | Yes | — | +| `service-account` | Cloudsmith service account slug | Yes | — | +| `verify-auth` | Run `cloudsmith whoami` after configuration | No | `false` | + +Run `install-cli` before using `verify-auth`. + +### `ensure-api-key` + +Validates that `CLOUDSMITH_API_KEY` is present and fails the job immediately when it is missing. + +### Default executor + +The `default` executor uses the `cimg/base` convenience image with the `current` tag. You can also use the orb commands with another Linux or macOS executor that provides `bash` and either `curl` or `wget`. + +## Environment variables + +The orb persists configuration for later steps through CircleCI’s `$BASH_ENV` file. + +| Authentication method | Variable | Handling | +| --- | --- | --- | +| OIDC | `CLOUDSMITH_ORG` | Exported by `configure-oidc` | +| OIDC | `CLOUDSMITH_SERVICE_SLUG` | Exported by `configure-oidc` | +| OIDC | `CIRCLE_OIDC_TOKEN_V2` or `CIRCLE_OIDC_TOKEN` | Issued automatically when the job uses a CircleCI context | +| API key | `CLOUDSMITH_API_KEY` | Supply through a context or project environment variable | + +## Publish a package + +The following workflow installs the CLI with OIDC authentication and publishes a raw package: ```yaml version: 2.1 orbs: - cloudsmith: cloudsmith/cloudsmith@2.0.0 + cloudsmith: cloudsmith/cloudsmith@3.0.0 + +workflows: + publish: + jobs: + - publish: + context: my-context jobs: publish: executor: cloudsmith/default steps: - checkout - - cloudsmith/ensure-api-key - cloudsmith/install-cli + - cloudsmith/configure-oidc: + organization: YOUR-NAMESPACE + service-account: YOUR-SERVICE-ACCOUNT - run: - name: Build and publish - command: | - pip install build - python -m build --wheel - cloudsmith push python cloudsmith/examples dist/*.whl + name: Publish package + command: cloudsmith push raw YOUR-NAMESPACE/YOUR-REPOSITORY dist/app.tar.gz ``` -## Development +See [Supported Formats](https://docs.cloudsmith.com/formats) for the upload command and options for each package format. -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. +## Migrating from v2 -### Generating the orb +Version 3 installs the standalone CLI instead of the Python package and uses CLI-native OIDC authentication. -We store the orb in git as individual YAML files. Before we can use the orb or perform further actions we need to "pack" it up into a single `orb.yml` file. We do so with the `pack` command: +> [!NOTE] +> OIDC authentication is now lazy: the CLI exchanges the token on its first authenticated command. Use `verify-auth: true` if the setup step should validate credentials immediately. -``` -$ circleci config pack src/ > orb.yml -``` +
+View removed commands, parameters, and migration steps -### Validating the orb +### Installation changes -Once generated, we can use the CLI to validate that the orb is correctly structured and meets basic standards: +| In v2 | In v3 | Migration | +| --- | --- | --- | +| `install-cli` downloads a Python zipapp | Installs a SHA-256-verified standalone binary | Remove Python and pip setup used only by this orb. | +| `pip-install: true` | Removed | Delete the parameter. | +| `install-path` defaults to `$HOME/bin` and receives the binary directly | Defines the versioned installation root | Update custom paths if the old single-file layout is required elsewhere. | +| `cli-version: ""` selects the latest release | `latest` is the explicit default | Remove empty overrides or replace them with `latest`. | +| `default` executor uses `cimg/python` | Uses `cimg/base` | Add Python explicitly only when other job steps require it. | -``` -$ circleci orb validate orb.yml +### Authentication changes + +| In v2 | In v3 | Migration | +| --- | --- | --- | +| `authenticate-with-oidc` | Replaced by `configure-oidc` | Rename the command. The CLI now performs the token exchange itself. | +| `authenticate-with-oidc` exports `CLOUDSMITH_API_KEY` | No Cloudsmith API token is exported | Use `cloudsmith` commands for authenticated operations. | +| `oidc-audience` | Removed | Configure custom audiences on the Cloudsmith service account’s OIDC provider. | +| `oidc-auth-retry` | Removed | The CLI manages token exchange retries. | + +### Publishing changes + +The `publish` command has been removed. Run `cloudsmith push` directly after installation and authentication. + +| Removed `publish` parameter | CLI equivalent | +| --- | --- | +| `package-format`, `cloudsmith-repository`, `package-path` | `cloudsmith push FORMAT OWNER/REPOSITORY FILE` | +| `allow-republish: true` | `--republish` | +| `package-distribution` | Distribution path, such as `OWNER/REPOSITORY/ubuntu/focal` | +| `package-pom-file` | `--pom-file PATH` | +| Raw package metadata | `--name`, `--version`, `--summary`, and `--description` | + +
+ +## Contributing + +The orb source is stored as individual YAML and shell files under `src/`. The CircleCI CLI packs these files into `orb.yml` for validation and publishing. + +
+View local development and release commands + +Pack and validate the orb from the repository root: + +```bash +circleci orb pack src/ > orb.yml +circleci orb validate orb.yml +shellcheck src/scripts/*.sh ``` -## Release Management +`src/scripts/install.sh` is synchronized with the [Cloudsmith CLI installer](https://github.com/cloudsmith-io/cloudsmith-cli-install-script) and bundled into the published orb. Do not edit it directly; update it together with `src/scripts/install.sh.version`. -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. +Branches publish development releases automatically. To create a production release after merging to `master`, create a `v`-prefixed semantic version tag such as `v3.0.0`. -### Dev/Alpha releases -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. +## Support -For example, if you create a tag named `v2.0.0` it'll result in a public release to `cloudsmith/cloudsmith@2.0.0`. +For help, [open a GitHub issue](https://github.com/cloudsmith-io/orb/issues) or contact [Cloudsmith Support](https://support.cloudsmith.com/). diff --git a/src/@orb.yml b/src/@orb.yml index 56d8ecf..010c7b1 100644 --- a/src/@orb.yml +++ b/src/@orb.yml @@ -2,8 +2,8 @@ version: 2.1 description: | - Install the Cloudsmith CLI and publish packages to Cloudsmith repositories. - Supports OIDC and API key authentication with configurable CLI options. + Install the standalone Cloudsmith CLI and authenticate with OIDC or an + API key, then run any Cloudsmith CLI command directly in your steps. display: home_url: https://cloudsmith.io/ diff --git a/src/commands/authenticate-with-oidc.yml b/src/commands/authenticate-with-oidc.yml deleted file mode 100644 index 99c997b..0000000 --- a/src/commands/authenticate-with-oidc.yml +++ /dev/null @@ -1,102 +0,0 @@ ---- -description: > - Authenticate with Cloudsmith using OpenID Connect (OIDC) to generate a - 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. 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. - service-account: - type: string - 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/" - - # 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 - }') - - if [[ -n "$oidc_audience" ]]; then - payload=$(echo "$payload" | jq \ - --arg audience "$oidc_audience" \ - '. + {audience: $audience}') - fi - - response=$(curl -s -X POST \ - -H "Content-Type: application/json" \ - -d "$payload" \ - --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)." - - 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/configure-oidc.yml b/src/commands/configure-oidc.yml new file mode 100644 index 0000000..dff153b --- /dev/null +++ b/src/commands/configure-oidc.yml @@ -0,0 +1,34 @@ +--- +description: > + Configures the Cloudsmith CLI to authenticate with CircleCI's OIDC token. + Exports CLOUDSMITH_ORG and CLOUDSMITH_SERVICE_SLUG for subsequent steps via + $BASH_ENV; the CLI exchanges the CircleCI OIDC token for Cloudsmith + credentials when the first authenticated command runs. No API token is + exchanged or exported by this command. The job must use at least one + CircleCI context, otherwise no OIDC token is issued. +parameters: + organization: + type: string + description: Cloudsmith organization (workspace) slug. + service-account: + type: string + description: Cloudsmith service account slug used for the OIDC token exchange. + verify-auth: + type: boolean + default: false + description: > + Run "cloudsmith whoami" to verify authentication. Requires the CLI to + be installed first (see install-cli). +steps: + - run: + name: Configure Cloudsmith OIDC + environment: + PARAM_ORGANIZATION: <> + PARAM_SERVICE_ACCOUNT: <> + command: <> + - when: + condition: <> + steps: + - run: + name: Verify Cloudsmith authentication + command: cloudsmith whoami diff --git a/src/commands/install-cli.yml b/src/commands/install-cli.yml index 13b9ccf..f50a53f 100644 --- a/src/commands/install-cli.yml +++ b/src/commands/install-cli.yml @@ -1,28 +1,24 @@ --- description: > - 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. + Installs the standalone Cloudsmith CLI binary on Linux or macOS executors. + The installer is bundled with the published orb and verifies the downloaded + archive against its SHA-256 checksum. The CLI directory is added to PATH for + subsequent steps via $BASH_ENV. Optional API configuration parameters are + written to the CLI config file (~/.cloudsmith/config.ini). 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 + default: "latest" description: > - Install via pip instead of the default zipapp. When true pip must be - available — the step fails if pip is not found. + Exact Cloudsmith CLI version to install (e.g. "1.20.0"), or "latest" + for the newest release. install-path: type: string - default: "$HOME/bin" + default: "" description: > - Directory where the Cloudsmith CLI zipapp binary is installed. Ignored - when installing via pip. + Absolute path to the root directory for versioned CLI installations. + Leave empty to use the default ($XDG_DATA_HOME/cloudsmith-cli or + $HOME/.local/share/cloudsmith-cli). api-host: type: string default: "" @@ -45,152 +41,22 @@ parameters: 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. + 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: | - 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 - echo "Cloudsmith CLI is already installed." - fi - - elif [[ "$force_pip" == "true" ]]; then - install_with_pip - else - install_zipapp - fi + name: Download and verify Cloudsmith CLI + environment: + CLOUDSMITH_CLI_VERSION: <> + CLOUDSMITH_CLI_INSTALL_ROOT: <> + CLOUDSMITH_CLI_OUTPUT_FILE: /tmp/cloudsmith-cli-install/result.txt + command: <> + - run: + name: Add Cloudsmith CLI to PATH + environment: + CLOUDSMITH_CLI_OUTPUT_FILE: /tmp/cloudsmith-cli-install/result.txt + PARAM_API_HOST: <> + PARAM_API_PROXY: <> + PARAM_API_SSL_VERIFY: <> + PARAM_API_USER_AGENT: <> + command: <> diff --git a/src/commands/publish.yml b/src/commands/publish.yml deleted file mode 100644 index 5d13edc..0000000 --- a/src/commands/publish.yml +++ /dev/null @@ -1,100 +0,0 @@ ---- -description: > - 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 - description: The Cloudsmith repository to which packages will be published. - package-format: - type: string - description: Format of the package being published. - package-path: - type: string - description: Path to the package to be published (accepts shell globs). - package-distribution: - type: string - default: "" - description: distro/release for which the package is built (required only for deb, rpm and alpine). - package-pom-file: - type: string - default: "" - description: Path to the POM file associated with a maven release (required only for maven). - allow-republish: - type: boolean - default: false - description: Allow publishing a package that overwrites an existing version on Cloudsmith. - package-description: - type: string - default: "" - description: Optional description for the package being published (applied only to raw). - package-name: - type: string - default: "" - description: Name of the package being published (applied only to raw). - package-summary: - type: string - default: "" - description: Summary for the package being published (applied only to raw). - package-version: - type: string - default: "" - description: Version of the package being published (applied only to raw). -steps: - - run: - name: Publish Package - command: | - cloudsmith_repository="<>" - package_format="<>" - package_distribution="<>" - package_pom_file="<>" - package_description="<>" - package_name="<>" - package_summary="<>" - package_version="<>" - - # Use `ls` to resolve any shell globs in parameters.package-path - package_path="$(ls <>)" - - # Handle optional arguments - additional_args= - <<# parameters.allow-republish >> - additional_args+="--republish" - <> - - # Handle per-format required arguments - if [[ "$package_format" == "alpine" || "$package_format" == "deb" || "$package_format" == "rpm" ]]; then - if [[ "$package_distribution" == "" ]]; then - echo "\"package-distribution\" parameter is required when \"package-format\" is alpine, deb, or rpm" - exit 1 - fi - cloudsmith_repository+="/$package_distribution" - fi - if [[ "$package_format" == "maven" ]]; then - if [[ "$package_pom_file" == "" ]]; then - echo "\"package-pom-file\" parameter is required when \"package-format\" is maven" - exit 1 - fi - additional_args+=" --pom-file $package_pom_file" - fi - if [[ "$package_format" == "raw" ]]; then - if [[ ! -z "$package_description" ]]; then - additional_args+=" --description $package_description" - fi - if [[ ! -z "$package_name" ]]; then - additional_args+=" --name $package_name" - fi - if [[ ! -z "$package_summary" ]]; then - additional_args+=" --summary $package_summary" - fi - if [[ ! -z "$package_version" ]]; then - additional_args+=" --version $package_version" - fi - fi - - # Publish package using the Cloudsmith CLI - cloudsmith push $package_format $additional_args $cloudsmith_repository $package_path diff --git a/src/examples/authenticate-with-oidc.yml b/src/examples/authenticate-with-oidc.yml deleted file mode 100644 index 5768444..0000000 --- a/src/examples/authenticate-with-oidc.yml +++ /dev/null @@ -1,25 +0,0 @@ ---- -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 deleted file mode 100644 index 27e4a74..0000000 --- a/src/examples/cli-with-oidc.yml +++ /dev/null @@ -1,32 +0,0 @@ ---- -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 deleted file mode 100644 index cdfd752..0000000 --- a/src/examples/command-publish.yml +++ /dev/null @@ -1,28 +0,0 @@ ---- -description: Customize your Cloudsmith workflow using the commands from this orb -usage: - version: 2.1 - - orbs: - cloudsmith: cloudsmith/cloudsmith@2.0.0 - - workflows: - cloudsmith_publish: - jobs: - - publish - - jobs: - publish: - executor: - cloudsmith/default - steps: - - checkout - - cloudsmith/ensure-api-key - - cloudsmith/install-cli - - run: - name: Build Python package - command: python setup.py bdist_wheel - - cloudsmith/publish: - cloudsmith-repository: cloudsmith/examples - package-path: dist/package-*.whl - package-format: python diff --git a/src/examples/install-with-api-key.yml b/src/examples/install-with-api-key.yml new file mode 100644 index 0000000..dbd12f2 --- /dev/null +++ b/src/examples/install-with-api-key.yml @@ -0,0 +1,26 @@ +--- +description: > + Install the Cloudsmith CLI and authenticate with an API key provided via + the CLOUDSMITH_API_KEY environment variable (for example from a context). +usage: + version: 2.1 + + orbs: + cloudsmith: cloudsmith/cloudsmith@3.0.0 + + workflows: + cloudsmith_publish: + jobs: + - publish: + context: my-context + + jobs: + publish: + executor: cloudsmith/default + steps: + - checkout + - cloudsmith/ensure-api-key + - cloudsmith/install-cli + - run: + name: Publish package + command: cloudsmith push raw my-org/my-repo dist/app.tar.gz diff --git a/src/examples/install-with-oidc.yml b/src/examples/install-with-oidc.yml new file mode 100644 index 0000000..8ebd31a --- /dev/null +++ b/src/examples/install-with-oidc.yml @@ -0,0 +1,30 @@ +--- +description: > + Install the Cloudsmith CLI and authenticate with CircleCI OIDC, then run + CLI commands directly. The job must use a context so that CircleCI issues + an OIDC token. +usage: + version: 2.1 + + orbs: + cloudsmith: cloudsmith/cloudsmith@3.0.0 + + workflows: + cloudsmith_oidc_publish: + jobs: + - publish: + context: my-context + + jobs: + publish: + executor: cloudsmith/default + steps: + - checkout + - cloudsmith/install-cli + - cloudsmith/configure-oidc: + organization: my-org + service-account: my-service-account + verify-auth: true + - run: + name: Publish package + command: cloudsmith push raw my-org/my-repo dist/app.tar.gz diff --git a/src/executors/default.yml b/src/executors/default.yml index 89e10b8..46be432 100644 --- a/src/executors/default.yml +++ b/src/executors/default.yml @@ -1,12 +1,13 @@ --- description: | - Uses the cimg/python convenience image, which has the prerequisites for installing Cloudsmith's CLI. + A minimal cimg/base image. The standalone Cloudsmith CLI has no Python or + other runtime requirements. parameters: tag: type: string - default: "3.10" + default: "current" description: >- - Pick a specific cimg/python image version tag: - https://circleci.com/developer/images/image/cimg/python + Pick a specific cimg/base image tag: + https://circleci.com/developer/images/image/cimg/base docker: - - image: cimg/python:<> + - image: cimg/base:<> diff --git a/src/scripts/configure-oidc.sh b/src/scripts/configure-oidc.sh new file mode 100644 index 0000000..a1dc701 --- /dev/null +++ b/src/scripts/configure-oidc.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Exports CLOUDSMITH_ORG and CLOUDSMITH_SERVICE_SLUG for subsequent steps. +# The Cloudsmith CLI exchanges the CircleCI OIDC token itself when the first +# authenticated command runs. + +if [ -z "${BASH_VERSION:-}" ]; then + echo "configure-oidc requires bash; install it first (e.g. apk add bash)" >&2 + exit 1 +fi +set -euo pipefail +: "${BASH_ENV:?BASH_ENV is required to persist environment variables between steps}" + +[[ -n "$PARAM_ORGANIZATION" ]] || { echo "organization must not be empty" >&2; exit 1; } +[[ -n "$PARAM_SERVICE_ACCOUNT" ]] || { echo "service-account must not be empty" >&2; exit 1; } + +if [[ -z "${CIRCLE_OIDC_TOKEN_V2:-}" && -z "${CIRCLE_OIDC_TOKEN:-}" ]]; then + echo "No CircleCI OIDC token is available (CIRCLE_OIDC_TOKEN_V2 / CIRCLE_OIDC_TOKEN)." >&2 + echo "CircleCI only issues OIDC tokens to jobs that use at least one context." >&2 + echo "Add a context to this job in your workflow configuration and re-run." >&2 + exit 1 +fi + +export CLOUDSMITH_ORG="$PARAM_ORGANIZATION" +export CLOUDSMITH_SERVICE_SLUG="$PARAM_SERVICE_ACCOUNT" +printf 'export CLOUDSMITH_ORG=%q\n' "$CLOUDSMITH_ORG" >> "$BASH_ENV" +printf 'export CLOUDSMITH_SERVICE_SLUG=%q\n' "$CLOUDSMITH_SERVICE_SLUG" >> "$BASH_ENV" + +echo "Cloudsmith OIDC configured for organization $CLOUDSMITH_ORG" diff --git a/src/scripts/install.sh b/src/scripts/install.sh new file mode 100755 index 0000000..3288337 --- /dev/null +++ b/src/scripts/install.sh @@ -0,0 +1,379 @@ +#!/usr/bin/env sh +# Cloudsmith CLI standalone binary installer. +# +# Detects the target, verifies the release archive, and installs it into a +# versioned directory. Does not touch PATH or authenticate. +set -eu + +PROGRAM="install.sh" + +# Where releases are downloaded from. Flags and CLOUDSMITH_CLI_* environment +# variables override the defaults; --manifest-url bypasses URL construction. +DOWNLOAD_BASE_URL="https://dl.cloudsmith.io/public" +# TODO: revert to the production repository before release. +DEFAULT_REPOSITORY="bart-demo-org-terraform/cli-binary-release-test" +MANIFEST_NAME_PREFIX="cloudsmith-cli-manifest" + +REPOSITORY="${CLOUDSMITH_CLI_REPOSITORY:-$DEFAULT_REPOSITORY}" +REQUESTED_VERSION="${CLOUDSMITH_CLI_VERSION:-latest}" +INSTALL_ROOT="${CLOUDSMITH_CLI_INSTALL_ROOT:-}" +TARGET_OVERRIDE="${CLOUDSMITH_CLI_TARGET:-}" +OUTPUT_FILE="${CLOUDSMITH_CLI_OUTPUT_FILE:-}" +MANIFEST_URL="${CLOUDSMITH_CLI_MANIFEST_URL:-}" +ALLOW_INSECURE_URLS="${CLOUDSMITH_CLI_ALLOW_INSECURE_URLS:-false}" +FORCE="0" + +WORK_DIR="" +LOCK_DIR="" + +log() { printf '%s: %s\n' "$PROGRAM" "$*" >&2; } +die() { log "$*"; exit 1; } + +usage() { + cat <<'USAGE' +Usage: install.sh [options] + + --version VERSION CLI version, or "latest" + --install-root DIR Versioned installation root + --target TARGET Override target detection + --output-file FILE Write key=value installation results + --repository OWNER/REPO Cloudsmith public raw repository (advanced) + --manifest-url URL Override generated manifest URL (advanced/testing) + --force Reinstall an existing matching version + -h, --help Show help +USAGE +} + +require_value() { + [ -n "${2-}" ] || die "$1 requires a value" +} + +parse_args() { + while [ "$#" -gt 0 ]; do + case "$1" in + --version) require_value "$1" "${2-}"; REQUESTED_VERSION="$2"; shift 2 ;; + --install-root) require_value "$1" "${2-}"; INSTALL_ROOT="$2"; shift 2 ;; + --target) require_value "$1" "${2-}"; TARGET_OVERRIDE="$2"; shift 2 ;; + --output-file) require_value "$1" "${2-}"; OUTPUT_FILE="$2"; shift 2 ;; + --repository) require_value "$1" "${2-}"; REPOSITORY="$2"; shift 2 ;; + --manifest-url) require_value "$1" "${2-}"; MANIFEST_URL="$2"; shift 2 ;; + --force) FORCE="1"; shift ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac + done + + case "$REQUESTED_VERSION" in ''|.|..|*[!A-Za-z0-9._+-]*) die "invalid version: $REQUESTED_VERSION" ;; esac + case "$REPOSITORY" in */*) ;; *) die "repository must be OWNER/REPOSITORY" ;; esac + case "$REPOSITORY" in *[!A-Za-z0-9._/-]*) die "invalid repository: $REPOSITORY" ;; esac +} + +default_install_root() { + if [ -n "${XDG_DATA_HOME:-}" ]; then + printf '%s\n' "$XDG_DATA_HOME/cloudsmith-cli" + elif [ -n "${HOME:-}" ]; then + printf '%s\n' "$HOME/.local/share/cloudsmith-cli" + else + die "HOME or XDG_DATA_HOME must be set unless --install-root is provided" + fi +} + +set_default_install_root() { + [ -n "$INSTALL_ROOT" ] || INSTALL_ROOT="$(default_install_root)" +} + +cleanup() { + [ -z "$LOCK_DIR" ] || rmdir "$LOCK_DIR" 2>/dev/null || true + [ -z "$WORK_DIR" ] || rm -rf "$WORK_DIR" +} + +require_https() { + # Args: