From 878a08c1be7f14cb09f7daec4f37b5c20f21b4bd Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Mon, 19 Oct 2026 05:37:29 +0100 Subject: [PATCH 01/11] Publish on version tags, isolate write permissions in workflows, add publint - Publish runs when a `v*` tag is pushed, after admins run `npm version` and push the commit and tag together, so version commits land on `main`. It checks that the tag matches `package.json`, is on `main`, and isn't published yet. - Publish builds in a read-only job and publishes `lib/` from a separate job, so no dependency code runs with `id-token: write`. - Update Screenshots regenerates screenshots in a read-only job, and removes the label and pushes from separate jobs that run no dependencies. - `vp pack` lints the packed package with publint. - Remove `skipLibCheck` from `tsconfig.vite.json` and `tsconfig.website.json`. - Document the workflow conventions and the release steps in AGENTS.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/publish.yml | 94 +++++++++++++++++------- .github/workflows/update-screenshots.yml | 90 +++++++++++++++++++---- AGENTS.md | 4 +- package-lock.json | 69 +++++++++++++++++ package.json | 1 + tsconfig.vite.json | 3 +- tsconfig.website.json | 3 +- vite.config.ts | 2 + 8 files changed, 219 insertions(+), 47 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 33f6f58d81..45284f0e8b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,12 +1,15 @@ name: Publish +# To publish a version, admins run on an up-to-date `main`: +# npm version +# git push --atomic --follow-tags origin main +# +# `npm version` commits the new version and tags it, +# and the push updates `main` and the tag together, which runs this workflow. on: - workflow_dispatch: - inputs: - version: - description: 'Version to publish (e.g., 0.1.1, 0.2.0, 1.0.0)' - required: true - type: string + push: + tags: + - 'v*' permissions: {} @@ -21,49 +24,86 @@ concurrency: cancel-in-progress: false jobs: - publish: - if: github.ref == 'refs/heads/main' + # dependencies only run in this read-only job, so they cannot mint an npm publishing token + build: runs-on: ubuntu-latest timeout-minutes: 10 permissions: - contents: write # push the version tag and create the GitHub release - id-token: write # npm trusted publishing - - env: - VERSION: ${{ inputs.version }} + contents: read # check out the repository steps: - # credentials are persisted to push the version tag + # the full history includes `main`, to check that the tag is on it - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + persist-credentials: false - uses: voidzero-dev/setup-vp@68f39c78aaf39be468cabd2afb09c162574c84af # v1.21.2 with: run-install: false - registry-url: 'https://registry.npmjs.org' - - run: vp install --frozen-lockfile - - # before `vp run ci`, so `vp pack` prefixes classes with the new version - - name: Update version + # fails early, before `vp run ci` + - name: Check version run: | - git config user.name "$GITHUB_ACTOR" - git config user.email "$GITHUB_ACTOR_ID+$GITHUB_ACTOR@users.noreply.github.com" - npm version "$VERSION" + VERSION=$(jq --raw-output .version package.json) + if [ "$GITHUB_REF_NAME" != "v$VERSION" ]; then + echo "::error::Tag $GITHUB_REF_NAME does not match version $VERSION in package.json" + exit 1 + fi + if ! git merge-base --is-ancestor HEAD origin/main; then + echo "::error::Tag $GITHUB_REF_NAME is not on main" + exit 1 + fi + if npm view "react-data-grid@$VERSION" version > /dev/null 2>&1; then + echo "::error::Version $VERSION is already published to npm" + exit 1 + fi + + - run: vp install --frozen-lockfile - run: vp run ci timeout-minutes: 6 + - name: Upload package + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: lib + path: lib/ + if-no-files-found: error + retention-days: 1 + + publish: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 5 + + permissions: + contents: write # create the GitHub release + id-token: write # npm trusted publishing + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: voidzero-dev/setup-vp@68f39c78aaf39be468cabd2afb09c162574c84af # v1.21.2 + with: + run-install: false + registry-url: 'https://registry.npmjs.org' + + - name: Download package + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: lib + path: lib + # https://docs.npmjs.com/trusted-publishers + # `--ignore-scripts` skips the `prepare` script, so no project code runs either - name: Publish to npm - run: npm publish --tag latest - - - name: Push tag - run: git push origin "v$VERSION" + run: npm publish --tag latest --ignore-scripts - name: Create GitHub Release - run: gh release create "v$VERSION" --title "v$VERSION" --generate-notes + run: gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --generate-notes --verify-tag env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/update-screenshots.yml b/.github/workflows/update-screenshots.yml index 5371a3ff16..14ab678cca 100644 --- a/.github/workflows/update-screenshots.yml +++ b/.github/workflows/update-screenshots.yml @@ -12,48 +12,108 @@ cache-mode: none env: FORCE_COLOR: 1 +# dependencies only run in the read-only `screenshots` job, +# the jobs with write permissions only run gh and git jobs: - update-screenshots: + remove-label: + if: github.event.label.name == 'Update Screenshots' + runs-on: ubuntu-latest + timeout-minutes: 5 + + permissions: + pull-requests: write # remove the label + + steps: + - name: Remove label + run: gh pr edit "$PR_URL" --remove-label 'Update Screenshots' + env: + GH_TOKEN: ${{ github.token }} + PR_URL: ${{ github.event.pull_request.html_url }} + + screenshots: if: ${{ github.event.label.name == 'Update Screenshots' || github.event_name == 'workflow_dispatch' }} runs-on: ubuntu-latest timeout-minutes: 10 permissions: - contents: write # push the new screenshots - pull-requests: write # remove the label + contents: read # check out the repository # one at a time per branch concurrency: group: update-screenshots@${{ github.head_ref || github.ref }} cancel-in-progress: true + outputs: + commit: ${{ steps.checkout.outputs.commit }} + changed: ${{ steps.changes.outputs.changed }} + steps: - # credentials are persisted to push the new screenshots - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - id: checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.head_ref }} + persist-credentials: false - uses: voidzero-dev/setup-vp@68f39c78aaf39be468cabd2afb09c162574c84af # v1.21.2 with: run-install: false - - name: Remove label - if: github.event_name == 'pull_request' - run: gh pr edit --remove-label 'Update Screenshots' - env: - GH_TOKEN: ${{ github.token }} - - run: vp install --frozen-lockfile - - run: rm -r test/**/screenshots/** + - run: rm -r test/visual/screenshots - run: vp run test test/visual --update timeout-minutes: 4 + - name: Check for changes + id: changes + run: | + if [ -n "$(git status --porcelain -- test/visual/screenshots)" ]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Upload screenshots + if: steps.changes.outputs.changed == 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: screenshots + path: test/visual/screenshots/ + if-no-files-found: error + retention-days: 1 + + push: + needs: screenshots + if: needs.screenshots.outputs.changed == 'true' + runs-on: ubuntu-latest + timeout-minutes: 5 + + permissions: + contents: write # push the new screenshots + + steps: + # the commit the screenshots were taken on, so the push fails if the branch has moved since + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.screenshots.outputs.commit }} + persist-credentials: false + + # removed screenshots are deleted too + - run: rm -r test/visual/screenshots + + - name: Download screenshots + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: screenshots + path: test/visual/screenshots + - name: Push new screenshots run: | git config user.name "$GITHUB_ACTOR" git config user.email "$GITHUB_ACTOR_ID+$GITHUB_ACTOR@users.noreply.github.com" - git add "test/**/screenshots/**" - git diff-index --quiet HEAD || git commit -m "Update screenshots" - git push + git add test/visual/screenshots + git commit -m "Update screenshots" + gh auth setup-git + git push origin "HEAD:refs/heads/$BRANCH" + env: + BRANCH: ${{ github.head_ref || github.ref_name }} + GH_TOKEN: ${{ github.token }} diff --git a/AGENTS.md b/AGENTS.md index bf5a5b7d88..a00450a6c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,9 @@ website/ # demo site (Vite + TanStack Router) - **Dual classnames** — components apply both a semantic class (`rdg-cell`) and a generated hash. Preserve both. - **Light/dark mode** — handled via CSS `light-dark()` + `color-scheme`, not JS. - **Accessibility first** — ARIA attributes (e.g. `aria-colindex`, `aria-rowindex`, `aria-selected`, roles) are required. Tests query by role. -- **Build** — `vp pack` bundles the library to `lib/`; `ecij` plugin prefixes classes with `rdg-{version}-` (dots→dashes) to avoid cross-version conflicts. +- **Build** — `vp pack` bundles the library to `lib/`, then lints the packed package with publint; `ecij` plugin prefixes classes with `rdg-{version}-` (dots→dashes) to avoid cross-version conflicts. +- **Workflows** — pin every action to a full commit SHA with its version in a comment (Dependabot updates both), pass expressions to `run` steps through `env` instead of inlining `${{ }}`, grant each job only the `permissions` it uses, and check out with `persist-credentials: false`. Jobs that install dependencies only get read permissions: publishing, pushing, and editing PRs happen in separate jobs that receive build output as artifacts and never run dependency code. +- **Releases** — the header of `.github/workflows/publish.yml` explains how to publish. Its read-only `build` job hands `lib/` to the `publish` job, so no dependency code runs with the `id-token: write` permission that npm's trusted publishing relies on. ## Testing diff --git a/package-lock.json b/package-lock.json index 73f3e78b8d..14340ec2b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "jspdf-autotable": "^5.0.7", "oxc-transform-react": "^0.153.0", "playwright": "^1.62.1", + "publint": "^0.3.25", "react": "^19.3.0", "react-dom": "^19.3.0", "typescript": "^7.0.2", @@ -1785,6 +1786,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@publint/pack": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@publint/pack/-/pack-0.1.7.tgz", + "integrity": "sha512-4EDEmvxWtgsCnnVeBvtFIFZtUhPPt1+bA9JrSwU4Sa//6oKtzCSlGGXYJr44OD9aGISymbieJ4mCKHUygUDU+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyexec": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://bjornlu.com/sponsor" + } + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", @@ -6337,6 +6354,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -6607,6 +6634,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-manager-detector": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.9.0.tgz", + "integrity": "sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==", + "dev": true, + "license": "MIT" + }, "node_modules/pako": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", @@ -6788,6 +6822,28 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/publint": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/publint/-/publint-0.3.25.tgz", + "integrity": "sha512-WMMbn/d7JqRlfXQL/JUI4n5skC8cJ5WVr8/wT5mmiYWkUdENkZr1I5rgs2+dQPVRgoJd+JRYtB46MTsuDylw9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@publint/pack": "^0.1.7", + "package-manager-detector": "^1.8.0", + "picocolors": "^1.1.1", + "sade": "^1.8.1" + }, + "bin": { + "publint": "src/cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://bjornlu.com/sponsor" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -6927,6 +6983,19 @@ "@rolldown/binding-win32-x64-msvc": "1.2.13" } }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/scheduler": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", diff --git a/package.json b/package.json index 6bef34c33e..14398e94b2 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "jspdf-autotable": "^5.0.7", "oxc-transform-react": "^0.153.0", "playwright": "^1.62.1", + "publint": "^0.3.25", "react": "^19.3.0", "react-dom": "^19.3.0", "typescript": "^7.0.2", diff --git a/tsconfig.vite.json b/tsconfig.vite.json index 6ca1f39909..1498a6dd94 100644 --- a/tsconfig.vite.json +++ b/tsconfig.vite.json @@ -1,8 +1,7 @@ { "extends": "./tsconfig.base.json", "compilerOptions": { - "lib": ["ESNext", "DOM"], - "skipLibCheck": true + "lib": ["ESNext", "DOM"] }, "include": ["package.json", "vite.config.ts"] } diff --git a/tsconfig.website.json b/tsconfig.website.json index becf2b7502..2fb6f9fc43 100644 --- a/tsconfig.website.json +++ b/tsconfig.website.json @@ -1,8 +1,7 @@ { "extends": "./tsconfig.base.json", "compilerOptions": { - "lib": ["ESNext", "DOM"], - "skipLibCheck": true + "lib": ["ESNext", "DOM"] }, "include": ["src/css.d.ts", "website/**/*"], "references": [{ "path": "tsconfig.src.json" }] diff --git a/vite.config.ts b/vite.config.ts index 54aaac3a41..6fc250f889 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -201,6 +201,8 @@ export default defineConfig({ build: true, tsconfig: './tsconfig.src.json' }, + // lint the packed package, `failOnWarn` also applies to its warnings + publint: true, plugins: [ ecij({ // We add the package version as prefix to avoid style conflicts From 3f61a4cbe52e4525bf653bf2b47909922a2421f4 Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Mon, 19 Oct 2026 15:45:53 +0100 Subject: [PATCH 02/11] publint: fail on warnings Co-Authored-By: Claude Opus 5.5 (1M context) --- vite.config.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index 6fc250f889..39f77d9cc4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -201,8 +201,10 @@ export default defineConfig({ build: true, tsconfig: './tsconfig.src.json' }, - // lint the packed package, `failOnWarn` also applies to its warnings - publint: true, + // lint the packed package, its warnings are errors + publint: { + strict: true + }, plugins: [ ecij({ // We add the package version as prefix to avoid style conflicts From 16f20b26049438926529cbd729e8fe53fe5201cd Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Mon, 19 Oct 2026 15:55:08 +0100 Subject: [PATCH 03/11] Remove the main, module, browser, and types fields `exports` covers every entry point, and publint suggested replacing `browser` with an `exports` condition. Tools that ignore `exports`, like TypeScript's `node10` module resolution, webpack 4, and Jest before 28, no longer resolve the package. Co-Authored-By: Claude Opus 5.5 (1M context) --- package.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/package.json b/package.json index 14398e94b2..13e736138e 100644 --- a/package.json +++ b/package.json @@ -21,10 +21,6 @@ "src/index.ts", "**/*.css" ], - "main": "./lib/index.js", - "module": "./lib/index.js", - "browser": "./lib/index.js", - "types": "./lib/index.d.ts", "exports": { "./lib/styles.css": "./lib/styles.css", ".": { From 29cb42ec5b4ebfed50c90d3bb646757b91dfe488 Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Mon, 19 Oct 2026 15:58:02 +0100 Subject: [PATCH 04/11] remove redundant comment --- vite.config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/vite.config.ts b/vite.config.ts index 39f77d9cc4..2672801fe7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -201,7 +201,6 @@ export default defineConfig({ build: true, tsconfig: './tsconfig.src.json' }, - // lint the packed package, its warnings are errors publint: { strict: true }, From 24e01235e6abbf28959020f45da65f3d53784891 Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Mon, 19 Oct 2026 16:10:26 +0100 Subject: [PATCH 05/11] Update Screenshots: match screenshots in any test directory again Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/update-screenshots.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/update-screenshots.yml b/.github/workflows/update-screenshots.yml index 14ab678cca..2f4855a44d 100644 --- a/.github/workflows/update-screenshots.yml +++ b/.github/workflows/update-screenshots.yml @@ -60,7 +60,7 @@ jobs: - run: vp install --frozen-lockfile - - run: rm -r test/visual/screenshots + - run: rm -r test/**/screenshots/** - run: vp run test test/visual --update timeout-minutes: 4 @@ -68,7 +68,7 @@ jobs: - name: Check for changes id: changes run: | - if [ -n "$(git status --porcelain -- test/visual/screenshots)" ]; then + if [ -n "$(git status --porcelain -- "test/**/screenshots/**")" ]; then echo "changed=true" >> "$GITHUB_OUTPUT" fi @@ -77,7 +77,7 @@ jobs: uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: screenshots - path: test/visual/screenshots/ + path: test/**/screenshots/** if-no-files-found: error retention-days: 1 @@ -98,19 +98,20 @@ jobs: persist-credentials: false # removed screenshots are deleted too - - run: rm -r test/visual/screenshots + - run: rm -r test/**/screenshots/** + # the artifact keeps the paths after `test/`, where the upload's wildcard starts - name: Download screenshots uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: screenshots - path: test/visual/screenshots + path: test - name: Push new screenshots run: | git config user.name "$GITHUB_ACTOR" git config user.email "$GITHUB_ACTOR_ID+$GITHUB_ACTOR@users.noreply.github.com" - git add test/visual/screenshots + git add "test/**/screenshots/**" git commit -m "Update screenshots" gh auth setup-git git push origin "HEAD:refs/heads/$BRANCH" From 8d5f688e9e7dee803954969ebe71136e03ff0f7c Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Mon, 19 Oct 2026 16:13:17 +0100 Subject: [PATCH 06/11] Update Screenshots: delete and stage screenshots at any depth `rm -r test/**/screenshots/**` only reached screenshot directories one level under `test/`, as bash's `globstar` is off. Tracked screenshots are now deleted with `git ls-files`, and a single `SCREENSHOTS` pattern is shared by the git commands and the upload. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/update-screenshots.yml | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/update-screenshots.yml b/.github/workflows/update-screenshots.yml index 2f4855a44d..05bb16427b 100644 --- a/.github/workflows/update-screenshots.yml +++ b/.github/workflows/update-screenshots.yml @@ -11,6 +11,8 @@ cache-mode: none env: FORCE_COLOR: 1 + # as a git pathspec and an upload-artifact glob, it matches screenshots at any depth + SCREENSHOTS: 'test/**/screenshots/**' # dependencies only run in the read-only `screenshots` job, # the jobs with write permissions only run gh and git @@ -60,7 +62,9 @@ jobs: - run: vp install --frozen-lockfile - - run: rm -r test/**/screenshots/** + # so that removed screenshots are detected + - name: Delete screenshots + run: git ls-files -z -- "$SCREENSHOTS" | xargs -0 --no-run-if-empty rm - run: vp run test test/visual --update timeout-minutes: 4 @@ -68,7 +72,7 @@ jobs: - name: Check for changes id: changes run: | - if [ -n "$(git status --porcelain -- "test/**/screenshots/**")" ]; then + if [ -n "$(git status --porcelain -- "$SCREENSHOTS")" ]; then echo "changed=true" >> "$GITHUB_OUTPUT" fi @@ -77,7 +81,7 @@ jobs: uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: screenshots - path: test/**/screenshots/** + path: ${{ env.SCREENSHOTS }} if-no-files-found: error retention-days: 1 @@ -97,8 +101,9 @@ jobs: ref: ${{ needs.screenshots.outputs.commit }} persist-credentials: false - # removed screenshots are deleted too - - run: rm -r test/**/screenshots/** + # so that removed screenshots are deleted too + - name: Delete screenshots + run: git ls-files -z -- "$SCREENSHOTS" | xargs -0 --no-run-if-empty rm # the artifact keeps the paths after `test/`, where the upload's wildcard starts - name: Download screenshots @@ -111,7 +116,7 @@ jobs: run: | git config user.name "$GITHUB_ACTOR" git config user.email "$GITHUB_ACTOR_ID+$GITHUB_ACTOR@users.noreply.github.com" - git add "test/**/screenshots/**" + git add -- "$SCREENSHOTS" git commit -m "Update screenshots" gh auth setup-git git push origin "HEAD:refs/heads/$BRANCH" From 56e4a06f9425b60c9001a5d1b3295289de38beee Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Mon, 19 Oct 2026 17:01:56 +0100 Subject: [PATCH 07/11] Publish: split the tag checks, simplify package.json metadata - Check that the tag is on main and matches the package version in separate steps, and drop the npm check: tags can't be pushed twice, and `npm publish` rejects an existing version. - List `prerelease` in the release steps, as `patch`, `minor`, and `major` all turn a beta into a stable release. - Use the `github:` shorthand for `repository`, and drop `homepage` and `bugs`, which npm derives from it when publishing. - Enable provenance in `publishConfig`, so publishing outside of CI fails. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/publish.yml | 42 ++++++++++++++--------------------- package.json | 10 ++++----- 2 files changed, 21 insertions(+), 31 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 45284f0e8b..84a5eef05a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,11 +1,14 @@ -name: Publish - -# To publish a version, admins run on an up-to-date `main`: -# npm version +# To publish a new version, run these commands on an up-to-date main branch: +# npm version # git push --atomic --follow-tags origin main # # `npm version` commits the new version and tags it, -# and the push updates `main` and the tag together, which runs this workflow. +# and the push updates main and the tag together. +# The new tag runs this workflow, which checks the tagged commit, +# publishes it to npm, and creates the GitHub release. + +name: Publish + on: push: tags: @@ -18,13 +21,13 @@ cache-mode: none env: FORCE_COLOR: 1 -# never run two publishes at once, and never cancel one midway +# One release at a time, never canceled midway concurrency: group: ${{ github.workflow }} cancel-in-progress: false jobs: - # dependencies only run in this read-only job, so they cannot mint an npm publishing token + # Dependencies only run in this read-only job, so they cannot mint an npm publishing token build: runs-on: ubuntu-latest timeout-minutes: 10 @@ -43,29 +46,18 @@ jobs: with: run-install: false - # fails early, before `vp run ci` - - name: Check version - run: | - VERSION=$(jq --raw-output .version package.json) - if [ "$GITHUB_REF_NAME" != "v$VERSION" ]; then - echo "::error::Tag $GITHUB_REF_NAME does not match version $VERSION in package.json" - exit 1 - fi - if ! git merge-base --is-ancestor HEAD origin/main; then - echo "::error::Tag $GITHUB_REF_NAME is not on main" - exit 1 - fi - if npm view "react-data-grid@$VERSION" version > /dev/null 2>&1; then - echo "::error::Version $VERSION is already published to npm" - exit 1 - fi + - name: Check that the tag is on main + run: git merge-base --is-ancestor HEAD origin/main + + - name: Check that the tag matches the package version + run: test "$GITHUB_REF_NAME" = "v$(node -p 'require("./package.json").version')" - run: vp install --frozen-lockfile - run: vp run ci timeout-minutes: 6 - - name: Upload package + - name: Upload build uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: lib @@ -92,7 +84,7 @@ jobs: run-install: false registry-url: 'https://registry.npmjs.org' - - name: Download package + - name: Download build uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: lib diff --git a/package.json b/package.json index 13e736138e..591e00ff3d 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,8 @@ "data grid", "react" ], - "homepage": "https://github.com/Comcast/react-data-grid#readme", - "bugs": "https://github.com/Comcast/react-data-grid/issues", "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/Comcast/react-data-grid.git" - }, + "repository": "github:Comcast/react-data-grid", "files": [ "lib" ], @@ -28,6 +23,9 @@ "default": "./lib/index.js" } }, + "publishConfig": { + "provenance": true + }, "scripts": { "prepare": "vp config" }, From 5f722519739f52df7aa34748f18681ebe27401ad Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Mon, 19 Oct 2026 18:47:36 +0100 Subject: [PATCH 08/11] Address review: separate release job, PNG-only screenshots - Publish: create the GitHub release in its own job, so the job with `id-token: write` has no write token, and a failed release can be re-run on its own once the version is already on npm. - Publish: read the version with `npm pkg get version`. - Update Screenshots: only match PNGs, so the job that pushes can only commit images, not files the read-only job could have planted. - AGENTS.md: the write jobs never install the project's dependencies, which is what they guarantee, as the publish job still sets up Vite+. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/publish.yml | 17 ++++++++++++++--- .github/workflows/update-screenshots.yml | 5 +++-- AGENTS.md | 4 ++-- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 84a5eef05a..7e681c4263 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -50,7 +50,7 @@ jobs: run: git merge-base --is-ancestor HEAD origin/main - name: Check that the tag matches the package version - run: test "$GITHUB_REF_NAME" = "v$(node -p 'require("./package.json").version')" + run: test "$GITHUB_REF_NAME" = "v$(npm pkg get version)" - run: vp install --frozen-lockfile @@ -71,7 +71,7 @@ jobs: timeout-minutes: 5 permissions: - contents: write # create the GitHub release + contents: read # check out the repository id-token: write # npm trusted publishing steps: @@ -95,7 +95,18 @@ jobs: - name: Publish to npm run: npm publish --tag latest --ignore-scripts + # A separate job, so the job that can publish to npm has no write token, + # and a failed release can be re-run once the version is already on npm + release: + needs: publish + runs-on: ubuntu-latest + timeout-minutes: 5 + + permissions: + contents: write # create the GitHub release + + steps: - name: Create GitHub Release - run: gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --generate-notes --verify-tag + run: gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --title "$GITHUB_REF_NAME" --generate-notes --verify-tag env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/update-screenshots.yml b/.github/workflows/update-screenshots.yml index 05bb16427b..6a91323eee 100644 --- a/.github/workflows/update-screenshots.yml +++ b/.github/workflows/update-screenshots.yml @@ -11,8 +11,9 @@ cache-mode: none env: FORCE_COLOR: 1 - # as a git pathspec and an upload-artifact glob, it matches screenshots at any depth - SCREENSHOTS: 'test/**/screenshots/**' + # as a git pathspec and an upload-artifact glob, it matches screenshots at any depth, + # and only images, so the job that pushes can only commit images + SCREENSHOTS: 'test/**/screenshots/**/*.png' # dependencies only run in the read-only `screenshots` job, # the jobs with write permissions only run gh and git diff --git a/AGENTS.md b/AGENTS.md index a00450a6c3..cfc1e0744e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,8 +35,8 @@ website/ # demo site (Vite + TanStack Router) - **Light/dark mode** — handled via CSS `light-dark()` + `color-scheme`, not JS. - **Accessibility first** — ARIA attributes (e.g. `aria-colindex`, `aria-rowindex`, `aria-selected`, roles) are required. Tests query by role. - **Build** — `vp pack` bundles the library to `lib/`, then lints the packed package with publint; `ecij` plugin prefixes classes with `rdg-{version}-` (dots→dashes) to avoid cross-version conflicts. -- **Workflows** — pin every action to a full commit SHA with its version in a comment (Dependabot updates both), pass expressions to `run` steps through `env` instead of inlining `${{ }}`, grant each job only the `permissions` it uses, and check out with `persist-credentials: false`. Jobs that install dependencies only get read permissions: publishing, pushing, and editing PRs happen in separate jobs that receive build output as artifacts and never run dependency code. -- **Releases** — the header of `.github/workflows/publish.yml` explains how to publish. Its read-only `build` job hands `lib/` to the `publish` job, so no dependency code runs with the `id-token: write` permission that npm's trusted publishing relies on. +- **Workflows** — pin every action to a full commit SHA with its version in a comment (Dependabot updates both), pass expressions to `run` steps through `env` instead of inlining `${{ }}`, grant each job only the `permissions` it uses, and check out with `persist-credentials: false`. Jobs that install dependencies only get read permissions: publishing, pushing, and editing PRs happen in separate jobs that receive build output as artifacts and never install the project's dependencies. +- **Releases** — the header of `.github/workflows/publish.yml` explains how to publish. Its read-only `build` job hands `lib/` to the `publish` job, so the project's dependencies never run with the `id-token: write` permission that npm's trusted publishing relies on, and a separate `release` job creates the GitHub release. ## Testing From 7fc718468b964894299ad18bb407308c52805205 Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Mon, 19 Oct 2026 19:36:56 +0100 Subject: [PATCH 09/11] Publish: document the full release steps Start from GitHub's `main` and tags, and push the version commit with the tag named explicitly. With `--follow-tags`, a tag that a fetch pruned before the push left only `main` to push, so nothing was published; now the push fails instead, and other local tags stay out of it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/publish.yml | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7e681c4263..4aca9b5875 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,9 +1,20 @@ -# To publish a new version, run these commands on an up-to-date main branch: +# To publish a new version, run these commands: +# git switch main +# git fetch --prune --prune-tags origin +# git reset --keep origin/main # npm version -# git push --atomic --follow-tags origin main +# git push --atomic origin main "v$(npm pkg get version)" +# +# The fetch also deletes local tags that aren't on GitHub, and the reset moves main to GitHub's main, +# keeping uncommitted changes, which `npm version` refuses to run with. +# `npm version` commits the new version and tags it. +# Naming the tag makes the push fail if the tag is missing, and leaves other local tags out, +# while `--atomic` pushes main and the tag together, or neither. +# +# Until it's pushed, any fetch that prunes tags, like an editor's auto-fetch, deletes the new tag. +# If the push can't find it, recreate it and push again: +# git tag --annotate "v$(npm pkg get version)" --message "$(npm pkg get version)" # -# `npm version` commits the new version and tags it, -# and the push updates main and the tag together. # The new tag runs this workflow, which checks the tagged commit, # publishes it to npm, and creates the GitHub release. From 19c5a23fdfda3d08949d54c2460013245d4061c1 Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Mon, 19 Oct 2026 19:55:44 +0100 Subject: [PATCH 10/11] Publish: push with --no-follow-tags With `push.followTags` set, naming the tag isn't enough to keep other local annotated tags reachable from `main` out of the release push. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/publish.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4aca9b5875..5bd1f914d0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,13 +3,13 @@ # git fetch --prune --prune-tags origin # git reset --keep origin/main # npm version -# git push --atomic origin main "v$(npm pkg get version)" +# git push --atomic --no-follow-tags origin main "v$(npm pkg get version)" # # The fetch also deletes local tags that aren't on GitHub, and the reset moves main to GitHub's main, # keeping uncommitted changes, which `npm version` refuses to run with. # `npm version` commits the new version and tags it. -# Naming the tag makes the push fail if the tag is missing, and leaves other local tags out, -# while `--atomic` pushes main and the tag together, or neither. +# Naming the tag makes the push fail if the tag is missing, and `--no-follow-tags` leaves other +# local tags out, even with `push.followTags` set. `--atomic` pushes main and the tag together, or neither. # # Until it's pushed, any fetch that prunes tags, like an editor's auto-fetch, deletes the new tag. # If the push can't find it, recreate it and push again: From 35e88431dcd3c9a2d8ced4894e3f6c2dcd896e9d Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Mon, 19 Oct 2026 20:28:57 +0100 Subject: [PATCH 11/11] Add a `version` task for the first step of a release `vp run version ` switches to `main`, matches it and the local tags to GitHub's, then runs `npm version` with the arguments, as `vp run` appends them to the last command. The push stays a separate command, as Vite Task doesn't run commands through a shell on Windows, so the tag name can't come from `$(...)` in a task. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/publish.yml | 10 +++------- vite.config.ts | 13 +++++++++++++ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5bd1f914d0..d1375fd564 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,13 +1,9 @@ # To publish a new version, run these commands: -# git switch main -# git fetch --prune --prune-tags origin -# git reset --keep origin/main -# npm version +# vp run version # git push --atomic --no-follow-tags origin main "v$(npm pkg get version)" # -# The fetch also deletes local tags that aren't on GitHub, and the reset moves main to GitHub's main, -# keeping uncommitted changes, which `npm version` refuses to run with. -# `npm version` commits the new version and tags it. +# The `version` task in `vite.config.ts` switches to main and matches it and the local tags to GitHub's, +# then runs `npm version` with the arguments, which commits the new version and tags it. # Naming the tag makes the push fail if the tag is missing, and `--no-follow-tags` leaves other # local tags out, even with `push.followTags` set. `--atomic` pushes main and the tag together, or neither. # diff --git a/vite.config.ts b/vite.config.ts index 2672801fe7..e2fc3280f0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -113,6 +113,19 @@ export default defineConfig({ // `vp pack` cleans `lib/` before writing to it input: [{ auto: true }, '!lib/**'] } + }, + // the first step of a release, see the header of `.github/workflows/publish.yml`: + // arguments are passed to `npm version`, as `vp run` appends them to the last command + version: { + command: [ + 'git switch main', + // also deletes local tags that aren't on GitHub + 'git fetch --prune --prune-tags origin', + // keeps uncommitted changes, which `npm version` refuses to run with + 'git reset --keep origin/main', + 'npm version' + ], + cache: false } } },