feat: Textual onboarding wizard + consolidated sbomify API client (with fixes)#239
feat: Textual onboarding wizard + consolidated sbomify API client (with fixes)#239aurangzaib048 wants to merge 62 commits into
Conversation
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.
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.
There was a problem hiding this comment.
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. initis repurposed as an alias for the newwizardCLI; the old questionary wizard, its tests, and thequestionarydependency 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_indexis set to 8, but the wizard's own step list (inwelcome.py._steps_listandhelp.py) places Review at step 07 and Apply at step 08. BecauseApplyScreen.step_indexis also 8, the crumb track in_base.py._crumb_markupwill 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. ReuseTOTAL_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_productinapply.pyassignsstate.created_product_idfor 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 onplan.create_product(vsplan.use_product_id) or split the field onWizardStateintocreated_product_id/used_product_idso 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.idis e.g.\"rel-trunk\",pressed.id.split(\"-\", 1)[1]yields\"trunk\". But the cast is unchecked — any future radio whose id doesn't follow theprefix-<strategy>convention (or one whose suffix isn't in{trunk,tag,manual}) will silently smuggle an invalid value past theReleaseStrategyLiteral. Consider matching the suffix against a known set (or using an explicit dict mappingpressed.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.
| 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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.
There was a problem hiding this comment.
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 whenGITHUB_REFactually starts withrefs/tags/. For theworkflow_dispatchtrigger that's also declared above,GITHUB_REFisrefs/heads/<branch>, so the parameter expansion no-ops andCOMPONENT_VERSION/PRODUCT_RELEASEend up as e.g.refs/heads/master— which is uploaded as the release/version verbatim. Either dropworkflow_dispatch:from the generatedon:block when the release strategy istag, 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 byci_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.discoveredis empty, Cancel is rendered withvariant=\"primary\", buton_button_pressedcallsself.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 with0(or a dedicated non-zero like2for "no work to do") to disambiguate.
There was a problem hiding this comment.
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 Exceptionhere will also swallow unexpected errors (e.g. a programming bug inquery_one) and silently focus the cancel button. Since you already know deterministically whether#startexists (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
ckeybind ("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 presscexpecting 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 makingccopy the entire list as a multi-line block.
There was a problem hiding this comment.
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
idshadows the Python builtin. This is propagated intosuper().__init__(id=id)and severalf"#{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 passingid=widget_idtosuper().__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_cancelreferenced indirectly via_CANCEL_WINDOWin other comments does not exist as a named constant — it's hardcoded to3.0insideWizardApp.action_quit_with_cancel. The narrative comments here and onWizardApp.action_quit_with_cancel("within_CANCEL_WINDOWseconds") 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.
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.
|
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:
|
|
Closing in favor of a PR that targets the feature branch |
Summary
This is the Textual onboarding wizard + consolidated sbomify API client feature (the same work as #238), opened from a fork against
masterbecause 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:
dc4b02cfix(wizard): valid component_type, existing sbomify.json, error polishcomponent_type"sbom"→"bom"— the backendComponentTypeenum is{bom, document};"sbom"returned HTTP 422 on every wizard onboarding and Yocto upload. Adds a client-sideVALID_COMPONENT_TYPESguard so a bad literal fails fast instead of as an opaque 422.sbomify.json, and apply no longer dead-ends on a hand-authored one (keeps it as-is; the action reads it at run time).field: msg);run_command(log_errors=)so cdxgen's expected fallback failures don't spam ERROR; corrected the misleading token-scope warning copy.cee6d02fix: harden enrichment, dtrack, cargo, chainguard edge cases —has_data()countscle_release_date; dtrack catches baseRequestException; cargo generator gets thecheck_tool_availableguard its siblings have; chainguard readsdigestvia.get().55f1a1bci: add textual to mypy pre-commit deps so the hook passes — the mypy hook's isolated env was missingtextual, so it saw the wizard's Textual base classes asAnyand failed on every commit (in-venv mypy was already clean).f29d96drefactor: address comprehensive-review findings — explicit warning when wizard edits aren't written to a hand-authoredsbomify.json;log_errorsnow also covers timeout/missing-binary; added regression tests for the apply call-site,_load_from_diskerror paths, and the chainguard provenance digest path; assorted polish.Testing
pre-commit run --all-files: ruff, ruff-format, and mypy all green.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 targetmasteralone. It duplicates #238; if you'd rather keep #238 as the canonical PR, the 4 fix commits can be cherry-picked onto its branch fromaurangzaib048:feat/wizard-and-shared-api-client(dc4b02c cee6d02 55f1a1b f29d96d).