This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
A Manifest V3 browser extension (Chrome + Firefox) that automatically commits solved DSA problems from LeetCode, GeeksForGeeks, Codeforces, NeetCode and takeuforward to a user-owned GitHub repository. Backed by a Cloudflare Worker (Hono) that handles GitHub OAuth and serves the landing page.
- Domain:
codeledger.vkrishna04.me - Auth worker:
https://codeledger.vkrishna04.me/api - Extension root:
src/— every import path is relative to it. You loaddist/chromium(ordist/firefox) unpacked, notsrc/: the build is what picks one of the two source manifests and writes it out asmanifest.json. - Stack: Pure ES6 modules, no bundler and no transpiler for our own code.
Preact + htm are vendored into
src/vendor/as committed esbuild bundles built from npm — nothing is fetched from a CDN at runtime. Tailwind CSS compiles the stylesheet only.
npm install
npm run build:css # Tailwind → src/ui/styles/compiled.css (run after CSS changes)
npm run build # CSS + dist packaging
npm run watch # rebuild on file changes (dev mode)
npm run lint # type gate — fails on undefined names, see below
npm test # node:test suites under test/ and worker/test/npm run lint runs dev/typecheck.js, which type-checks every file with
tsc --checkJs and fails the build on the four errors that are never a false
positive on untyped JS: TS2304 / TS2552 (a name that does not exist), TS2349
(calling something that is not a function), TS1117 (a duplicate key in an object
literal). Structural complaints about untyped object literals are printed as a
count only; read them with npm run lint:all. Plain tsc --noEmit is not enough
— it only reads files carrying a @ts-check pragma, which was 11 of 153, and
four crashing bugs shipped through that gap.
npm run build:fast skips the Tailwind step and is the fast inner loop; run
npm run build:css after touching a class or the new utility silently does
nothing. Then load dist/chromium unpacked in chrome://extensions.
cd worker && npm install
npx wrangler dev # local dev (requires wrangler.toml with secrets)
npx wrangler deploy # deploy to production
cd .. && npm run deploy:worker # shorthand from rootworker/wrangler.toml is git-ignored — copy worker/wrangler.toml.example to
worker/wrangler.toml. Secrets never go in that file; see Worker Secrets below.
node dev/generate-manifest-domains.js # regenerates host_permissions from dom-selectors DOMAINS exports
node dev/build-canonical-map.js # validate data/canonical-map.json against schema
node dev/package-chrome.js # produce codeledger-chrome-vX.zip
node dev/package-firefox.js # produce codeledger-firefox-vX.zip
node dev/import-profile/leetcode-importer.js --github-token=TOKEN --repo=owner/repo
# GFG bulk import has no CLI — use the extension's "Import All Solves" button on
# your GFG profile page (dev/import-profile/gfg-importer.js is a placeholder)npm run release # validates, builds zips, commits, tags, pushes all in one command
npm run release -- --dry-run # preview what would happen (no git changes)Or manually:
npm run publish # clean → build:css → build:dist → zip Chrome + Firefox + source
git commit -m "chore: release vX.Y.Z"
git tag vX.Y.Z
git push origin main vX.Y.Z # triggers .github/workflows/release.ymlcurl -sf https://codeledger.vkrishna04.me/api/healthmanifest-chromium.json / manifest-firefox.json — the build emits one manifest.json per target
├── background/service-worker.js — SW: init, event bus, handles problem:solved, commits
│ ├── sync-engine.js — cross-device sync via repo index.json
│ └── migration-manager.js — versioned storage migrations run on install/update
├── content/handler-loader.js — matches hostname → dynamically imports platform handler
│ └── presence-marker.js — injects #codeledger-present on landing page
├── handlers/
│ ├── _base/BasePlatformHandler.js — safeQuery(), MutationObserver lifecycle
│ ├── platforms/{leetcode,geeksforgeeks,codeforces,neetcode,takeuforward}/index.js
│ ├── ai/{gemini,openai,claude,deepseek,ollama,openrouter}/index.js
│ └── git/github/index.js — the only implemented git provider (see below)
├── core/
│ ├── constants.js — SINGLE SOURCE OF TRUTH for all URLs, keys, storage key names
│ ├── storage.js — unified storage abstraction (wraps browser-compat)
│ ├── event-bus.js — typed pub/sub (problem:solved → service-worker)
│ ├── handler-registry.js — resolves a provider name to its handler instance
│ ├── canonical-mapper.js — resolves platform problem → canonical ID
│ └── ai-prompts.js — prompt templates + normalizeAIPrompts()
├── lib/
│ ├── browser-compat.js — the cross-browser wrapper for the extension APIs
│ ├── sanitize-html.js — allow-list sanitiser; one importer (leetcode/modal-tabs.js)
│ └── debug.js — createDebugger() with console.bind() trick
├── ui/
│ ├── components/ — shared Preact components (ModelSelector, panels, modals)
│ ├── components/GitHubOnboardingModal.js — first-time repo setup wizard (Trees API)
│ └── floating-timer.js — draggable solve-time stopwatch (content-script safe, no framework)
└── welcome/
└── welcome.js — onboarding checklist page (auto-opened on first repo link)
GitHub is the only git provider. The GitLab and Bitbucket handlers were deleted
in 1.7.0 — every method on them threw, and they were still reachable from the
provider chips, the mirror picker and the @gitlab/@bitbucket chat mentions.
Do not reintroduce a provider anywhere in the UI before its commit() works.
- Content script (
handler-loader.js) → imports platform handler → callshandler.init() - Platform handler detects accepted submission (DOM / GraphQL / REST)
- Fires
eventBus.emit("problem:solved", data)→ caught by service-worker - SW saves to IndexedDB, optionally calls AI review, then calls
_commitWithFailover() _commitWithFailover()resolves the ordered target list (primary, then any configured mirrors) and callshandler.commit()on each until one succeedssrc/handlers/git/github/index.jscommit()builds a single atomic commit through the GitHub Trees API (POST /git/trees→POST /git/commits→PATCH /git/refs/heads/{branch})
- Built with Hono framework
- Routes:
GET /api/health,GET /api/auth/:provider,GET /api/auth/github/callback,POST /api/webhook/github,GET /api/data/canonical-map.json,POST /api/admin/canonical GET /*serves the static landing page fromworker/public/- OAuth callback delivers
{ type: 'CODELEDGER_AUTH', provider, token }via thedata-authattribute on#codeledger-auth-result(read by the extension's content script), with an origin-locked same-windowpostMessageas fallback — the extension listens for exactly this message type GET /api/auth/:providermints an HMAC-signedstate(10-minute TTL) into anHttpOnly; Secure; SameSite=Laxcookie; the callback verifies it in constant time before exchanging the code. A missing or stale cookie is a hard failure, not a warning.- Every route is documented in
docs/OPENAPI.yaml. Adding or changing one means changing the spec in the same commit.
- The extension's own full-page UI (sidebar and the expanded library tab), built from the same Preact components.
- Reads solve data from IndexedDB through
src/core/storage.js. - The code carries an
IS_EXTENSION = !!chrome.runtime?.idbranch for a GitHub-API-backed web build. That build is not deployed — the worker serves no/libraryroute, so the branch is currently dead. Do not describe the library as a hosted web app.
New code should call extension APIs through src/lib/browser-compat.js, which
is what makes a call work on both targets.
This is a direction, not a verified invariant: 31 files besides it still reach
for the namespaces directly — chrome.runtime.sendMessage in 51 places,
chrome.storage.local in 15, chrome.tabs.* across the tab helpers. It works
because Firefox MV3 aliases chrome.*, so treat those as debt rather than as
precedent, and do not read the wrapper as the only door.
Use createDebugger('HandlerName') from src/lib/debug.js. The .bind() trick preserves caller file+line in DevTools.
import { createDebugger } from "../../lib/debug.js";
const dbg = createDebugger("MyHandler");
dbg.log("message"); // shows at the correct source location in DevToolsThe extension root is src/. chrome.runtime.getURL('handlers/...') — no src/ prefix in the path. This is a common bug source.
No JSX. No webpack. No transpilation of our own code. Every UI file starts with:
import { h, render } from "../../vendor/preact-bundle.js";
import { useState, useEffect } from "../../vendor/preact-bundle.js";
import { htm } from "../../vendor/preact-bundle.js";
const html = htm.bind(h);src/vendor/preact-bundle.js is a generated file committed to the repo — an
esbuild bundle of the preact and htm packages installed from npm, produced by
npm run vendor:preact. It is not a CDN shim, and nothing at runtime fetches a
remote script: MV3's script-src 'self' would block that anyway, and a store
reviewer must be able to read every line that executes. chart-bundle.js is
built the same way. Never reintroduce an https://esm.sh (or any remote) import
— regenerate the bundle instead.
Worker delivers: { type: 'CODELEDGER_AUTH', provider: 'github', token: '...' } — primarily as the data-auth attribute of #codeledger-auth-result, with a same-window origin-locked postMessage fallback.
Extension listens for exactly data.type === 'CODELEDGER_AUTH'. Any mismatch silently drops the token.
- OAuth tokens:
Storage.setAuthToken(provider, token)→ stored atauth.tokens - AI API keys:
Storage.setAIKeys(map)→ stored atai.keys - Manual PAT:
settings['github_token'] GitHubHandler.getToken()checks OAuth path first, then settings PAT — order matters.- If a token is missing or invalid, stop the flow and prompt the user to reauthenticate instead of continuing with a partial login state.
When GitHub requests fail:
- Check rate limiting first — HTTP 403 with
x-ratelimit-remaining: 0means wait until reset time - Check transient errors — 5xx or 429 (too many requests) should retry once with exponential backoff
- Check auth issues — 401/403 (unauthorized) means token expired or is invalid; prompt for reauthentication
- Check payload issues — 422 (validation failed) or 400 (bad request) usually indicates malformed commit or tree payload
- Fallback to mirror — If primary target fails after retry, push to mirror repo (if configured) instead of blocking the user
Never silently drop errors. Always log the full response status, message, and headers; this is critical for debugging cross-device sync issues.
If commits fail, check in this order:
- Owner/repo are set correctly —
settings.github_ownerandsettings.github_repo(orgitRepolegacy) - Token is valid —
Storage.getAuthToken('github')is non-empty; if empty, OAuth failed - GitHub response status — Check response.status before parsing body; 404 usually means repo doesn't exist
- Files array is valid — Each file has
path(string),content(string); if missing, commit prep was incomplete - Base branch exists — the ref lookup fails in two different ways and they need different remedies:
- 409 means the repository has zero commits. It rejects every git-data
write,
POST /git/blobsandPOST /git/treesincluded, so no tree can be built yet. Seed it first withapi.bootstrapEmptyRepo(), which writes one file throughPUT /contents— the only endpoint an empty repo accepts — and then continue as the ordinary non-empty case. - 404 means the repo has commits but this branch does not exist. Build a
root commit (no
base_tree, noparents) and create the ref rather than patching it. Seeding is wrong here:PUT /contentsonto a branch that does not exist 404s.
- 409 means the repository has zero commits. It rejects every git-data
write,
If all checks pass but commit still fails, the issue is likely in the Trees API call itself (check OPENAPI.yaml for the exact endpoint contract).
Source of truth: docs/OPENAPI.yaml
When to update the spec:
- Adding new Worker endpoints or routes
- Changing request/response schemas, parameters, or status codes
- Modifying authentication methods or security schemes
- Updating server URLs (must match
CONSTANTS.URLSinsrc/core/constants.js)
Spec compliance rules:
- Every Worker route must be documented in
docs/OPENAPI.yaml - Code implementation must match the spec (path, method, parameters, response format)
- If Worker behavior changes, update the spec and the code in the same commit
- Run
npm run validate:openapi(or equivalent) to lint the spec for syntax errors - Use the spec as the source of truth for API contracts; never let code drift from documented behavior
- When implementing features that touch Worker routes, reference
docs/OPENAPI.yamlfirst
Consistent naming across the codebase reduces cognitive load and makes intent immediately clear. Follow these patterns strictly.
All handlers live in src/handlers/ and follow a strict structure:
-
Platform handlers:
src/handlers/platforms/{name}/index.js— the platform handler class extendingBasePlatformHandlerdom-selectors.js— DOM selectors, legacy selectors, and DOMAINS export (for manifest)page-detector.js— exportsdetectPage()andisSolveCapablePage()- Optional:
enhanced-selectors.jsfor version-specific overrides (e.g., LeetCode) - Examples:
leetcode/,geeksforgeeks/,codeforces/
-
AI provider handlers:
src/handlers/ai/{name}/index.js— AI handler class extendingBaseAIHandler; this is the only required file. Model listing is centralised insrc/core/model-fetch.js(fetchModelsForProvider), which reads the provider's entry inCONSTANTS.AI_PROVIDERS.openrouter/ships asindex.jsalone.- Examples:
gemini/,openai/,claude/,deepseek/,ollama/,openrouter/
-
Git repository handlers:
src/handlers/git/{name}/index.js— Git handler class extendingBaseGitHandlergithub/is the only one
-
Reusable components:
src/ui/components/{PascalCase}.js- One component per file; file name matches exported class/function name
- Examples:
GitHubOnboardingModal.js,DedupReviewQueue.js,AIReviewPanel.js - These are shared between extension sidebar and web app
-
Library views:
src/library/views/{PascalCase}View.js- Always end with
Viewsuffix to distinguish from generic components - Examples:
ProblemsView.js,AnalyticsView.js,SettingsPageView.js,AIChatsView.js,GraphView.js
- Always end with
-
Core modules:
src/core/{hyphen-case}.js- Examples:
constants.js,storage.js,event-bus.js,ai-deduplication.js,canonical-mapper.js,ai-prompts.js,duplicate-detector.js - Each core module handles one responsibility
- Examples:
-
Library utilities:
src/lib/{hyphen-case}.js- Examples:
debug.js,browser-compat.js
- Examples:
| Category | Style | Examples | Notes |
|---|---|---|---|
| Handler directories | kebab-case | leetcode, gemini, github |
All handlers indexed by name |
| Handler files | index.js |
index.js |
Standard entry point |
| Support files | kebab-case | dom-selectors.js, page-detector.js, graphql-queries.js |
Clear purpose from name |
| Components | PascalCase | GitHubOnboardingModal.js, ModelSelector.js |
React/Preact convention |
| Views | PascalCase + View | ProblemsView.js, SettingsPageView.js |
Distinguish from generic components |
| Core/lib modules | kebab-case | ai-deduplication.js, browser-compat.js |
Lowercase for utility modules |
| Storage keys | CONSTANT_CASE | CONSTANTS.SK.TELEMETRY_OPT_IN |
Via CONSTANTS.SK.* where it has an entry |
| CSS files | kebab-case | theme-variables.css |
Tailwind input files or compiled |
| Data files | kebab-case | canonical-map.json, metadata.json |
In src/data/ |
Prefer CONSTANTS.SK.* from src/core/constants.js over a bare string — but only
where the key has an entry there. SK covers the keys the extension itself
namespaces (TELEMETRY_OPT_IN, SYNC_STATE, BEHAVIOR_BANK, …). It has no
GITHUB_REPO, GITHUB_OWNER or GITHUB_TOKEN entry, and the GitHub settings
are read as plain keys off the settings map with a legacy fallback:
// ✅ Correct — these three have no SK entry; the fallback is the compatibility path
const repo = settings.github_repo || settings.gitRepo;
// ✅ Correct — this one does have an SK entry, so use it
const optIn = settings[CONSTANTS.SK.TELEMETRY_OPT_IN] === true;
// ❌ Wrong — invents an SK member that does not exist and reads undefined
const repo = settings[CONSTANTS.SK.GITHUB_REPO];Canonical storage paths (must never vary across modules):
- OAuth tokens:
auth.tokens[provider]viaStorage.setAuthToken(provider, token)— NOT settings - Settings:
chrome.storage.localviaStorage.setSettings(map) - AI keys:
ai.keys[provider]viaStorage.setAIKeys(map)— NOT settings - Problems: IndexedDB via
Storage.setProblems(problems)
Before reading or writing a storage key, validate it exists in CONSTANTS.SK; if a key is unsupported or missing, fall back to the documented constant instead of throwing. This prevents silent data loss on schema changes.
-
From extension root: Use relative paths with no
src/prefiximport { createDebugger } from "../../lib/debug.js"; import { BasePlatformHandler } from "../_base/BasePlatformHandler.js";
-
In the manifest paths: Use paths relative to
src/, withoutsrc/prefix"background": { "service_worker": "background/service-worker.js" } "content_scripts": [{ "js": ["content/handler-loader.js"] }]
-
In chrome.runtime.getURL(): Same as manifest paths — no
src/prefixconst url = chrome.runtime.getURL("handlers/platforms/leetcode/index.js");
When DOM selectors change between platform versions (e.g., LeetCode refactors):
- Base selectors live in
dom-selectors.js(current version) - Legacy selectors in same file via
LEGACY_SELECTORSexport - Platform handler tries base selectors first, then legacy fallback
- If multiple major versions need different selectors, create
enhanced-selectors.jsand conditionally load
Example pattern in dom-selectors.js:
export const SELECTORS = {
/* current */
};
export const LEGACY_SELECTORS = {
/* old versions */
};
export const DOMAINS = ["leetcode.com", "www.leetcode.com"];These conventions apply across all files. Inconsistency here causes silent commit failures.
| Key | Where stored | Canonical name | Notes |
|---|---|---|---|
| GitHub repo name | chrome.storage.local (via Storage.setSettings) |
github_repo |
Do NOT use gitRepo (legacy camelCase) — always use settings.github_repo || settings.gitRepo when reading for backwards compat |
| GitHub repo owner | chrome.storage.local |
github_owner |
Falls back to github_username then gitUser from API |
| GitHub PAT (manual) | chrome.storage.local |
github_token |
Only for legacy PAT — OAuth tokens go in auth.tokens |
| OAuth token | auth.tokens (via Storage.setAuthToken) |
accessed via Storage.getAuthToken("github") |
Never save OAuth tokens to settings |
When reading the repo name anywhere in the codebase, always use:
const repo = settings.github_repo || settings.gitRepo;Auth uses a classic GitHub OAuth App, not a GitHub App. This is load-bearing:
GitHub Apps silently ignore the scope parameter and issue expiring
user-to-server tokens that get 403 Resource not accessible by integration on
POST /user/repos — which is exactly the "permission denied while creating the
repository" a store reviewer hit. The callback detects an App-shaped token
response (expires_in/refresh_token present, scope absent) and reports the
misconfiguration at sign-in rather than letting it surface as a later 403.
| Secret name | Required | Source |
|---|---|---|
CODELEDGER_OAUTH_CLIENT_ID |
yes | OAuth App Client ID (Ov23li…; Iv23li… is a GitHub App) |
CODELEDGER_OAUTH_CLIENT_SECRET |
yes | OAuth App client secret |
SESSION_SECRET |
yes | 32 random bytes — signs the OAuth state cookie |
CANONICAL_UPLOAD_TOKEN |
no | 32 random bytes — guards POST /api/admin/canonical |
CODELEDGER_GH_APP_WEBHOOK_SECRET |
no | 32 random bytes — HMAC for POST /api/webhook/github |
The worker also accepts the older CODELEDGER_GH_APP_CLIENT_ID /
CODELEDGER_GH_APP_CLIENT_SECRET names as fallbacks so an existing deployment
keeps working; new setups should use the CODELEDGER_OAUTH_* names.
Sign-in returns 500 until SESSION_SECRET is set — the state cookie cannot be
signed without it, and an unsigned state is not accepted.
Set each one interactively (the command takes the name only and prompts for the value — never pass the secret as an argument, it lands in shell history):
cd worker && npx wrangler secret put CODELEDGER_OAUTH_CLIENT_IDGenerate the random ones locally. openssl is not on PATH in a default Windows
PowerShell; this works anywhere Node does:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"- Create
src/handlers/platforms/{name}/index.jsextendingBasePlatformHandler - Create
dom-selectors.jswith versionedSELECTORS,LEGACY_SELECTORS, andDOMAINSexport - Create
page-detector.jswithdetectPage()andisSolveCapablePage() - Add hostname match in
src/content/handler-loader.js - Run
node dev/generate-manifest-domains.jsto update host_permissions in both manifests - See
docs/guides/development/adding-platform-handler.mdfor full contract
- Create
src/handlers/ai/{name}/index.jsextendingBaseAIHandler - Add the provider config to
CONSTANTS.AI_PROVIDERSinsrc/core/constants.js— this is whatsrc/core/model-fetch.jsreads to list models, so no per-provider fetcher is needed - Import the class in
src/handlers/init.jsand add it to theaisarray;initializeHandlers()registers it and calls itsgetSettingsSchema()if it defines one - If the provider needs a non-standard response shape, override
fetchModels()on the handler rather than adding a new module
Two rules the handler has to respect, both because init.js is imported by the
service worker:
- Nothing an AI handler imports may touch the DOM. The worker has no
document. A handler that needs a page —manual, which asks a person — reaches its UI through an indirection installed at runtime (src/core/manual-bridge.js), never through a static import. - A provider that cannot answer without a person sets
requiresHuman: trueon itsAI_PROVIDERSentry. The worker filters those out of both provider chains it builds, and they must stay out ofAI_FALLBACK_CHAIN— a solve review runs with the tab possibly closed, so one entry that waits for input burns the 30s timeout and answers nothing. SetsupportsLiveFetch: falsealongside it if there is no model endpoint, or the generic listing path builds the relative URL/modelsand fetches the extension's own origin.
The GitHubOnboardingModal (src/ui/components/GitHubOnboardingModal.js) handles first-time repo setup:
- Create new repo: Uses
auto_init: false, then writes the first commit itself.initializeRepository()detects the empty repo (thegit/ref/heads/{branch}lookup fails) and callsbootstrapEmptyRepo()to give it a HEAD before building anything; the Trees API commit that follows then carries a normalbase_treeandparents. The init tree writesindex.json,index.htmland.gitignoreonly, so the placeholder README the seed writes survives until the first solve commit, whoseinfra-buildermerges the generated README over it. Noteapi-client.jscreateRepo()still passesauto_init: truefor the non-onboarding path — both work, but the two paths differ. - Repo init: Uses the Trees API (
POST /git/trees→POST /git/commits→PATCH /git/refs/heads/main) for atomic multi-file creation. Never use the Contents API (PUT /contents/) for a multi-file write — it creates one commit per file. The single exception isbootstrapEmptyRepo(): a repository with no commits 409s every git-data endpoint, soPUT /contentsis the only way to author its first commit. It goes throughutf8ToBase64()rather than barebtoa(), which throws above U+00FF (emoji). - Token flow: OAuth token is already saved to
auth.tokensby the time the modal opens (saved bylibrary.jshandleOAuthMessage). The modal should NOT re-save the token to settings. - Trigger: Only
library.jsshows the modal (viashowGitHubOnboardingstate). The Git settings panel does NOT trigger onboarding — it only stores the token and fetches the username.
The problem:solved event payload (emitted by platform handlers, consumed by service-worker):
{
title: string,
titleSlug: string,
platform: string, // a key of CONSTANTS.PLATFORMS
difficulty: "Easy" | "Medium" | "Hard",
lang: { name: string, ext: string, slug: string },
tags: string[],
topic: string, // first tag, used as folder path
timestamp: number, // Unix ms
code: string,
files: [{ path: string, content: string }], // pre-built by handler
aiReview: string, // populated by SW after AI review
runtime: string, memory: string, runtimePct: number, memoryPct: number,
elapsedSeconds: number, // solve time from floating timer (0 if timer unused)
}The files array drives the git commit. If absent, the SW builds the path through
src/core/path-builder.js, which is the single authority on layout:
with a canonical match: problems/{canonicalId}/{platform}/{platformId}.{ext}
without one: problems/{platformId}/{platformId}.{ext}
The topics/{topic}/… layout is the pre-1.5 one. It survives only in
migration-manager.js and the old-layout detection in service-worker.js, which
exist to move a repository off it — never write a new path in that shape. The SW
includes index.json in every commit it builds (position in the files array is
irrelevant — the Trees API takes an unordered set).
Version is canonical in three places that must always match:
package.json→"version"src/manifest-chromium.json→"version"src/manifest-firefox.json→"version"
The landing page states it twice more — the hero badge and the softwareVersion
in its structured data — and those are not gates, but they are public claims.
Source of truth for releases: package.json. node dev/sync-manifests.js copies
it into both manifests and both landing-page strings, and the CI release pipeline
validates the manifests match before tagging. The script exits non-zero if either
landing-page pattern stops matching, so the site cannot quietly fall behind.
- Add a
## [x.y.z] — YYYY-MM-DDsection todocs/CHANGELOG.md(Added / Fixed / Changed / Removed / Security). - Bump
package.json, then runnode dev/sync-manifests.jsto carry the version into both manifests and the landing page. - Run
npm run release(validates, builds, commits, tags, pushes all at once).- Or
npm run release -- --dry-runto preview first.
- Or
- GitHub Actions (
.github/workflows/release.yml) triggers automatically on tag push → creates GitHub Release with attached zips.
- Added — new features or capabilities
- Fixed — bug fixes
- Changed — behaviour changes in existing features
- Removed — removed features or APIs
- Security — vulnerabilities fixed (always document these)
When landing a feature mid-sprint, add it under ## [Unreleased] at the top of the file. Promote to a numbered section at release time.