diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml new file mode 100644 index 0000000000000..46d602ed2519e --- /dev/null +++ b/.github/workflows/pr-build.yml @@ -0,0 +1,302 @@ +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 + 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 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" + + 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..68fa901132267 --- /dev/null +++ b/tools/scripts/validate-pr-build.mjs @@ -0,0 +1,96 @@ +// 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(".", "\\.")}$`); + +// 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)) { + 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. + // 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(" ").replace(/\/$/, ""); + const segments = entryPath.split("/"); + 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}`); + assert(segments.length === 1 || allowedRootEntries.has(segments[1]), `${filename}: unexpected root entry: ${entryPath}`); + entryPaths.add(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); +}