Skip to content

fix(ci)!: build the bundle before packing, and make the caches stop lying - #13

Merged
sunib merged 7 commits into
mainfrom
fix/ci-build-the-bundle
Jul 14, 2026
Merged

fix(ci)!: build the bundle before packing, and make the caches stop lying#13
sunib merged 7 commits into
mainfrom
fix/ci-build-the-bundle

Conversation

@sunib

@sunib sunib commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Started as "CI would publish a broken package." Ended as three bugs with one disease: a step that cannot tell whether its own output exists.

1. CI packed a bundle it never built

CI built with npx tsc and packed that tree. tsc emits the per-module dist/; it does not run esbuild, so dist/krm-stream.js was never built — and release.yml publishes CI's exact bytes rather than rebuilding, deliberately.

So the next release publishes a package whose exports map says:

"./bundle": { "default": "./dist/krm-stream.js" }   // ...not in the tarball

Every import "@configbutler/krm-stream/bundle" fails with ERR_MODULE_NOT_FOUND, in a release that passed CI green — because nothing in the pipeline ever imports the published artifact. Worse than not shipping the bundle: a missing feature is invisible, a broken exports entry is a runtime failure for the first person who trusts the README.

Reproduced against the real sequence (npm cinpx tscnpm pack): tarball has dist/index.js, no bundle. My npm pack --dry-run in #8 passed only because I'd built locally first — a clean checkout never would have.

2. task build-client could not see its own outputs

$ rm packages/krm-stream/dist/krm-stream.js
$ task build-client
task: Task "build-client" is up to date     # ← rebuilt nothing. The bundle is still gone.

generates: ["dist/**/*.js"] is only a checksum key. Task never asks whether the files exist, so a half-deleted dist/ survives a build. Same bug as CI, one layer down.

3. npm install prunes node_modules across branches

node_modules is shared mutable state that git does not track, and npm install reconciles it to the current branch's package.json. Run any task from a branch that predates a devDependency and it is silently deleted; switch back to main and the build dies on sh: 1: esbuild: not found — a message that points at nothing. This is what happened during the final check on main, and it is exactly the "costs tons of tokens and frustration" failure.

The fixes (Taskfile refactored on the gitops-reverser model)

  • Every cache-worthy task delivers a file, named individually in generates — never a glob.
  • Every such task carries a status: that probes the filesystem. This is the check a checksum cannot do, and it makes a deleted artifact or a pruned dependency self-heal instead of being skipped.
  • npm ci, never npm install (_client-deps, _example-deps): the tree becomes a pure function of two committed files and cannot drift from the branch you are on.
  • One definition of a thing. task pack-client builds, packs, and asserts every entry point in the exports map is in the tarball. CI now runs that same task instead of a hand-copied shell line. task verify is the whole gate in CI's order — if it passes, CI passes.
  • task clean for when a cache does lie. It deliberately does not touch Go's build cache, which is content-addressed and sound.
  • Internal tasks are _-prefixed and internal: true, so task --list stays the commands people type.

Verified, not asserted

deleted output now rebuilds (was: "up to date")
nothing changed still skips — the cache is still a cache
pruned node_modules self-heals via npm ci
good tarball guard passes
tsc-only tarball guard rejects it
task verify from a cold cache green, end to end

The guard found a bug in itself on its first run. tar … | grep -q under set -o pipefail reports failure on a match: grep -q exits the moment it finds the line, tar takes SIGPIPE, and the pipeline inherits tar's status. It failed a perfectly good tarball. It lists once into a variable now — and that is the argument for running a new check against a known-good input before trusting it to gate a release.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added automated security analysis for Go and TypeScript code.
    • Added a documented process for privately reporting security vulnerabilities.
    • Added consolidated verification covering fixtures, linting, tests, end-to-end checks, and client packaging.
  • Quality Improvements

    • Expanded validation for GitHub Actions workflows and Dockerfiles.
    • Improved client dependency, build, cleanup, and package completeness checks.
    • Added ShellCheck, Actionlint, and Hadolint tooling to development environments.

