Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: monthly
open-pull-requests-limit: 5
- package-ecosystem: github-actions
directory: /
schedule:
interval: monthly
open-pull-requests-limit: 5
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: CI

on:
pull_request:
push:
branches:
- master
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
verify:
name: Node.js 24 coordinated release gate
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Check out the repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24.20.0
package-manager-cache: false
- name: Use npm 12
run: npm install --global npm@12.0.2
- name: Install locked dependencies
run: npm ci --no-audit --no-fund
- name: Verify dependencies, runtime and coordinated candidates
run: npm run verify
172 changes: 172 additions & 0 deletions .github/workflows/publish-npm.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
name: Publish coordinated npm release

on:
workflow_dispatch:
inputs:
tag:
description: Annotated coordinated release tag, for example v0.6.0
required: true
type: string
manifest_sha256:
description: Accepted SHA-256 of spreadsheet-release-VERSION.json
required: true
type: string

permissions:
contents: read
id-token: write

concurrency:
group: npm-publish-${{ inputs.tag }}
cancel-in-progress: false

jobs:
publish:
name: Verify and publish six exact assets
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24.20.0
registry-url: https://registry.npmjs.org
package-manager-cache: false

- name: Install an OIDC-capable npm CLI
run: npm install --global npm@12.0.2

- name: Preflight and publish the coordinated release
env:
GH_TOKEN: ${{ github.token }}
INPUT_MANIFEST_SHA256: ${{ inputs.manifest_sha256 }}
INPUT_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail

