feat: Textual onboarding wizard + consolidated sbomify API client#238
Open
vpetersson wants to merge 78 commits into
Open
feat: Textual onboarding wizard + consolidated sbomify API client#238vpetersson wants to merge 78 commits into
vpetersson wants to merge 78 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.
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>
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>
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).
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.
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>
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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds an interactive Textual TUI (
sbomify-action wizard, aliased asinit) 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 asbomify.jsonfor 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. AddsAuthError(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).Wizard (
sbomify_action/cli/wizard/)welcome→discover→authenticate→product→components→configure_workflow(release strategy + credentials) →configure_sbom(enrichment + augmentation + formats + provenance) →review→apply→doneconfigure_sbom:create_profile(POST a new contact profile to the workspace) andconfigure_sbomify_json(form-fills an in-repo metadata file, folding back the oldinitcommand's fields).github/workflows/sboms.yml; header-sentinel-guarded overwrite — refuses to clobber hand-authored workflows.sboms.ymlandsbomify.jsonso re-runs never silently lose hand edits.StatefulRadioButtonso radio selection reads correctly even when colour info is stripped (eg copy/paste): selected radios render▐●▌, unselected render▐○▌.Three honest augmentation paths
AUGMENT=false, accept blank organisational fields+ Create newsentinel 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 viapatch_component.sbomify.jsonfile (saved to the repo) — full form (supplier / manufacturer / authors / security contact / lifecycle phase + dates) mirroring the oldinit. apply writes the file with a__sbomify_wizard__sentinel so re-runs respect hand edits. Configure (SBOM) detects an existingsbomify.jsonat 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=trueenv var; the action's existing provider priority picks the right source at workflow run time.Multi-workspace handling
Authenticateworker picks the user's default workspace (is_default_team+is_mefrom the workspaces response) — the same signal the backend uses to scopelist_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: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
wizardis the primary commandinitis a backwards-compatible alias with a deprecation note--token > \$SBOMIFY_TOKEN > \$TOKENprecedence via_resolve_token--debugstreams DEBUG logs to stdout after the TUI exitsDependencies
textual>=0.85.0,pytest-asyncioquestionaryDogfood
.github/workflows/sboms.ymlis wizard-generated, exercising the production component IDs / product release for this repo.Design decisions locked
sboms.yml) + sentinel comment. Drops all the previous-wizard machinery for parsing arbitrary user workflows.Quality bar
This PR went through three rounds of
/code-reviewat extra-high effort (45 finder angles, 1-vote verification, gap sweep) — every finding was either fixed or explicitly noted as out-of-scope. Highlights:priority=TrueEnter consequences, workspace endpoint URL fix, etc.)All review fixes have regression test coverage where the bug was reachable from a test harness.
Test plan
uv run pytest— 2329 passed, 4 skippeduv run ruff check sbomify_action tests— cleanuv run ruff format --check sbomify_action tests— cleanuv run mypy sbomify_action— clean (139 source files)list_workspacesreturns 5 entries; create + bind + delete contact-profile roundtrip workstest_wizard_done.py(auto-success vs manual-fallback Done screen branches) and apply-plan tests forcreate_oidc_bindinguv.lock: launch wizard, walk all three augmentation paths, verifysboms.yml+ (where applicable)sbomify.jsonare emitted with the right sentinel🤖 Generated with Claude Code