Skip to content

feat: Textual onboarding wizard + consolidated sbomify API client (with fixes)#239

Closed
aurangzaib048 wants to merge 62 commits into
sbomify:masterfrom
aurangzaib048:feat/wizard-and-shared-api-client
Closed

feat: Textual onboarding wizard + consolidated sbomify API client (with fixes)#239
aurangzaib048 wants to merge 62 commits into
sbomify:masterfrom
aurangzaib048:feat/wizard-and-shared-api-client

Conversation

@aurangzaib048
Copy link
Copy Markdown
Contributor

Summary

This is the Textual onboarding wizard + consolidated sbomify API client feature (the same work as #238), opened from a fork against master because we don't have push access to #238's branch (sbomify:feat/wizard-and-shared-api-client).

Relative to #238's branch, this adds 4 fix commits on top:

  • dc4b02c fix(wizard): valid component_type, existing sbomify.json, error polish
    • component_type "sbom""bom" — the backend ComponentType enum is {bom, document}; "sbom" returned HTTP 422 on every wizard onboarding and Yocto upload. Adds a client-side VALID_COMPONENT_TYPES guard so a bad literal fails fast instead of as an opaque 422.
    • Wizard now detects and pre-fills an existing repo-root sbomify.json, and apply no longer dead-ends on a hand-authored one (keeps it as-is; the action reads it at run time).
    • Readable API validation errors (collapses pydantic 422 list-detail into field: msg); run_command(log_errors=) so cdxgen's expected fallback failures don't spam ERROR; corrected the misleading token-scope warning copy.
  • cee6d02 fix: harden enrichment, dtrack, cargo, chainguard edge caseshas_data() counts cle_release_date; dtrack catches base RequestException; cargo generator gets the check_tool_available guard its siblings have; chainguard reads digest via .get().
  • 55f1a1b ci: add textual to mypy pre-commit deps so the hook passes — the mypy hook's isolated env was missing textual, so it saw the wizard's Textual base classes as Any and failed on every commit (in-venv mypy was already clean).
  • f29d96d refactor: address comprehensive-review findings — explicit warning when wizard edits aren't written to a hand-authored sbomify.json; log_errors now also covers timeout/missing-binary; added regression tests for the apply call-site, _load_from_disk error paths, and the chainguard provenance digest path; assorted polish.

Testing

  • Full test suite: 2315 passed, 4 skipped.
  • pre-commit run --all-files: ruff, ruff-format, and mypy all green.
  • Wizard verified end-to-end via the real Textual TUI and a live config matrix (OIDC/token × trunk/tag/manual × cyclonedx/spdx/both × skip/profile/json_config) against a local sbomify backend.

Note for maintainers

This necessarily includes the full feature diff (60 commits vs master) because the 4 fixes patch feature-only code and can't target master alone. It duplicates #238; if you'd rather keep #238 as the canonical PR, the 4 fix commits can be cherry-picked onto its branch from aurangzaib048:feat/wizard-and-shared-api-client (dc4b02c cee6d02 55f1a1b f29d96d).

vpetersson added 30 commits May 27, 2026 18:40
Five subsystems (yocto, upload, augmentation, releases, releases
processor) currently hand-roll their own requests.* calls with copies
of header construction, pagination, and error parsing. Add a single
SbomifyApiClient class that they will all delegate to in the next
five commits, plus a fresh AuthError(APIError) for 401s so callers can
tell credentials-rejected apart from anything else.

The class is intentionally narrow — one method per endpoint, no
get-or-create-style orchestration except where it already lived in the
callers (DUPLICATE_NAME recovery for components and releases,
DUPLICATE_ARTIFACT idempotency for tag-with-release).
Yocto's component CRUD now delegates through a one-shot SbomifyApiClient
per call. Public signatures (api_base_url + token) are unchanged, so the
pipeline doesn't have to know about the client class. The tests collapse
into a delegation-contract pin — pagination / DUPLICATE_NAME / plan-limit
semantics are already covered in test_sbomify_api.py.
releases_api.py collapses into a thin per-call facade over
SbomifyApiClient. Public signatures (api_base_url + token) are
preserved so the Yocto pipeline and SbomifyReleasesProcessor pick up
the consolidation for free.

The legacy URL-construction audit and the SbomifyReleasesProcessor
integration tests are rewritten to patch at the client / factory
layer; the friendly-name pure-helper tests are kept as-is.
The destination keeps its plugin-protocol shape (is_configured, upload)
and its error-to-UploadResult translation, but the raw requests.post
call is replaced with SbomifyApiClient.upload_sbom. The client raises
APIError on connection/timeout failures, which the destination
translates to UploadResult.failure_result with the same messages users
have always seen.

Gzip-compression behaviour is unchanged — destination still computes
compressed vs raw payload locally and passes content_encoding through
to the client.
The augmentation provider's _fetch_backend_metadata is now a one-line
call to SbomifyApiClient.get_augmentation_meta. The provider keeps its
own contact-profile / lifecycle field extraction; only the HTTP
plumbing collapses into the shared client.

Tests across augmentation, schema-compliance, container-NTIA and the
audit suite now patch _fetch_backend_metadata directly so they're
decoupled from the underlying transport.
Builds the data spine for the new Textual wizard without wiring any
UI yet:

- WizardOptions: frozen CLI-level input dataclass.
- WizardState / Plan / PlannedComponent / RepoFacts / WorkspaceSnapshot:
  three-layer state model. RepoFacts is immutable observation, Plan is
  the staged mutation, WorkspaceSnapshot is the prefetched API state.
- io.write_workflow: header-sentinel-guarded writer for sboms.yml.
  Refuses to overwrite hand-authored files; backs up wizard-written
  ones to .bak before overwriting.

The old questionary-based wizard still ships unchanged — it stays
exported until commit 10 swaps the CLI command.
- discovery.discover() walks the repo for supported lockfiles, picks
  the highest-priority one per directory, and suggests a component
  name per lockfile based on repo slug + ecosystem.
- repo_facts.gather_repo_facts() snapshots git + filesystem state
  (default branch, remote URL → owner/repo slug, has-tags). This
  drives the OIDC binding instructions and the release-strategy
  default.
- existing.wizard_workflow_exists() is the entire detection surface:
  does .github/workflows/sboms.yml exist with the wizard sentinel?
  No YAML parsing, no inference from on: triggers — the sentinel is
  the contract.
ci_emitter renders the wizard's single canonical workflow
(.github/workflows/sboms.yml) — three release strategies (trunk / tag
/ manual) × two credential modes (oidc / token), single matrix job
per file. Templates are plain f-strings: PyYAML round-trips mangle
GitHub Actions expressions and add cosmetic churn for no payoff.

apply_plan is the single mutation point: resolve / create product,
get-or-create components, attach the union set to the product, then
write the workflow file last. Workflow write goes through
io.write_workflow so existing wizard-stamped files are backed up to
.bak before overwrite, and hand-authored files are refused.

PINNED_ACTION_SHA points at v26.2.0; bump these two constants
together when cutting a new sbomify-action release.
Builds the actual interactive surface — Textual app, shared screen
frame, and the eight phase screens (welcome, discover, authenticate,
product, configure, review, apply, done). app.py gathers RepoFacts +
discovery + sentinel-detection synchronously before mount so the
welcome screen renders accurate stats; everything else (workspace
prefetch, apply) runs on Textual workers so the UI never blocks.

Dependencies:
- textual>=0.85.0 added.
- pytest-asyncio added (with asyncio_mode=auto) for App.run_test()
  smoke tests in tests/test_wizard_textual.py.

The old questionary-based wizard still ships unchanged; commit 10
swaps init_cmd over and drops questionary.
- Add _resolve_token helper (precedence: --token, $SBOMIFY_TOKEN, $TOKEN)
  and reuse it from build_config + yocto's token guard.
- Replace the old `init` subcommand (which generated sbomify.json
  metadata via questionary) with the new Textual wizard. `init` stays
  as a backwards-compatible alias for `wizard` with a one-line
  deprecation note pointing at `wizard`.
- Add _wizard_in_ci() guard refusing to launch the TUI when
  GITHUB_ACTIONS or CI is truthy (Textual needs a real TTY).
- Validate --output-dir resolves to repo_root/.github/workflows;
  silently writing non-functional workflows elsewhere is worse than
  failing fast.

Deleted the legacy wizard modules (runner / sections / prompts /
validators) and their tests. Removed `questionary` from
project.dependencies. Added the wizard / init usage examples to the
top-level help.
Generated by running the wizard's emit_workflow against this repo's
production component IDs (action / frontend), product, and contact
profile.

Triggered via workflow_dispatch only so it can be exercised against
production sbomify without colliding with the existing
generate-source-sboms-production job in sbomify.yaml on tag pushes.
The existing pipeline keeps publishing SPDX, build-provenance
attestation, and the docker PURL — capabilities the wizard does not
yet emit. A follow-up PR can collapse the two once those features
land in the emitter.
…NENT_NAME

Brings the wizard up to parity with the generalizable features in
this repo's hand-authored generate-source-sboms-* jobs:

- Multi-format: Plan now has sbom_formats (default ["cyclonedx"]).
  Each lockfile becomes one matrix row per requested format; row
  names are suffixed with the format only when more than one is
  emitted, so single-format workflows stay readable.
- Provenance: Plan.attestation toggle. Adds an
  actions/attest-build-provenance step targeting the just-written
  OUTPUT_FILE and ensures permissions: attestations: write.
- Cache: actions/cache@<pinned> step always emitted, plus
  SBOMIFY_CACHE_DIR + SYFT_CACHE_DIR env vars. Generic speedup for
  enrichment + syft.
- OUTPUT_FILE: derived per matrix row as <slug>.<cdx|spdx>.json.
- COMPONENT_NAME: passed through matrix.component_name so the
  Dependency Track destination + sbomify display name see the
  human-readable name the user picked.

Configure screen gains two radio panels (formats + provenance).

PINNED_{CHECKOUT,CACHE,ATTEST}_SHA constants added per SECURITY.md.

Dogfood sboms.yml regenerated with both formats + provenance to
demonstrate the new features. Kept workflow_dispatch-only until we
decide whether to wholesale-replace generate-source-sboms-production
in sbomify.yaml (loses repo-specific COMPONENT_PURL with docker tag).
- sboms.yml now tag-triggered (push: tags ['v*'] + workflow_dispatch),
  with both CycloneDX and SPDX × both components and provenance
  attestation. This is the actual prod publisher for source SBOMs.
- Deleted generate-source-sboms-staging + generate-source-sboms-production
  from .github/workflows/sbomify.yaml. Container SBOMs stay in
  sbomify.yaml because the wizard doesn't emit Docker-image jobs yet
  and they depend on push-images for the image digest.
- Known loss: COMPONENT_PURL: pkg:docker/sbomify/sbomify-action@<digest>
  on the action component. That env var depends on
  steps.version.outputs.docker_tag from a custom composite action and
  is repo-specific orchestration the wizard intentionally doesn't try
  to model. Wizard users who need a PURL can hand-edit (which breaks
  the sentinel-managed contract) or wait for a per-component PURL
  template feature.

Also gates the attestation feature in the wizard's configure screen
with a one-line note: works on public repos on any plan; private /
internal repos require GitHub Enterprise Cloud; not supported on
GitHub Enterprise Server. Same note appears as a comment in the
emitted YAML next to the attest step.
The brief two-line comment we emitted before doesn't make the gating
obvious enough to anyone reading the generated workflow. Expand it
into a four-condition support matrix that names each combination
explicitly:

  - public repo on any plan: supported via public-good Sigstore
  - private/internal on GHEC: supported via private Sigstore
  - private/internal on Free/Pro/Team: NOT supported (workflow fails)
  - any repo on GHES: NOT supported

Same expanded note now appears in the wizard's configure-screen
panel, with a one-liner pointing out the YAML carries the same
annotation. Tests pin the four conditions + the upstream reference
URL so a copy-edit can't quietly drop one.

Dogfood sboms.yml regenerated.
Pulls the design tokens from sbomify.com/tailwind.config.js +
the @theme block in _assets/css/tailwind.css and lifts them into
sbomify_action/cli/wizard/styles.tcss as $sbom-* variables so the
TUI matches the marketing site. Same primary (#141035) / purple
(#8A7DFF) / secondary BG (#1A1B2A) / muted text (#CBCCCE) palette,
same signature blue→magenta→peach gradient (#4059D0 → #CC58BB →
#F4B57F) used for the welcome banner title.

Every wizard widget now has explicit styling — buttons (default +
.-primary variant), inputs, RadioSet/RadioButton, SelectionList,
OptionList, DataTable, LoadingIndicator, ProgressBar, RichLog. Inline
markup colours (red/yellow/green strings) replaced with brand-coherent
hex codes:
  * #F87171 for errors (soft red, reads well on dark)
  * #F4B57F for warnings/notices (gradient peach)
  * #86EFAC for success (mint that pairs with the palette)
  * #CBCCCE for info / muted body text

Intentionally does NOT redefine Textual's built-in design tokens
($accent / $panel / $primary / etc.) — Textual's own stylesheet uses
those in contexts like `hatch: right $panel` that don't accept
arbitrary hex colours, so shadowing them blows up CSS parsing on app
startup. Comment in the TCSS file calls this out.

Welcome screen's banner now renders the title in the sbomify gradient
so the wizard's first impression matches the marketing site's hero.
Pivots the look away from inline `[b]Title[/]` headers and bare radio
groups toward the visual language modern devsecops + AI-security
CLIs use (Charm tooling, Vercel CLI wizards, Linear setup, lazygit):

- Step indicator (in screens/_base.py) becomes a connected segmented
  track + numbered position chip + step title:
      01 / 06  │  ●━━━○━━━○━━━○━━━○━━━○  │  Welcome
  Past steps stay filled, current is bolded, future is dimmed.

- Every panel uses Textual's border_title / border_subtitle for a
  card-with-title-bar shape. Title gets the brand purple; subtitle
  gets the muted text colour right-aligned for "subtle metadata".
  Replaces the inline panel headers with the same effect baked into
  the border, removing one redundant line per panel.

- Welcome screen redesigned as a hero card with the marketing
  tagline "Zero to SBOM Hero" in gradient magenta + gradient title
  + a numbered preview of the six upcoming steps that mirrors the
  step indicator. Three cards total: hero, what-we'll-do, repo.

- Status iconography across every screen: ✓ on completed items,
  ⚠ on caveats, ⏳ on the apply screen header, ✗ on errors, ◌ for
  empty states. Configure's attestation panel uses ✓ / ✗ on the
  GHEC support matrix; review's "files to write" uses arrow chars.

- Done screen "Applied" summary now lists each created
  product/component/file with a green ✓ glyph and aligned columns.
  OIDC instructions panel uses the ⚠ "one more step" prefix to
  signal it's an action the user still owes.

Smoke test (App.run_test) confirms the new stylesheet parses and
the welcome → discover transition still works. 2266 tests passing,
ruff + mypy clean.

The remaining "one-question-per-screen" pattern (Configure splitting
into 5 dedicated screens, à la Vercel CLI) is a follow-up: bigger
restructure, less obviously a win given the current scrollable
density is already readable.
1. "Container#auth-progress" rendering as visible text
   The custom Container subclass in screens/authenticate.py inherited
   Widget's default render() — which falls back to the widget's repr
   when there are no children. Result: an empty placeholder displayed
   its own selector ("Container#auth-progress") on screen until the
   LoadingIndicator was mounted. Switch to textual.containers.Container
   and add an #auth-progress CSS rule pinning min-height: 0 so the
   slot collapses cleanly while idle.

2. Escape doesn't go back from Authenticate
   The password Input swallowed Escape before the screen-level
   binding fired, leaving users stuck. Add priority=True to the
   Escape bindings on Authenticate, Product, and Configure — every
   screen that hosts a focusable Input. Welcome/Discover/Review/Apply
   don't need it; their Escape was already firing.

Regression test (test_escape_from_authenticate_returns_to_discover)
walks Welcome → Discover → Authenticate and verifies that pressing
Escape with the Input focused returns the user to Discover.
A 13-line ASCII wizard figure sits above the gradient title now. The
gradient is layered across rows of the mascot — peach at the hat
tip, magenta through the body, blue down the robe — so it echoes the
gradient-colored title sitting beneath it.

Pure-ASCII chars (no box-drawing) so the figure renders uniformly
across terminals and SSH sessions; `.wizard-hero-mascot` centers it
inside the hero card.

Headless smoke confirms the mascot lays out at 66x13 cells inside
the hero card — comfortable on any standard 80x24+ terminal.
Previous attempt was lopsided and read more like a hooded face with
weird arms than a wizard. Redrew with the four signals that actually
say 'wizard': pointed conical hat with sparkles, peering eyes, a
flowing WWW-pattern beard (decades-old ASCII convention), and a
robe widening at the base.

Coloring still echoes the sbomify gradient — peach hat tip, magenta
hat base, silvery beard (Gandalf cue), blue robe deepening to
brand-primary at the hem.

Renders at 66x13 cells; pure ASCII.
Previous attempts had the hat too short and squat — a wizard hat
needs to be roughly as tall as the face + beard or the figure reads
as 'hooded gnome' rather than 'wizard'. Redrawn from the standard
ASCII vocabulary (/\ hat outline, WWW beard, ( o o ) face) with
a 7-row pointed hat over a face peering out, beard flowing into the
robe at the base.

Hero card now splits into two columns: gradient title + tagline +
strap on the left at 1fr, mascot on the right at auto-width. Cleaner
than stacking everything vertically and lets the figure read as the
mascot rather than as a chunk of body copy.

Headless smoke confirms text column = 45x7 and mascot column = 17x16
side-by-side inside the hero card.
The previous draft read as a pirate because of two things:
- The horizontal brim line (===) — pirate hat / bandana shape, not
  a wizard's conical hat.
- Round ( o o ) eyes — cartoon-mascot, parrot-on-shoulder energy.

Replaced with: no brim (hat flows straight into the face), a 9-row
exaggerated-tall pointed hat, wise squinting ^ ^ eyes, and the
WWW beard widening directly out of the face into the robe. Pure
ASCII primitives so the figure stays portable across terminals.

Mascot column lays out at 20x17 cells beside the text column on the
welcome hero, smoke test still green.
Previous drafts had the beard contained within the robe outline, so
the figure read as 'hat on torso' rather than 'wizard'. Real Gandalf-
shaped wizards have the beard extending visibly *below* the chest,
tapering to a point.

New shape:
  - 9-row pointed hat with two stars (peach → magenta gradient)
  - bushy ~^~  ~^~ eyebrows (was just ^  ^ before)
  - small o  o eyes peering out from under the brows
  - \--/ mouth
  - beard that widens from /WWWWWWW\ at the face down to
    /WWWWWWWWWWW\ at the shoulders, then cascades *past* the
    silhouette as a separate column tapering WWWWWWWWWWW → WWW

Pure ASCII primitives, gradient layered across rows so the figure
echoes the sbomify title. 20 rows tall, ~20 cells wide; still sits
side-by-side with the hero text inside the welcome card.
The figure was reading as 'hooded man with long beard' rather than
'wizard' because the wizard's most unambiguous prop was missing.
Added a staff (vertical | column) to the right of the wizard with
a glowing orb ( ) at the top in peach (the gradient end colour, so
it reads as magic) and a planted base _|_ at the bottom.

Final composition:
  - tall pointed hat with two stars (peach -> magenta gradient)
  - bushy ~^~ eyebrows
  - o  o eyes peering out
  - \--/ mouth
  - beard widening from face down to the shoulders, then cascading
    past the body as its own column, tapering to a fine WWW point
  - staff with glowing orb on the right, planted at the base

27 cells wide x 22 tall; sits to the right of the gradient title +
tagline + strap in the hero card. 35x9 left column, 27x22 right
column.
The generic 'SelectionList { height: 1fr }' rule made the lockfile
picker stretch to fill all remaining vertical space, dwarfing the
two-or-three rows it actually contained. Override it for
#lockfile-list specifically: height: auto so it sizes to one row
per option, max-height: 20 as a safety cap for the rare repo with
a degenerate number of lockfiles (a scrollbar kicks in past that).

Smoke confirms a 2-lockfile picker now lays out at 2 cells tall
instead of consuming the whole panel.
Same fix as #lockfile-list — override the generic 1fr height on
SelectionList/OptionList for #product-list specifically so a
workspace with two or three products renders compactly instead of
stretching to fill the panel. max-height: 20 caps large workspaces
behind a scrollbar.
…on note

Welcome now shows a 'Visibility' line under 'This repository' with
one of three chips:
  - public:  green check + '(attestation works on any plan)'
  - private: peach warn  + '(attestation needs GitHub Enterprise Cloud)'
  - unknown: muted dot   + '(non-github remote or no network)'

Configure's attestation panel uses the same signal to swap its
multi-line GHEC warning for a one-line 'Public repository — supported'
when we're confident the repo is public. Private and unknown still
get the full plan-tier warning so we don't accidentally tell someone
on Free/Pro/Team that attestation will work.

Detection (sbomify_action.cli.wizard.repo_facts.detect_visibility):
unauthenticated GET against api.github.com/repos/{owner}/{repo} with
a 2-second timeout. 200 + private:false -> public; 404 -> private
(same UX outcome as non-existent); anything else (rate-limited,
network error, non-github remote) -> unknown. Runs once during
gather_repo_facts so the result is fixed for the session.

Tests pin every branch (public / private / non-github short-circuit
/ network failure / rate limit) and an autouse stub keeps the rest
of the wizard suite from hitting the real API.
- Token input placeholder was 'sbom_xxxxxxx', but sbomify access
  tokens are JWTs (eyJ…). Updated the placeholder to match what the
  user actually pastes.
- Review screen's #components-table inherited 'DataTable { height:
  1fr }' from the generic rule, so a 2-component plan stretched the
  table to fill the panel. Same fix as #lockfile-list and
  #product-list: height: auto, max-height: 20.
Textual takes over stdout while the TUI is running, so logging
during a session would either get swallowed or interfere with
rendering. Solution: when --debug is set, attach a StreamHandler at
DEBUG level to the 'sbomify_action' and 'textual' loggers, write
into an in-memory StringIO during the session, and dump the buffer
to stdout after launch_wizard() returns.

The invocation stays pipeable:

  sbomify-action wizard --debug 2>&1 | tee /tmp/debug.log

Output format mimics the standard sbomify_action logger:

  === sbomify wizard DEBUG log ===
  14:22:01.483  INFO     sbomify_action  Cached 3 components
  14:22:02.117  DEBUG    sbomify_action  Created component widget-py (xyz)
  ...
  === end DEBUG log ===

Plumbed through both 'wizard' and 'init' commands; flag advertised
in --help next to --dry-run.
…eeds

Bug report from --debug: user reached 'Pick a product' but couldn't
get past it; the screen just dinged. The 'Contact profiles endpoint
not available' DEBUG line in the transcript was a red herring (404
on contact-profiles, harmless — list_contact_profiles() returns []).

Root cause was in product.py:on_mount — when products exist we
focus the OptionList but never set a default highlight. Textual's
OptionList leaves highlighted=None until the user navigates with
arrow keys, so when the user pressed Enter from a freshly-focused
list, _advance() saw highlighted=None, hit app.bell(), and returned
silently.

Two fixes:
- Pre-highlight the first row in on_mount when any products exist
  (listing.highlighted = 0) so Enter does what the user expects.
- Replace the bare bell() branches with a visible #product-status
  line so 'why didn't it proceed' is never silent again. Also added
  a defensive 'selected product has no id' branch and a logger.debug
  call so the --debug transcript records the decision.
vpetersson and others added 15 commits May 29, 2026 04:52
The profile picker added in the previous commit never appeared because
pressing Enter on the augmentation RadioSet to pick 'Use a contact
profile' was hijacked by the screen's priority=True Enter binding —
action_submit fired, route_enter saw no non-primary button, called
forward(), advanced to ConfigureSbom before the radio could change.
Same root cause as the Back-button bug, different widget.

Extend WizardScreen.route_enter so a focused RadioSet commits the
highlighted radio via action_toggle_button instead of forwarding.
The picker (which appears in response to RadioSet.Changed) now
actually shows.

Regression test (test_enter_on_focused_radio_set_toggles_radio) walks
the wizard to ConfigureWorkflow, focuses the augmentation RadioSet,
arrows down to highlight 'Use a contact profile', presses Enter, and
asserts the screen did NOT advance, the radio committed to
aug-profile, and the profile-picker OptionList became visible with
both stub profiles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The profile picker was technically visible after toggling the
augmentation radio (display=True) but the generic OptionList CSS
rule (height: 1fr) made it consume 20+ rows for 2 profile options,
pushing the Next button below the viewport and rendering the picker
invisible on typical 24-40 row terminals.

Add #profile-picker / #profile-help CSS overrides: size to content
with max-height: 8 so the picker sits compactly under the radio and
the Next button stays on screen. Probe confirms region drops from
26 rows to 4 (two profiles + border).

Also adds a Back-from-Components regression test covering both
focus states (OptionList and the "Create new" Input) so future
binding refactors can't silently strand the user on Components.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two real bugs:

  1. Wrong URL: the wizard was hitting GET /api/v1/contact-profiles,
     which doesn't exist. The actual sbomify endpoint is
     GET /api/v1/teams/{team_key}/contact-profiles and it returns a
     bare JSON list, not a paginated envelope. So every workspace
     showed "none configured" regardless of how many profiles the
     user actually had — including workspaces full of them.

  2. Wrong screen: Augmentation lived on Configure (workflow)
     alongside Release strategy + Credentials, which are runtime
     concerns. Enrichment was on Configure (SBOM). Both Augmentation
     and Enrichment are metadata-source choices (one pulls from
     sbomify, one pulls from PyPI / deps.dev / Repology); they belong
     on the same screen. Move Augmentation to Configure (SBOM).

Changes:

  - SbomifyApiClient.list_teams() — new method, GET /api/v1/teams/,
    returns the workspace list with team `key` fields.
  - SbomifyApiClient.list_contact_profiles(team_key) — now requires
    a team key, hits /teams/{team_key}/contact-profiles, parses the
    bare-list response, returns [] on 404.
  - Authenticate worker fetches list_teams() up front (one extra
    round-trip), picks the first team's key, threads it through the
    parallel _list_profiles future.
  - WorkspaceSnapshot.team_key carries the key through to apply.
  - ConfigureWorkflow shrinks to Release + Credentials. ConfigureSbom
    gains Augmentation + the inline profile picker.
  - Workspaces with zero profiles see a hint pointing at
    "Settings → Contacts" in the sbomify UI, then re-run the wizard.
  - Stub _stub_client to return list_teams() so existing tests still
    drive the auth path; otherwise the team_key probe would fail and
    profiles would skip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous commit hit ``GET /api/v1/teams/`` which 404s because the
backend mounts the router at ``/workspaces`` (the ``team_key`` URL
parameter inside is kept for legacy code, but the mount point is
``/workspaces``). Same applies to the contact-profiles route —
``/api/v1/workspaces/{team_key}/contact-profiles``.

Verified end-to-end against stage with a real PAT (returns 5
workspaces, 1 contact profile per workspace).

  - Rename ``list_teams`` -> ``list_workspaces`` so the method name
    matches the path; downgrade workspace-list / profile-list
    failures from fatal apply-blocking errors to logged warnings,
    so a token that can read components but can't enumerate
    workspaces still gets through auth (the Augmentation panel
    just appears empty).
  - Update auth worker and test stubs accordingly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous message — "Pick which profile binds to every applied
component. AUGMENT=true at workflow run time reads contact_profile_id
off the component on the backend." — was framed around internal API
details (env var name, FK column on the backend) which the wizard
user shouldn't have to care about.

Replace with a description of what the user will SEE in their SBOM:
the profile's supplier, contacts, and authors become metadata
attached to every SBOM the emitted workflow generates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Augmentation profile-picker help now explains that the supplier
and SBOM author the contact profile contributes are minimum elements
under NTIA, CISA, and the EU CRA — so SBOMs without them fail
compliance checks. That's the WHY a user should care, framed in the
language sbomify.com uses across its compliance pages.

Apply the same treatment to Enrichment: the previous "external
package metadata (licenses, descriptions, …)" framing told the user
what the feature does mechanically but never WHY they'd want it.
Replace with a short intro that ties the missing data (licenses,
PURLs/CPEs, EOL dates) to the concrete failure modes — vulnerability
matching, license compliance, NTIA — so the recommended path
doesn't feel like a gratuitous "✓ recommended" tag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous Augmentation panel buried the WHY inside the
profile-help Static, which only appears AFTER the user picks the
profile radio. By then they've already committed to AUGMENT=true
without seeing the justification. Worse, the radio label itself
leaked the env-var name into user-facing text:

  "Use a contact profile  (AUGMENT=true, bound to every component)"

Move the symmetric treatment Enrichment got into the persistent slot
above the augmentation radios: a Static that names Supplier Name +
Author of SBOM Data as NTIA / CISA / EU CRA minimum elements, so a
user sees the WHY before they pick. Strip AUGMENT=true from the
radio label (it's an internal env knob, not user-facing) and add
the ✓ recommended tag to mirror the Enrichment + OIDC patterns. The
remaining profile-help Static just calls out "same profile applies
to every component" since the deeper WHY now lives above.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Workspaces with zero profiles previously showed a disabled
"Use a contact profile" radio with a "create one in Settings →
Contacts" hint — kicking the user out of the wizard to bind a
profile they actually want to use now.

Replace the disabled-radio path with a "+ Create a new profile"
sentinel row at the top of the profile picker. Selecting it pushes
a new CreateProfileScreen that collects the minimum-but-compliance-
relevant fields:

  - Profile name (internal label)
  - Organisation entity: name, email, phone, address, website
    (supplier + manufacturer roles on by default — both feed the
    SBOM's Supplier Name NTIA minimum element)
  - Security contact: name + email (required by backend AND EU CRA)
  - Author: name + email (NTIA "Author of SBOM Data" element)

Submit → POST /api/v1/workspaces/{team_key}/contact-profiles → on
success the new profile gets appended to state.workspace.
contact_profiles, the screen pops back to ConfigureSbom, and the
on_screen_resume hook re-renders the picker with the new entry
auto-selected and the augmentation radio flipped to "Use a contact
profile". The user can then hit Next and the wizard binds the
freshly-created profile to every component in apply.

Includes SbomifyApiClient.create_contact_profile and updates the
existing tests for the picker option_count (now 3: + Create new +
the two stub profiles).

Verified end-to-end against stage: create + bind + cleanup all
work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pre-Textual ``init`` command wrote a ``sbomify.json`` file at the
repo root with supplier / manufacturer / authors / licenses /
security contact / lifecycle dates. Teams whose compliance program
requires that metadata to live in-repo (version-controlled with the
code it describes) needed it, but the Textual rewrite dropped the
feature entirely.

Bring it back as a third augmentation strategy:

  - state.AugmentationStrategy gains ``"json_config"``. Same
    AUGMENT=true env at runtime as ``profile``; the action's
    existing json_config provider picks the local file up via
    provider priority.
  - state.Plan.sbomify_json_data carries the form payload through
    to apply.
  - ConfigureSbomScreen gets a third radio: "Write a sbomify.json
    file (saved to the repo)". An inline status row below the
    augmentation radios tells the user whether the form has been
    filled out yet.
  - When user picks the radio and hits Next, ConfigureSbomScreen
    pushes a new ConfigureSbomifyJsonScreen with fields mirroring
    the old init: Supplier (name+email+url), Manufacturer
    (optional), Author (NTIA Author of SBOM Data), Security
    contact (CRA), Lifecycle phase + dates. Form pre-populates from
    Plan.sbomify_json_data so the user can navigate back and edit.
  - apply.py writes sbomify.json before the workflow file when the
    json_config strategy is active (dry-run skips); recorded on
    state.written_files so the Done screen lists it.
  - Review summary surfaces the supplier name when the strategy is
    json_config (mirrors the profile-name surface).
  - ci_emitter.augmentation_to_env now resolves both ``profile``
    and ``json_config`` to ``AUGMENT=true``.

The user now has three honest options:

  - Skip — accept blank organisational fields, deal with it later.
  - Use a contact profile — sbomify-hosted, picker + + Create new.
  - Write sbomify.json — local file, full form on its own screen.

All three feed the same AUGMENT env var at runtime; the action
chooses its source via provider priority.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The screen's ``priority=True`` Enter binding consumes the keystroke
before Textual fires ``OptionList.OptionSelected``, so the
``on_option_list_option_selected`` handler I added in the previous
commit was dead code. Pressing Enter on the highlighted sentinel
ran ``_advance`` → saw no real profile id → fell back to Skip →
pushed Review. Net effect: picking "+ Create new" took the user to
Review without ever opening the create form.

Detect the sentinel in ``_advance`` instead: when augmentation is
``profile`` and the picker is highlighting the sentinel, push
CreateProfileScreen and bail before the fall-back-to-Skip branch.
``on_screen_resume`` already auto-selects the freshly-created
profile when CreateProfileScreen pops back, so the user only sees
the form once.

Add a regression test that walks to ConfigureSbom, toggles to
``aug-profile``, highlights the sentinel, focuses the Next button,
and asserts pressing Enter lands on CreateProfileScreen (not
ReviewScreen).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fifteen wizard correctness + integration fixes from the latest
extra-high-effort code review:

  sbomify#1  io.write_sbomify_json: sentinel + ownership check, mirrors the
      workflow file's WIZARD_HEADER_SENTINEL. Refuses to overwrite a
      pre-existing sbomify.json that lacks the __sbomify_wizard__ key.
      Protects users carrying over a hand-crafted sbomify.json (full
      licenses / multi-entity suppliers / vcs_* overrides) against
      silent clobber.
  sbomify#2  ConfigureSbomScreen: when toggling to aug-profile, default
      picker.highlighted to the first REAL profile when one exists
      (not the + Create new sentinel). Workspaces with existing
      profiles can now single-Enter to advance with the most-likely
      selection.
  sbomify#3  ConfigureSbomScreen: detect Escape from ConfigureSbomifyJson
      screen via _json_form_visited flag; flip augmentation back to
      Skip + notify. Breaks the Enter→Escape→Enter form re-push loop.
  sbomify#4  CreateProfileScreen: distinguish dict-without-id from error
      string. A successful POST whose body the client can't parse
      now surfaces "Profile may have been created but the response
      was unexpected. Check the sbomify UI before re-submitting" —
      no more confusing "✗  {}" + duplicate-POST trap.
  sbomify#5/sbomify#6 Authenticate worker picks the user's DEFAULT workspace via
      is_default_team / is_me — same signal the backend uses to scope
      list_components / list_products. Multi-workspace PATs no longer
      silently bind a profile to components in a different workspace.
      Helper _pick_default_workspace_key is unit-tested.
  sbomify#7  Review _summary escapes profile / supplier / product names
      through rich.markup.escape so names containing '[' don't crash
      Rich render or emit garbled output at the about-to-apply moment.
  sbomify#8  ConfigureSbomScreen.on_screen_resume snapshots picker.highlighted
      BEFORE clear_options() and restores it after add_options() when
      no fresh auto-select target is set. Cancelled CreateProfile no
      longer silently downgrades augmentation to "skip".
  sbomify#9  on_radio_set_changed None-guards event.pressed — programmatic
      RadioButton.value loops in on_screen_resume / _populate_from
      fire Changed events with possible intermediate None state.
      Same fragile pattern guarded across ConfigureSbom and
      ConfigureSbomifyJsonScreen.
  sbomify#10 ConfigureSbomifyJsonScreen._populate_from narrows the
      lifecycle-restore except clause to NoMatches — real radio
      iteration bugs now fail loud in tests instead of leaving the
      wrong phase silently selected.
  sbomify#11 list_workspaces docstring records the trailing-slash rules
      (verified against stage): /workspaces/ requires the slash;
      nested /workspaces/{key}/contact-profiles must NOT have one.
      Future maintainers stop trying to "normalise" the URLs.
  sbomify#12 list_workspaces now accepts both bare-list AND paginated
      envelope shapes, matching every other list endpoint. Protects
      against a backend migration silently returning [] and breaking
      team_key resolution.
  sbomify#13 Deleted dead on_input_submitted handlers in CreateProfile +
      ConfigureSbomifyJson — they were unreachable due to the screens'
      priority=True Enter binding.
  sbomify#14 test_plan_defaults pins sbomify_json_data is None AND
      contact_profile_id is None — regression guard so flipping
      either to a default_factory dict can't silently produce the
      data-loss / cross-workspace-bind bugs above.
  sbomify#15 WorkspaceSnapshot.team_key docstring + styles.tcss comment
      updated to reflect /workspaces/ route + ConfigureSbom owner
      (both still said /teams/ and ConfigureWorkflow respectively).

Adds eight new tests in tests/test_wizard_state.py covering
write_sbomify_json create / overwrite / refuse-handauthored /
refuse-malformed, the sentinel helper, and _pick_default_workspace_key.

All gates clean: 2295 passed, ruff + ruff-format + mypy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five bugs surfaced by end-to-end wizard testing against a live backend,
plus two follow-ups from adversarial review.

P0  component_type "sbom" is not a valid backend enum value ({bom, document})
    — it 422'd on every wizard onboarding and Yocto upload. Switch the three
    call sites to "bom" and add a VALID_COMPONENT_TYPES guard in
    create_component that fails fast (ValueError) on an unknown literal rather
    than letting it become an opaque server 422.

P1  The wizard ignored a pre-existing repo-root sbomify.json: the config form
    opened blank and apply then refused to overwrite (no wizard sentinel),
    dead-ending the run. Detect it (RepoFacts.has_sbomify_json), pre-fill the
    form from disk, and have apply warn-and-continue on the ownership check
    (the file is kept and read by the action at run time) so the workflow and
    components still get created.

P2  - authenticate: correct the misleading "Token spans N workspaces" copy —
      /workspaces returns all of the user's workspaces regardless of token
      scope (the backend does not filter that endpoint).
    - sbomify_api: collapse pydantic 422 list-detail into readable
      "<field>: <msg>" text (clean_validation_error, module-level) instead of
      a raw dict repr; wired into the client error paths and the upload
      destination.
    - generation: add run_command(log_errors=) so cdxgen's expected fallback
      failures log at DEBUG, not ERROR, on the happy path.

Plan-limit detection keys off the raw string detail (not the cleaned text) so
a 403 validation list can't be misclassified as PlanLimitError.

Regression tests added for each fix; full suite 2307 passed.
Four low-severity robustness/consistency fixes found by a follow-up audit
of the action subsystems (each with a regression test):

- enrichment: has_data() now counts cle_release_date — a source returning
  only a release date was wrongly treated as empty and silently discarded
  (metadata.py).

- dependency-track upload: catch the base requests.RequestException, not just
  ConnectionError/Timeout, so an SSLError/ProxyError returns a clean
  UploadResult.failure_result instead of propagating and crashing the step.

- cyclonedx-cargo generator: add the check_tool_available guard its
  cyclonedx-py / syft siblings already have, so supports() returns False when
  cargo-cyclonedx isn't installed (pip installs) — avoids a spurious ERROR and
  a wasted attempt before the orchestrator falls through.

- chainguard: read manifest/layer "digest" via .get() instead of direct
  indexing, so a malformed/non-spec OCI manifest yields a graceful None rather
  than an uncaught KeyError.

Full suite 2312 passed.
The mypy hook runs in an isolated env (pass_filenames: false,
additional_dependencies only). It omitted textual, so mypy saw the wizard's
Textual base classes (App/Screen/widgets) as Any and emitted 10 spurious
"Class cannot subclass value of type Any" / "Returning Any" errors — the hook
failed on every commit even though `uv run mypy sbomify_action` (textual
installed) reports "Success: no issues found".

Adding textual>=0.85.0 to the hook deps resolves all 10 at the root: no code
changes, no type: ignore. Pre-commit now passes mypy, ruff, and ruff-format.
Follow-ups from the multi-dimensional review of the wizard/hardening changes:

- apply.py: the sbomify.json ownership-skip warning now states explicitly that
  values entered in the wizard were NOT written (the form seeds from a
  hand-authored file, so a silent skip was a data-loss surprise); and document
  why a workflow ownership conflict stays fatal while json_config only warns.

- utils.run_command: log_errors now also covers the timeout and missing-binary
  branches, so a cdxgen timeout on a Python lockfile no longer spams ERROR when
  a later generator succeeds (previously only the non-zero-exit path was gated).

- tests: pin the apply.py component_type="bom" call-site directly; cover
  _load_from_disk's malformed/non-dict error paths (form opens blank, no crash);
  cover the chainguard provenance missing-digest path.

- polish: pin the mypy hook's textual to ==8.2.7 (uv.lock version) for a
  reproducible type view; hoist _load_from_disk's local imports to module top;
  isinstance(... list | tuple) idiom; trailing newline in .pre-commit-config.

Full suite 2315 passed; pre-commit (ruff, ruff-format, mypy) green.
Copilot AI review requested due to automatic review settings May 29, 2026 17:23
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Replaces the questionary-based sbomify.json wizard with a full Textual TUI for repository onboarding (lockfile discovery → auth → product/component selection → workflow generation), and refactors several modules to delegate HTTP calls to a consolidated SbomifyApiClient.

Changes:

  • New Textual wizard package (sbomify_action/cli/wizard/) with screens, state model, apply phase, and emitted-workflow ownership sentinels.
  • init is repurposed as an alias for the new wizard CLI; the old questionary wizard, its tests, and the questionary dependency are removed.
  • HTTP plumbing for releases/Yocto/sbomify-API augmentation moved behind SbomifyApiClient; provider/processor tests updated to patch the new seams; minor hardening in chainguard, enrichment, generator priority-chain logging.

Reviewed changes

Copilot reviewed 69 out of 70 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
sbomify_action/cli/main.py Adds wizard/init Click commands, _resolve_token precedence helper, debug log buffering, CI guard.
sbomify_action/cli/wizard/* New Textual app, screens, state, apply, discovery, IO sentinels, repo-facts, widgets, CSS.
sbomify_action/cli/wizard/validators.py, sections.py, runner.py, prompts.py Removed (old questionary wizard).
sbomify_action/_augmentation/providers/sbomify_api.py Delegates backend fetch to SbomifyApiClient.get_augmentation_meta.
sbomify_action/_processors/releases_api.py Now a thin facade over SbomifyApiClient.
sbomify_action/_yocto/api.py Same refactor; create_component now sends component_type='bom'.
sbomify_action/_upload/destinations/sbomify.py Routes upload through SbomifyApiClient; tags AUTH_FAILED on 401.
sbomify_action/_upload/destinations/dependency_track.py Catches generic RequestException to keep upload contract.
sbomify_action/_generation/utils.py Adds log_errors=False mode to silence priority-chain fallbacks.
sbomify_action/_generation/generators/cdxgen.py, cyclonedx_cargo.py Use new log_errors=False; cargo gates supports() on tool availability.
sbomify_action/_generation/chainguard.py Tolerates missing digest in manifest-list/attestation entries.
sbomify_action/_enrichment/metadata.py has_data() now counts cle_release_date.
sbomify_action/exceptions.py New AuthError(APIError).
tests/* Re-targeted patches to new client seams; new discovery/repo-facts/generator/chainguard tests; old wizard tests removed.
pyproject.toml, .pre-commit-config.yaml Drops questionary, adds textual + pytest-asyncio + mypy textual stub.
.github/workflows/sbomify.yaml Deletes the two generate-source-sboms-* jobs.
.github/workflows/sboms.yml New wizard-generated workflow file.
Comments suppressed due to low confidence (4)

sbomify_action/cli/wizard/screens/review.py:1

  • ReviewScreen.step_index is set to 8, but the wizard's own step list (in welcome.py._steps_list and help.py) places Review at step 07 and Apply at step 08. Because ApplyScreen.step_index is also 8, the crumb track in _base.py._crumb_markup will render Review and Apply as the same numbered position, and the connected segmented track will never show a half-filled state between them. Set this to 7 to match the documented flow.
    sbomify_action/cli/wizard/screens/welcome.py:1
  • The step count is hard-coded here, in help.py, and in _base.TOTAL_STEPS. Reuse TOTAL_STEPS (or expose a single constant) so the welcome subtitle and the help cheat-sheet can't drift from the crumb track when steps are added or renumbered (see also the Review/Apply step_index mismatch flagged separately).
    sbomify_action/cli/wizard/screens/done.py:1
  • _resolve_product in apply.py assigns state.created_product_id for both newly-created products AND existing-product reuse (plan.use_product_id), so this line will render a green check labeled "Product" with the product id even when nothing was created. Either gate this on plan.create_product (vs plan.use_product_id) or split the field on WizardState into created_product_id / used_product_id so the Done summary can differentiate created-vs-reused for products the same way it does for components.
    sbomify_action/cli/wizard/screens/configure_workflow.py:1
  • If pressed.id is e.g. \"rel-trunk\", pressed.id.split(\"-\", 1)[1] yields \"trunk\". But the cast is unchecked — any future radio whose id doesn't follow the prefix-<strategy> convention (or one whose suffix isn't in {trunk,tag,manual}) will silently smuggle an invalid value past the ReleaseStrategy Literal. Consider matching the suffix against a known set (or using an explicit dict mapping pressed.id → ReleaseStrategy) and falling back to \"trunk\" so an id typo can't crash the workflow emitter downstream with an opaque error.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 1139 to 1143
echo \`\`\` >> ${GITHUB_STEP_SUMMARY}

# =========================================================================
# Generate Source SBOMs (Staging) - master branch only
# =========================================================================
generate-source-sboms-staging:
name: Generate SBOMs (${{ matrix.component }}-${{ matrix.format }}-staging)
needs: push-images
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
permissions:
id-token: write
contents: read
attestations: write
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- component: action
format: cyclonedx
component_id: 'XNsX40tonzvv'
component_name: 'sbomify Action'
lock_file: 'uv.lock'
output_file: 'sbomify-action.cdx.json'
product_release_id: 'Engh9j4XTTwD'
has_product_release: true
- component: action
format: spdx
component_id: 'XNsX40tonzvv'
component_name: 'sbomify Action'
lock_file: 'uv.lock'
output_file: 'sbomify-action.spdx.json'
product_release_id: 'Engh9j4XTTwD'
has_product_release: true
- component: javascript
format: cyclonedx
component_id: 'PGyKXNbQd5eq'
component_name: 'JavaScript Dependencies'
lock_file: 'bun.lock'
output_file: 'sbomify-action-frontend.cdx.json'
product_release_id: 'Engh9j4XTTwD'
has_product_release: true
- component: javascript
format: spdx
component_id: 'PGyKXNbQd5eq'
component_name: 'JavaScript Dependencies'
lock_file: 'bun.lock'
output_file: 'sbomify-action-frontend.spdx.json'
product_release_id: 'Engh9j4XTTwD'
has_product_release: true
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Cache sbomify data
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .sbomify-cache
key: sbomify-${{ runner.os }}

- name: Determine version
id: version
uses: ./.github/actions/determine-version

- name: Generate and Upload SBOM
uses: sbomify/sbomify-action@master
env:
# Trusted publishing via GitHub OIDC — no TOKEN secret required.
# Binding configured in sbomify UI for this repo + component.
API_BASE_URL: 'https://stage.sbomify.com'
OIDC_AUDIENCE: 'stage.sbomify.com'
COMPONENT_ID: ${{ matrix.component_id }}
COMPONENT_NAME: ${{ matrix.component_name }}
COMPONENT_VERSION: ${{ steps.version.outputs.version }}
COMPONENT_PURL: ${{ matrix.component == 'action' && format('pkg:docker/sbomify/sbomify-action@{0}?repository_url=ghcr.io', steps.version.outputs.docker_tag) || '' }}
LOCK_FILE: ${{ matrix.lock_file }}
PRODUCT_RELEASE: ${{ matrix.has_product_release && format('["{0}:{1}"]', matrix.product_release_id, steps.version.outputs.version) || '' }}
SBOM_FORMAT: ${{ matrix.format }}
AUGMENT: true
ENRICH: true
UPLOAD: true
OUTPUT_FILE: ${{ matrix.output_file }}
SBOMIFY_CACHE_DIR: ${{ github.workspace }}/.sbomify-cache

SYFT_CACHE_DIR: ${{ github.workspace }}/.sbomify-cache/syft

- name: Attest SBOM
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-path: '${{ github.workspace }}/${{ matrix.output_file }}'

# =========================================================================
# Generate Source SBOMs (Production) - tags only
# =========================================================================
generate-source-sboms-production:
name: Generate SBOMs (${{ matrix.component }}-${{ matrix.format }}-production)
needs: push-images
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
permissions:
id-token: write
contents: read
attestations: write
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- component: action
format: cyclonedx
component_id: 'Gu9wem8mkX'
component_name: 'sbomify Action'
lock_file: 'uv.lock'
output_file: 'sbomify-action.cdx.json'
product_release_id: 'IeIn1dGJXULh'
has_product_release: true
- component: action
format: spdx
component_id: 'Gu9wem8mkX'
component_name: 'sbomify Action'
lock_file: 'uv.lock'
output_file: 'sbomify-action.spdx.json'
product_release_id: 'IeIn1dGJXULh'
has_product_release: true
- component: javascript
format: cyclonedx
component_id: 'IxwrYSb9rGql'
component_name: 'JavaScript Dependencies'
lock_file: 'bun.lock'
output_file: 'sbomify-action-frontend.cdx.json'
product_release_id: 'IeIn1dGJXULh'
has_product_release: true
- component: javascript
format: spdx
component_id: 'IxwrYSb9rGql'
component_name: 'JavaScript Dependencies'
lock_file: 'bun.lock'
output_file: 'sbomify-action-frontend.spdx.json'
product_release_id: 'IeIn1dGJXULh'
has_product_release: true
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Cache sbomify data
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .sbomify-cache
key: sbomify-${{ runner.os }}

- name: Determine version
id: version
uses: ./.github/actions/determine-version

- name: Generate and Upload SBOM
uses: sbomify/sbomify-action@master
env:
# Trusted publishing via GitHub OIDC — no TOKEN secret required.
# Binding configured in sbomify UI for this repo + component.
COMPONENT_ID: ${{ matrix.component_id }}
COMPONENT_NAME: ${{ matrix.component_name }}
COMPONENT_VERSION: ${{ steps.version.outputs.version }}
COMPONENT_PURL: ${{ matrix.component == 'action' && format('pkg:docker/sbomify/sbomify-action@{0}?repository_url=ghcr.io', steps.version.outputs.docker_tag) || '' }}
LOCK_FILE: ${{ matrix.lock_file }}
PRODUCT_RELEASE: ${{ matrix.has_product_release && format('["{0}:{1}"]', matrix.product_release_id, steps.version.outputs.version) || '' }}
SBOM_FORMAT: ${{ matrix.format }}
AUGMENT: true
ENRICH: true
UPLOAD: true
OUTPUT_FILE: ${{ matrix.output_file }}
SBOMIFY_CACHE_DIR: ${{ github.workspace }}/.sbomify-cache

SYFT_CACHE_DIR: ${{ github.workspace }}/.sbomify-cache/syft

- name: Attest SBOM
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-path: '${{ github.workspace }}/${{ matrix.output_file }}'

# =========================================================================
# Generate Container SBOMs (Staging) - master branch only
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Real regression, but it originates in ee4f28d ("Swap source-SBOM publishing over to the wizard-emitted workflow") on the feature branch — not in the fix commits this fork PR adds. The replacement sboms.yml publishes ca-certificates-dbg/ca-certificates-dev against uv.lock and tests/test-data/uv.lock (a wizard test-run artifact), dropping the real 'sbomify Action' + 'JavaScript Dependencies' self-SBOM jobs for stage + prod. Tracking it on the canonical PR #238 for the feature author to fix (re-emit sboms.yml with the real self-SBOM components — the stage/prod IDs are in the removed sbomify.yaml). Resolving here since it's out of scope for this PR's fixes and shouldn't be silently reverted on a duplicate.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Update: leaving this thread open to revisit later rather than resolving — the analysis above and the #238 cross-link stand; the underlying sboms.yml regression will be handled separately.

Comment thread sbomify_action/cli/wizard/existing.py Outdated
Comment thread sbomify_action/cli/main.py
Comment thread sbomify_action/_upload/destinations/sbomify.py
The module docstring said apply overwrites "after .bak backup", but io.py
deliberately writes no .bak (git is the source of truth). Align the contract
text with io.py's actual behavior. Addresses a PR sbomify#239 review comment.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 69 out of 70 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (2)

.github/workflows/sboms.yml:1

  • The v=${GITHUB_REF#refs/tags/} substitution only strips when GITHUB_REF actually starts with refs/tags/. For the workflow_dispatch trigger that's also declared above, GITHUB_REF is refs/heads/<branch>, so the parameter expansion no-ops and COMPONENT_VERSION / PRODUCT_RELEASE end up as e.g. refs/heads/master — which is uploaded as the release/version verbatim. Either drop workflow_dispatch: from the generated on: block when the release strategy is tag, or compute the version conditionally (e.g. fall back to ${GITHUB_SHA::7} or a dispatch input when not on a tag ref). Since this file appears to be produced by ci_emitter.emit_workflow, the fix likely belongs there.
# Generated by `sbomify-action wizard`. Re-run the wizard to update.

sbomify_action/cli/wizard/screens/welcome.py:1

  • When state.discovered is empty, Cancel is rendered with variant=\"primary\", but on_button_pressed calls self.wizard.exit(130) — exit code 130 is conventionally reserved for SIGINT (Ctrl-C). Pressing the on-screen Cancel button on the empty-repo path is an explicit user choice, not an interrupt, so the parent shell (and any CI orchestrator) will see a 130 and may treat it as a signalled abort. Consider exiting with 0 (or a dedicated non-zero like 2 for "no work to do") to disambiguate.

Comment thread sbomify_action/_processors/releases_api.py
Comment thread sbomify_action/cli/main.py
Comment thread sbomify_action/cli/main.py
Comment thread sbomify_action/cli/wizard/apply.py
@aurangzaib048 aurangzaib048 requested a review from Copilot May 30, 2026 12:25
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

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

Comments suppressed due to low confidence (2)

sbomify_action/cli/wizard/screens/welcome.py:1

  • A bare except Exception here will also swallow unexpected errors (e.g. a programming bug in query_one) and silently focus the cancel button. Since you already know deterministically whether #start exists (self.wizard.state.discovered), branch on that instead of using exception-driven control flow.
    sbomify_action/cli/wizard/screens/done.py:1
  • The OIDC instructions list every component's settings URL inline but the c keybind ("Copy URL") only copies the first component's URL (next(iter(state.component_ids.values()))). For repos with multiple lockfiles/components this is surprising — the user will press c expecting to grab "the URL they were just reading" and silently get a different one. Consider either rendering numbered URLs and letting the user copy by index, or making c copy the entire list as a multi-line block.

Comment thread sbomify_action/cli/main.py
Comment thread sbomify_action/cli/main.py
Comment thread sbomify_action/cli/main.py
Comment thread sbomify_action/cli/wizard/screens/apply.py
Comment thread sbomify_action/cli/wizard/apply.py
Comment thread sbomify_action/_generation/chainguard.py
Comment thread sbomify_action/cli/main.py
@aurangzaib048 aurangzaib048 requested a review from Copilot May 30, 2026 12:28
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 69 out of 70 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (2)

sbomify_action/cli/wizard/widgets/pick_or_create.py:1

  • The constructor parameter id shadows the Python builtin. This is propagated into super().__init__(id=id) and several f"#{self.id}-list" lookups, which works but is easy to misuse in the body and trips some linters. Renaming to e.g. widget_id (and passing id=widget_id to super().__init__) keeps the public callers unchanged at the keyword level while avoiding the shadow.
    sbomify_action/cli/wizard/screens/done.py:1
  • The action action_quit_with_cancel referenced indirectly via _CANCEL_WINDOW in other comments does not exist as a named constant — it's hardcoded to 3.0 inside WizardApp.action_quit_with_cancel. The narrative comments here and on WizardApp.action_quit_with_cancel ("within _CANCEL_WINDOW seconds") read as if it were a named module-level constant. Either extract it to a module constant so the docstring is accurate, or rewrite the docstring to refer to the literal "3-second window" so future readers don't grep for a symbol that isn't there.

Comment thread sbomify_action/cli/wizard/apply.py Outdated
Comment thread sbomify_action/cli/main.py
Comment thread sbomify_action/cli/main.py
Comment thread sbomify_action/cli/main.py
Scoped to the code this fork PR's fix commits touched (the feature-code
comments are tracked on the canonical sbomify#238):

- apply.py: surface the unwritten sbomify.json payload in the apply log when
  the ownership check skips a hand-authored file, so the values entered in the
  wizard are recoverable without re-running it.
- apply.py: correct the apply_plan docstring's stale ".bak backup" claim to
  match io.py (sentinel-guarded overwrite, no .bak; git holds prior versions).
- chainguard.py: comment the .get("digest") continue-on-missing semantics in
  _detect_chainguard_from_provenance (mirrors _resolve_platform_digest).

Full suite 2315 passed; ruff/format/mypy green.
@aurangzaib048
Copy link
Copy Markdown
Contributor Author

The remaining Copilot comments are on pre-existing feature code (the wizard CLI / generators / processors), not the fix commits this fork PR adds on top. To keep this PR focused on its fixes and avoid overriding the feature author's intentional design on a duplicate PR, they're tracked on the canonical #238 for the author to address there. Resolving here as out of scope for this PR:

  • _processors/releases_api.pycreate_release return type (Optional[str]str)
  • cli/main.py_install_debug_buffer global-logger handler leak (no teardown)
  • cli/main.py--output-dir advertised as configurable but pinned to .github/workflows
  • cli/main.pyinit repurposed to a wizard alias (breaking for init -o)
  • cli/main.py — root --token not propagated to the main action config
  • cli/main.pyTYPE_CHECKING-guarded import io can move to module level
  • cli/wizard/screens/apply.py — direct #apply-log write vs call_from_thread (consistency)
  • cli/wizard/apply.py — workflow still written when component-attach failed (ordering invariant)

@aurangzaib048
Copy link
Copy Markdown
Contributor Author

Closing in favor of a PR that targets the feature branch feat/wizard-and-shared-api-client directly instead of master. This PR's base-against-master made it a 60-commit duplicate of #238 (the whole feature); the replacement scopes the diff to just the 6 fix commits on top of the feature branch, which is the right unit to review and merge into #238's branch. Re-opening as a new PR with the same head.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants