Skip to content

feat: Textual onboarding wizard + consolidated sbomify API client#238

Open
vpetersson wants to merge 78 commits into
masterfrom
feat/wizard-and-shared-api-client
Open

feat: Textual onboarding wizard + consolidated sbomify API client#238
vpetersson wants to merge 78 commits into
masterfrom
feat/wizard-and-shared-api-client

Conversation

@vpetersson
Copy link
Copy Markdown
Contributor

@vpetersson vpetersson commented May 27, 2026

Summary

Adds an interactive Textual TUI (sbomify-action wizard, aliased as init) that takes a fresh repo from zero to working SBOM pipeline in one terminal session: discovers lockfiles, authenticates, picks a product, creates components, optionally creates / picks a contact profile (or writes a sbomify.json for in-repo metadata), auto-registers the OIDC trusted-publisher binding, and writes a single canonical .github/workflows/sboms.yml.

As a precondition (and the bigger architectural win), every subsystem that talks to sbomify now routes through one shared SbomifyApiClient. The previous scatter (_upload/destinations/sbomify.py, _augmentation/providers/sbomify_api.py, _yocto/api.py, _processors/releases_api.py, _processors/processors/releases.py) had six copies of session handling, pagination, and error parsing; they now all delegate to one place.

What's in the PR

Shared API client

  • sbomify_action/sbomify_api.py — new shared client. Adds AuthError(APIError) for 401s. Method surface is the union of every call site's needs (components, products, releases, contact profiles, augmentation metadata, upload, workspaces, contact-profile creation, OIDC binding registration).
  • Five migrations of existing scatter — yocto, releases_api, SbomifyDestination, SbomifyApiProvider, SbomifyReleasesProcessor. Public signatures preserved.