tag="${INPUT_TAG}"
expected_manifest_sha256="${INPUT_MANIFEST_SHA256}"
if [[ ! "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "tag must match vMAJOR.MINOR.PATCH" >&2
exit 1
fi
if [[ ! "${expected_manifest_sha256}" =~ ^[0-9a-f]{64}$ ]]; then
echo "manifest_sha256 must contain 64 lowercase hexadecimal characters" >&2
exit 1
fi

version="${tag#v}"
manifest_name="spreadsheet-release-${version}.json"
release_json="${RUNNER_TEMP}/release.json"
ref_json="${RUNNER_TEMP}/tag-ref.json"
tag_json="${RUNNER_TEMP}/annotated-tag.json"
compare_json="${RUNNER_TEMP}/compare.json"
manifest="${RUNNER_TEMP}/${manifest_name}"
package_rows="${RUNNER_TEMP}/packages.tsv"

gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${tag}" > "${ref_json}"
# shellcheck disable=SC2016
annotated_tag_sha="$(node -e '
const fs = require("node:fs");
const ref = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
if (ref.object?.type !== "tag" || !/^[0-9a-f]{40}$/.test(ref.object?.sha)) throw new Error("release tag must be annotated");
process.stdout.write(ref.object.sha);
' "${ref_json}")"
gh api "repos/${GITHUB_REPOSITORY}/git/tags/${annotated_tag_sha}" > "${tag_json}"
# shellcheck disable=SC2016
tag_commit="$(node -e '
const fs = require("node:fs");
const [file, expectedTag] = process.argv.slice(1);
const tagObject = JSON.parse(fs.readFileSync(file, "utf8"));
if (tagObject.tag !== expectedTag || tagObject.object?.type !== "commit" || !/^[0-9a-f]{40}$/.test(tagObject.object?.sha)) throw new Error("annotated tag identity is invalid");
process.stdout.write(tagObject.object.sha);
' "${tag_json}" "${tag}")"
gh api "repos/${GITHUB_REPOSITORY}/compare/${tag_commit}...${GITHUB_SHA}" > "${compare_json}"
# shellcheck disable=SC2016
node -e '
const fs = require("node:fs");
const [file, expectedBase] = process.argv.slice(1);
const comparison = JSON.parse(fs.readFileSync(file, "utf8"));
if (!["ahead", "identical"].includes(comparison.status) || comparison.merge_base_commit?.sha !== expectedBase) throw new Error("tag commit is not contained in dispatched master");
' "${compare_json}" "${tag_commit}"

gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}" > "${release_json}"
# shellcheck disable=SC2016
manifest_url="$(node -e '
const fs = require("node:fs");
const [file, expectedTag, expectedName, expectedDigest] = process.argv.slice(1);
const release = JSON.parse(fs.readFileSync(file, "utf8"));
if (release.tag_name !== expectedTag || release.draft || release.prerelease || release.assets?.length !== 7) throw new Error("release state or asset count is invalid");
const asset = release.assets.find(candidate => candidate.name === expectedName);
if (!asset || asset.state !== "uploaded" || asset.digest !== `sha256:${expectedDigest}` || asset.size <= 0) throw new Error("release manifest asset is invalid");
process.stdout.write(asset.browser_download_url);
' "${release_json}" "${tag}" "${manifest_name}" "${expected_manifest_sha256}")"
curl --fail --location --proto '=https' --tlsv1.2 --output "${manifest}" "${manifest_url}"
actual_manifest_sha256="$(shasum -a 256 "${manifest}" | cut -d ' ' -f 1)"
test "${actual_manifest_sha256}" = "${expected_manifest_sha256}"

# shellcheck disable=SC2016
node -e '
const fs = require("node:fs");
const [manifestFile, releaseFile, rowsFile, expectedTag, expectedVersion, expectedRevision] = process.argv.slice(1);
const manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8"));
const release = JSON.parse(fs.readFileSync(releaseFile, "utf8"));
const names = ["@witqq/spreadsheet", "@witqq/spreadsheet-plugins", "@witqq/spreadsheet-react", "@witqq/spreadsheet-vue", "@witqq/spreadsheet-angular", "@witqq/spreadsheet-widget"];
if (manifest.schemaVersion !== 1 || manifest.tag !== expectedTag || manifest.version !== expectedVersion || manifest.sourceRevision !== expectedRevision) throw new Error("coordinated manifest identity is invalid");
if (JSON.stringify(manifest.publishOrder) !== JSON.stringify(names) || manifest.packages?.length !== names.length) throw new Error("publish order or package count is invalid");
const releaseAssets = new Map(release.assets.map(asset => [asset.name, asset]));
const rows = manifest.packages.map((item, index) => {
const expectedAsset = `${names[index].slice(1).replace("/", "-")}-${expectedVersion}.tgz`;
if (item.name !== names[index] || item.version !== undefined || item.asset !== expectedAsset || item.repositoryDirectory !== item.workspace || !/^packages\/(core|plugins|react|vue|angular|widget)$/.test(item.workspace) || !/^[0-9a-f]{64}$/.test(item.sha256) || !Number.isSafeInteger(item.size) || item.size <= 0) throw new Error(`manifest package ${index} is invalid`);
const asset = releaseAssets.get(item.asset);
if (!asset || asset.state !== "uploaded" || asset.digest !== `sha256:${item.sha256}` || asset.size !== item.size) throw new Error(`release asset ${item.asset} is invalid`);
return [index, item.name, item.workspace, item.asset, item.sha256, asset.browser_download_url].join("\t");
});
const expectedAssets = new Set([`spreadsheet-release-${expectedVersion}.json`, ...manifest.packages.map(item => item.asset)]);
if (releaseAssets.size !== expectedAssets.size || [...releaseAssets.keys()].some(name => !expectedAssets.has(name))) throw new Error("release contains an unexpected asset");
fs.writeFileSync(rowsFile, `${rows.join("\n")}\n`);
' "${manifest}" "${release_json}" "${package_rows}" "${tag}" "${version}" "${tag_commit}"

while IFS=$'\t' read -r index package_name workspace asset_name expected_sha256 asset_url; do
tarball="${RUNNER_TEMP}/${asset_name}"
package_manifest="${RUNNER_TEMP}/package-${index}.json"
curl --fail --location --proto '=https' --tlsv1.2 --output "${tarball}" "${asset_url}"
actual_sha256="$(shasum -a 256 "${tarball}" | cut -d ' ' -f 1)"
test "${actual_sha256}" = "${expected_sha256}"
tar -xOf "${tarball}" package/package.json > "${package_manifest}"
# shellcheck disable=SC2016
node -e '
const fs = require("node:fs");
const [file, expectedName, expectedVersion, expectedWorkspace] = process.argv.slice(1);
const manifest = JSON.parse(fs.readFileSync(file, "utf8"));
if (manifest.name !== expectedName || manifest.version !== expectedVersion || manifest.repository?.url !== "https://github.com/witqq/spreadsheet.git" || manifest.repository?.directory !== expectedWorkspace || manifest.engines?.node !== ">=24.20.0" || manifest.publishConfig?.access !== "public") throw new Error("package identity is invalid");
if (expectedName !== "@witqq/spreadsheet" && manifest.dependencies?.["@witqq/spreadsheet"] !== `^${expectedVersion}`) throw new Error("internal dependency version is invalid");
' "${package_manifest}" "${package_name}" "${version}" "${workspace}"
done < "${package_rows}"

while IFS=$'\t' read -r index package_name workspace asset_name expected_sha256 asset_url; do
existing_url="$(npm view "${package_name}@${version}" dist.tarball --json 2>/dev/null || true)"
if [[ -n "${existing_url}" && "${existing_url}" != "null" ]]; then
registry_url="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]))' "${existing_url}")"
registry_tarball="${RUNNER_TEMP}/registry-preflight-${index}.tgz"
curl --fail --location --proto '=https' --tlsv1.2 --output "${registry_tarball}" "${registry_url}"
registry_sha256="$(shasum -a 256 "${registry_tarball}" | cut -d ' ' -f 1)"
test "${registry_sha256}" = "${expected_sha256}"
fi
done < "${package_rows}"

while IFS=$'\t' read -r index package_name workspace asset_name expected_sha256 asset_url; do
if npm view "${package_name}@${version}" version --json >/dev/null 2>&1; then
echo "${package_name}@${version} already contains the accepted bytes; skipping"
else
npm publish --access public "${asset_url}"
fi
done < "${package_rows}"

while IFS=$'\t' read -r index package_name workspace asset_name expected_sha256 asset_url; do
registry_url="$(npm view "${package_name}@${version}" dist.tarball --json | node -e 'let value=""; process.stdin.on("data", chunk => value += chunk); process.stdin.on("end", () => process.stdout.write(JSON.parse(value)));')"
registry_tarball="${RUNNER_TEMP}/registry-final-${index}.tgz"
curl --fail --location --proto '=https' --tlsv1.2 --output "${registry_tarball}" "${registry_url}"
registry_sha256="$(shasum -a 256 "${registry_tarball}" | cut -d ' ' -f 1)"
test "${registry_sha256}" = "${expected_sha256}"
done < "${package_rows}"
2 changes: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,9 @@ moira-ws/
# Claude supervisor logs
.supervisor_logs/
.idea/
docker-compose.remote.yml
CLAUDE.md

# Internal files (not for public repo)
research/
deploy-logs/
.github/workflows/
tmp/
1 change: 1 addition & 0 deletions .node-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
24.20.0
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
24.20.0
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.6.0] — 2026-09-04

