From 7e82ba53e6d09be191d896a42bcbde50fb3d59d3 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Tue, 10 Mar 2026 11:09:57 +0200 Subject: [PATCH] Add JSR publish flow and Deno build integration updates --- .github/workflows/release.yml | 65 ++++++-- .../dev-deno-with-deno-build/deno.json | 6 + .../dev-deno-with-deno-build/package.json | 7 + .../test.js | 0 .../package.json | 2 +- .../dev-deno-with-node-build/test.js | 18 +++ .../prod-deno-with-deno-build/deno.json | 6 + .../prod-deno-with-deno-build/package.json | 7 + .../test.js | 0 .../package.json | 2 +- .../prod-deno-with-node-build/test.js | 12 ++ resources/build-deno.ts | 146 ++++++++++++++---- resources/build-npm.ts | 24 ++- resources/integration-test.ts | 9 +- resources/utils.ts | 22 +++ 15 files changed, 268 insertions(+), 58 deletions(-) create mode 100644 integrationTests/dev-deno-with-deno-build/deno.json create mode 100644 integrationTests/dev-deno-with-deno-build/package.json rename integrationTests/{dev-deno => dev-deno-with-deno-build}/test.js (100%) rename integrationTests/{dev-deno => dev-deno-with-node-build}/package.json (93%) create mode 100644 integrationTests/dev-deno-with-node-build/test.js create mode 100644 integrationTests/prod-deno-with-deno-build/deno.json create mode 100644 integrationTests/prod-deno-with-deno-build/package.json rename integrationTests/{prod-deno => prod-deno-with-deno-build}/test.js (100%) rename integrationTests/{prod-deno => prod-deno-with-node-build}/package.json (93%) create mode 100644 integrationTests/prod-deno-with-node-build/test.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1b8d187e6f..afd4cd3778 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,6 @@ jobs: tag: ${{ steps.release_metadata.outputs.tag }} dist_tag: ${{ steps.release_metadata.outputs.dist_tag }} prerelease: ${{ steps.release_metadata.outputs.prerelease }} - tarball_name: ${{ steps.release_metadata.outputs.tarball_name }} concurrency: group: ${{ github.workflow }}-${{ github.ref_name }} cancel-in-progress: true @@ -50,7 +49,6 @@ jobs: "dist_tag=\(.distTag)", "prerelease=\(.prerelease)", "package_spec=\(.packageSpec)", - "tarball_name=\(.tarballName)", "should_publish=\(.shouldPublish)" ' <<< "${release_metadata_json}" >> "${GITHUB_OUTPUT}" jq -r '.releaseNotes' <<< "${release_metadata_json}" > ./release-notes.md @@ -67,16 +65,23 @@ jobs: if: steps.release_metadata.outputs.should_publish == 'true' run: npm run build:npm - - name: Pack npmDist package + - name: Upload npmDist package if: steps.release_metadata.outputs.should_publish == 'true' - run: npm pack ./npmDist --pack-destination . > /dev/null + uses: actions/upload-artifact@v4 + with: + name: npmDist + path: ./npmDist + + - name: Build Deno package + if: steps.release_metadata.outputs.should_publish == 'true' + run: npm run build:deno - - name: Upload npm package tarball + - name: Upload denoDist package if: steps.release_metadata.outputs.should_publish == 'true' uses: actions/upload-artifact@v4 with: - name: npmDist-tarball - path: ./${{ steps.release_metadata.outputs.tarball_name }} + name: denoDist + path: ./denoDist - name: Upload release notes if: steps.release_metadata.outputs.should_publish == 'true' @@ -89,7 +94,7 @@ jobs: name: Publish npm package needs: check-publish # Keep this guard on every job for defense-in-depth in case job dependencies are refactored. - if: ${{ !github.event.repository.fork && github.repository == 'graphql/graphql-js' && github.ref_name == '17.x.x' && needs.check-publish.outputs.should_publish == 'true' && needs.check-publish.result == 'success' }} + if: ${{ !github.event.repository.fork && github.repository == 'graphql/graphql-js' && github.ref_name == '17.x.x' && needs.check-publish.outputs.should_publish == 'true' }} runs-on: ubuntu-latest environment: release permissions: @@ -109,22 +114,54 @@ jobs: - name: Download npmDist package uses: actions/download-artifact@v4 with: - name: npmDist-tarball - path: ./artifacts + name: npmDist + path: ./npmDist - - name: Dry-run npm publish + - name: Publish to npm + working-directory: ./npmDist run: | if [ -n "${{ needs.check-publish.outputs.dist_tag }}" ]; then - npm publish --provenance --tag "${{ needs.check-publish.outputs.dist_tag }}" "./artifacts/${{ needs.check-publish.outputs.tarball_name }}" + npm publish --provenance --tag "${{ needs.check-publish.outputs.dist_tag }}" else - npm publish --provenance "./artifacts/${{ needs.check-publish.outputs.tarball_name }}" + npm publish --provenance fi + publish-jsr: + name: Publish JSR package + needs: check-publish + # Keep this guard on every job for defense-in-depth in case job dependencies are refactored. + if: ${{ !github.event.repository.fork && github.repository == 'graphql/graphql-js' && github.ref_name == '17.x.x' && needs.check-publish.outputs.should_publish == 'true' }} + runs-on: ubuntu-latest + environment: release + permissions: + contents: read # for actions/checkout + id-token: write # for JSR trusted publishing via OIDC + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Download denoDist package + uses: actions/download-artifact@v4 + with: + name: denoDist + path: ./denoDist + + - name: Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Publish to JSR + working-directory: ./denoDist + run: deno publish + create-release: name: Create release needs: check-publish # Keep this guard on every job for defense-in-depth in case job dependencies are refactored. - if: ${{ !github.event.repository.fork && github.repository == 'graphql/graphql-js' && github.ref_name == '17.x.x' && needs.check-publish.outputs.should_publish == 'true' && needs.check-publish.result == 'success' }} + if: ${{ !github.event.repository.fork && github.repository == 'graphql/graphql-js' && github.ref_name == '17.x.x' && needs.check-publish.outputs.should_publish == 'true' }} runs-on: ubuntu-latest environment: release permissions: diff --git a/integrationTests/dev-deno-with-deno-build/deno.json b/integrationTests/dev-deno-with-deno-build/deno.json new file mode 100644 index 0000000000..e34a896dd4 --- /dev/null +++ b/integrationTests/dev-deno-with-deno-build/deno.json @@ -0,0 +1,6 @@ +{ + "imports": { + "graphql": "../graphql-deno-dist/__dev__/index.ts", + "graphql/": "../graphql-deno-dist/__dev__/" + } +} diff --git a/integrationTests/dev-deno-with-deno-build/package.json b/integrationTests/dev-deno-with-deno-build/package.json new file mode 100644 index 0000000000..2a46c93312 --- /dev/null +++ b/integrationTests/dev-deno-with-deno-build/package.json @@ -0,0 +1,7 @@ +{ + "description": "graphql-js development mode should work with Deno with deno build", + "private": true, + "scripts": { + "test": "docker run --rm --volume \"$PWD/..\":/usr/src/app -w /usr/src/app/dev-deno-with-deno-build denoland/deno:alpine-\"$DENO_VERSION\" deno run test.js" + } +} diff --git a/integrationTests/dev-deno/test.js b/integrationTests/dev-deno-with-deno-build/test.js similarity index 100% rename from integrationTests/dev-deno/test.js rename to integrationTests/dev-deno-with-deno-build/test.js diff --git a/integrationTests/dev-deno/package.json b/integrationTests/dev-deno-with-node-build/package.json similarity index 93% rename from integrationTests/dev-deno/package.json rename to integrationTests/dev-deno-with-node-build/package.json index 16653782cb..88939c37f7 100644 --- a/integrationTests/dev-deno/package.json +++ b/integrationTests/dev-deno-with-node-build/package.json @@ -1,5 +1,5 @@ { - "description": "graphql-js development mode should work with Deno", + "description": "graphql-js development mode should work with Deno with node build", "private": true, "scripts": { "test": "docker run --rm --volume \"$PWD\":/usr/src/app -w /usr/src/app denoland/deno:alpine-\"$DENO_VERSION\" deno run --conditions=development test.js" diff --git a/integrationTests/dev-deno-with-node-build/test.js b/integrationTests/dev-deno-with-node-build/test.js new file mode 100644 index 0000000000..ed874f9d38 --- /dev/null +++ b/integrationTests/dev-deno-with-node-build/test.js @@ -0,0 +1,18 @@ +import { isObjectType } from 'graphql'; + +class GraphQLObjectType { + get [Symbol.toStringTag]() { + return 'GraphQLObjectType'; + } +} + +try { + isObjectType(new GraphQLObjectType()); + throw new Error( + 'Expected isObjectType to throw an error in Deno development mode.', + ); +} catch (error) { + if (!error.message.includes('from another module or realm')) { + throw error; + } +} diff --git a/integrationTests/prod-deno-with-deno-build/deno.json b/integrationTests/prod-deno-with-deno-build/deno.json new file mode 100644 index 0000000000..bf1ba58641 --- /dev/null +++ b/integrationTests/prod-deno-with-deno-build/deno.json @@ -0,0 +1,6 @@ +{ + "imports": { + "graphql": "../graphql-deno-dist/index.ts", + "graphql/": "../graphql-deno-dist/" + } +} diff --git a/integrationTests/prod-deno-with-deno-build/package.json b/integrationTests/prod-deno-with-deno-build/package.json new file mode 100644 index 0000000000..9f5ef854ec --- /dev/null +++ b/integrationTests/prod-deno-with-deno-build/package.json @@ -0,0 +1,7 @@ +{ + "description": "graphql-js production mode should work with Deno with deno build", + "private": true, + "scripts": { + "test": "docker run --rm --volume \"$PWD/..\":/usr/src/app -w /usr/src/app/prod-deno-with-deno-build denoland/deno:alpine-\"$DENO_VERSION\" deno run test.js" + } +} diff --git a/integrationTests/prod-deno/test.js b/integrationTests/prod-deno-with-deno-build/test.js similarity index 100% rename from integrationTests/prod-deno/test.js rename to integrationTests/prod-deno-with-deno-build/test.js diff --git a/integrationTests/prod-deno/package.json b/integrationTests/prod-deno-with-node-build/package.json similarity index 93% rename from integrationTests/prod-deno/package.json rename to integrationTests/prod-deno-with-node-build/package.json index 6f7d4fa953..afe2c881aa 100644 --- a/integrationTests/prod-deno/package.json +++ b/integrationTests/prod-deno-with-node-build/package.json @@ -1,5 +1,5 @@ { - "description": "graphql-js production mode should work with Deno", + "description": "graphql-js production mode should work with Deno with node build", "private": true, "scripts": { "test": "docker run --rm --volume \"$PWD\":/usr/src/app -w /usr/src/app denoland/deno:alpine-\"$DENO_VERSION\" deno run test.js" diff --git a/integrationTests/prod-deno-with-node-build/test.js b/integrationTests/prod-deno-with-node-build/test.js new file mode 100644 index 0000000000..6ed13f37bf --- /dev/null +++ b/integrationTests/prod-deno-with-node-build/test.js @@ -0,0 +1,12 @@ +import { isObjectType } from 'graphql'; + +class GraphQLObjectType { + get [Symbol.toStringTag]() { + return 'GraphQLObjectType'; + } +} + +const result = isObjectType(new GraphQLObjectType()); +if (result !== false) { + throw new Error('isObjectType should return false in Deno production mode.'); +} diff --git a/resources/build-deno.ts b/resources/build-deno.ts index 43a4b292b1..b7179bf7ca 100644 --- a/resources/build-deno.ts +++ b/resources/build-deno.ts @@ -1,3 +1,4 @@ +import assert from 'node:assert'; import fs from 'node:fs'; import path from 'node:path'; @@ -6,43 +7,136 @@ import ts from 'typescript'; import { changeExtensionInImportPaths } from './change-extension-in-import-paths.js'; import { inlineInvariant } from './inline-invariant.js'; import { + buildESMDevModeStub, prettify, + readPackageJSON, readTSConfig, showDirStats, writeGeneratedFile, } from './utils.js'; -fs.rmSync('./denoDist', { recursive: true, force: true }); -fs.mkdirSync('./denoDist'); +console.log('\n./denoDist'); +await buildPackage('./denoDist'); +showDirStats('./denoDist'); + +async function buildPackage(outDir: string): Promise { + const devDir = path.join(outDir, '__dev__'); + fs.rmSync(outDir, { recursive: true, force: true }); + fs.mkdirSync(outDir); + fs.mkdirSync(devDir); + + const emittedTSFiles = await emitTSFiles(outDir); + emitDevTSFiles(outDir, devDir, emittedTSFiles); + await writeJSRConfig(outDir, emittedTSFiles); + + fs.copyFileSync('./LICENSE', path.join(outDir, 'LICENSE')); + fs.copyFileSync('./README.md', path.join(outDir, 'README.md')); +} + +async function emitTSFiles(outDir: string): Promise> { + const emittedTSFiles = []; + const tsProgram = ts.createProgram(['src/index.ts'], readTSConfig()); + + for (const sourceFile of tsProgram.getSourceFiles()) { + if ( + tsProgram.isSourceFileFromExternalLibrary(sourceFile) || + tsProgram.isSourceFileDefaultLibrary(sourceFile) + ) { + continue; + } -const tsProgram = ts.createProgram(['src/index.ts'], readTSConfig()); -for (const sourceFile of tsProgram.getSourceFiles()) { - if ( - tsProgram.isSourceFileFromExternalLibrary(sourceFile) || - tsProgram.isSourceFileDefaultLibrary(sourceFile) - ) { - continue; + const transformed = ts.transform(sourceFile, [ + changeExtensionInImportPaths({ extension: '.ts' }), + inlineInvariant, + ]); + const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); + const newContent = printer.printBundle( + ts.factory.createBundle(transformed.transformed), + ); + + transformed.dispose(); + + const filepath = path.relative('./src', sourceFile.fileName); + const destPath = path.join(outDir, filepath); + // eslint-disable-next-line no-await-in-loop + const prettified = await prettify(destPath, newContent); + writeGeneratedFile(destPath, prettified); + emittedTSFiles.push(filepath); } - const transformed = ts.transform(sourceFile, [ - changeExtensionInImportPaths({ extension: '.ts' }), - inlineInvariant, - ]); - const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); - const newContent = printer.printBundle( - ts.factory.createBundle(transformed.transformed), - ); + return emittedTSFiles.sort((a, b) => a.localeCompare(b)); +} + +function emitDevTSFiles( + outDir: string, + devDir: string, + emittedTSFiles: ReadonlyArray, +): void { + for (const filepath of emittedTSFiles) { + const devPath = path.join(devDir, filepath); + const relativePathToOutDir = path.relative(path.dirname(devPath), outDir); - transformed.dispose(); + writeGeneratedFile( + devPath, + buildESMDevModeStub( + `${relativePathToOutDir}/devMode.ts`, + `${relativePathToOutDir}/${filepath}`, + ), + ); + } +} - const filepath = path.relative('./src', sourceFile.fileName); - const destPath = path.join('./denoDist', filepath); - // eslint-disable-next-line no-await-in-loop - const prettified = await prettify(destPath, newContent); - writeGeneratedFile(destPath, prettified); +interface JSRConfig { + name: string; + version: string; + exports: { [entrypoint: string]: string }; } -fs.copyFileSync('./LICENSE', './denoDist/LICENSE'); -fs.copyFileSync('./README.md', './denoDist/README.md'); +async function writeJSRConfig( + outDir: string, + emittedTSFiles: ReadonlyArray, +): Promise { + const jsrConfigPath = path.join(outDir, 'jsr.json'); -showDirStats('./denoDist'); + const { version } = readPackageJSON(); + const jsrExports: { [entrypoint: string]: string } = {}; + + for (const filepath of emittedTSFiles) { + const devEntrypointPath = `./__dev__/${filepath}`; + const devEntrypointKey = `./dev/${filepath}`; + setJSRExport(jsrExports, devEntrypointKey, devEntrypointPath); + } + + for (const filepath of emittedTSFiles) { + const prodEntrypointPath = `./${filepath}`; + const prodEntrypointKey = `./${filepath}`; + setJSRExport(jsrExports, prodEntrypointKey, prodEntrypointPath); + + if (filepath === 'index.ts') { + setJSRExport(jsrExports, '.', prodEntrypointPath); + setJSRExport(jsrExports, './mod.ts', prodEntrypointPath); + } + } + + const jsrConfig: JSRConfig = { + name: '@graphql/graphql-js', + version, + exports: jsrExports, + }; + + const prettified = await prettify(jsrConfigPath, JSON.stringify(jsrConfig)); + writeGeneratedFile(jsrConfigPath, prettified); +} + +function setJSRExport( + jsrExports: { [entrypoint: string]: string }, + entrypoint: string, + targetPath: string, +): void { + const existingPath = jsrExports[entrypoint]; + assert( + existingPath === undefined || existingPath === targetPath, + `JSR export "${entrypoint}" cannot target both "${existingPath}" and "${targetPath}".`, + ); + jsrExports[entrypoint] = targetPath; +} diff --git a/resources/build-npm.ts b/resources/build-npm.ts index 36bbcc6868..97a830d6a9 100644 --- a/resources/build-npm.ts +++ b/resources/build-npm.ts @@ -8,6 +8,8 @@ import { changeExtensionInImportPaths } from './change-extension-in-import-paths import { inlineInvariant } from './inline-invariant.js'; import type { PlatformConditionalExports } from './utils.js'; import { + buildCJSDevModeStub, + buildESMDevModeStub, prettify, readPackageJSON, readTSConfig, @@ -156,26 +158,20 @@ async function buildPackage(outDir: string, isESMOnly: boolean): Promise { const relativePathAndName = path.relative(outDir, `${dir}/${name}`); - let lines = [ - `const { enableDevMode } = require('${relativePathToProd}/devMode.js');`, - 'enableDevMode();', - `module.exports = require('${relativePathToProd}/${relativePathAndName}.js');`, - ]; - writeGeneratedFile( path.join(devDir, path.relative(outDir, `${dir}/${name}.js`)), - lines.join('\n'), + buildCJSDevModeStub( + `${relativePathToProd}/devMode.js`, + `${relativePathToProd}/${relativePathAndName}.js`, + ), ); - lines = [ - `import { enableDevMode } from '${relativePathToProd}/devMode.mjs';`, - 'enableDevMode();', - `export * from '${relativePathToProd}/${relativePathAndName}.mjs';`, - ]; - writeGeneratedFile( path.join(devDir, path.relative(outDir, `${dir}/${name}.mjs`)), - lines.join('\n'), + buildESMDevModeStub( + `${relativePathToProd}/devMode.mjs`, + `${relativePathToProd}/${relativePathAndName}.mjs`, + ), ); if (base === 'index.js') { diff --git a/resources/integration-test.ts b/resources/integration-test.ts index 34d97b31e3..ca99b99fdd 100644 --- a/resources/integration-test.ts +++ b/resources/integration-test.ts @@ -26,6 +26,9 @@ describe('Integration Tests', () => { fs.renameSync(tmpDirPath(archiveEsmName), tmpDirPath('graphql-esm.tgz')); npm().run('build:deno'); + fs.cpSync(localRepoPath('denoDist'), tmpDirPath('graphql-deno-dist'), { + recursive: true, + }); function testOnNodeProject(projectName: string) { const projectPath = tmpDirPath(projectName); @@ -56,7 +59,8 @@ describe('Integration Tests', () => { // Development mode tests testOnNodeProject('dev-explicit'); testOnNodeProject('dev-node'); - testOnNodeProject('dev-deno'); + testOnNodeProject('dev-deno-with-deno-build'); + testOnNodeProject('dev-deno-with-node-build'); testOnNodeProject('dev-bun'); testOnNodeProject('dev-webpack'); testOnNodeProject('dev-rspack'); @@ -68,7 +72,8 @@ describe('Integration Tests', () => { // Production mode tests testOnNodeProject('prod-node'); - testOnNodeProject('prod-deno'); + testOnNodeProject('prod-deno-with-deno-build'); + testOnNodeProject('prod-deno-with-node-build'); testOnNodeProject('prod-bun'); testOnNodeProject('prod-webpack'); testOnNodeProject('prod-rspack'); diff --git a/resources/utils.ts b/resources/utils.ts index 919961be15..d8be953b64 100644 --- a/resources/utils.ts +++ b/resources/utils.ts @@ -253,6 +253,28 @@ export function writeGeneratedFile(filepath: string, body: string): void { fs.writeFileSync(filepath, body); } +export function buildCJSDevModeStub( + devModeSpecifier: string, + targetSpecifier: string, +): string { + return [ + `const { enableDevMode } = require('${devModeSpecifier}');`, + 'enableDevMode();', + `module.exports = require('${targetSpecifier}');`, + ].join('\n'); +} + +export function buildESMDevModeStub( + devModeSpecifier: string, + targetSpecifier: string, +): string { + return [ + `import { enableDevMode } from '${devModeSpecifier}';`, + 'enableDevMode();', + `export * from '${targetSpecifier}';`, + ].join('\n'); +} + interface PackageJSON { description: string; version: string;