diff --git a/.claude/skills/release-shinyreact/SKILL.md b/.claude/skills/release-shinyreact/SKILL.md index 65bc06b5..b321b654 100644 --- a/.claude/skills/release-shinyreact/SKILL.md +++ b/.claude/skills/release-shinyreact/SKILL.md @@ -11,7 +11,7 @@ does **not** oblige releasing the others. | Package | Version lives in | Tag | Automation | |---|---|---|---| | `shinyreact` (PyPI) | `pyproject.toml` (repo root) `version` | `py/v1.2.3` | `.github/workflows/release-py.yaml` | -| `@posit-dev/shinyreact` (npm) | `pkg-js/package.json` `version` | `js/v1.2.3` | `.github/workflows/release-js.yaml` | +| `@posit-dev/shinyreact` (npm) | `pkg-js/package.json` `version` | `js/v1.2.3` | `.github/workflows/release-js.yaml` — **stages only, a human approves** | | `shinyreact` (R) | `pkg-r/DESCRIPTION` `Version` | `r/v1.2.3` | none — CRAN is a manual submission | The `/v` prefix is what keeps the three apart; nothing else distinguishes @@ -55,16 +55,70 @@ Trusted publishing must be configured once at ## JS → npm -1. Bump `version` in `pkg-js/package.json`. Open a PR, merge it. +Unlike PyPI, **CI cannot publish npm** — it can only stage. The tag kicks off a +build that uploads a pending tarball; a human then approves it with 2FA. Two +steps, and the second one is not yours to do. + +1. Bump `version` in `pkg-js/package.json` (`cd pkg-js && npm version 1.2.3 + --no-git-tag-version` also updates `package-lock.json`). Open a PR, merge it. 2. `git tag js/v1.2.3 && git push origin js/v1.2.3`. 3. `release-js.yaml` verifies the tag matches `package.json`, lints, tests, - builds (IIFE + npm ESM + types), and runs `npm publish --provenance`. - Requires the `NPM_TOKEN` repository secret. + builds (IIFE + npm ESM + types), and runs `npm stage publish`. Nothing is on + npm yet. The job summary ends with the approval commands. +4. **Hand off to the human.** Report the run URL and stop — do not try to + approve. Approval needs proof of presence (2FA), which an OIDC token cannot + provide: + + ```bash + npm stage list @posit-dev/shinyreact # find the stage id + npm stage view # inspect what CI built + npm stage download # or pull the actual tarball + npm stage approve --otp # publishes it + npm stage reject # throws it away + ``` + + The Staged Packages tab on + does the same thing in a browser; 2FA is prompted either way. A stage is not + on the registry and nobody can install it, so a bad build is rejected rather + than unpublished — there is no 72-hour `npm unpublish` window to race. + +There is **no `NPM_TOKEN`**, and adding one would be a regression — npm's own +guidance is "when trusted publishing is available for your workflow, always +prefer it over long-lived tokens", and direct publishing with a granular token +is being removed entirely in January 2027. Auth is npm trusted publishing +(OIDC): configured once on the package's Settings page against org +`posit-dev`, this repo, workflow filename `release-js.yaml`, and +`npm stage publish` as the *allowed action* — so the workflow cannot publish +directly even if someone edits it. Provenance is automatic under OIDC; the +workflow passes no `--provenance`. + +Settings → Publishing access is also set to **"Require two-factor +authentication and disallow tokens"**. That closes the side door a token would +open without touching CI, since trusted publishing is OIDC rather than a +token. npm calls stage-only trusted publishing plus disallowed tokens the +maximum security posture; if you ever find yourself creating a token to work +around a release, that is the thing you are undoing. + +Requires npm ≥ 11.15.0 and Node ≥ 22.14.0. `pkg-js/.nvmrc` pins Node 22, which +bundles npm 10.x, so the workflow upgrades npm explicitly — if a release fails +with an unknown `stage` command, that step is why. A JS release is usually paired with a Python and R release, since the packages ship the same bundle — but they are separate versions and separate tags. Do the `make update-dist` PR first so all three ship the same code. +### The two things staging cannot do + +- **A brand-new package cannot be staged**, and trusted publishing cannot be + configured until the package exists. `0.1.0` was therefore published by hand + from a maintainer's laptop — `npm login` and `npm publish --access public`, + interactively with 2FA, **no token created**. It has no `js/v0.1.0` tag + (pushing one would only have triggered a job that failed on a version already + taken) and no provenance attestation, since that requires a CI publish. Every + release from `0.1.1` on follows the flow above. +- **A new scope or a new package name** puts you back in that bootstrap case: + publish once interactively, then configure the trusted publisher. + ## R → CRAN No workflow. CRAN submission is interactive and cannot be automated by a tag. @@ -95,3 +149,7 @@ produces the same tidyverse checklist. Nothing is automated post-release. Check the artifact actually landed (`pip index versions shinyreact`, `npm view @posit-dev/shinyreact version`) and say so — a green workflow with a skipped publish step is the usual failure. + +For npm, a green workflow means *staged*, not published: `npm view` will keep +reporting the previous version until a human runs `npm stage approve`. Don't +report a JS release as done on the strength of a green run. diff --git a/.github/workflows/release-js.yaml b/.github/workflows/release-js.yaml index 979abb7f..a35d1f40 100644 --- a/.github/workflows/release-js.yaml +++ b/.github/workflows/release-js.yaml @@ -1,8 +1,18 @@ name: release-js -# Publishes @posit-dev/shinyreact to npm. Trigger manually (workflow_dispatch) or -# by pushing a tag like js/v0.0.1 (the `js/` prefix distinguishes it from the -# Python and R release tags). Requires the NPM_TOKEN repository secret. +# Stages @posit-dev/shinyreact for npm. Trigger manually (workflow_dispatch) or +# by pushing a tag like js/v0.1.0 (the `js/` prefix distinguishes it from the +# Python and R release tags). +# +# This job does NOT publish. It runs `npm stage publish`, which uploads the +# tarball and leaves it pending until a human approves it — see the JS section +# of .claude/skills/release-shinyreact/SKILL.md. Approval requires proof of +# presence (2FA) and so cannot be done by CI. +# +# There is no NPM_TOKEN. Auth is npm trusted publishing (OIDC), configured at +# https://www.npmjs.com/package/@posit-dev/shinyreact/access against this +# workflow filename with `npm stage publish` as the allowed action. Provenance +# is automatic under OIDC; `--provenance` is not needed. on: workflow_dispatch: push: @@ -11,10 +21,10 @@ on: permissions: contents: read - id-token: write # npm provenance + id-token: write # npm trusted publishing (OIDC) + provenance jobs: - publish: + stage: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -31,6 +41,11 @@ jobs: node-version: "${{ steps.nvm.outputs.version }}" registry-url: "https://registry.npmjs.org" + # Node 22 bundles npm 10.x; `npm stage publish` and OIDC auth need 11.15+. + # Pinned to the major so an npm 12 cannot break a release unannounced. + - name: 🆕 Upgrade npm + run: npm install -g npm@11 + - name: 🔍 Check tag matches package version if: startsWith(github.ref, 'refs/tags/') working-directory: pkg-js @@ -44,8 +59,6 @@ jobs: - name: 🆙 Install dependencies run: make js-setup-ci - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: 🧪 Lint and test run: | @@ -56,8 +69,26 @@ jobs: working-directory: pkg-js run: npm run build - - name: 🚀 Publish @posit-dev/shinyreact + - name: 🚀 Stage @posit-dev/shinyreact working-directory: pkg-js - run: npm publish --access public --provenance - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm stage publish + + # `npm stage list` is not run here: only `npm stage publish` accepts an + # OIDC token, every other stage subcommand needs proof of presence. + - name: 📋 How to approve + run: | + { + echo "### Staged, not published" + echo + echo "Nothing is on npm yet, and nobody can install this. Approve or" + echo "reject it — 2FA is required, so CI cannot:" + echo + echo '```' + echo 'npm stage list @posit-dev/shinyreact' + echo 'npm stage view ' + echo 'npm stage approve --otp # publish it' + echo 'npm stage reject # throw it away' + echo '```' + echo + echo "Or the Staged Packages tab at ." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/pkg-js/README.md b/pkg-js/README.md new file mode 100644 index 00000000..9c304c7a --- /dev/null +++ b/pkg-js/README.md @@ -0,0 +1,117 @@ +# @posit-dev/shinyreact + + + +[![check-js](https://github.com/posit-dev/shinyreact/actions/workflows/check-js.yaml/badge.svg)](https://github.com/posit-dev/shinyreact/actions/workflows/check-js.yaml) +[![npm](https://img.shields.io/npm/v/@posit-dev/shinyreact)](https://www.npmjs.com/package/@posit-dev/shinyreact) + + +React hooks and components for [Shiny](https://shiny.posit.co/). The Shiny server — [Python](https://posit-dev.github.io/shinyreact/py/) or [R](https://posit-dev.github.io/shinyreact/r/) — contains only reactive computation, and the UI is a React client you own. This package is the bridge, and it ships zero UI components: no buttons, no layout, no theme. You bring the component library. + +One React client works against an `app.py` or an `app.R` server unchanged. The [full site](https://posit-dev.github.io/shinyreact/) covers all three packages. + +## Do you need this package? + +Only if you use a bundler. shinyreact apps come in two tiers: + +- **No build step** — the server serves the same runtime as an IIFE and the client reads it off `window.shinyreact`. Nothing to install; you can stop reading. +- **Bundler** (Vite, esbuild, webpack) — `npm install` this package and `import` the hooks. You get types, tree-shaking, a development React with Fast Refresh, and your own dependency graph. + +Both tiers are built from the same source and speak the same protocol version. + +## Installation + +```bash +npm install @posit-dev/shinyreact react react-dom +``` + +React 19 is a peer dependency, resolved by your bundler. + +Then tell the page entry point that the client brings its own copy, so the server does not also serve the IIFE: + +```python +set_react_page(shinyreact_js="client") # or page_react(), page_react_html(), ReactApp() +``` + +```r +page_react(shinyreact_js = "client") +``` + +Skip that and the app still works — the registries are page-scoped, so both copies share one set of inputs, outputs, and message handlers — but the page downloads and parses a second React for nothing. The bundle logs a `console.warn` when it detects this. + +## Usage + +```jsx +import { useShinyInput, useShinyOutputValue } from "@posit-dev/shinyreact"; +import "@posit-dev/shinyreact/styles"; +import { createRoot } from "react-dom/client"; + +function App() { + const [name, setName] = useShinyInput("name", "world"); + const greeting = useShinyOutputValue("greeting"); + + return ( +
+ setName(e.target.value)} /> +

