From f2531f95f2bf633d3edd4fae922c680461a69099 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:47:54 -0800 Subject: [PATCH 1/6] Publish PR builds to npm for installation and the Playground PR builds used to be packed by an Azure DevOps pipeline into a tarball that was attached to the build, plus a separate Monaco build for the Playground. That flow doesn't fit the native compiler's per-platform packages, and URL tarballs can't express the dependencies between them. Instead, the bot can dispatch a workflow that builds the PR's test merge commit and publishes the whole package set to npm under the @typescript-deploys scope via trusted publishing. Users install it with "typescript": "npm:@typescript-deploys/pr-build@", and the Playground can load the same version straight from a CDN. The platform packages are resolved relative to the main package's scope so that the renamed packages work, the platform list comes from the release matrix on main, and the published tarballs are checked by a validator from main before anything is published. PR code never runs with credentials, and the workflow does not use caches. --- .github/workflows/pr-build.yml | 294 ++++++++++++++++++ Herebyfile.mjs | 78 ++++- packages/typescript/lib/getExePath.js | 4 +- packages/vscode-typescript/src/tsdkPackage.ts | 2 +- packages/vscode-typescript/src/util.ts | 6 +- .../test/tsdkPackage.test.ts | 30 +- tools/scripts/validate-pr-build.mjs | 85 +++++ 7 files changed, 466 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/pr-build.yml create mode 100644 tools/scripts/validate-pr-build.mjs diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml new file mode 100644 index 0000000000000..8c749b28ad422 --- /dev/null +++ b/.github/workflows/pr-build.yml @@ -0,0 +1,294 @@ +name: PR build + +on: + workflow_dispatch: + inputs: + pr_number: + description: Pull request number + required: true + type: string + expected_head_sha: + description: Expected PR head commit (defaults to the current head) + required: false + default: '' + type: string + + # Inputs provided by the bot + distinct_id: + description: '(bot) A distinct ID' + required: false + default: '' + source_issue: + description: '(bot) The issue that triggered this workflow' + required: false + default: '' + requesting_user: + description: '(bot) The user who requested this workflow' + required: false + default: '' + status_comment: + description: '(bot) The comment to update with the status of this workflow' + required: false + default: '' + +run-name: "${{ github.workflow }} #${{ inputs.pr_number }}${{ inputs.distinct_id && format(' (bot run {0})', inputs.distinct_id) || '' }}" + +permissions: + contents: read + +cache-mode: none + +defaults: + run: + shell: bash + +jobs: + prepare: + if: github.repository == 'microsoft/TypeScript' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + head_sha: ${{ steps.pr.outputs.head_sha }} + merge_sha: ${{ steps.pr.outputs.merge_sha }} + prerelease: ${{ steps.pr.outputs.prerelease }} + matrix: ${{ steps.matrix.outputs.matrix }} + packages: ${{ steps.matrix.outputs.packages }} + steps: + - name: Resolve pull request + id: pr + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ inputs.pr_number }} + EXPECTED_HEAD_SHA: ${{ inputs.expected_head_sha }} + RUN_NUMBER: ${{ github.run_number }} + run: | + set -euo pipefail + if ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then + echo "Invalid PR number: $PR_NUMBER" >&2 + exit 1 + fi + + for attempt in {1..10}; do + pr="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER")" + mergeable="$(jq -r .mergeable <<< "$pr")" + [[ "$mergeable" != "null" ]] && break + sleep 5 + done + + state="$(jq -r .state <<< "$pr")" + headSha="$(jq -r .head.sha <<< "$pr")" + mergeSha="$(jq -r .merge_commit_sha <<< "$pr")" + if [[ "$state" != "open" ]]; then + echo "PR is $state." >&2 + exit 1 + fi + if [[ -n "$EXPECTED_HEAD_SHA" && "$headSha" != "$EXPECTED_HEAD_SHA" ]]; then + echo "PR head is $headSha, but $EXPECTED_HEAD_SHA was requested; the PR was updated after the request." >&2 + exit 1 + fi + if [[ "$mergeable" != "true" ]]; then + echo "PR cannot be merged with its base branch; resolve conflicts first." >&2 + exit 1 + fi + mergeHeadParent="$(gh api "repos/$GITHUB_REPOSITORY/commits/$mergeSha" --jq '.parents[1].sha')" + if [[ "$mergeHeadParent" != "$headSha" ]]; then + echo "Test merge commit $mergeSha is not based on PR head $headSha; try again." >&2 + exit 1 + fi + + echo "head_sha=$headSha" >> "$GITHUB_OUTPUT" + echo "merge_sha=$mergeSha" >> "$GITHUB_OUTPUT" + echo "prerelease=pr.$PR_NUMBER.$RUN_NUMBER" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 'lts/*' + - run: npm ci + - name: Set build matrix + id: matrix + env: + PRERELEASE: ${{ steps.pr.outputs.prerelease }} + run: npx hereby typescript:build-platform-matrix --forRelease --prBuild --setPrerelease "$PRERELEASE" + + build-platform: + needs: prepare + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + matrix: ${{ fromJSON(needs.prepare.outputs.matrix) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.prepare.outputs.merge_sha }} + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 'lts/*' + - uses: ./.github/actions/setup-go + - run: npm ci + - env: + PLATFORM: ${{ matrix.platform }} + PRERELEASE: ${{ needs.prepare.outputs.prerelease }} + run: npx hereby typescript:build-platform --platform "$PLATFORM" --forRelease --prBuild --setPrerelease "$PRERELEASE" + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ matrix.npmDirName }} + path: built/npm/${{ matrix.npmDirName }} + if-no-files-found: error + retention-days: 7 + + pack: + needs: [prepare, build-platform] + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.prepare.outputs.merge_sha }} + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 'lts/*' + - uses: ./.github/actions/setup-go + - run: npm ci + - env: + PRERELEASE: ${{ needs.prepare.outputs.prerelease }} + run: npx hereby typescript:build-main --forRelease --prBuild --setPrerelease "$PRERELEASE" + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + path: built/npm + - name: Restore executable permissions + env: + MATRIX: ${{ needs.prepare.outputs.matrix }} + run: | + set -euo pipefail + for npmDirName in $(jq -r '.include[].npmDirName' <<< "$MATRIX"); do + packageDir="built/npm/$npmDirName" + executable="$(find "$packageDir/lib" -maxdepth 1 -type f \( -name tsc -o -name tsc.exe -o -name tsc.wasm \) -print -quit)" + if [[ -z "$executable" ]]; then + echo "Missing platform executable in $packageDir." >&2 + exit 1 + fi + if [[ "$executable" != *.wasm ]]; then + chmod +x "$executable" + fi + done + - env: + PRERELEASE: ${{ needs.prepare.outputs.prerelease }} + run: npx hereby typescript:test-package --forRelease --prBuild --setPrerelease "$PRERELEASE" + - env: + PRERELEASE: ${{ needs.prepare.outputs.prerelease }} + run: npx hereby typescript:pack --forRelease --prBuild --setPrerelease "$PRERELEASE" + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: npm + path: built/npm/*.tgz + if-no-files-found: error + retention-days: 7 + + publish: + needs: [prepare, pack] + runs-on: ubuntu-latest + environment: + name: npm-pr-build + deployment: false + permissions: + contents: read + id-token: write + outputs: + version: ${{ steps.publish.outputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + sparse-checkout: tools/scripts/validate-pr-build.mjs + sparse-checkout-cone-mode: false + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 'lts/*' + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: npm + path: ${{ runner.temp }}/npm + - name: Validate and publish + id: publish + env: + NPM_DIR: ${{ runner.temp }}/npm + PRERELEASE: ${{ needs.prepare.outputs.prerelease }} + MERGE_SHA: ${{ needs.prepare.outputs.merge_sha }} + PACKAGES: ${{ needs.prepare.outputs.packages }} + run: | + set -euo pipefail + mapfile -t tarballs < <(node tools/scripts/validate-pr-build.mjs "$NPM_DIR" "$PRERELEASE" "$MERGE_SHA" "$PACKAGES") + + # Trusted publishing needs npm 11.5.1 or later. + npm install -g npm@11 + for tarball in "${tarballs[@]}"; do + manifest="$(tar -xzOf "$NPM_DIR/$tarball" package/package.json)" + name="$(jq -r .name <<< "$manifest")" + version="$(jq -r .version <<< "$manifest")" + if npm view "$name@$version" version > /dev/null 2>&1; then + echo "$name@$version is already published." + continue + fi + npm publish "$NPM_DIR/$tarball" --access public --tag pr + done + echo "version=$version" >> "$GITHUB_OUTPUT" + + report: + needs: [prepare, build-platform, pack, publish] + if: ${{ !cancelled() && github.repository == 'microsoft/TypeScript' && inputs.distinct_id }} + runs-on: ubuntu-latest + environment: + name: azure + deployment: false + permissions: + contents: read + id-token: write + steps: + - uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + - name: Create GitHub App token + id: app-token + uses: microsoft/create-github-app-token-via-key-vault@5ba0d436e9c3cac52feff4d1f2f66f9698ce4a2d # v1 + with: + client-id: ${{ vars.TYPESCRIPT_AUTOMATION_GITHUB_APP_CLIENT_ID }} + key-id: ${{ vars.TYPESCRIPT_AUTOMATION_GITHUB_APP_KEY_ID }} + owner: microsoft + repositories: TypeScript + permission-issues: write + - name: Check build result + env: + PUBLISH_RESULT: ${{ needs.publish.result }} + run: '[[ "$PUBLISH_RESULT" == "success" ]]' + - uses: microsoft/typescript-bot-test-triggerer/.github/actions/post-workflow-result@08bcbbf253fd6bc533dd8b9ab47a58582be57fbe # master + if: ${{ !cancelled() }} + with: + success_comment: |- + I've packed ${{ needs.prepare.outputs.head_sha }} into [an installable package](https://www.npmjs.com/package/${{ fromJSON(needs.prepare.outputs.packages).main }}/v/${{ needs.publish.outputs.version }}). You can install it for testing by referencing it in your `package.json` like so: + ```json + { + "devDependencies": { + "typescript": "npm:${{ fromJSON(needs.prepare.outputs.packages).main }}@${{ needs.publish.outputs.version }}" + } + } + ``` + and then running `npm install`. + + There is also a playground [for this build](https://www.typescriptlang.org/play/v2/?ts=${{ needs.publish.outputs.version }}). + failure_comment: 'I was unable to build and publish this PR.' + github_token: ${{ steps.app-token.outputs.token }} + distinct_id: ${{ inputs.distinct_id }} + source_issue: ${{ inputs.source_issue }} + requesting_user: ${{ inputs.requesting_user }} + status_comment: ${{ inputs.status_comment }} diff --git a/Herebyfile.mjs b/Herebyfile.mjs index d92906228993a..05e3f96e625f5 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -113,6 +113,7 @@ const { values: rawOptions } = parseArgs({ forRelease: { type: "boolean" }, respectGoEnv: { type: "boolean" }, vscodeTypescriptRelease: { type: "boolean" }, + prBuild: { type: "boolean" }, platform: { type: "string" }, race: { type: "boolean", default: parseEnvBoolean("RACE") }, @@ -136,7 +137,13 @@ const options = /** @type {Options} */ (rawOptions); const typeScriptReleaseVersion = /** @type {string | undefined} */ (undefined); const releaseVscodeTypescript = !!options.vscodeTypescriptRelease; const produceVscodeTypescriptVsix = releaseVscodeTypescript; -const produceTypeScriptNightlyVsix = !typeScriptReleaseVersion && !releaseVscodeTypescript; +// PR builds are published under a separate package name so that unreviewed +// code never ends up in the real TypeScript packages. +const prBuild = !!options.prBuild; +const prBuildScope = "@typescript-deploys"; +const prBuildBaseName = "pr-build"; +const prBuildPrereleasePattern = /^pr\.[1-9]\d*\.[1-9]\d*$/; +const produceTypeScriptNightlyVsix = !typeScriptReleaseVersion && !releaseVscodeTypescript && !prBuild; const usePublishedPlatformPackagesForVsix = releaseVscodeTypescript; const produceAnyVsix = produceVscodeTypescriptVsix || produceTypeScriptNightlyVsix; @@ -152,6 +159,17 @@ if (options.respectGoEnv && options.forRelease) { if (options.respectGoEnv && options.setPrerelease) { throw new Error("respectGoEnv requires the version declared in the source"); } +if (prBuild) { + if (releaseVscodeTypescript) { + throw new Error("prBuild cannot be combined with vscodeTypescriptRelease"); + } + if (typeof options.setPrerelease !== "string" || !prBuildPrereleasePattern.test(options.setPrerelease)) { + throw new Error("prBuild requires --setPrerelease pr.."); + } +} +else if (typeof options.setPrerelease === "string" && options.setPrerelease.startsWith("pr.")) { + throw new Error("pr.* prereleases are only published as PR builds; pass --prBuild"); +} const defaultGoBuildTags = [ ...(options.noembed ? ["noembed"] : []), @@ -1874,7 +1892,7 @@ export class Debouncer { } const getVersion = memoize(() => { - if (typeScriptReleaseVersion) { + if (typeScriptReleaseVersion && !prBuild) { return typeScriptReleaseVersion; } @@ -1897,6 +1915,9 @@ const getVersion = memoize(() => { }); function getPublishTag() { + if (prBuild) { + return "pr"; + } const version = getVersion(); if (!version) { throw new Error("Publishing 'typescript' requires a version before selecting an npm tag."); @@ -2193,16 +2214,17 @@ function cpWithoutNodeModulesOrTsconfig(src, dest) { } const mainTypeScriptPackage = { - npmPackageName: "typescript", - npmDir: path.join(builtNpm, "typescript"), - npmTarball: path.join(builtNpm, "typescript.tgz"), + npmPackageName: prBuild ? `${prBuildScope}/${prBuildBaseName}` : "typescript", + npmDir: path.join(builtNpm, prBuild ? prBuildBaseName : "typescript"), + npmTarball: path.join(builtNpm, prBuild ? `${prBuildBaseName}.tgz` : "typescript.tgz"), }; +const wasip1PackageDirName = prBuild ? `${prBuildBaseName}-wasip1-wasm` : "typescript-wasip1-wasm"; const wasip1Package = { - npmPackageName: "@typescript/typescript-wasip1-wasm", - npmDirName: "typescript-wasip1-wasm", - npmDir: path.join(builtNpm, "typescript-wasip1-wasm"), - npmTarball: path.join(builtNpm, "typescript-wasip1-wasm.tgz"), + npmPackageName: `${prBuild ? prBuildScope : "@typescript"}/${wasip1PackageDirName}`, + npmDirName: wasip1PackageDirName, + npmDir: path.join(builtNpm, wasip1PackageDirName), + npmTarball: path.join(builtNpm, `${wasip1PackageDirName}.tgz`), }; const typescriptMacEntitlements = [ @@ -2374,7 +2396,7 @@ function nodeToGOARCH(arch, os) { const getPlatforms = memoize(() => { const publishTag = releaseVscodeTypescript ? undefined : getPublishTag(); - let supportedPlatforms = !releaseVscodeTypescript && publishTag !== "next" + let supportedPlatforms = !releaseVscodeTypescript && !prBuild && publishTag !== "next" ? platforms : platforms.filter(({ vsix }) => vsix); @@ -2384,10 +2406,10 @@ const getPlatforms = memoize(() => { } return supportedPlatforms.map(({ os, arch, cert = "LinuxSign", vsix, alpine }) => { - const npmDirName = `typescript-${os}-${arch}`; + const npmDirName = `${prBuild ? prBuildBaseName : "typescript"}-${os}-${arch}`; const npmDir = path.join(builtNpm, npmDirName); const npmTarball = `${npmDir}.tgz`; - const npmPackageName = `@typescript/${npmDirName}`; + const npmPackageName = `${prBuild ? prBuildScope : "@typescript"}/${npmDirName}`; /** @type {VsixExtension[]} */ let extensions = []; @@ -2657,6 +2679,14 @@ export const buildTypeScriptPlatformMatrix = task({ ], ); console.log(`##vso[task.setvariable variable=matrix;isOutput=true]${JSON.stringify(matrix)}`); + if (process.env.GITHUB_OUTPUT) { + const packages = { + main: mainTypeScriptPackage.npmPackageName, + platforms: getPlatforms().map(p => p.npmPackageName), + wasm: wasip1Package.npmPackageName, + }; + fs.appendFileSync(process.env.GITHUB_OUTPUT, `matrix=${JSON.stringify({ include: Object.values(matrix) })}\npackages=${JSON.stringify(packages)}\n`); + } }, }); @@ -2684,7 +2714,25 @@ async function buildMainTypeScriptPackage(platforms) { await fs.promises.writeFile(path.join(mainPackageDir, "package.json"), JSON.stringify(mainPackage, undefined, 4)); await fs.promises.copyFile("LICENSE.txt", path.join(mainPackageDir, "LICENSE")); await fs.promises.copyFile("NOTICE.txt", path.join(mainPackageDir, "NOTICE.txt")); - await fs.promises.copyFile("README.md", path.join(mainPackageDir, "README.md")); + if (prBuild) { + const prNumber = getVersion().match(/-pr\.(\d+)\./)?.[1]; + const readme = [ + `# \`${mainTypeScriptPackage.npmPackageName}\``, + "", + `This package is an unofficial, unsigned build of TypeScript, built from microsoft/TypeScript#${prNumber}.`, + "It is published for testing only and must not be used in production.", + "", + "To try it, install it under the \`typescript\` name:", + "", + "```sh", + `npm install typescript@npm:${mainTypeScriptPackage.npmPackageName}@${getVersion()}`, + "```", + ]; + await fs.promises.writeFile(path.join(mainPackageDir, "README.md"), readme.join("\n") + "\n"); + } + else { + await fs.promises.copyFile("README.md", path.join(mainPackageDir, "README.md")); + } // Build the JS API and copy dist into the main package. await run("npm", ["run", "-w", "@typescript/typescript", "build"]); @@ -2723,6 +2771,7 @@ async function buildWasip1Package() { ? [] : getReleaseBuildFlags(options.setPrerelease || typeScriptReleaseVersion ? getVersion() : undefined); const wasmPackageJson = JSON.parse(fs.readFileSync(path.join(wasmInputDir, "package.json"), "utf8")); + wasmPackageJson.name = wasip1Package.npmPackageName; wasmPackageJson.version = getVersion(); wasmPackageJson.gitHead = inputPackageJson.gitHead; wasmPackageJson.publishConfig = { @@ -2913,6 +2962,9 @@ async function runSignTypeScriptPackages() { if (!options.forRelease) { throw new Error("This task should not be run in non-release builds."); } + if (prBuild) { + throw new Error("PR builds contain unreviewed code and must never be signed."); + } if (usePublishedPlatformPackagesForVsix) { checkPublishedPlatformPackagesForVsix(); console.log("Skipping npm package signing; VSIX packaging will use published platform packages."); diff --git a/packages/typescript/lib/getExePath.js b/packages/typescript/lib/getExePath.js index b0c27400828ef..5760b09dab47f 100644 --- a/packages/typescript/lib/getExePath.js +++ b/packages/typescript/lib/getExePath.js @@ -10,7 +10,7 @@ export default function getExePath({ fs, module, path, url }) { const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")); const pkgName = pkg.name; - const baseName = pkgName.startsWith("@") ? pkgName.split("/")[1] : pkgName; + const [scope, baseName] = pkgName.startsWith("@") ? pkgName.split("/") : ["@typescript", pkgName]; const binNames = pkg.bin && typeof pkg.bin === "object" ? Object.keys(pkg.bin) : []; if (binNames.length !== 1 || binNames[0] !== "tsc") { throw new Error(`Expected ${pkgName} to declare exactly one bin entry named tsc.`); @@ -29,7 +29,7 @@ export default function getExePath({ fs, module, path, url }) { } else { // We're actually running from an installed package. - const platformPackageName = "@typescript/" + expectedPackage; + const platformPackageName = scope + "/" + expectedPackage; try { if (typeof import.meta.resolve === "undefined") { // v16.20.1 diff --git a/packages/vscode-typescript/src/tsdkPackage.ts b/packages/vscode-typescript/src/tsdkPackage.ts index eb934a0f31d86..1e4164f22e216 100644 --- a/packages/vscode-typescript/src/tsdkPackage.ts +++ b/packages/vscode-typescript/src/tsdkPackage.ts @@ -77,7 +77,7 @@ export function isSameTypeScriptInstallation(current: ExeInfo | undefined, selec export function resolvePackageExecutable(packageJsonPath: string, platformPackage: string, exeName: string): string { const require = module.createRequire(packageJsonPath); - const platformPackageJson = require.resolve(`@typescript/${platformPackage}/package.json`); + const platformPackageJson = require.resolve(`${platformPackage}/package.json`); return path.join(path.dirname(platformPackageJson), "lib", exeName); } diff --git a/packages/vscode-typescript/src/util.ts b/packages/vscode-typescript/src/util.ts index 9cb0fa729fac6..03a0b2ad75272 100644 --- a/packages/vscode-typescript/src/util.ts +++ b/packages/vscode-typescript/src/util.ts @@ -333,13 +333,13 @@ export async function resolveTsdkPathToExe(tsdkPath: string): Promise { testResolution("resolves the TypeScript 7 package", { packagePath: "node_modules/typescript", - platformPath: `node_modules/@typescript/${platformPackage}`, + platformPath: `node_modules/${platformPackage}`, }); testResolution("resolves the TypeScript 7 scoped alias recommended in the 7.0 release post", { packagePath: "node_modules/@typescript/native", - platformPath: `node_modules/@typescript/${platformPackage}`, + platformPath: `node_modules/${platformPackage}`, }); testResolution("resolves an unscoped TypeScript 7 alias", { packagePath: "node_modules/typescript-next", - platformPath: `node_modules/@typescript/${platformPackage}`, + platformPath: `node_modules/${platformPackage}`, }); - const nativePlatformPackage = `native-preview-${process.platform}-${process.arch}`; + const nativePlatformPackageBaseName = `native-preview-${process.platform}-${process.arch}`; + const nativePlatformPackage = `@typescript/${nativePlatformPackageBaseName}`; testResolution("resolves the native-preview package", { packagePath: "node_modules/@typescript/typescript", - platformPath: `node_modules/@typescript/${nativePlatformPackage}`, + platformPath: `node_modules/${nativePlatformPackage}`, platformPackage: nativePlatformPackage, exeName: nativeExeName, }); testResolution("resolves a non-hoisted platform package", { packagePath: "node_modules/@typescript/native", - platformPath: `node_modules/@typescript/native/node_modules/@typescript/${platformPackage}`, + platformPath: `node_modules/@typescript/native/node_modules/${platformPackage}`, }); testResolution("resolves a platform package hoisted above a workspace", { packagePath: "packages/app/node_modules/@typescript/native", - platformPath: `node_modules/@typescript/${platformPackage}`, + platformPath: `node_modules/${platformPackage}`, }); const pnpmStore = "node_modules/.pnpm/typescript@7.0.2/node_modules"; testResolution("resolves a pnpm virtual-store package", { packagePath: `${pnpmStore}/typescript`, - platformPath: `node_modules/.pnpm/@typescript+${platformPackage}@7.0.2/node_modules/@typescript/${platformPackage}`, - dependencyLink: `${pnpmStore}/@typescript/${platformPackage}`, + platformPath: `node_modules/.pnpm/@typescript+${platformPackageBaseName}@7.0.2/node_modules/${platformPackage}`, + dependencyLink: `${pnpmStore}/${platformPackage}`, }); const nativePreviewVersion = "7.0.0-dev.20260707.2"; const pnpmNativePreviewStore = `node_modules/.pnpm/@typescript+native-preview@${nativePreviewVersion}/node_modules`; testResolution("resolves a pnpm native-preview virtual-store package", { packagePath: `${pnpmNativePreviewStore}/@typescript/typescript`, - platformPath: `node_modules/.pnpm/@typescript+${nativePlatformPackage}@${nativePreviewVersion}/node_modules/@typescript/${nativePlatformPackage}`, - dependencyLink: `${pnpmNativePreviewStore}/@typescript/${nativePlatformPackage}`, + platformPath: `node_modules/.pnpm/@typescript+${nativePlatformPackageBaseName}@${nativePreviewVersion}/node_modules/${nativePlatformPackage}`, + dependencyLink: `${pnpmNativePreviewStore}/${nativePlatformPackage}`, platformPackage: nativePlatformPackage, exeName: nativeExeName, }); @@ -184,8 +186,8 @@ describe("tsdk package resolution", { concurrency: true }, () => { const npmStore = "node_modules/.store/typescript@7.0.2-hash/node_modules"; testResolution("resolves an npm linked-store package", { packagePath: `${npmStore}/typescript`, - platformPath: `node_modules/.store/@typescript+${platformPackage}@7.0.2-hash/node_modules/@typescript/${platformPackage}`, - dependencyLink: `${npmStore}/@typescript/${platformPackage}`, + platformPath: `node_modules/.store/@typescript+${platformPackageBaseName}@7.0.2-hash/node_modules/${platformPackage}`, + dependencyLink: `${npmStore}/${platformPackage}`, }); test("throws when the platform package is missing", t => { diff --git a/tools/scripts/validate-pr-build.mjs b/tools/scripts/validate-pr-build.mjs new file mode 100644 index 0000000000000..424c06e042980 --- /dev/null +++ b/tools/scripts/validate-pr-build.mjs @@ -0,0 +1,85 @@ +// Validates the tarballs produced by the PR build workflow before they are +// published to npm. The tarballs are built from unreviewed PR code, so nothing +// in them (including the publish manifest) is trusted; everything is checked +// against values computed by the trusted part of the workflow. +// +// Usage: node validate-pr-build.mjs +// +// is the JSON package list written by typescript:build-platform-matrix +// when run from trusted code: { main, platforms: [...], wasm }. +// +// Prints the tarball file names in publish order, one per line. + +import assert from "node:assert"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const [tarballDir, prerelease, gitHead, packagesJson] = process.argv.slice(2); +assert(tarballDir && prerelease && gitHead && packagesJson, "Usage: validate-pr-build.mjs "); +assert.match(prerelease, /^pr\.[1-9]\d*\.[1-9]\d*$/); +assert.match(gitHead, /^[0-9a-f]{40}$/); + +/** @type {{ main: string; platforms: string[]; wasm: string }} */ +const { main: mainName, platforms: platformNames, wasm: wasmName } = JSON.parse(packagesJson); +assert(typeof mainName === "string" && typeof wasmName === "string" && Array.isArray(platformNames) && platformNames.length > 0, "Invalid package list"); +const expectedNames = new Set([mainName, ...platformNames, wasmName]); +const versionPattern = new RegExp(`^\\d+\\.\\d+\\.\\d+-${prerelease.replaceAll(".", "\\.")}$`); + +/** @type {Map} */ +const packages = new Map(); +for (const filename of fs.readdirSync(tarballDir)) { + if (!filename.endsWith(".tgz")) continue; + const tarball = path.join(tarballDir, filename); + + // npm extracts everything under package/; reject anything that is not a + // plain file or directory there. + const listing = execFileSync("tar", ["-tvzf", tarball], { encoding: "utf8" }).split("\n").filter(Boolean); + for (const line of listing) { + const entryPath = line.split(/\s+/).slice(5).join(" "); + assert(line[0] === "-" || line[0] === "d", `${filename}: unexpected entry type: ${line}`); + assert(entryPath.startsWith("package/") && !entryPath.split("/").includes(".."), `${filename}: unexpected path: ${entryPath}`); + } + + const manifest = JSON.parse(execFileSync("tar", ["-xzOf", tarball, "package/package.json"], { encoding: "utf8" })); + const { name, version } = manifest; + assert(expectedNames.has(name), `${filename}: unexpected package name ${name}`); + assert(!packages.has(name), `${filename}: duplicate package ${name}`); + assert.match(version, versionPattern, `${filename}: unexpected version`); + assert.equal(manifest.gitHead, gitHead, `${filename}: unexpected gitHead`); + assert.deepEqual(manifest.publishConfig, { access: "public", tag: "pr" }, `${filename}: unexpected publishConfig`); + // No lifecycle scripts; installing a PR build must not run code. + assert.equal(manifest.scripts, undefined, `${filename}: must not declare scripts`); + for (const field of ["dependencies", "peerDependencies", "bundleDependencies", "bundledDependencies"]) { + assert.equal(manifest[field], undefined, `${filename}: must not declare ${field}`); + } + if (name === mainName) { + assert.deepEqual(manifest.bin, { tsc: "./bin/tsc" }, `${filename}: unexpected bin`); + } + else { + assert.equal(manifest.bin, undefined, `${filename}: must not declare bin`); + assert.equal(manifest.optionalDependencies, undefined, `${filename}: must not declare optionalDependencies`); + } + packages.set(name, { filename, version, manifest }); +} + +for (const name of expectedNames) { + assert(packages.has(name), `Missing package ${name}`); +} + +const versions = new Set([...packages.values()].map(p => p.version)); +assert.equal(versions.size, 1, `Packages have mismatched versions: ${[...versions].join(", ")}`); +const [version] = versions; + +const main = /** @type {NonNullable>} */ (packages.get(mainName)); +assert.deepEqual( + main.manifest.optionalDependencies, + Object.fromEntries(platformNames.map(name => [name, version])), + "Main package must depend on exactly the platform packages at the same version", +); + +// Platform and WASM packages first, so the main package never refers to a +// version that does not exist yet. +for (const name of [...platformNames, wasmName, mainName]) { + console.log(packages.get(name)?.filename); +} From 0fe84ef70c46f2d8b46a096b3316a20eb89bd892 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 9 Nov 2026 09:34:36 -0800 Subject: [PATCH 2/6] Reject non-canonical and duplicate paths in PR build tarballs GNU tar, which the validator uses to read package.json, takes the first matching entry, while npm's extraction keeps the last one written and treats paths like package/./package.json as the same file. A tarball could therefore show the validator one manifest and install another, bypassing the lifecycle script and dependency checks. --- tools/scripts/validate-pr-build.mjs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tools/scripts/validate-pr-build.mjs b/tools/scripts/validate-pr-build.mjs index 424c06e042980..40cd01d4707c8 100644 --- a/tools/scripts/validate-pr-build.mjs +++ b/tools/scripts/validate-pr-build.mjs @@ -34,11 +34,17 @@ for (const filename of fs.readdirSync(tarballDir)) { // npm extracts everything under package/; reject anything that is not a // plain file or directory there. + // Paths must be canonical and unique, since GNU tar and npm disagree on + // which duplicate wins. const listing = execFileSync("tar", ["-tvzf", tarball], { encoding: "utf8" }).split("\n").filter(Boolean); + const entryPaths = new Set(); for (const line of listing) { - const entryPath = line.split(/\s+/).slice(5).join(" "); + const entryPath = line.split(/\s+/).slice(5).join(" ").replace(/\/$/, ""); + const segments = entryPath.split("/"); assert(line[0] === "-" || line[0] === "d", `${filename}: unexpected entry type: ${line}`); - assert(entryPath.startsWith("package/") && !entryPath.split("/").includes(".."), `${filename}: unexpected path: ${entryPath}`); + assert(segments[0] === "package" && segments.every(s => s !== "" && s !== "." && s !== ".."), `${filename}: unexpected path: ${entryPath}`); + assert(!entryPaths.has(entryPath), `${filename}: duplicate entry: ${entryPath}`); + entryPaths.add(entryPath); } const manifest = JSON.parse(execFileSync("tar", ["-xzOf", tarball, "package/package.json"], { encoding: "utf8" })); From 619479e2db3d24183d52bbaa99ac8662f8eac5ed Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 9 Nov 2026 09:34:36 -0800 Subject: [PATCH 3/6] Reject binding.gyp in PR build tarballs npm runs node-gyp rebuild on install for any package with a root binding.gyp and no install script, so forbidding the scripts field alone does not stop a PR build from running code on install. --- tools/scripts/validate-pr-build.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/scripts/validate-pr-build.mjs b/tools/scripts/validate-pr-build.mjs index 40cd01d4707c8..6c188492c0bb4 100644 --- a/tools/scripts/validate-pr-build.mjs +++ b/tools/scripts/validate-pr-build.mjs @@ -44,6 +44,8 @@ for (const filename of fs.readdirSync(tarballDir)) { assert(line[0] === "-" || line[0] === "d", `${filename}: unexpected entry type: ${line}`); assert(segments[0] === "package" && segments.every(s => s !== "" && s !== "." && s !== ".."), `${filename}: unexpected path: ${entryPath}`); assert(!entryPaths.has(entryPath), `${filename}: duplicate entry: ${entryPath}`); + // npm implicitly runs node-gyp for packages with a root binding.gyp. + assert(entryPath !== "package/binding.gyp", `${filename}: must not contain binding.gyp`); entryPaths.add(entryPath); } From f80fdb2f8894e1da1037cd4afa04adfb7ec081af Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 9 Nov 2026 09:34:36 -0800 Subject: [PATCH 4/6] Refuse to mix commits when publishing a rerun PR build Rerunning every job keeps the run number, and so the version, but resolves the PR's merge commit again. If the PR or its base moved, packages left over from the earlier attempt would be skipped as already published and mixed with packages built from a different commit. --- .github/workflows/pr-build.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 8c749b28ad422..0dfa183e1d446 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -230,14 +230,22 @@ jobs: # Trusted publishing needs npm 11.5.1 or later. npm install -g npm@11 + toPublish=() for tarball in "${tarballs[@]}"; do manifest="$(tar -xzOf "$NPM_DIR/$tarball" package/package.json)" name="$(jq -r .name <<< "$manifest")" version="$(jq -r .version <<< "$manifest")" - if npm view "$name@$version" version > /dev/null 2>&1; then + if publishedHead="$(npm view "$name@$version" gitHead 2> /dev/null)" && [[ -n "$publishedHead" ]]; then + if [[ "$publishedHead" != "$MERGE_SHA" ]]; then + echo "$name@$version was already published from $publishedHead; request a new build." >&2 + exit 1 + fi echo "$name@$version is already published." continue fi + toPublish+=("$tarball") + done + for tarball in "${toPublish[@]}"; do npm publish "$NPM_DIR/$tarball" --access public --tag pr done echo "version=$version" >> "$GITHUB_OUTPUT" From c8693c48621285bc1f841669325a9c1ea4394bbb Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 9 Nov 2026 09:34:36 -0800 Subject: [PATCH 5/6] Report PR build failures that happen before the matrix is set When prepare fails, its packages output is empty and fromJSON('') makes the comment inputs fail to evaluate, so the bot never posts the failure or clears its pending status. --- .github/workflows/pr-build.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 0dfa183e1d446..46d602ed2519e 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -283,11 +283,11 @@ jobs: if: ${{ !cancelled() }} with: success_comment: |- - I've packed ${{ needs.prepare.outputs.head_sha }} into [an installable package](https://www.npmjs.com/package/${{ fromJSON(needs.prepare.outputs.packages).main }}/v/${{ needs.publish.outputs.version }}). You can install it for testing by referencing it in your `package.json` like so: + I've packed ${{ needs.prepare.outputs.head_sha }} into [an installable package](https://www.npmjs.com/package/${{ fromJSON(needs.prepare.outputs.packages || '{}').main }}/v/${{ needs.publish.outputs.version }}). You can install it for testing by referencing it in your `package.json` like so: ```json { "devDependencies": { - "typescript": "npm:${{ fromJSON(needs.prepare.outputs.packages).main }}@${{ needs.publish.outputs.version }}" + "typescript": "npm:${{ fromJSON(needs.prepare.outputs.packages || '{}').main }}@${{ needs.publish.outputs.version }}" } } ``` From 34c34c205c6a4f0e347a090f1c30dc45d5daebb5 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 9 Nov 2026 10:10:09 -0800 Subject: [PATCH 6/6] Allow only known root entries in PR build tarballs npm gives several root files install-time meaning: binding.gyp in any case on case-insensitive filesystems, npm-shrinkwrap.json redirecting dependency resolution, and node_modules as bundled dependencies. Rejecting them one at a time leaves gaps for whatever is missed, so restrict the root to the entries the packages actually ship. --- tools/scripts/validate-pr-build.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tools/scripts/validate-pr-build.mjs b/tools/scripts/validate-pr-build.mjs index 6c188492c0bb4..68fa901132267 100644 --- a/tools/scripts/validate-pr-build.mjs +++ b/tools/scripts/validate-pr-build.mjs @@ -26,6 +26,10 @@ assert(typeof mainName === "string" && typeof wasmName === "string" && Array.isA const expectedNames = new Set([mainName, ...platformNames, wasmName]); const versionPattern = new RegExp(`^\\d+\\.\\d+\\.\\d+-${prerelease.replaceAll(".", "\\.")}$`); +// npm gives some root files (binding.gyp, npm-shrinkwrap.json, node_modules) +// install-time meaning, so only allow what the packages actually contain. +const allowedRootEntries = new Set(["LICENSE", "NOTICE.txt", "README.md", "bin", "dist", "enums", "lib", "package.json", "schemas", "vendor"]); + /** @type {Map} */ const packages = new Map(); for (const filename of fs.readdirSync(tarballDir)) { @@ -44,8 +48,7 @@ for (const filename of fs.readdirSync(tarballDir)) { assert(line[0] === "-" || line[0] === "d", `${filename}: unexpected entry type: ${line}`); assert(segments[0] === "package" && segments.every(s => s !== "" && s !== "." && s !== ".."), `${filename}: unexpected path: ${entryPath}`); assert(!entryPaths.has(entryPath), `${filename}: duplicate entry: ${entryPath}`); - // npm implicitly runs node-gyp for packages with a root binding.gyp. - assert(entryPath !== "package/binding.gyp", `${filename}: must not contain binding.gyp`); + assert(segments.length === 1 || allowedRootEntries.has(segments[1]), `${filename}: unexpected root entry: ${entryPath}`); entryPaths.add(entryPath); }