### Infrastructure

- Require Node.js 24.20.0 across the monorepo and all public package contracts.
- Update direct and transitive dependencies, add pinned CI and coordinated immutable GitHub Release asset publication for all six public packages through npm OpenID Connect trusted publishing.

### Added

- `DecoratorsPlugin`: built-in plugin bundling six reusable cell decorators — TreeExpander, SortIcon, ProgressBar, Link, Image, and Spinner. Configurable via `DecoratorsPluginConfig` (enable/disable individual decorators)
Expand Down Expand Up @@ -76,7 +83,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- `@witqq/spreadsheet-react`: peerDependencies now include `react: ^17.0.0` and `react-dom: ^17.0.0`. The wrapper uses only React ≤16-era hooks (`useEffect`, `useRef`, `useImperativeHandle`, `forwardRef`) and the automatic JSX transform (`react-jsx`) available since React 17.0.0
- `@witqq/spreadsheet-react`: peer dependencies support React and React DOM 17, 18, and 19. The wrapper uses only React ≤16-era hooks (`useEffect`, `useRef`, `useImperativeHandle`, `forwardRef`) and the automatic JSX transform (`react-jsx`) available since React 17.0.0
- `DatePickerEditor`: refactored from thin adapter (delegating to `DatePickerOverlay`) to direct `BaseOverlayEditor` subclass. Public API unchanged
- `DateTimeEditor`: refactored from monolithic 821-line class to ~300-line `BaseOverlayEditor` subclass. Public API unchanged
- `DatePickerOverlay`: internal utility functions replaced with imports from `calendar-utils` module. Public API unchanged
Expand Down
8 changes: 5 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
# Contributing to witqq spreadsheet

