Skip to content

Commit 4dfa3dd

Browse files
feat: add image --upload to push an SBOM for an image tag OD-710 (#50)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent a389153 commit 4dfa3dd

8 files changed

Lines changed: 377 additions & 13 deletions

File tree

‎.changeset/great-images-upload.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@codacy/codacy-cloud-cli": minor
3+
---
4+
5+
Add `codacy image <provider> <org> <image> --tag <tag> --upload <file>` to upload an SBOM (SPDX or CycloneDX) for a container image tag.
6+
7+
`-e, --environment <name>` and `-r, --repository <name>` optionally record where the image is deployed and which repository it belongs to. The file is checked before the request, so a wrong path or an empty file fails immediately.

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ Supported providers: GitHub (`gh`), GitLab (`gl`), Bitbucket (`bb`).
156156
| `patterns [provider] [org] [repo] <tool>` | List patterns for a tool, or bulk enable/disable them |
157157
| `pattern [provider] [org] [repo] <tool> <id>` | Show a pattern, or enable, disable, or set parameters for it |
158158
| `images <provider> <org>` | List Docker images with SBOMs uploaded to an organization |
159-
| `image <provider> <org> <image>` | List an image's tags, show one tag, or delete a tag or the whole image |
159+
| `image <provider> <org> <image>` | List an image's tags, show one tag, upload an SBOM, or delete a tag or the whole image |
160160

161161
Run `codacy <command> --help` for full argument and option details for any command.
162162

‎SPECS/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ This is the single source of truth for all project tasks and specs.
99
| Task | Spec | Notes |
1010
|---|---|---|
1111
| Tag count on `ImageSummary` | [images.md](commands/images.md) | **Backend**: add a tag count to `listOrganizationImages`' response so `images` can show a Tags column without one extra request per image. Deriving it client-side was deliberately dropped from OD-710's first PR |
12-
| `image --upload` (`uploadImageSbom`) | [images.md](commands/images.md) | Stacked PR 2 of OD-710. The operation is already in the generated client |
1312
| `image --delete --keep-latest <n>` | [images.md](commands/images.md) | Stacked PR 3 of OD-710. **Blocked**: every tag delete zero-fills org-wide Container Scanning metrics until the next nightly scan, so a delete loop fires it N times. Waits on "Fix org-wide metrics wipe on image tag deletion" |
1413

1514
## Command Inventory
@@ -32,7 +31,7 @@ This is the single source of truth for all project tasks and specs.
3231
| `patterns` | `pats` | ✅ Done (--matches-stack added) | [tools-and-patterns.md](commands/tools-and-patterns.md) |
3332
| `pattern` | `pat` | ✅ Done (info mode + guards added) | [tools-and-patterns.md](commands/tools-and-patterns.md) |
3433
| `images` | `imgs` | ✅ Done | [images.md](commands/images.md) |
35-
| `image` | `img` | ✅ Done (list/show tags + delete; upload/bulk pending) | [images.md](commands/images.md) |
34+
| `image` | `img` | ✅ Done (list/show tags + upload + delete; bulk cleanup pending) | [images.md](commands/images.md) |
3635
| `analysis` | N/A | ✅ Done | [analysis.md](commands/analysis.md) |
3736
| `json-output` | N/A | ✅ Done | [json-output.md](commands/json-output.md) |
3837
| `login` | N/A | ✅ Done | — |
@@ -96,4 +95,5 @@ This is the single source of truth for all project tasks and specs.
9695
| 2026-09-09 | New `-k, --matches-stack [value]` filter on `patterns`, surfacing the API's `matchesStack` query param (filter a tool's code patterns by whether they match the repository's detected stack). Tri-state, matching the existing `issues --false-positives`: the bare flag or `true` sends `matchesStack=true`, `false` sends `matchesStack=false`, omitting it sends nothing — read explicitly rather than by truthiness so an explicit `false` stays distinct from "not requested". Applies in **both** list mode (`listRepositoryToolPatterns`) and bulk mode (`updateRepositoryToolPatterns`), like every other filter; the post-update `toolPatternsOverview` call deliberately stays unfiltered, since its counts describe the whole tool rather than the updated subset. The shared tri-state coercion `parseBooleanOption` moved out of `issues.ts` into a new `utils/options.ts` (+ tests) and is now imported by both commands. **Required an API bump: pinned `57.3.9` → `57.4.17`** (`matchesStack` first ships in `57.4.14`; `57.4.17` is the latest published build). The spec delta is purely additive — 2 unused new operations, 4 new schemas, `stackTagsFilterParam` on `listOrganizationRepositories` (unused; the CLI calls `...WithAnalysis`) — but `matchesStack` is inserted *mid-signature* on `listRepositoryToolPatterns` (arg 12, before `sort`), so every full-positional-arg assertion in `patterns.test.ts` gained a trailing `undefined`; `pattern.ts`/`issues.ts` stop at `search` (arg 9) and were unaffected. `SPECS/repository-tokens.md` re-verified: `57.4.x` now declares the `ProjectTokenAuth` scheme in the spec (it was absent in `57.3.9`), making the whitelist machine-checkable, and it is **14** operations, not 13 — the addition is `searchAiInventoryCategories`, unused here. `patterns` stays fully whitelisted, so no new token guard (11 new tests, 625 total) |
9796
| 2026-09-10 | Coverage **status** surfaced in `repositories` and `repository`, from the API's new `Coverage.status` ([`CoverageStatus`](https://api.codacy.com/api/api-docs#tocs_coveragestatus): `None`/`UpToDate`/`Waiting`/`Stopped`), mirroring codacy-spa#3110. **No API bump** — pinned `57.4.17` already ships the field. It rides on `Coverage`, embedded only in `RepositoryWithAnalysis`, so these two commands are the only places it can appear; `ls`/`directories` (flat `coverageWithDecimals`) and `pull-request`/`pull-requests` (`PullRequestCoverage`/`DiffCoverage`) carry no status. The payload shapes differ in more than `status`, which is what drove the rendering: `Waiting` returns a **stale** percentage (from `lastCommitWithCoverage`, `valueUpdatedAt` older than `statusUpdatedAt`), `Stopped` returns **no percentage at all**, `None` returns nothing but the status, and `status` is `undefined` on a large share of repositories. **`repositories`:** a dim `⋯` after a `Waiting` value, a dim `⊘` *instead of* a `Stopped` value, and a legend under the table carrying only the statuses actually present (`coverageStatusLegend`). Glyphs follow the existing vocabulary — `⋯` is already `formatStandards`'s "not final yet" marker, `⊘` shares the Mathematical Operators block with the `⊙` public-repo marker — no emojis. **`repository`:** the Metrics row spells the state out (`Not reported yet for the latest commit — value from 11h ago (5474cbf)` / `Stopped receiving reports 2026-08-26 — last report 8752dbd`, plus `— coverage gate no longer enforced` when `goals.minCoveragePercentage` is set / dim `Not set up` for `None`, which a bare `N/A` could never distinguish from an uncomputed metric), colored on `formatAnalysisStatus`'s existing scale (blueBright = in-flight, yellow = attention, dim = nothing there). **Analysis row rewritten:** `formatAnalysisStatus` gained an authoritative `coverageStatus` that wins over its `expectsCoverage`/`hasCoverageData` heuristic, extracted into `coverageAnalysisSuffix`. The heuristic was *wrong* for `Waiting` — a waiting repo still reports a (stale) percentage, so `hasCoverageData` was true and the row said nothing in exactly the case worth surfacing — and vague for `Stopped` ("Missing coverage reports"). That let `repository` **drop its `listCoverageReports` call entirely** (5 parallel requests → 4), which in turn means repository-token users get the coverage state for the first time (`getRepositoryWithAnalysis` is whitelisted, `listCoverageReports` is not) and `unavailable` is now `["pullRequests"]` alone. `pull-request` keeps the heuristic — its coverage models have no status field — and is untouched. Accepted trade-off: with `status` undefined, `repository`'s Analysis row now shows no coverage hint where the heuristic might have said "Missing coverage reports". Also de-duplicated `repositories.ts`'s local `formatMetric` (a stale copy of the shared `colorMetric` returning a bare `"N/A"`), which would otherwise have let coverage and complexity/duplication drift inside the same table. Five new helpers in `utils/formatting.ts` (`coverageStatusGlyph`/`formatRepoCoverageCell`/`coverageStatusLegend`/`coverageStatusNote`/`formatRepoCoverageDetail`); `formatCoverageCell` deliberately untouched — it renders file/folder coverage, which has no status. JSON gains `coverage.status`/`lastCommitWithCoverage`/`statusUpdatedAt`/`valueUpdatedAt` on both commands (`valueUpdatedAt` is what tells a consumer the `Waiting` value is stale — the job the glyph does in the table); `pickDeep` drops undefined, so a `None` repo emits `{"status":"None"}` and a status-less one gains no keys (39 new tests, 664 total) |
9897
| 2026-09-21 | (OD-710) New `images` (`imgs`) and `image` (`img`) commands — container images with SBOMs uploaded to an organization, and the tags under them. Built entirely on operations already present in the generated client (`SbomService`): **no `npm run update-api`**. `images <provider> <org>` lists images (Image / Latest Tag / Last Upload / Last Generated) in a single request. `image <provider> <org> <image>` lists that image's tags (Tag / Environment / Repository / Generated / Uploaded / Last Analysed), shows one with `-t, --tag <tag>`, and deletes with `-D, --delete`. **`--delete` is the action and `--tag` is the scope**, the same split `issues --ignore` makes with its filters — the flag that narrows what is acted on is the flag that narrows what is shown — so `--delete` alone removes the image and every SBOM under it, `--tag X --delete` removes one tag, and there is no second delete verb needing a mutual-exclusion guard. `--tag` without an action pages the listing and matches exactly (the tags endpoint has no per-tag filter — the same shape `pull-request --issue <id>` uses) and errors naming the tag when absent; with `--delete` it skips the lookup, since the API 404s on a missing tag at lower cost. **No tag count on the `images` table**: it is the number an org at the 1000-tag cap actually wants, but `ImageSummary` doesn't carry one and deriving it costs one request per image, so it is being added server-side instead (pending task above) rather than fanned out from the client. A whole-image `--delete` still reads a `limit: 1` `pagination.total` so its prompt can name how many tags are about to go; a failed lookup drops the count rather than blocking, and `-y` skips it. Both delete scopes go through the shared `confirmAction` (non-TTY without `-y` aborts, as in `issues --ignore`) and print a yellow notice first: a single SBOM delete currently zero-fills Container Scanning metrics for the **whole organization** until the next nightly scan. That defect is also why this is the first of three stacked PRs — bulk cleanup (`--keep-latest`) would fire it once per tag and waits on the fix; `--upload` is PR 2. Both commands are **account-token only** — no image operation is on the 14-operation repository-token whitelist — and are covered by the cross-cutting `repository-token-refusals.test.ts` rather than their own token tests. Image/tag/environment/repository names arrive with the SBOM upload, so all four go through `sanitizeText()` (26 new tests, 699 total) |
98+
| 2026-09-21 | (OD-710, PR 2) `image --upload <file>`: push an SBOM (SPDX or CycloneDX) for one image tag, via the already-generated `uploadImageSbom`. Fits the command's existing action/scope split — `--upload` is a verb, `--tag` scopes it — and **requires `--tag`**, since the API keys the upload on image *and* tag with no untagged fallback; that is refused by name before the file is read. The file is validated locally first (unreadable path, empty file), so the common mistakes fail with something actionable instead of a 400 from the other side of the network. Sent as a `File` rather than a bare `Blob` so the multipart part carries the real filename — a `Blob` goes out as `filename="blob"` — with the media type inferred from the extension (`.json`/`.xml`, else `application/octet-stream`, letting the API decide rather than guessing wrong in the request); the generated `isBlob` accepts both shapes. Optional `-e, --environment` and `-r, --repository` map to the API's `environment`/`repositoryName` and are omitted from the form rather than sent as undefined. `--upload` and `--delete` are refused together: unlike `--delete`'s two scopes these are two different verbs, and asking for both says nothing coherent about what should happen to the SBOM. Still account-token only, which is the awkward part — uploading from a pipeline is exactly where a project token would be natural, so the gap is logged in `SPECS/missing-endpoints.md` (9 new tests, 708 total) |
9999
| 2026-09-22 | (OD-710, review follow-up) `confirmAction` now prompts on **stderr**, not stdout. `process.stdin.isTTY` is true whenever *stdin* is a terminal — including when stdout is a pipe — so `image … --delete --output json | jq` sent the question and the echoed keystroke into `jq`. Fixed in `utils/prompt.ts` rather than per command, so every caller is covered and no command has to thread its output format into the prompt; new `utils/prompt.test.ts` pins the stream, the y/N parsing and the non-TTY refusal. Also replaced two `images.test.ts` assertions that could not fail: the dash test asserted only that the image name appeared, and the `--limit` cap test asserted a page size that is constant for any limit ≥ 100 (5 new tests, 705 total) |

‎SPECS/commands/images.md‎

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ Split into stacked PRs, because one piece is blocked on a backend fix:
1616

1717
| PR | Scope | State |
1818
|---|---|---|
19-
| 1 | `images` (list) and `image` (list tags, show a tag, `--delete` scoped by `--tag`) | this one |
20-
| 2 | `--upload` (`uploadImageSbom`) | follow-up |
19+
| 1 | `images` (list) and `image` (list tags, show a tag, `--delete` scoped by `--tag`) | done |
20+
| 2 | `--upload` (`uploadImageSbom`) | this one |
2121
| 3 | bulk cleanup — `--delete --keep-latest <n>` | **blocked** |
2222

2323
**Why bulk cleanup is blocked.** Every single tag delete currently zero-fills
@@ -43,7 +43,7 @@ All four operations already exist in the generated client
4343
| `deleteImageTag` | DELETE | `/organizations/{provider}/{org}/image-sboms/{imageName}/tags/{tag}` |
4444
| `deleteImageSboms` | DELETE | `/organizations/{provider}/{org}/image-sboms/{imageName}` |
4545

46-
`uploadImageSbom` (POST `/image-sboms`, multipart) is left for PR 2.
46+
| `uploadImageSbom` | POST | `/organizations/{provider}/{org}/image-sboms` (multipart) |
4747

4848
## Tokens
4949

@@ -75,6 +75,9 @@ what `codacy image <image>` shows. Do not reintroduce a fan-out here.
7575
|---|---|
7676
| `-t, --tag <tag>` | act on a single tag instead of the whole image |
7777
| `-n, --limit <n>` | max tags to return (default 100, max 1000) |
78+
| `-u, --upload <file>` | upload an SBOM file (SPDX or CycloneDX) for `--tag` |
79+
| `-e, --environment <name>` | environment the image is deployed to (with `--upload`) |
80+
| `-r, --repository <name>` | repository to associate the upload with |
7881
| `-D, --delete` | delete the image's SBOMs, or just `--tag`'s |
7982
| `-y, --skip-confirmation` | skip the confirmation prompt |
8083

@@ -87,8 +90,9 @@ has to learn. Three modes fall out of the one flag:
8790

8891
| Invocation | Result |
8992
|---|---|
90-
| (neither) | list every tag |
93+
| (no action) | list every tag |
9194
| `--tag <tag>` | show that one tag's details |
95+
| `--tag <tag> --upload <file>` | upload an SBOM for that tag |
9296
| `--delete` | delete the image and every SBOM under it |
9397
| `--tag <tag> --delete` | delete that tag's SBOM |
9498

@@ -124,6 +128,38 @@ number that decides whether this is routine cleanup or a mistake. A count lookup
124128
that fails must not block the delete, so the prompt falls back to "all of its
125129
tags". Under `-y` the lookup is skipped entirely.
126130

131+
### `--upload <file>`
132+
133+
Uploads an SBOM (SPDX or CycloneDX) for one image tag.
134+
135+
**`--tag` is required**, because the API's upload is keyed on image *and* tag —
136+
there is no untagged SBOM to fall back to. Refused with a message naming the
137+
flag, before the file is even read.
138+
139+
**The file is validated locally first.** A missing/unreadable path and an empty
140+
file both fail immediately with something actionable, rather than as a 400 from
141+
the other side of the network.
142+
143+
**Sent as a `File`, not a bare `Blob`**, so the multipart part carries the real
144+
filename — a `Blob` goes out as `filename="blob"`, which tells the server and
145+
anyone reading a request log nothing. The generated client's `isBlob` accepts
146+
both. The media type comes from the extension (`.json` → `application/json`,
147+
`.xml` → `application/xml`, anything else → `application/octet-stream`, letting
148+
the API decide rather than guessing wrong in the request).
149+
150+
`--environment` and `--repository` are optional passthroughs to the API's
151+
`environment`/`repositoryName` fields, omitted from the form rather than sent as
152+
undefined.
153+
154+
**`--upload` and `--delete` are refused together.** Unlike `--delete`'s two
155+
scopes, these are two different verbs — asking for both says nothing coherent
156+
about what should happen to the SBOM.
157+
158+
**Account token, even in CI.** `uploadImageSbom` is not on the repository-token
159+
whitelist, which is awkward: uploading an SBOM from a pipeline is exactly where
160+
a project token would be natural. Logged in
161+
[missing-endpoints.md](../missing-endpoints.md) as a whitelist gap.
162+
127163
## Sanitization
128164

129165
Image names, tags, environments and repository names all arrive with the SBOM
@@ -133,5 +169,5 @@ through `sanitizeText()` before styling, per the CWE-150 rule in
133169

134170
## Tests
135171

136-
`images.test.ts` (8) + `image.test.ts` (16) + 2 refusal cases in
137-
`repository-token-refusals.test.ts` = 26.
172+
`images.test.ts` (8) + `image.test.ts` (25) + 2 refusal cases in
173+
`repository-token-refusals.test.ts` = 35.

‎SPECS/missing-endpoints.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ otherwise repository-scoped work.
2020
| 4 | `searchRepositoryIgnoredIssues` | POST | `codacy issues --ignored` | Read-only, and a natural sibling of the already-whitelisted `searchRepositoryIssues`. |
2121
| 5 | `bulkIgnoreIssues` | POST | `codacy issues --ignore` | Write. Would let the auto-configuration flow ignore noisy issues instead of only disabling patterns. Note: a future read-only repository token must block this by operationId. |
2222
| 6 | `updateIssueState` | PATCH | `codacy issue --ignore/--unignore` | Same category as #5, single-issue. |
23-
| 7 | `listCoverageReports` | GET | The coverage-expectation suffix on `codacy repository`'s Analysis row | Lowest value of the reads: it affects one optional suffix and has **zero** JSON impact (no coverage key is projected). Listed for completeness. |
23+
| 7 | `uploadImageSbom` | POST | `codacy image --upload` from CI | Write, and the odd one out on this list: it is organization-scoped like the rest of `SbomService`, but it is the one image operation a *pipeline* runs, and pipelines are exactly where a repository token is the natural credential (the coverage reporter already reads `CODACY_PROJECT_TOKEN` there). The upload already names a `repositoryName`, so a repository-scoped token has an obvious meaning for it. Needs a decision from the API owners rather than an assumption. |
24+
| 8 | `listCoverageReports` | GET | The coverage-expectation suffix on `codacy repository`'s Analysis row | Lowest value of the reads: it affects one optional suffix and has **zero** JSON impact (no coverage key is projected). Listed for completeness. |
2425

2526
## Deliberately out of scope
2627

‎src/commands/AGENTS.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,12 @@ the parts that constrain future edits:
487487
`--tag` without `--delete` pages the listing and matches exactly — the shape
488488
`pull-request --issue <id>` uses. `--tag --delete` deliberately skips it: the
489489
API 404s on a missing tag, which is the same answer for one fewer call.
490+
- **`--upload` requires `--tag`, and validates the file before the request.**
491+
The API keys an upload on image *and* tag, so there is no untagged fallback;
492+
an unreadable or empty file fails locally rather than as a remote 400. It is
493+
sent as a `File` (not a `Blob`) so the multipart part carries the real
494+
filename, with the media type inferred from the extension. `--upload` and
495+
`--delete` *are* refused together — two verbs, not two scopes.
490496
- **The metrics-wipe notice is temporary.** Deleting any SBOM currently
491497
zero-fills Container Scanning metrics for the whole organization until the
492498
next nightly scan, so both delete scopes print `METRICS_WIPE_NOTICE` above the

0 commit comments

Comments
 (0)