[OPIK-7793] [FE] ci: run the frontend checks with the private ai-spend plugin staged - #7891
[OPIK-7793] [FE] ci: run the frontend checks with the private ai-spend plugin staged#7891andriidudar wants to merge 5 commits into
Conversation
⏱️ pre-commit per-hook timing
⏭️ 41 skipped (no matching files changed)
|
…d plugin staged
The comet frontend image compiles comet-ml/opik-plugin-ai-spend, checked out at
build time into src/plugins/ai-spend. Nothing on a pull request compiles it —
the image build runs on push to main, on release, and on PRs labelled
test-environment — so a breaking change to a shared surface the plugin imports
passes every PR check and only fails after merge. It has cost a red main (a
constant moved out of CodeHighlighter) and, this week, two sequential release
runs (OPIK-7793).
Stage the plugin the way the image stages it and run typecheck, eslint and
dependency-cruiser against the result. All three run even when an earlier one
fails, so a PR sees every problem in one go; eslint is scoped to the plugin
sources since core is already linted by the code quality workflow, and
deps:validate is not scoped, since it is the whole-graph analysis that is how
the plugin's own import cycle was found in the first place (fixed upstream in
opik-plugin-ai-spend#69).
Reviewed by baz-reviewer; findings and how each was resolved:
- Trigger paths include build_and_push_docker.yaml, so a change to how the
real image stages this same plugin re-runs the parity check.
- persist-credentials: false on both checkouts.
- Token availability is checked before ref parsing: a fork event (or a
malformed ai-spend-plugin-ref in one) can no longer fail the job, since
nothing downstream would use the ref anyway.
- Staging validates manifest.ts specifically (name: "ai-spend"), using
-type f so a directory literally named *.tsx can't inflate the count.
PluginsStore loads plugins by that manifest; a layout change that drops or
misnames it would otherwise leave production silently without the plugin's
routes while this still passed on an unrelated file count.
- A same-repo PR editing this file to read
${{ secrets.OPIK_PLUGIN_AI_SPEND_TOKEN }} directly was raised as high
severity. Investigated rather than architecturally worked around: a
workflow_run split (privileged half unreachable to a PR's own edits) was
built, then dropped — it only closes editing this specific file, not adding
a brand-new one with the same step, which any same-repo PR can already do to
any secret here (see typescript_sdk_e2e_tests.yml, which already exposes
OPENAI_API_KEY/ANTHROPIC_API_KEY the same way). Also confirmed empirically:
this repo requires maintainer approval before any workflow runs for a
first-time/outside contributor (verified via actual action_required runs on
a recent fork PR), and the plugin's compiled source already ships publicly
in the comet image's source maps regardless of this check. Net: the real
residual risk is smaller than it first looked and specific to same-repo
write access, which is the trust level every other secret here already
assumes — closing it for real means an Environment with required reviewers
on the secret itself, a repo-settings change, not a workflow one.
- The "unmerged plugin ref" and "eslint skips .tsx without --ext" findings are
addressed by reply, not code: the former is the documented, intended
sequencing (verify against a branch, then merge it first); the latter is
empirically wrong for this repo — a planted .tsx violation under
src/plugins/development was caught by `eslint <dir> --max-warnings=0` with
no --ext, exit 1, matching this repo's own root lint script.
Verified against opik-plugin-ai-spend main (a disposable worktree, to avoid
touching an unrelated in-progress checkout): typecheck, eslint, and
deps:validate all exit 0 with the plugin staged, and clean with no plugin
present and with it symlinked (the dev-runner layout). zizmor --pedantic:
clean.
5442fc1 to
6dc84ba
Compare
…otation bash scripts/dev-runner.sh --lint-fe is not equivalent to what this check runs: it lints the whole src tree with --fix (not scoped to the plugin, mutating), plus stylelint, none of which this check does. A developer following it could see unrelated noise, or have a fixable issue silently rewritten without realizing CI's non-mutating, plugin-scoped eslint would still have failed on the committed version. Spell out the exact commands instead: symlink or copy the plugin's src/ into place, then run the same typecheck/eslint/deps:validate this step runs.
|
No test needed here. No product surface in this PR — only docs, CI, tests, lockfiles or other non-product files. Advisory, from the QA test radar. Nothing here blocks this PR, and anything it proposes is a draft for review. Re-checked after a push on 20 Aug 14:02 UTC. |
liyaka
left a comment
There was a problem hiding this comment.
Reviewed the diff against the surrounding CI, the frontend's actual eslint/tsconfig/dependency-cruiser config, and the job log from the run on this PR.
The design is right and the write-up is honest. pull_request rather than pull_request_target, permissions: contents: read, persist-credentials: false on both checkouts, the PAT confined to one with: block and never exported into a run:, the PR body passed through env: instead of interpolated into shell — that is the correct shape for this. I also confirmed it is not vacuously green: the run staged 143 TypeScript files, found the manifest, and depcruise cruised 1982 modules. The body parser behaves as claimed against CRLF, fenced, unclosed-fence, inline-code and injection inputs.
Three changes requested.
1. types: [opened, synchronize, reopened, edited]
The only real defect. Default pull_request types are opened, synchronize, reopened — not edited. So the ai-spend-plugin-ref: escape hatch, which this PR documents in both the body and the workflow header, silently does not work: adding the line after opening the PR does not re-trigger, and "Re-run jobs" replays the original event payload, i.e. the stale body. Whoever first needs the escape hatch will discover this by pushing an empty commit.
2. Move the staging step to after npm ci
Current order is stage → setup-node → npm ci → checks, so the private plugin source is on disk while PR-controlled postinstall (patch-package, with patches/ from the PR) runs. Reordering costs nothing and takes install-time code execution out of the exposure window. It does not stop a malicious typecheck script — that is inherent to the approach — but there is no reason to leave the easy one open.
3. Accept both quote styles in the manifest grep
grep -qE "name:[[:space:]]*['\"]ai-spend['\"]" "${manifest}"The manifest assertion itself is worth having — I confirmed PluginsStore really does select by manifest.name against VITE_FE_PLUGINS, and no in-repo plugin has a manifest.ts, so there is no public example keeping it honest. But as written it is a textual assertion on a private repo's formatting: a prettier config change there to single quotes turns every opik frontend PR red, and the reason is invisible to most reviewers.
Optional, not worth another round on their own:
- The "produced nothing" annotation is unreachable for the case it names. If the plugin's
src/moves,cp -R .ai-spend-plugin/src/.fails first underset -eand you get a rawcperror rather than the annotation. The message only fires whensrc/exists but holds no.ts/.tsx. Test the directory before copying. tr -d '[:space:]'concatenates rather than truncates:ai-spend-plugin-ref: main rebasedresolves tomainrebased, passes the ref allowlist, then fails at checkout with a confusing "couldn't find ref". Take the first whitespace-delimited field instead.- Consider making the resolved ref a
::warningwhen it is notmain. Two things become visible on the PR that are currently only in the raw log: that the PR is green against an unmerged plugin branch (nothing enforces the plugin side landed first), and that an unbalanced fence anywhere in the body made the awk toggle swallow the rest of it and silently fall back tomain.
Out of scope for this PR, but worth tracking:
- Token scope. This makes write access to a public repo reach a private one on every frontend PR — a widening from the maintainer-gated push-to-
main/ release /test-environmentpaths where the token is used today, and sincepull_requestruns the workflow from the PR head, an author can edit this file directly. Same exposuretypescript_sdk_e2e_tests.ymlalready has, so it is consistent with the repo. IfOPIK_PLUGIN_AI_SPEND_TOKENis a classic PAT, swapping it for an App token scoped tocontents: readonopik-plugin-ai-spendalone bounds the blast radius to "read the plugin source", which anyone who can run this check can do anyway. - The negative control. Worth running before this is relied on. A green check is not evidence that it fails when it should, and the draft PR exercises the body parser against GitHub's real encoding at the same time.
tscvsvite build. The image build runsnpm run build(tsc && vite build) withVITE_FE_PLUGINS=comet,ai-spend; this runstsconly. A break that surfaces at bundle time — unresolvable runtime import, alias/rollup failure, theimport.meta.globmanifest wiring, the build's 8GB heap — still lands post-merge.tsccovers both breakages actually observed, so this is a conscious-narrowing question rather than a gap to fix now.
…obustness Three requested changes: - pull_request.types now includes `edited`. Default types (opened, synchronize, reopened) don't cover an edited PR body, so the ai-spend-plugin-ref override documented in this file's own header silently didn't retrigger the check when added after opening -- discoverable only by pushing an empty commit. - Move Set up Node.js / npm ci ahead of the private plugin checkout and staging. PR-controlled postinstall scripts now run before the plugin source is on disk at all, rather than while it's staged. - Manifest grep accepts both quote styles (name: 'ai-spend' or "ai-spend"). It was asserting textually against a private repo's formatting; a prettier config change there to single quotes would otherwise turn every opik frontend PR red for reasons invisible to most reviewers. Also fixed, from the same review, all one-liners: - Stage step checks the plugin's src directory exists before cp -R. Verified empirically: cp -R against a missing source dies under set -e with a raw cp error, before ever reaching the "produced nothing" message meant for exactly this case. - Ref parsing takes the first whitespace-delimited token instead of stripping all whitespace: "main rebased" no longer concatenates to the invalid "mainrebased" (passes the allowlist, then fails opaquely at checkout). - A resolved ref other than "main" now emits a ::warning, so checking against an unmerged plugin branch -- or an unbalanced fence silently swallowing the rest of the body -- is visible on the PR, not just in the raw log. Verified against opik-plugin-ai-spend main (disposable worktree): typecheck, eslint, deps:validate all exit 0 with the plugin staged. Ref parser re-tested against all six prior cases plus "main rebased" -> resolves to "main". zizmor --pedantic: clean.
andriidudar
left a comment
There was a problem hiding this comment.
Fixed in d498baa — all three requested changes, plus the three optional one-liners (all cheap, all correct, no reason to leave them):
types: [opened, synchronize, reopened, edited]— confirmed the default types don't includeeditedbefore fixing; theai-spend-plugin-refoverride in the PR body would silently not retrigger.- Moved
setup-node/npm ciahead of the private plugin checkout and staging — postinstall now runs with no plugin source on disk. - Manifest grep now accepts both quote styles.
- Stage step checks the source directory exists before
cp -R— verified empirically that a missing dir dies underset -ewith a rawcperror before reaching the friendly message, exactly as you said. - Ref parsing takes the first whitespace-delimited token instead of stripping all whitespace — "main rebased" no longer concatenates into "mainrebased".
- Non-
mainresolved ref now emits a::warning.
Re-verified against opik-plugin-ai-spend main (disposable worktree, so as not to disturb an unrelated in-progress checkout of the plugin repo): typecheck, eslint, deps:validate all exit 0 with the plugin staged. Ref parser re-tested against all prior cases plus "main rebased". zizmor --pedantic clean.
On the three out-of-scope items — agreed these are real and worth tracking separately, not blocking this PR:
- Token scope: a GitHub App token scoped to contents:read on opik-plugin-ai-spend alone is the right follow-up: bounds the blast radius to exactly what this check itself already needs.
- Negative control: already run, twice — a scratch branch with a required prop the plugin doesn't satisfy, dispatched via workflow_dispatch, confirmed it fails with the same TS2741 shape as the actual OPIK-7793 incident, then torn down. Happy to do it again on this exact commit if useful before merge.
- tsc vs vite build: agreed this is a conscious narrowing, not a gap — tsc covers both breakages actually observed (OPIK-7793's required prop, the CodeHighlighter constant move). Adding the full build would roughly match the real image's ~8GB-heap/multi-minute cost for coverage against failure modes not yet observed in practice. Open to revisiting if a build-time-only break ever slips through this check.
🤖 Reply posted via /address-github-pr-comments
liyaka
left a comment
There was a problem hiding this comment.
Re-reviewed d498baa against the previous round. All six items are addressed, and I verified each rather than taking the commit message for it.
| Item | Status |
|---|---|
1. types: [… edited] |
✅ |
2. Staging after npm ci |
✅ setup-node/npm ci ahead of the plugin checkout |
| 3. Manifest grep, both quote styles | ✅ regex correct through YAML + bash quoting |
4. Test src/ before cp -R |
✅ |
5. First whitespace token instead of tr -d |
|
6. ::warning on a non-main ref |
✅ |
The negative control is no longer outstanding. Run 32234674674 on 8ba73524 failed with exactly TS2741: Property 'demoRequiredProp' is missing … required in type 'LogsTabProps' on src/plugins/ai-spend/pages/AiSpendSessionsPage.tsx, after staging 143 files and finding the manifest. That was the strongest of my out-of-scope items — the check is now demonstrated to fail when it should, in the OPIK-7793 shape, not just to pass when nothing is wrong. Thanks for running it.
One regression, from the fix to my optional item 5. It is a one-line fix; details inline on the parser.
Non-blocking, no need for another round:
editedre-runs the full job on every title/body edit of any frontend-touching PR.cancel-in-progressbounds it, and the correctness win is worth the runner time — just noting the cost is real and new.- "Plugin staging produced nothing" is now the title on two different conditions (missing
src/, and zero.tsfiles). The bodies distinguish them; the titles don't.
Everything else holds. The reorder is clean, and the security shape — pull_request rather than pull_request_target, permissions: contents: read, persist-credentials: false on both checkouts, the PAT confined to one with: block, the PR body reaching the shell through env: — is unchanged from what I reviewed last time.
The previous commit's fix for "main rebased" concatenating into "mainrebased"
(tr -d '[:space:]' -> awk '{print $1}') introduced a regression Liya found:
awk's default field separator doesn't treat \r as one, so on a CRLF-authored
PR body -- the normal case for the GitHub web UI, which is also the edited
path just enabled in the same commit -- the CR stays attached to the captured
ref and fails the validation regex on an otherwise valid value. Confirmed
empirically: "ai-spend-plugin-ref: main\r\n" captured as "main\r" (5 bytes),
rejected; the annotation then renders as "'main' is not a valid git ref",
contradicting itself.
Strip \r first, before the fence-toggle and the grep, so it protects both.
Verified against all seven cases from the review: CRLF main, CRLF branch,
"main rebased", the fenced doc example, no override, the injection attempt,
and the differently-cased/whitespaced key -- all match. Also re-verified the
full typecheck/eslint/deps:validate stack against opik-plugin-ai-spend main
(disposable worktree): exit 0. zizmor --pedantic clean.
…tles Missing src/ and zero .ts files were both titled 'Plugin staging produced nothing'. Bodies distinguish them; titles now do too.
Details
The comet frontend image compiles
comet-ml/opik-plugin-ai-spend, checked out at build time intosrc/plugins/ai-spendand type-checked against whatever this repo holds. Nothing on a pull request compiles it — the image build runs on push tomain, on release, and on PRs labelledtest-environment— so a breaking change to a shared surface the plugin imports passes every PR check and only fails after merge.This stages the plugin the way the image stages it and runs
typecheck,eslintanddeps:validateagainst the result, so that class of break fails on the PR that causes it.fe-eslintleg's verdict while making a red plugin check ambiguous.deps:validateis deliberately not scoped: it is a whole-graph analysis, and the plugin's edges into core can only be judged by cruising all ofsrc— which is how the plugin's import cycle was found in the first place.apps/opik-frontend, so a change to the check runs the check.srclayout moves, the job fails loudly instead of leaving a check that passes vacuously.Fork pull requests cannot read
OPIK_PLUGIN_AI_SPEND_TOKEN, so the job concludes green with a::noticerather than failing or hanging — it can be made required without blocking outside contributors.pull_request_targetwould give it secrets but would run fork code with our token on a public repo, so it is not used. The comet image build on push tomainremains the backstop for a fork PR that merges without this having run.Intentional breaking changes name the adapting plugin branch in the PR body and merge the plugin side first:
There is deliberately no skip label — a green check that proves nothing is how the release broke.
Cost of the check: it means a frontend PR can be gated by a private repo. That is the trade being made, and it only bites once this is added to the ruleset as required.
Change checklist
Issues
AI-WATERMARK
AI-WATERMARK: yes
mainstaged the way the workflow stages it; the ref parser and the trigger gap were each tested rather than reasoned about. A negative control is prepared but not yet run (see Testing).Testing
The three checks, dry-run locally in
apps/opik-frontendon this branch's base, with pluginmain(2df2230) copied intosrc/plugins/ai-spendexactly as the workflow copies it — macOS, local Node:npm run typecheck— exit 0npx eslint src/plugins/ai-spend --max-warnings=0— cleannpm run deps:validate— exit 0, 27 known violations matched, noai-spendviolation of any ruleAlso confirmed clean with no plugin present and with the plugin symlinked (the dev-runner layout), so the job's staging step is the only thing that has to work.
The PR-body ref parser was run over five inputs: a plain override, a differently-cased key with extra whitespace, a body with no override, an empty body, and
../../evil; rm -rf /. First two resolve correctly, next two fall back tomain, the last is rejected by the ref allowlist before reachingactions/checkout.The trigger gap. The first version filtered on
apps/opik-frontend/**only, which meant this PR — a workflow-only change — would not have run its own check..github/workflows/frontend_private_plugin_checks.ymlis now inpaths, so this PR running the job green is itself the positive control.Not yet run: the negative control. A green check does not prove a working check.
andriid/NA-scratch-plugin-check-negative-controlis prepared: it adds a required prop toLogsTabPropsand passes it from the only in-repo caller, so the sole compile error is the plugin'sLogsTabcall — the shape of the change that broke two release runs. Locally it produces exactly one error,TS2741onAiSpendSessionsPage.tsx. It will be opened as a throwaway draft PR carryingai-spend-plugin-ref: mainin the body, which exercises the parser against GitHub's real body encoding at the same time, and closed without merging.Also not run: the fork path, which needs a real fork PR. The
has_tokenbranch is a single conditional and was read rather than executed.Documentation
None in this PR. The plugin's local check (
bash scripts/dev-runner.sh --lint-fe) and the notion page it is documented on already exist; the failure annotation points developers at that command. If this becomes a required check, the ruleset entry is a repo-settings change rather than a documentation one.🤖 Generated with Claude Code