Thank you for your interest in contributing! For full documentation, interactive demos, and API reference, visit the [project website](https://witqq.dev).
Thank you for your interest in contributing! For full documentation, interactive demos, and API reference, visit the [project website](https://spreadsheet.witqq.dev).

## Getting Started

Node.js 24.20.0 or newer and npm 12.0.2 are required for development.

1. Fork the repository
2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/wit-table.git`
2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/spreadsheet.git`
3. Install dependencies: `npm install`
4. Start the development server: `npm run dev`

Expand Down Expand Up @@ -37,7 +39,7 @@ npm run docs:npm # Generate npm package docs from site MDX

1. Create a feature branch from `master`
2. Make your changes with tests
3. Run `npm run build && npm run test` to verify
3. Run `npm run verify` to execute the complete release gate
4. Submit a pull request

## Code Style
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Canvas-based spreadsheet and datagrid engine for React, Vue, Angular, and vanilla JS. Zero external dependencies in the core package.

[![CI](https://github.com/witqq/spreadsheet/actions/workflows/ci.yml/badge.svg)](https://github.com/witqq/spreadsheet/actions/workflows/ci.yml)

```bash
npm install @witqq/spreadsheet @witqq/spreadsheet-react
```
Expand Down Expand Up @@ -96,6 +98,8 @@ Key areas:

## Development

Node.js 24.20.0 or newer is required. Development, CI and release checks use npm 12.0.2.

```bash
npm install # Install dependencies
npm run build # Build all packages
Expand All @@ -106,6 +110,8 @@ npm run lint # ESLint
npm run dev # Docker dev server on port 3150
```

Maintainers use the coordinated immutable-asset process in [`docs/RELEASE.md`](docs/RELEASE.md) to release all six public packages together.

## License

[BSL 1.1](LICENSE) — Free for non-commercial use. Commercial use requires a paid license. Change Date: 2030-03-01 → Apache License 2.0.
Expand Down
11 changes: 9 additions & 2 deletions config/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
# syntax=docker/dockerfile:1

FROM node:20 AS deps
FROM node:24.20.0-bookworm AS deps
RUN npm install --global npm@12.0.2
WORKDIR /app
COPY package*.json ./
COPY packages/core/package.json packages/core/
COPY packages/react/package.json packages/react/
COPY packages/vue/package.json packages/vue/
COPY packages/angular/package.json packages/angular/
COPY packages/widget/package.json packages/widget/
COPY packages/plugins/package.json packages/plugins/
COPY packages/server/package.json packages/server/
COPY packages/demo/package.json packages/demo/
Expand All @@ -21,13 +25,16 @@ RUN --mount=type=cache,target=/root/.npm \
FROM deps AS build
COPY packages/core/ packages/core/
COPY packages/react/ packages/react/
COPY packages/vue/ packages/vue/
COPY packages/angular/ packages/angular/
COPY packages/widget/ packages/widget/
COPY packages/plugins/ packages/plugins/
COPY packages/server/ packages/server/
COPY packages/demo/ packages/demo/
COPY packages/site/ packages/site/
COPY tsconfig*.json ./
ARG BUILD_TIME=unknown
RUN npm run build && npm run build -w packages/demo && npm run build -w packages/site
RUN npm run build

FROM nginx:alpine
ARG BUILD_TIME=unknown
Expand Down
8 changes: 7 additions & 1 deletion config/Dockerfile.collab
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
# syntax=docker/dockerfile:1

FROM node:20-slim
FROM node:24.20.0-bookworm-slim
RUN npm install --global npm@12.0.2
WORKDIR /app
COPY package*.json ./
COPY packages/core/package.json packages/core/
COPY packages/react/package.json packages/react/
COPY packages/vue/package.json packages/vue/
COPY packages/angular/package.json packages/angular/
COPY packages/widget/package.json packages/widget/
COPY packages/plugins/package.json packages/plugins/
COPY packages/server/package.json packages/server/
COPY packages/demo/package.json packages/demo/
COPY packages/site/package.json packages/site/
RUN --mount=type=cache,target=/root/.npm \
npm ci --ignore-scripts
COPY packages/core/ packages/core/
Expand Down
Loading