diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 33f6f58d81..d1375fd564 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,12 +1,25 @@ +# To publish a new version, run these commands: +# vp run version +# git push --atomic --no-follow-tags origin main "v$(npm pkg get version)" +# +# 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. +# +# 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)" +# +# The new tag runs this workflow, which checks the tagged commit, +# publishes it to npm, and creates the GitHub release. + name: Publish 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: {} @@ -15,55 +28,92 @@ 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: - 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 + - name: Check that the tag is on main + run: git merge-base --is-ancestor HEAD origin/main - # before `vp run ci`, so `vp pack` prefixes classes with the new version - - name: Update version - run: | - git config user.name "$GITHUB_ACTOR" - git config user.email "$GITHUB_ACTOR_ID+$GITHUB_ACTOR@users.noreply.github.com" - npm version "$VERSION" + - name: Check that the tag matches the package version + run: test "$GITHUB_REF_NAME" = "v$(npm pkg get version)" + + - run: vp install --frozen-lockfile - run: vp run ci timeout-minutes: 6 + - name: Upload build + 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: read # check out the repository + 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 build + 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 + run: npm publish --tag latest --ignore-scripts - - name: Push tag - run: git push origin "v$VERSION" + # 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 "v$VERSION" --title "v$VERSION" --generate-notes + 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 5371a3ff16..6a91323eee 100644 --- a/.github/workflows/update-screenshots.yml +++ b/.github/workflows/update-screenshots.yml @@ -11,49 +11,116 @@ cache-mode: none env: FORCE_COLOR: 1 + # 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 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/** + # 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 + - name: Check for changes + id: changes + run: | + if [ -n "$(git status --porcelain -- "$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: ${{ env.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 + + # 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 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: 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/**/screenshots/**" - git diff-index --quiet HEAD || git commit -m "Update screenshots" - git push + git add -- "$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..cfc1e0744e 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 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 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..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" ], @@ -21,10 +16,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", ".": { @@ -32,6 +23,9 @@ "default": "./lib/index.js" } }, + "publishConfig": { + "provenance": true + }, "scripts": { "prepare": "vp config" }, @@ -53,6 +47,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..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 } } }, @@ -201,6 +214,9 @@ export default defineConfig({ build: true, tsconfig: './tsconfig.src.json' }, + publint: { + strict: true + }, plugins: [ ecij({ // We add the package version as prefix to avoid style conflicts