Wizard (sbomify_action/cli/wizard/)

  • Ten Textual screens with route-aware Enter/Escape navigation:
    • welcomediscoverauthenticateproductcomponentsconfigure_workflow (release strategy + credentials) → configure_sbom (enrichment + augmentation + formats + provenance) → reviewapplydone
    • Two sub-screens off configure_sbom: create_profile (POST a new contact profile to the workspace) and configure_sbomify_json (form-fills an in-repo metadata file, folding back the old init command's fields)
  • One matrix-job emitter writing .github/workflows/sboms.yml; header-sentinel-guarded overwrite — refuses to clobber hand-authored workflows.
  • Single apply step as the only mutation site. Sentinel-protected writes for both sboms.yml and sbomify.json so re-runs never silently lose hand edits.
  • State-aware StatefulRadioButton so radio selection reads correctly even when colour info is stripped (eg copy/paste): selected radios render ▐●▌, unselected render ▐○▌.

Three honest augmentation paths

  1. SkipAUGMENT=false, accept blank organisational fields
  2. Use a contact profile (saved to sbomify) — picker + + Create new sentinel pushes a full-form create screen that POSTs to the workspace; the new profile is auto-selected on return. The contact profile gets bound to every applied component via patch_component.
  3. Write a sbomify.json file (saved to the repo) — full form (supplier / manufacturer / authors / security contact / lifecycle phase + dates) mirroring the old init. apply writes the file with a __sbomify_wizard__ sentinel so re-runs respect hand edits. Configure (SBOM) detects an existing sbomify.json at the repo root and surfaces what apply will do (overwrite if wizard-stamped, keep as-is otherwise) before the user fills the form.

Both AUGMENT-true paths emit the same AUGMENT=true env var; the action's existing provider priority picks the right source at workflow run time.

Multi-workspace handling

Authenticate worker picks the user's default workspace (is_default_team + is_me from the workspaces response) — the same signal the backend uses to scope list_products / list_components. Personal-access tokens spanning multiple workspaces no longer silently bind a profile in workspace A to components owned by workspace B.

Automatic OIDC trusted-publisher registration

Apply now registers an OIDC binding for every created/reused component via the new SbomifyApiClient.create_oidc_binding(component_id, repo_slug) call. Done screen branches accordingly:

  • All bindings succeeded → ✓ "OIDC trusted publishing is set up — nothing else to do" panel
  • Some skipped or failed (private repo, missing slug, not owner/admin, backend error) → ⚠ panel that lists only the components that need manual setup, with the auto-registration reason explained inline (no more reading-between-the-lines about why some components weren't auto-bound)

Repository slug resolved locally (no GitHub-token dependency) so the wizard works against private GHE repos.

Compliance framing in the UI

Wizard help text cites the actual frameworks the underlying SBOM field maps to (NTIA Supplier Name, NTIA Author of SBOM Data, CISA, EU CRA) so users see why augmentation matters before they pick Skip.

CLI

  • wizard is the primary command
  • init is a backwards-compatible alias with a deprecation note
  • --token > \$SBOMIFY_TOKEN > \$TOKEN precedence via _resolve_token
  • --debug streams DEBUG logs to stdout after the TUI exits

Dependencies

  • Added textual>=0.85.0, pytest-asyncio
  • Dropped questionary

Dogfood

.github/workflows/sboms.yml is wizard-generated, exercising the production component IDs / product release for this repo.

Design decisions locked

  • One canonical workflow filename (sboms.yml) + sentinel comment. Drops all the previous-wizard machinery for parsing arbitrary user workflows.
  • Single matrix job per workflow. All components share one release-strategy + credential-mode + augmentation setting; multi-strategy emission is out of scope.
  • OIDC default for emitted workflows; token mode opt-in.
  • Direct product↔component attachment (no project layer).
  • mypy strict throughout; ruff clean; 2329 tests passing.

Quality bar

This PR went through three rounds of /code-review at extra-high effort (45 finder angles, 1-vote verification, gap sweep) — every finding was either fixed or explicitly noted as out-of-scope. Highlights:

  • Round 1: 15 correctness bugs in the shared client consolidation + wizard mechanics (Back navigation, RadioSet Enter handling, pagination termination, etc.)
  • Round 2: 8 wizard-flow bugs + 7 cleanup/altitude findings (sentinel routing, priority=True Enter consequences, workspace endpoint URL fix, etc.)
  • Round 3: 15 findings on the new CreateProfile + sbomify.json paths (sbomify.json overwrite protection, multi-workspace handling, picker sentinel default, Rich markup escaping, form re-push loop, etc.)

All review fixes have regression test coverage where the bug was reachable from a test harness.

Test plan

  • uv run pytest2329 passed, 4 skipped
  • uv run ruff check sbomify_action tests — clean
  • uv run ruff format --check sbomify_action tests — clean
  • uv run mypy sbomify_actionclean (139 source files)
  • End-to-end probe against stage sbomify: list_workspaces returns 5 entries; create + bind + delete contact-profile roundtrip works
  • OIDC auto-registration: covered by test_wizard_done.py (auto-success vs manual-fallback Done screen branches) and apply-plan tests for create_oidc_binding
  • Manual smoke on a scratch repo with uv.lock: launch wizard, walk all three augmentation paths, verify sboms.yml + (where applicable) sbomify.json are emitted with the right sentinel
  • Re-run wizard; confirm sentinel-protected overwrite for both files; confirm refusal when sentinel is removed
  • `GITHUB_ACTIONS=true sbomify-action wizard` exits non-zero with the CI refusal message
  • Dispatch the new `sboms.yml` workflow on this branch against production sbomify and confirm both components publish

🤖 Generated with Claude Code

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.
Textual's stock RadioButton always renders ▐●▌ regardless of state
— the on/off distinction is purely a CSS style (white+bold when
selected, muted otherwise). That looks fine in a colour-rich TTY
but breaks in two practical cases:

  - Limited colour / contrast accessibility: three radios all showing
    a filled circle is ambiguous even when the active one is bolder.
  - Copy/paste into a bug report or chat strips the colour
    formatting, leaving three identical ▐●▌ rows that look like a
    multi-select bug.

Add a StatefulRadioButton subclass that overrides ``_button`` to
render ▐●▌ when selected and ▐○▌ when not — the visual state is
encoded in the character itself. Swap it in at every RadioButton
constructor call site across the three wizard config screens; keep
the base RadioButton import for ``rs.query(RadioButton)`` calls
(StatefulRadioButton is a subclass, so the queries still match).

Also surface an inline notice on ConfigureSbom's Augmentation panel
when the repo already has a sbomify.json at the root:

  - Wizard-stamped (carries __sbomify_wizard__): "Apply will
    overwrite it with form values."
  - Hand-authored (no sentinel): warns that apply will keep the
    existing file as-is, the action will still read it at workflow
    run time, and shows the escape hatch ("delete the file or add
    __sbomify_wizard__: {} to let the wizard manage it"). This
    pre-empts the surprise-at-apply-time experience.

Probe confirms the glyph swap renders correctly:
  a value=True : '▐●▌'
  b value=False: '▐○▌'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 1, 2026 08:51
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 70 out of 71 changed files in this pull request and generated 7 comments.

Comment thread sbomify_action/cli/wizard/apply.py
Comment thread sbomify_action/cli/main.py Outdated
Comment thread sbomify_action/cli/main.py
Comment thread sbomify_action/cli/wizard/options.py
Comment thread sbomify_action/cli/wizard/screens/product.py
Comment thread sbomify_action/cli/wizard/screens/components.py
Comment thread sbomify_action/cli/wizard/screens/authenticate.py Outdated
CI's ``ruff format --check`` flagged a stray reformat the previous
commit missed — the autofix that organised the StatefulRadioButton
import left the file just outside ruff format's preferred line
boundaries.

No semantic change; just whitespace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 1, 2026 14:01
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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

The wizard emits an OIDC workflow but the trust binding still had to be
created by hand in the sbomify UI — the #1 reason a first OIDC publish
403s. Register it automatically during apply instead, using the new
binding-management API.

* SbomifyApiClient.create_oidc_binding(component_id, repository) — POSTs
  to /api/v1/auth/oidc/github/bindings. Idempotent: 409 (already bound)
  is success; 400/404/5xx raise APIError.
* apply_plan: new best-effort step (oidc mode only) that registers a
  binding per applied component. Never fatal — per-component failures are
  warnings; skipped with a clear note for private repos (backend can't
  resolve private GitHub IDs yet) and when no owner/repo slug is known.
* Done screen: shows a '✓ trusted publishing is set up' panel on success,
  or falls back to the manual instructions (prefixed with the reason)
  when auto-registration was skipped/failed.

12 new tests (client 201/409/error, apply per-component/token-skip/
409-counts/failure-is-warning/private-skip/no-slug, done rendering).
Full suite: 2329 passed.

Depends on the main-app binding API (sbomify/sbomify#988).
aurangzaib048 and others added 6 commits June 2, 2026 12:26
Auto-registration previously skipped private repos: sbomify can't read a
private repo's metadata anonymously, so the binding create 400'd. For a
private repo the wizard now resolves the immutable IDs itself, using the
operator's own local GitHub auth, and passes them to the binding API
(which accepts explicit IDs). The token is used to call GitHub directly
from the machine and is NEVER sent to sbomify — only the two integers are.

* repo_facts.github_token() — GH_TOKEN / GITHUB_TOKEN / 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.
* repo_facts.resolve_repo_ids(slug, token) — authenticated GET /repos/{slug}
  -> (repository_id, repository_owner_id); None on any failure.
* SbomifyApiClient.create_oidc_binding gains optional repository_id /
  repository_owner_id (sent only when both present).
* apply._register_oidc_bindings: private repo -> resolve IDs + register with
  them; no token or resolve failure -> graceful skip + note (unchanged for
  public repos, which still let the backend resolve the slug).

Verified live end-to-end against a real private repo: the wizard resolved
its IDs and created bindings carrying the correct immutable values, which a
GitHub Actions OIDC token from that repo would match at exchange time.

New tests for github_token, resolve_repo_ids, the client body, and the
apply paths (private+token, private+no-token, private+resolve-fail, public).
Full suite: 2342 passed.
…not /components/{id}/settings)

The Done screen's OIDC manual-fallback links and the 'copy URL' action
pointed at `/components/{id}/settings`, which 404s — the real component
page (carrying the Trusted Publishing section) is `/component/{id}/`
(singular, no /settings). Surfaced by the private-repo E2E. Updated the
done test to assert the correct path and guard against the old one.
…en logic

Per the converged design (backend now pins private-repo IDs on first OIDC
publish), the wizard no longer needs to resolve repository IDs itself. It just
sends 'org/repo' — same call for public and private repos, no GitHub token.

* Remove repo_facts.github_token() + resolve_repo_ids() (and the os/shutil
  imports + their tests).
* create_oidc_binding: name only (drop repository_id/repository_owner_id).
* apply._register_oidc_bindings: register by name for every component
  regardless of visibility; only skip when the git remote yields no
  'owner/repo' slug. No token, no per-visibility branching.

Keeps the Done-screen component-URL fix. Full suite: 2329 passed.
Depends on sbomify/sbomify#989 (deferred pinning).
Commit 5de2281 swept in untracked local scratch (docs/plans/, .agent/,
audit_trail.txt, and assorted *.cdx.json/*.spdx.json SBOM outputs) that don't
belong in the repo. Untrack them (left on disk locally). The OIDC code/test
changes from that commit are unaffected.
feat(wizard): auto-register OIDC trusted publisher during apply
Brings in the Dependabot bump of docker/login-action 4.1.0 → 4.2.0.
Copilot AI review requested due to automatic review settings June 2, 2026 15: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

Copilot reviewed 71 out of 72 changed files in this pull request and generated 2 comments.

Comment thread .github/workflows/sboms.yml
Comment thread .github/workflows/sboms.yml
vpetersson and others added 2 commits June 2, 2026 15:29
After apply_plan finished, the screen looked stuck: the user saw the
"success Wrote …" line followed by rows of empty log space and no
way to advance. The Continue button was actually enabled, focused,
and Enter-bound — it was just BELOW the visible terminal area.

Cause: the generic ``RichLog { height: 1fr }`` rule in styles.tcss
grows the apply-log to fill every remaining row of the viewport,
pushing the button row that sits OUTSIDE the panel below the
bottom edge of the terminal. Same failure mode the Review screen's
#workflow-diff already had — and fixed for — via a max-height
override.

Mirror the #workflow-diff fix on #apply-log:

    #apply-log { height: auto; max-height: 20; }

Probe (120x30 terminal, full wizard walk through to Apply):
  - apply-log region.height: 8 (sized to its 4 success lines)
  - continue button region: y=20-23 (well within viewport)
  - continue button focused, label="Continue ▸", disabled=False
  - Press Enter -> advances to DoneScreen

Also swap the panel border title from "⏳ Applying" to "✓ Applied"
on the success branch so the user has an unambiguous "done" signal
- the hourglass would otherwise hover above a finished operation.
Panel gets a stable id="apply-panel" so on_worker_state_changed can
target it via query_one without fragile text matching.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eight findings from Copilot's inline review on PR #238:

  1. (io.py) write_sbomify_json sentinel collision — moving the
     sentinel spread to AFTER the payload made a payload key
     containing __sbomify_wizard__ override or erase the ownership
     marker, silently breaking the overwrite contract on later
     re-runs. Spread payload first, sentinel last, so the sentinel
     always wins. Regression test pinned.

  2. (ci_emitter.py) PRODUCT_RELEASE shape — cli/main.py parses the
     env var with json.loads and rejects scalar strings. The
     wizard previously emitted PRODUCT_RELEASE: 'pid:ver' which
     failed validation at workflow run time before any upload.
     Wrap in JSON-array shape: '["pid:ver"]'. Test updated to
     pin the new shape.

  3. (ci_emitter.py) Tag-strategy version step — unconditional
     ${GITHUB_REF#refs/tags/} produced 'refs/heads/<branch>' on
     workflow_dispatch runs (slashes are invalid in a component
     version and broke downstream parsing). Wrap in a bash
     if/else that falls back to the short SHA when GITHUB_REF
     isn't a tag.

  4. (product.py, components.py) Picker labels rendered via Rich
     markup — escape API-supplied names through
     rich.markup.escape before passing them to PickOrCreate. A
     product or component literally named "Acme [Internal]" no
     longer crashes Rich's parser or emits garbled output. Map
     keys (existing_by_name) use the RAW name so the auto-match
     to lockfile.suggested_name still works.

  5. (main.py) wizard / init subcommands inherit parent group's
     --token when their own --token is unset. Matches the
     precedence the yocto subcommand already uses;
     `sbomify-action --token X wizard` now works as expected
     instead of silently dropping the token.

  6. (apply.py + main.py) --dry-run is now an honest no-mutation
     mode. Refactored apply_plan to short-circuit on opts.dry_run
     into a new _apply_plan_dry_run helper that simulates every
     step (populating state with [dry-run] markers like
     "<dry-run:component:foo>") without touching the API or the
     filesystem. Read-only auth + workspace prefetch still
     happens on Authenticate as before. Updated --dry-run /
     --debug help text on both root and wizard subcommands to
     describe the actual contract.

  7. (authenticate.py) Removed the dead on_input_submitted
     handler — same dead-code as create_profile.py /
     configure_sbomify_json.py: priority=True Enter binding
     consumes Enter before any focused Input can fire
     Input.Submitted, so the handler is unreachable. Replaced
     with a docstring comment explaining the Enter routing.

  8. (options.py) Documented WizardOptions.output_dir as a
     no-op constraint check rather than a routing field —
     workflow_path() hardcodes
     <repo_root>/.github/workflows/sboms.yml per the locked
     "one canonical filename" design decision. Removing the
     flag would break the CLI surface, so we keep it for
     validation but make the doc honest.

Test updates:
  - Renamed _dry_opts → _real_opts in test_wizard_state.py
    (dry_run=False) so the existing OIDC / API-mutation tests
    still assert on call counts after #6 tightened the contract.
  - Updated test_apply_plan_dry_run_skips_write to verify the
    NEW contract: no API mutations, no file writes, state
    populated with [dry-run] markers.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 2, 2026 16:10
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 71 out of 72 changed files in this pull request and generated 4 comments.

Comment thread .github/workflows/sboms.yml
Comment thread sbomify_action/cli/wizard/screens/welcome.py
Comment thread sbomify_action/cli/wizard/state.py Outdated
Comment thread sbomify_action/cli/wizard/app.py Outdated
vpetersson and others added 2 commits June 2, 2026 17:25
Fifteen findings ranked by severity, fixed in one batch:

- ci_emitter: pass state.created_product_id to emit_workflow so
  tag-strategy workflows for users who picked "create new product"
  emit PRODUCT_RELEASE — without this they silently downgrade to
  upload-only.
- apply: track OIDC failures per component (state.oidc_failed_components)
  and Done renders only those in the manual-fallback panel instead of
  blanket-listing every applied component (which made users re-bind
  successes and hit 409s).
- apply: state.reset_apply_artifacts() at apply_plan entry so a Back-
  on-failure retry starts clean — without it, prior oidc_binding_note
  survives a successful retry and routes Done to the wrong panel.
- apply: dry-run sets is_dry_run=True (NOT oidc_bindings_registered)
  and a "[dry-run] would register" note so Done renders a preview
  panel instead of the success card claiming a real binding.
- done: guard action_copy_first_url + render dry-run preview panel
  for OIDC so the synthetic <dry-run:component:foo> IDs don't surface
  as plausible-looking 404 URLs.
- main: distinguish "user typed --token X" from "Click filled --token
  from $TOKEN" via ctx.parent.get_parameter_source so the wizard's
  documented precedence ($SBOMIFY_TOKEN > $TOKEN) actually holds.
- apply: separate state.oidc_newly_registered (201) from
  oidc_bindings_registered (201+409) so Done's success message says
  "registered X new; Y already bound" instead of falsely claiming
  N fresh bindings on a no-op re-run.
- apply dry-run: append "would write" paths to state.written_files so
  Done's summary mentions them with a "◌ would write" glyph instead
  of going silent.
- apply: on partial OIDC success, append "registered ... N/M
  component(s)" to state.applied so Done's Applied panel surfaces
  what auto-succeeded.
- configure_sbomify_json: surface a visible warning at the top of the
  form when a hand-authored sbomify.json exists — apply refuses to
  overwrite it, so edits silently land in an info log without this.
- io: write .bak before overwriting a wizard-stamped sbomify.json
  when content changed (skip on byte-identical re-runs); sbomify.json
  carries hand-edited fields the form doesn't surface, unlike the
  workflow file which is fully wizard-owned.
- authenticate: single-workspace fast path in _pick_default_workspace_key
  (scoped tokens that backend-filters to one workspace get it right);
  surface picked workspace name on the auth-success status line so the
  user can spot a misdirection for the unfixable scoped-token-on-
  multi-workspace case.
- main: trim yocto --dry-run help text that wrongly claimed "auth /
  listing calls still run" — yocto's dry-run early-returns before
  any API client is constructed.
- apply: shared _per_component_best_effort helper backed by a bounded
  ThreadPoolExecutor; profile + OIDC binding loops now run in parallel
  (was two sequential O(N*RTT) cascades).
- sbomify_api: drop unused provider='github' kwarg from
  create_oidc_binding — URL is provider-specific so a future GitLab
  caller would silently POST to the github route.

Adds 9 regression tests covering: created_product_id in workflow,
partial-success failed_components tracking, 201/409 split surfacing,
all-409 re-run not claiming registration, state reset between runs,
dry-run is_dry_run flag + no real binding, dry-run would-be writes
on state.written_files, .bak on content change, no .bak on identical
re-write.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- welcome: route Enter through route_enter() so Enter on a focused
  Cancel button cancels instead of advancing, matching every other
  wizard screen
- state.py: workflow_exists comment said Review surfaces "(.bak
  created)" but write_workflow intentionally doesn't .bak — git
  tracks the prior version
- app.py: refresh the phase list in the module docstring (was stale
  6/6b/6c numbering with a single "configure" phase; now reflects the
  8-step Components / Configure(workflow) / Configure(SBOM) flow)
- dogfooded .github/workflows/sboms.yml: regenerate the version step
  and PRODUCT_RELEASE to match the emitter's current output (bash
  if/else fallback to short SHA on non-tag refs; JSON-array
  PRODUCT_RELEASE so the action's runtime parser accepts it)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 2, 2026 19:19
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 71 out of 72 changed files in this pull request and generated 4 comments.

Comment thread sbomify_action/cli/wizard/screens/apply.py
Comment thread sbomify_action/cli/wizard/screens/apply.py Outdated
Comment thread sbomify_action/cli/wizard/screens/apply.py
Comment thread .github/workflows/sboms.yml
…l edits

- screens/apply.py: rich.markup.escape around message / error text
  before interpolating into Rich-markup strings written to RichLog
  (markup=True) and the error banner. Without this, an exception
  message containing `[` (eg "APIError [404] - not owner/admin")
  collides with the colour-wrapping tags and either misrenders the
  log line or raises mid-apply.
- .github/workflows/sboms.yml: revert hand-edits from b977c39. The
  dogfooded workflow stays in the shape the wizard last emitted; the
  PRODUCT_RELEASE / version-step / API_BASE_URL choices are
  re-onboarding concerns that live outside this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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