CI ran `npx tsc` as its build step and then packed that tree. tsc emits the
per-module dist; it does not run esbuild, so dist/krm-stream.js was never
built — and release.yml publishes CI's exact bytes rather than rebuilding,
by design.

The next release would therefore have published a package whose exports map
advertises

    "./bundle": { "default": "./dist/krm-stream.js" }

pointing at a file that is not in the tarball. Every
`import "@configbutler/krm-stream/bundle"` fails with ERR_MODULE_NOT_FOUND,
in a release that passed CI green — because nothing in the pipeline imports
the published artifact. The whole point of the ./bundle work, silently
absent, plus a broken exports entry that is worse than not shipping it at
all.

Reproduced locally against the real sequence (npm ci → npx tsc → npm pack):
the tarball contains dist/index.js and no dist/krm-stream.js.

Two changes:

  - the build step is `npm run build`, which is tsc AND the esbuild flatten.
    One source of truth with package.json.
  - a guard after `npm pack` asserts that every entry point in the exports
    map is actually IN the tarball. A comment saying "keep these in step" rots;
    this fails the build. Verified it rejects the bundle-less tarball and
    passes the correct one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change expands local and CI linting, centralizes Task-based verification and client packaging, adds CodeQL analysis, and documents security reporting, vulnerability scope, supported versions, and workflow credential handling.

Changes

CI quality and packaging

Layer / File(s) Summary
Lint tooling and hygiene checks
.devcontainer/Dockerfile, .hadolint.yaml, Taskfile.yml, .github/workflows/ci.yml
Adds shellcheck, actionlint, and hadolint setup; configures Dockerfile linting; and introduces CI hygiene checks for workflows and the devcontainer.
Task verification and dependency state
Taskfile.yml
Adds the ordered verify gate, reproducible dependency tasks with filesystem checks, expanded lint dependencies, explicit client build outputs, tarball validation, and cleanup commands.
CI task integration
.github/workflows/ci.yml
Installs Task in CI, replaces the manual client packaging sequence with task pack-client, and suppresses shellcheck for literal backticks in a message.

Security analysis and policy

Layer / File(s) Summary
CodeQL workflow
.github/workflows/codeql.yml
Adds scheduled and push/pull-request CodeQL analysis for Go and JavaScript/TypeScript.
Security reporting and workflow posture
SECURITY.md, .github/workflows/scorecard.yml
Documents private vulnerability reporting, supported versions, vulnerability boundaries, design references, and Scorecard credential handling.

Estimated code review effort: 3 (Moderate) | ~25 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main CI/Taskfile changes around building before packaging and tightening cache/dependency validation.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ci-build-the-bundle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…ying

Three bugs, one disease: a step that cannot tell whether its own output
exists.

# 1. CI packed a bundle it never built

CI built with `npx tsc` and packed that tree. tsc emits the per-module dist;
it does not run esbuild, so dist/krm-stream.js was never built — and
release.yml publishes CI's exact bytes rather than rebuilding, by design.

The next release would have published a package whose exports map advertises
"./bundle": "./dist/krm-stream.js" pointing at a file NOT IN THE TARBALL.
Every `import "@configbutler/krm-stream/bundle"` fails with
ERR_MODULE_NOT_FOUND, in a release that passed CI green — because nothing in
the pipeline ever imports the published artifact.

# 2. `task build-client` could not see its own outputs

    rm dist/krm-stream.js && task build-client
    → "Task is up to date"      (nothing rebuilt)

`generates: ["dist/**/*.js"]` is only a checksum key; Task never asks whether
the files exist. Outputs are now named individually AND guarded by a `status:`
that probes the filesystem, so a missing artifact rebuilds.

# 3. npm install prunes node_modules across branches

node_modules is shared mutable state that git does not track, and `npm install`
reconciles it to the CURRENT branch's package.json. Running any task from a
branch predating a devDependency DELETES it; switching back leaves a build
dying on `sh: 1: esbuild: not found`, a message that points at nothing.

