From d97f758bc4e6de798a7b8b5525a17860410185a9 Mon Sep 17 00:00:00 2001 From: Eric Charles Date: Sat, 26 Sep 2026 05:01:44 +0200 Subject: [PATCH] 1.0.3, one version everywhere; CI and tag-driven release; bump script; a wheel that builds from a plain checkout Versions: datalayer_decks/__version__.py is the one source (hatch reads it; the CLI and extension manifests import it), the three package.json files are copies, and dev/bump_version.py (make bump-patch|minor|major) moves all four or none. Aligned on 1.0.3, above every published number (npm @datalayer/decks 1.0.2, decks-plugin-ai-agents 1.0.1; PyPI has no datalayer-decks yet). ci.yaml builds and tests both halves on push and pull request; release.yaml publishes on a v* tag through trusted publishing (environments pypi and npm): the wheel and sdist of datalayer-decks, @datalayer/decks and @datalayer/decks-plugin-ai-agents. Both check the reactor out beside the repository at REACTOR_REF, the layout the TypeScript configuration resolves @datalayer/reactor from. RELEASE.md documents it. What a plain checkout lacked and the workspace hoisted, found by building from clean exports: @types/node (the typecheck covers src/__tests__); a process shim, stream, assert and prettier 3's estree plugin reachable from the plugin's own node_modules (absolute aliases in app/rsbuild.config.ts, the primer-addons checkout alias now conditional on the checkout being there); the federation entry named remoteEntry.js as extension.py expects (@module-federation/rsbuild-plugin 2.x hashes it by default); and the built share/ halves declared as hatch artifacts, without which the git-ignored files were left out of the wheel's shared-data. repository and publishConfig on the two published package.json files, for npm provenance. --- .github/workflows/ci.yaml | 112 +++++++++++++++ .github/workflows/release.yaml | 239 +++++++++++++++++++++++++++++++++ .gitignore | 2 + Makefile | 14 ++ RELEASE.md | 68 ++++++++++ app/package.json | 5 +- app/rsbuild.config.ts | 31 ++++- datalayer_decks/__init__.py | 2 + datalayer_decks/__version__.py | 7 + datalayer_decks/cli.py | 3 +- datalayer_decks/extension.py | 7 +- dev/bump_version.py | 166 +++++++++++++++++++++++ extension/rsbuild.config.ts | 4 + package.json | 10 +- plugins/ai-agents/package.json | 6 +- pyproject.toml | 20 ++- 16 files changed, 686 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/release.yaml create mode 100644 RELEASE.md create mode 100644 datalayer_decks/__version__.py create mode 100644 dev/bump_version.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..8dc91bc --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,112 @@ +# Copyright (c) 2022-2026 Datalayer, Inc. +# +# Datalayer License + +# Builds and tests both halves on every push and pull request to main: the +# TypeScript package, its plugin, the interface and the Module Federation +# container (what the wheel carries), and the Python package. The reactor is +# checked out beside this repository, because the TypeScript configuration +# resolves `@datalayer/reactor` from `../reactor` (its source for the type +# check and the tests, its built lib/ for the build) — the layout of the +# Datalayer workspace, reproduced here. See release.yaml for the same build +# feeding a release. + +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + # The reactor checked out beside this repository, as ../reactor. Kept at + # the release the package floors on (pyproject.toml, package.json). + REACTOR_REF: v1.0.4 + +jobs: + typescript: + name: TypeScript, interface and container + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + path: decks + - uses: actions/checkout@v4 + with: + repository: datalayer/reactor + ref: ${{ env.REACTOR_REF }} + path: reactor + - uses: actions/setup-node@v4 + with: + node-version: '24' + + - name: Build the reactor beside this repository + working-directory: reactor + run: | + npm install --no-audit --no-fund + npm run build + + - name: Install + working-directory: decks + run: npm install --no-audit --no-fund + + - name: Type-check against the reactor source + working-directory: decks + run: npm run typecheck + + - name: Build the package (lib/) + working-directory: decks + run: npm run build:lib + + - name: Test + working-directory: decks + run: npm test + + - name: Build the AI agents plugin + working-directory: decks/plugins/ai-agents + run: | + npm install --no-audit --no-fund + npm run build + + - name: Build the interface and the container (share/) + working-directory: decks + run: | + (cd app && npm install --no-audit --no-fund) + npm run app:build + npm run extension:build + test -f share/datalayer/reactor/apps/decks/index.html + test -f share/datalayer/reactor/extensions/decks/remoteEntry.js + + python: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.10', '3.12', '3.13'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install + # The JavaScript halves are the other job's; an editable install + # never builds them (hatch_build.py), and the tests do not need them. + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[test]" + - name: Test + run: python -m pytest tests -q + - name: The command and the entry points + run: | + datalayer-decks --help + python -c "from datalayer_decks import __version__; print(__version__)" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..b881aa3 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,239 @@ +# Copyright (c) 2022-2026 Datalayer, Inc. +# +# Datalayer License + +# Publishes the release a tag names: the wheel and sdist of datalayer-decks +# to PyPI, and @datalayer/decks and @datalayer/decks-plugin-ai-agents to +# npm, with no stored token — both registries trust this workflow file +# (OIDC trusted publishing, through the `pypi` and `npm` environments). +# Every package carries the same version (datalayer_decks/__version__.py +# and the package.json copies; `make bump-patch` moves them together), +# which the tag must name; a package already published at that version is +# skipped, so a re-run after a partial upload publishes the rest. +# @datalayer/decks-app is private and travels inside the wheel instead. +# See RELEASE.md. + +name: Release + +on: + push: + tags: + - 'v*' + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +env: + REACTOR_REF: v1.0.4 + # The npm packages this workflow releases: the root package first (the + # plugin depends on it as a peer of the same host). + NPM_PACKAGES: . plugins/ai-agents + +jobs: + build: + runs-on: ubuntu-latest + outputs: + npm: ${{ steps.plan.outputs.npm }} + python: ${{ steps.plan.outputs.python }} + steps: + - uses: actions/checkout@v4 + with: + path: decks + - uses: actions/checkout@v4 + with: + repository: datalayer/reactor + ref: ${{ env.REACTOR_REF }} + path: reactor + - uses: actions/setup-node@v4 + with: + node-version: '24' + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Check the tag names the version every package carries + working-directory: decks + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME#v}" + py_version="$(python -c "import re;print(re.search(r'__version__\s*=\s*\"([^\"]+)\"', open('datalayer_decks/__version__.py').read())[1])")" + echo "tag=$tag datalayer-decks=$py_version" + test "$tag" = "$py_version" + for pkg in $NPM_PACKAGES app; do + name="$(node -p "require('./$pkg/package.json').name")" + version="$(node -p "require('./$pkg/package.json').version")" + echo "tag=$tag $name=$version" + test "$tag" = "$version" + done + + - name: Find what is not published yet + id: plan + working-directory: decks + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + npm_todo="" + for pkg in $NPM_PACKAGES; do + name="$(node -p "require('./$pkg/package.json').name")" + if npm view "$name@$version" version >/dev/null 2>&1; then + echo "npm: $name@$version is already published, skipping" + else + echo "npm: $name@$version will be published" + npm_todo="$npm_todo $pkg" + fi + done + echo "npm=${npm_todo# }" >> "$GITHUB_OUTPUT" + status="$(curl -sL -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/datalayer-decks/$version/json")" + if [ "$status" = "200" ]; then + echo "PyPI: datalayer-decks $version is already published, skipping" + echo "python=" >> "$GITHUB_OUTPUT" + else + echo "PyPI: datalayer-decks $version will be published" + echo "python=yes" >> "$GITHUB_OUTPUT" + fi + + - name: Build the reactor beside this repository + if: steps.plan.outputs.npm != '' || steps.plan.outputs.python != '' + working-directory: reactor + run: | + npm install --no-audit --no-fund + npm run build + + - name: Build and test the TypeScript package and its plugin + if: steps.plan.outputs.npm != '' || steps.plan.outputs.python != '' + working-directory: decks + run: | + set -euo pipefail + npm install --no-audit --no-fund + npm run typecheck + npm run build:lib + npm test + (cd plugins/ai-agents && npm install --no-audit --no-fund && npm run build) + + - name: Build the interface and the container (what the wheel carries) + if: steps.plan.outputs.python != '' + working-directory: decks + run: | + set -euo pipefail + (cd app && npm install --no-audit --no-fund) + npm run app:build + npm run extension:build + test -f share/datalayer/reactor/apps/decks/index.html + test -f share/datalayer/reactor/extensions/decks/remoteEntry.js + + - name: Pack the npm packages + if: steps.plan.outputs.npm != '' + working-directory: decks + run: | + set -euo pipefail + mkdir -p dist-npm + for pkg in ${{ steps.plan.outputs.npm }}; do + (cd "$pkg" && npm pack --pack-destination "$GITHUB_WORKSPACE/decks/dist-npm") + done + ls -la dist-npm + + - name: Build the Python package + if: steps.plan.outputs.python != '' + working-directory: decks + run: | + set -euo pipefail + python -m pip install --upgrade pip build + python -m pip install -e ".[test]" + python -m pytest tests -q + # share/ is built above, so hatch_build.py has nothing to build. + python -m build --sdist --wheel --outdir dist-py . + ls -la dist-py + python - <<'PY' + import glob, zipfile + names = zipfile.ZipFile(glob.glob("dist-py/*.whl")[0]).namelist() + assert any(n.endswith("apps/decks/index.html") for n in names), "the wheel lacks the interface" + assert any(n.endswith("extensions/decks/remoteEntry.js") for n in names), "the wheel lacks the container" + print("the wheel carries the interface and the container") + PY + + - uses: actions/upload-artifact@v4 + if: steps.plan.outputs.npm != '' + with: + name: npm + path: decks/dist-npm/*.tgz + + - uses: actions/upload-artifact@v4 + if: steps.plan.outputs.python != '' + with: + name: python + path: decks/dist-py/* + + pypi: + needs: build + if: needs.build.outputs.python != '' + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: python + path: dist + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + skip-existing: true + + npm: + needs: build + if: needs.build.outputs.npm != '' + runs-on: ubuntu-latest + environment: npm + permissions: + id-token: write + steps: + - uses: actions/setup-node@v4 + with: + node-version: '24' + registry-url: 'https://registry.npmjs.org' + # Trusted publishing needs npm 11.5.1 or later. + - run: npm install -g npm@^11.5.1 && npm --version + - uses: actions/download-artifact@v4 + with: + name: npm + path: dist + - name: Publish + run: | + set -euo pipefail + # @datalayer/decks first: the plugin is used beside it. + for tarball in $(ls dist/datalayer-decks-[0-9]*.tgz 2>/dev/null) $(ls dist/*.tgz | grep -v '/datalayer-decks-[0-9]'); do + name="$(tar -xOzf "$tarball" package/package.json | node -p "JSON.parse(require('fs').readFileSync(0)).name")" + version="$(tar -xOzf "$tarball" package/package.json | node -p "JSON.parse(require('fs').readFileSync(0)).version")" + if npm view "$name@$version" version >/dev/null 2>&1; then + echo "$name@$version is already published, skipping" + continue + fi + # "./": npm reads a bare "dist/x.tgz" as the GitHub shorthand + # github:dist/x.tgz and refuses to fetch it (EALLOWGIT). + npm publish "./$tarball" --provenance --access public + done + + release: + name: GitHub release + needs: [build, pypi, npm] + if: always() && needs.build.result == 'success' && needs.pypi.result != 'failure' && needs.npm.result != 'failure' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Create the release with generated notes + run: | + version="${GITHUB_REF_NAME#v}" + gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 && exit 0 + gh release create "$GITHUB_REF_NAME" --title "Release $version" --generate-notes \ + --notes "- [PyPI datalayer-decks](https://pypi.org/project/datalayer-decks/$version/) + - [npm @datalayer/decks](https://www.npmjs.com/package/@datalayer/decks/v/$version) · [@datalayer/decks-plugin-ai-agents](https://www.npmjs.com/package/@datalayer/decks-plugin-ai-agents/v/$version)" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index cbef4a1..ef66bf3 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ __pycache__/ *.egg-info/ .pytest_cache/ build/ +dist-py/ +dist-npm/ diff --git a/Makefile b/Makefile index 3162c03..f54be36 100644 --- a/Makefile +++ b/Makefile @@ -25,6 +25,20 @@ build: build-app build-extension ## build everything the wheel carries wheel: build ## build the wheel, JavaScript included python -m build --wheel +.PHONY: bump bump-patch bump-minor bump-major + +bump: ## bump the version in __version__.py and every package.json, asking which part + python dev/bump_version.py + +bump-patch: ## bump the patch version (1.0.3 -> 1.0.4) + python dev/bump_version.py patch + +bump-minor: ## bump the minor version (1.0.3 -> 1.1.0) + python dev/bump_version.py minor + +bump-major: ## bump the major version (1.0.3 -> 2.0.0) + python dev/bump_version.py major + typecheck: ## type-check the package against the reactor source npm run typecheck diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..7d2841d --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,68 @@ + + +# Making a release + +One tag releases every package, with no stored token: PyPI and npm trust +`.github/workflows/release.yaml` through OIDC (trusted publishing). + +| Package | Registry | GitHub environment | +| ----------------------------------- | -------- | ------------------ | +| `datalayer-decks` | PyPI | `pypi` | +| `@datalayer/decks` | npm | `npm` | +| `@datalayer/decks-plugin-ai-agents` | npm | `npm` | + +`@datalayer/decks-app` is private: the interface it builds travels inside the +wheel (`share/datalayer/reactor/apps/decks`), it is not published on its own. + +Every package carries **one version**. It lives in +`datalayer_decks/__version__.py` — hatch reads it, and so do the plugin +manifests — and the three `package.json` files carry copies. +`dev/bump_version.py` moves all four together, or refuses: + +```bash +make bump-patch # 1.0.3 -> 1.0.4 +make bump-minor # 1.0.3 -> 1.1.0 +make bump-major # 1.0.3 -> 2.0.0 +``` + +## Steps + +1. `make bump-patch` (or minor, major) on a branch, and open a pull request. + CI (`ci.yaml`) builds and tests both halves. + +1. Merge, then tag the merge commit and push the tag: + + ```bash + git checkout main && git pull + git tag vX.Y.Z + git push origin vX.Y.Z + ``` + +1. The `Release` workflow checks that the tag names the version every package + carries, builds the reactor beside the checkout (the TypeScript + configuration resolves `@datalayer/reactor` from `../reactor`, as in the + Datalayer workspace), builds and tests the package and its plugin, builds + the interface and the container into `share/`, builds the wheel and sdist + with them inside, publishes what is not on the registries yet, and creates + a GitHub release with generated notes. + +The reactor checked out beside the repository is the release the package +floors on: `REACTOR_REF` in both workflows, to move with the floors in +`pyproject.toml` and `package.json`. + +## Trusted publishing + +- PyPI project `datalayer-decks`: owner `datalayer`, repository `decks`, + workflow `release.yaml`, environment `pypi`. The project does not exist on + PyPI until the first release, so it is registered as a *pending* publisher. +- npm packages `@datalayer/decks` and `@datalayer/decks-plugin-ai-agents`: + GitHub Actions, organization `datalayer`, repository `decks`, workflow + filename `release.yaml`, environment `npm`. npm provenance also checks each + `package.json`'s `repository.url`, which names this repository. + +The registry matches the repository, the workflow filename and the +environment exactly; renaming any of them means re-registering. diff --git a/app/package.json b/app/package.json index 3cd842c..310f4de 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "@datalayer/decks-app", - "version": "1.0.0", + "version": "1.0.3", "private": true, "description": "The Datalayer Decks interface: a Reactor shell with the decks plugin, built into the wheel", "type": "module", @@ -31,7 +31,10 @@ "@rsbuild/plugin-styled-components": "^1.7.0", "@types/react": "19.2.18", "@types/react-dom": "19.2.5", + "assert": "^2.1.0", + "prettier": "^3.3.0", "process": "^0.11.10", + "stream-browserify": "^3.0.0", "typescript": "5.9.3" } } diff --git a/app/rsbuild.config.ts b/app/rsbuild.config.ts index 9f41614..ce8899c 100644 --- a/app/rsbuild.config.ts +++ b/app/rsbuild.config.ts @@ -20,6 +20,7 @@ import { defineConfig, rspack } from '@rsbuild/core'; import { pluginReact } from '@rsbuild/plugin-react'; import { pluginStyledComponents } from '@rsbuild/plugin-styled-components'; +import fs from 'node:fs'; import path from 'node:path'; export default defineConfig({ @@ -51,7 +52,15 @@ export default defineConfig({ * configuration rather than a new guess. */ rspack: { - plugins: [new rspack.ProvidePlugin({ process: 'process/browser' })], + // Named by absolute path: the plugin's own node_modules (a plain + // checkout installs one; the workspace hoists everything) carry + // modules that read `process` too, and a bare 'process/browser' + // resolves from where each module lives, not from here. + plugins: [ + new rspack.ProvidePlugin({ + process: path.resolve(import.meta.dirname, 'node_modules/process/browser.js'), + }), + ], }, /* * What JupyterLab needs from a bundler, for the one slide that runs a @@ -122,12 +131,30 @@ export default defineConfig({ // published package under the same version. Two copies would be two // theme stores; the older copy would be missing exports. A prefix, so // `@datalayer/primer-addons/lib/reactor` maps too (no `exports` map). - '@datalayer/primer-addons': path.resolve(import.meta.dirname, '../../../primer/addons'), + // ... when that checkout is there; a plain checkout (CI, a release + // build) has only the published package, which is then the one copy. + ...(fs.existsSync(path.resolve(import.meta.dirname, '../../../primer/addons')) + ? { '@datalayer/primer-addons': path.resolve(import.meta.dirname, '../../../primer/addons') } + : {}), // `@datalayer/core`'s navigation adapter does `require('next/navigation')` // inside a try/catch, for hosts that are Next.js applications. This one // is not, but rspack resolves the require all the same and drags Next // — and its Node-only dependencies — into a browser bundle. An empty // module is what the adapter expects to find when Next is absent. + // Node-shaped requests from the Jupyter stack (plotly's probe-image-size + // wants `stream` and `assert`): named by absolute path, because a plain + // checkout resolves each from the plugin's own node_modules, where + // neither is, while the workspace hoists them all to one place. + 'stream$': path.resolve(import.meta.dirname, 'node_modules/stream-browserify/index.js'), + 'assert$': path.resolve(import.meta.dirname, 'node_modules/assert/build/assert.js'), + // jupyter-lexical's formatter imports prettier 2's `parser-*` modules, + // which its own prettier provides, and prettier 3's `plugins/estree`, + // which only a prettier 3 has: this one. Exact, so the `parser-*` + // requests keep resolving to the prettier beside jupyter-lexical. + 'prettier/plugins/estree$': path.resolve( + import.meta.dirname, + 'node_modules/prettier/plugins/estree.js', + ), 'next/navigation': false, 'next/router': false, 'styled-components$': path.resolve( diff --git a/datalayer_decks/__init__.py b/datalayer_decks/__init__.py index cbdd22d..d24f7b1 100644 --- a/datalayer_decks/__init__.py +++ b/datalayer_decks/__init__.py @@ -4,6 +4,7 @@ """Datalayer Decks: presentations described as data, served by one command.""" +from .__version__ import __version__ from .api import build_decks_router from .extension import DecksPlugin, extension from .host import APP_NAME, create_app, main, ui_directory @@ -11,6 +12,7 @@ __all__ = [ "APP_NAME", + "__version__", "DeckRecord", "DeckStore", "DecksPlugin", diff --git a/datalayer_decks/__version__.py b/datalayer_decks/__version__.py new file mode 100644 index 0000000..4560de1 --- /dev/null +++ b/datalayer_decks/__version__.py @@ -0,0 +1,7 @@ +# Copyright (c) 2022-2026 Datalayer, Inc. +# +# Datalayer License + +"""The version, read by hatch (pyproject.toml) and by every manifest this package declares.""" + +__version__ = "1.0.3" diff --git a/datalayer_decks/cli.py b/datalayer_decks/cli.py index 9998682..87f4bf3 100644 --- a/datalayer_decks/cli.py +++ b/datalayer_decks/cli.py @@ -31,6 +31,7 @@ import yaml from reactor import PluginManifest +from .__version__ import __version__ from .storage import DeckNotFound, DeckStore app = typer.Typer( @@ -226,7 +227,7 @@ def delete( #: The identity of the extension, for the reactor that hosts the CLI. manifest = PluginManifest( name="decks-cli", - version="1.0.0", + version=__version__, description="The `decks` command group: serve, list, show, delete.", author="Datalayer", tags=["cli", "decks"], diff --git a/datalayer_decks/extension.py b/datalayer_decks/extension.py index f47d08a..c60772b 100644 --- a/datalayer_decks/extension.py +++ b/datalayer_decks/extension.py @@ -28,6 +28,7 @@ find_extension_frontend, ) +from .__version__ import __version__ from .api import build_decks_router from .commands import register_deck_commands from .storage import DeckStore @@ -42,7 +43,7 @@ DECKS_PLUGIN_MANIFEST = PluginManifest( name="decks", - version="1.0.0", + version=__version__, display_name="Decks", description="Stores and serves decks as JSON specifications.", ) @@ -98,7 +99,7 @@ def extension(store: DeckStore | None = None) -> ReactorExtension: plugins=[ FrontendPlugin( name="@datalayer/decks", - version="1.0.0", + version=__version__, display_name="Decks", description="List, open and create decks.", octicon="file-media", @@ -109,7 +110,7 @@ def extension(store: DeckStore | None = None) -> ReactorExtension: return ReactorExtension( manifest=ExtensionManifest( name="decks", - version="1.0.0", + version=__version__, display_name="Decks", description="Presentations described as data.", octicon="file-media", diff --git a/dev/bump_version.py b/dev/bump_version.py new file mode 100644 index 0000000..438da9b --- /dev/null +++ b/dev/bump_version.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +# Copyright (c) 2022-2026 Datalayer, Inc. +# +# Datalayer License + +"""Bump the version, in every file that carries a copy of it. + +The version lives in four places. `datalayer_decks/__version__.py` is the +source: hatch reads it (`[tool.hatch.version] path` in pyproject.toml), and +so do the plugin manifests the Python package declares. The three +`package.json` files -- the published `@datalayer/decks`, the published +`@datalayer/decks-plugin-ai-agents`, and the private `@datalayer/decks-app` +-- are copies, and a copy that gets missed ships a package whose manifest +disagrees with the wheel it travels with. + + python dev/bump_version.py patch # 1.0.3 -> 1.0.4 + python dev/bump_version.py minor # 1.0.3 -> 1.1.0 + python dev/bump_version.py major # 1.0.3 -> 2.0.0 + python dev/bump_version.py # asks + +Nothing is written unless **every** file can be updated. Either all of them +move or none do. + +@module dev.bump_version +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +#: The file the version is read *from*. Hatch reads it too, which is what +#: makes it the source rather than a fourth copy. +SOURCE = ROOT / "datalayer_decks" / "__version__.py" + +#: The copies: every package.json in the repository that carries the version. +COPIES = ( + ROOT / "package.json", + ROOT / "plugins" / "ai-agents" / "package.json", + ROOT / "app" / "package.json", +) + +PARTS = ("major", "minor", "patch") + + +class Unbumpable(Exception): + """A file this script cannot update, named with what it expected. + + Raised before anything is written. + """ + + +def read_version() -> str: + text = SOURCE.read_text() + match = re.search(r'__version__\s*=\s*"([^"]+)"', text) + if match is None: + raise Unbumpable(f'{SOURCE.relative_to(ROOT)} has no __version__ = "..."') + return match.group(1) + + +def bump(version: str, part: str) -> str: + """The next version, or a refusal naming what it could not read. + + Only `major.minor.patch`; a pre-release tag would need rules about what + bumping means for it, and guessing one is worse than saying so. + """ + match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)", version) + if match is None: + raise Unbumpable( + f"{version!r} is not major.minor.patch; bump it by hand and say " + "here what the next one should be" + ) + major, minor, patch = (int(value) for value in match.groups()) + if part == "major": + return f"{major + 1}.0.0" + if part == "minor": + return f"{major}.{minor + 1}.0" + return f"{major}.{minor}.{patch + 1}" + + +def _replace_once(path: Path, pattern: str, replacement: str, current: str) -> str: + """One substitution, refusing zero and refusing more than one. + + Zero means the file's format changed and this script would be editing + nothing while reporting success. More than one means the pattern is + matching something else as well -- a dependency's version, say. + """ + text = path.read_text() + found = re.findall(pattern, text, flags=re.M) + if len(found) != 1: + raise Unbumpable( + f"{path.relative_to(ROOT)}: expected exactly one " + f"{current!r} matching {pattern!r}, found {len(found)}" + ) + return re.sub(pattern, replacement, text, count=1, flags=re.M) + + +def planned_edits(current: str, new: str) -> dict[Path, str]: + """Every file's new content, or an exception. Nothing is written here.""" + escaped = re.escape(current) + edits: dict[Path, str] = {} + edits[SOURCE] = _replace_once( + SOURCE, rf'(__version__\s*=\s*")({escaped})(")', rf"\g<1>{new}\g<3>", current + ) + for path in COPIES: + # The package's own "version" is the only top-level one; a + # dependency's version sits under "dependencies" and is never a bare + # `"version": "x.y.z"` key, so exactly one match is the healthy case. + edits[path] = _replace_once( + path, rf'(^ "version"\s*:\s*")({escaped})(")', rf"\g<1>{new}\g<3>", current + ) + return edits + + +def check_json(edits: dict[Path, str]) -> None: + for path, text in edits.items(): + if path.suffix == ".json": + try: + json.loads(text) + except ValueError as error: + raise Unbumpable(f"{path.relative_to(ROOT)} would not parse: {error}") + + +def ask() -> str: + print(f"Current version: {read_version()}") + for index, part in enumerate(PARTS, start=1): + print(f" {index}) {part:5s} -> {bump(read_version(), part)}") + while True: + answer = input("Which? [major/minor/patch] ").strip().lower() + if answer in PARTS: + return answer + if answer in ("1", "2", "3"): + return PARTS[int(answer) - 1] + print(f"Say one of: {', '.join(PARTS)}") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("part", nargs="?", choices=PARTS, help="what to bump") + parser.add_argument("--dry-run", action="store_true", help="say what would change and write nothing") + arguments = parser.parse_args(argv) + try: + current = read_version() + part = arguments.part or ask() + new = bump(current, part) + edits = planned_edits(current, new) + check_json(edits) + except Unbumpable as error: + print(f"Refused: {error}", file=sys.stderr) + print("Nothing was written.", file=sys.stderr) + return 1 + for path, text in sorted(edits.items()): + if not arguments.dry_run: + path.write_text(text) + print(f"{'would bump' if arguments.dry_run else 'bumped'} {path.relative_to(ROOT)}") + print(f"{current} -> {new}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/extension/rsbuild.config.ts b/extension/rsbuild.config.ts index 9db6389..e73affe 100644 --- a/extension/rsbuild.config.ts +++ b/extension/rsbuild.config.ts @@ -34,6 +34,10 @@ export default defineConfig({ pluginReact(), pluginModuleFederation({ name: 'datalayer_decks', + // Named, because `datalayer_decks/extension.py` publishes exactly this + // file as the container's entry; the plugin's default is a hashed + // `static/js/datalayer_decks..js` that nothing would find. + filename: 'remoteEntry.js', exposes: { './plugin': './src/plugin.ts' }, shared: { react: { singleton: true, requiredVersion: '^19.0.0' }, diff --git a/package.json b/package.json index d6d18aa..42c1707 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,13 @@ { "name": "@datalayer/decks", - "version": "1.0.2", + "version": "1.0.3", + "repository": { + "type": "git", + "url": "https://github.com/datalayer/decks.git" + }, + "publishConfig": { + "access": "public" + }, "description": "Presentations described as data: a spec-driven Reveal.js deck engine, and the Reactor plugin that lists, opens and creates decks in any Reactor shell.", "license": "SEE LICENSE IN LICENSE", "type": "module", @@ -69,6 +76,7 @@ "react-dom": ">=18" }, "devDependencies": { + "@types/node": "^24", "@module-federation/rsbuild-plugin": "^2.9.0", "@rsbuild/core": "^2.2.1", "@rsbuild/plugin-react": "^1.4.6", diff --git a/plugins/ai-agents/package.json b/plugins/ai-agents/package.json index 7b668ae..ff7d737 100644 --- a/plugins/ai-agents/package.json +++ b/plugins/ai-agents/package.json @@ -1,6 +1,10 @@ { "name": "@datalayer/decks-plugin-ai-agents", - "version": "1.0.1", + "version": "1.0.3", + "repository": { + "type": "git", + "url": "https://github.com/datalayer/decks.git" + }, "publishConfig": { "access": "public" }, diff --git a/pyproject.toml b/pyproject.toml index adabffb..2c2e817 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "hatchling.build" [project] name = "datalayer_decks" -version = "1.0.0" +dynamic = ["version"] description = "Datalayer Decks: presentations described as data, served with their Reactor interface by one command" readme = "README.md" requires-python = ">=3.10" @@ -39,8 +39,19 @@ decks = "datalayer_decks.cli:plugin" [project.entry-points."datalayer.reactor.extensions"] decks = "datalayer_decks.extension:extension" +# The version lives in datalayer_decks/__version__.py; dev/bump_version.py moves +# it and every package.json copy together (`make bump-patch`). +[tool.hatch.version] +path = "datalayer_decks/__version__.py" + [tool.hatch.build.targets.wheel] packages = ["datalayer_decks"] +# The built halves are git-ignored, and hatch leaves ignored files out of +# shared-data unless they are declared as build artifacts. +artifacts = [ + "share/datalayer/reactor/apps/decks", + "share/datalayer/reactor/extensions/decks", +] # The JavaScript halves are built into share/ when the wheel is built and # they are missing; see hatch_build.py. @@ -74,6 +85,13 @@ include = [ "LICENSE", ] exclude = ["app/node_modules", "extension/node_modules", "**/dist"] +# The sdist carries the built halves too (git-ignored, so declared): a +# wheel built from it — which is what `python -m build` does — then needs +# neither npm nor the reactor beside it. +artifacts = [ + "share/datalayer/reactor/apps/decks", + "share/datalayer/reactor/extensions/decks", +] [tool.pytest.ini_options] testpaths = ["tests"]