{greeting}

+
+ ); +} + +// The page entry points emit no mount container, so make one. +createRoot(document.body.appendChild(document.createElement("div"))).render( + , +); +``` + +`useShinyInput` writes to `input.name()` / `input$name` on the server; `useShinyOutputValue` reads whatever the matching `reactive_output` returned: + +```python +@reactive_output +def greeting(): + return f"Hello, {input.name()}!" +``` + +```r +output$greeting <- reactive_output({ + paste0("Hello, ", input$name, "!") +}) +``` + +The `"./styles"` export is only needed for components that ship CSS (`ImageOutput`'s placeholder spinner); the hooks have no styles of their own. + +## API + +| | | +| -------------- | ---------------------------------------------------------------------- | +| **Inputs** | `useShinyInput` · `useShinyInputValue` · `useSetShinyInput` | +| **Outputs** | `useShinyOutputValue` · `useShinyOutputStatus` · `useShinyOutputError` | +| **Messaging** | `useShinyMessageHandler` | +| **Session** | `useShinyInitialized` · `useShinyBusy` | +| **Components** | `ShinyOutput` · `ImageOutput` · `ShinyModuleProvider` | +| **Utilities** | `MISSING` · `PROTOCOL_VERSION` | + +Pick the narrowest hook that fits the call site: a button that pushes events but never reads its own state wants `useSetShinyInput`, not `useShinyInput` with a discarded value. + +`ShinyOutput` renders a traditional Shiny output element — a `shiny-data-frame`, a plotly widget — inside a React tree, and handles binding for you. + +Full signatures and types: [JS API reference](https://posit-dev.github.io/shinyreact/js/). + +## Try it + +```bash +git clone https://github.com/posit-dev/shinyreact +cd shinyreact/examples/11-npm-local +shiny run app.py # builds the client bundle on first run +``` + +## Learn more + +- [Get started](https://posit-dev.github.io/shinyreact/) walks through the `ui.tsx` pattern: inputs, outputs, messages, and embedding traditional Shiny renderers. +- [TSX files and JavaScript build tools](https://posit-dev.github.io/shinyreact/articles/tsx-and-build-tools.html) explains `.tsx`, JSX, TypeScript, and what `npm run build` does. +- [Client hooks](https://posit-dev.github.io/shinyreact/articles/hooks.html) documents every hook in detail. +- The [examples catalog](https://github.com/posit-dev/shinyreact/blob/main/examples/README.md) lists runnable apps from no-build to Vite + HMR. + +## License + +MIT © Posit Software, PBC diff --git a/pkg-js/package-lock.json b/pkg-js/package-lock.json index 9dc47f25..f8097b27 100644 --- a/pkg-js/package-lock.json +++ b/pkg-js/package-lock.json @@ -1,12 +1,12 @@ { "name": "@posit-dev/shinyreact", - "version": "0.0.1", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@posit-dev/shinyreact", - "version": "0.0.1", + "version": "0.1.1", "license": "MIT", "devDependencies": { "@posit/shiny": "^1.11.1", diff --git a/pkg-js/package.json b/pkg-js/package.json index 59dde0c2..6c46e447 100644 --- a/pkg-js/package.json +++ b/pkg-js/package.json @@ -1,8 +1,10 @@ { "name": "@posit-dev/shinyreact", - "version": "0.0.1", + "version": "0.1.1", "description": "React hooks and components for Shiny (shinyreact) — the ui.tsx pattern's client runtime", "license": "MIT", + "homepage": "https://posit-dev.github.io/shinyreact/js/", + "bugs": "https://github.com/posit-dev/shinyreact/issues", "repository": { "type": "git", "url": "git+https://github.com/posit-dev/shinyreact.git",