_client-deps/_example-deps now use `npm ci` — the tree is a pure function of
two committed files — fingerprinted on those files and status-guarded on the
binaries actually being present.

# One definition of a thing

`task pack-client` builds, packs, and ASSERTS every entry point in the exports
map is in the tarball. CI runs that same task instead of a hand-copied shell
line, so the local gate and the release gate cannot drift again. `task verify`
is the whole gate in CI's order.

The guard caught a bug in itself on its first run: `tar | grep -q` under
pipefail reports FAILURE on a match, because grep exits early and tar takes
SIGPIPE. It lists once into a variable now. Verified in both directions — it
passes a good tarball and rejects a tsc-only one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sunib sunib changed the title fix(ci): build the bundle before packing, and prove it is in the tarball fix(ci)!: build the bundle before packing, and make the caches stop lying Jul 14, 2026
…, and lint the two unlinted languages

Scorecard was already wired correctly — pinned actions, least-privilege
tokens, OIDC, publish_results. The workflow was never the problem. The repo's
posture was: 4.8/10, with `main` completely unprotected.

# No PAT, on purpose

scorecard-action offers `repo_token`, and it is the thing people reflexively
add. It is needed for exactly one check: reading CLASSIC branch protection.
`main` is now protected by a repository RULESET, which the default GITHUB_TOKEN
can read — so Branch-Protection scores without one.

That is the better posture, not a shortcut. A PAT with repo scope is a
long-lived write credential, stored as a secret, in a workflow whose whole job
is to attest that the supply chain is sound. Not creating it is worth more than
the check it would buy.

The ruleset (applied via the API, id 18924161): no deletion, no force-push,
PRs required, CI required. Zero required approvals — a solo maintainer cannot
approve their own PR, and a bypass actor would have scored worse than the rule
is worth. Code-Review stays 0 until there is a second contributor, which is
honest rather than papered over.

# The two languages nobody counts as languages

Half this repo's supply-chain surface is a workflow YAML and a Dockerfile, and
they were the only files here with no linter pointed at them. That is not a
coincidence: the bug that packed a bundle it had never built lived in a
workflow, and every Go and TypeScript check in the pipeline was blind to it.
actionlint also runs shellcheck over every `run:` block — which is exactly where
the `tar | grep -q` under pipefail bug hid.

  - actionlint + hadolint in the devcontainer and in CI (new `hygiene` job,
    now a required check). Versions are read FROM the Dockerfile's ENV block in
    CI, so there is one definition and they cannot drift.
  - .hadolint.yaml ignores DL3008 with the reason written down: the devcontainer
    is a tooling image, distro packages are deliberately unpinned, and the
    versions that matter are pinned in ENV. The `# hadolint ignore=` comments
    already in that Dockerfile have been talking to a linter that was not there.

# The rest

  - SECURITY.md: private reporting, and — more usefully — what counts as a
    vulnerability HERE. Every interesting failure in this library is a
    disclosure failure: a redacted value reaching a browser, a subscriber served
    another tenant's object from the shared cache, a patch writing a field the
    browser was never shown.
  - CodeQL (Go + TypeScript, security-extended). The gateway parses untrusted
    input from a browser and hands back objects it has projected; that is the
    shape where taint tracking earns its runtime.

Deliberately NOT done: pinning the golangci-lint installer in the devcontainer.
It is a dev-only image from a source we already trust, and it is not in the
supply chain of anything this repo publishes. Pinned-Dependencies stays at 7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-advanced-security

Copy link
Copy Markdown

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

sunib and others added 2 commits July 14, 2026 10:17
…ms to

The hygiene job I added in the previous commit failed on its first CI run,
having passed locally on the same tree. That gap is the point.

actionlint SHELLS OUT to shellcheck to check every `run:` block. If shellcheck
is absent it silently skips that half of its job and still exits 0. The
devcontainer had no shellcheck, so `task lint` was green on a workflow CI
rejected — a local gate that is not weaker than CI, but LYING to you. Which is
the same disease as everything else in this PR: a check that reports success
about a thing it never looked at.

shellcheck is now installed in the devcontainer. Verified by reintroducing the
bug: local actionlint now reports the same SC2024 CI did.

The two findings it was hiding:

  - SC2024 in my own install step: `sudo tar … > /tmp/actionlint`. sudo does
    not affect a redirect — the shell opens the file, unprivileged. Harmless
    here, wrong everywhere it matters.
  - SC2016 in the `no module may carry a replace` check: backticks inside single
    quotes, which are literal prose in an error message and exactly right.
    Disabled inline with the reason, rather than by loosening the linter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
They explained the why at three times the length it needed. Kept the reason,
cut the sermon, dropped the em-dashes. The Taskfile alone goes from 98 added
comment lines to 59.

The one quoted back at me is the example: a two-line explanation of why
backticks inside single quotes are correct is now one line that says so.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
.github/workflows/ci.yml (1)

272-281: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider delegating the CI test and lint steps entirely to Task in the future.

Since task pack-client internally relies on _client-deps (which executes npm ci), the node_modules directory gets seamlessly recreated during the Task execution. This makes the earlier - run: npm ci step inside this job slightly redundant, though completely harmless.

While delegating the test and lint steps directly to Task (e.g., task test-client lint-client pack-client) would remove this duplication and align CI identically with the local environment, doing so today would fail: task test-client depends on fixtures. Without a warmed .task/ cache available in CI, Task will attempt to regenerate the fixtures (which requires Go) within this Node-only runner.

The current workflow correctly and safely bypasses the Go dependency by invoking the underlying Node tools directly. You might optionally refactor the Taskfile later (e.g., by adding a filesystem status check to fixtures or splitting the testing dependency graph) to allow CI to rely exclusively on Task without needing a multi-language runner.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 272 - 281, Keep the current CI
workflow’s direct Node-based test and lint commands and do not delegate them to
Task yet, because task test-client transitively requires fixtures and may invoke
Go generation on this Node-only runner. Leave the existing npm ci step unchanged
for now; any future Taskfile changes to make fixtures safely reusable in CI are
outside this change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In @.github/workflows/ci.yml:
- Around line 272-281: Keep the current CI workflow’s direct Node-based test and
lint commands and do not delegate them to Task yet, because task test-client
transitively requires fixtures and may invoke Go generation on this Node-only
runner. Leave the existing npm ci step unchanged for now; any future Taskfile
changes to make fixtures safely reusable in CI are outside this change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1a92c807-9377-4a3f-82f9-9a4034f1971f

📥 Commits

Reviewing files that changed from the base of the PR and between 0468dce and 2f214fc.

📒 Files selected for processing (7)
  • .devcontainer/Dockerfile
  • .github/workflows/ci.yml
  • .github/workflows/codeql.yml
  • .github/workflows/scorecard.yml
  • .hadolint.yaml
  • SECURITY.md
  • Taskfile.yml

sunib and others added 2 commits July 14, 2026 10:31
The README opened with two paragraphs of KRM theory before the reader could
tell whether the library was for them. For the audience this project is
actually trying to reach, a frontend or backend developer who is not a
Kubernetes expert, that is the wrong first page.

  - "Is this for you?" up top: yes-if / probably-not-if, in plain terms. It
    says out loud that a generic three-way merge library is a different thing,
    that Headlamp is the answer if you want a dashboard, and that the write
    path belongs to your application.
  - KRM in one line, then a link to the frontend glossary. The theory did not
    need two paragraphs to earn its place.
  - "Start here" now leads with the BROWSER half, because that is the half a
    frontend developer owns and the old order made them read Go first. The
    snippet is real: it typechecks against the actual store API, including the
    StreamChange uid.
  - Badges: CI, Scorecard, CodeQL, npm version, zero runtime deps, Go,
    TypeScript, license, issues. All verified to render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Shorter tagline, "What is KRM?" as the heading, and the glossary pointer cut
to one line.
@sunib sunib closed this Jul 14, 2026
@sunib sunib reopened this Jul 14, 2026
@sunib
sunib merged commit 8b593f4 into main Jul 14, 2026
22 checks passed
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.

2 participants