diff --git a/.github/workflows/bump-vscode-typescript.yml b/.github/workflows/bump-vscode-typescript.yml new file mode 100644 index 0000000000000..65bbe96afc2ec --- /dev/null +++ b/.github/workflows/bump-vscode-typescript.yml @@ -0,0 +1,179 @@ +name: Bump vscode-typescript + +on: + workflow_dispatch: + inputs: + bump: + description: Version component to bump + required: true + type: choice + options: + - patch + - minor + - major + +run-name: Bump vscode-typescript (${{ inputs.bump }}) + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + prepare: + if: github.repository == 'microsoft/TypeScript' + runs-on: ubuntu-latest + outputs: + source-sha: ${{ steps.source.outputs.sha }} + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + filter: blob:none + fetch-depth: 0 + persist-credentials: false + + - name: Record source commit + id: source + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 'lts/*' + + - run: npm ci + + - name: Update extension version + env: + BUMP: ${{ inputs.bump }} + run: | + set -euo pipefail + npm version "$BUMP" \ + --workspace native-preview \ + --no-git-tag-version \ + + packageVersion="$(jq -r '.version' packages/vscode-typescript/package.json)" + lockVersion="$(jq -r '.packages["packages/vscode-typescript"].version' package-lock.json)" + if [ "$packageVersion" != "$lockVersion" ]; then + echo "package.json version $packageVersion does not match package-lock.json version $lockVersion." >&2 + exit 1 + fi + + - run: npm test -w native-preview + + - name: Package extension + run: npx hereby vscode-typescript:pack --forRelease --vscodeTypescriptRelease + + - name: Create version bump patch + run: git diff --binary -- packages/vscode-typescript/package.json package-lock.json > vscode-typescript-bump.patch + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: vscode-typescript-bump + path: vscode-typescript-bump.patch + if-no-files-found: error + + create-pr: + needs: prepare + if: github.repository == 'microsoft/TypeScript' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + environment: + name: azure + deployment: false + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.prepare.outputs.source-sha }} + filter: blob:none + fetch-depth: 0 + persist-credentials: false + + - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0 + with: + name: vscode-typescript-bump + path: ${{ runner.temp }} + + - name: Apply version bump + run: | + set -euo pipefail + git apply --index "$RUNNER_TEMP/vscode-typescript-bump.patch" + + mapfile -t changedFiles < <(git diff --cached --name-only) + expectedFiles=("package-lock.json" "packages/vscode-typescript/package.json") + if [ "${changedFiles[*]}" != "${expectedFiles[*]}" ]; then + echo "Unexpected files in version bump: ${changedFiles[*]}" >&2 + exit 1 + fi + + - uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.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-contents: write + permission-pull-requests: write + + - name: Commit, push, and open pull request + env: + SOURCE_SHA: ${{ needs.prepare.outputs.source-sha }} + GITHUB_APP_TOKEN: ${{ steps.app-token.outputs.token }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + EXTENSION_VERSION="$(jq -r '.version' packages/vscode-typescript/package.json)" + branch="vscode-typescript-release/v$EXTENSION_VERSION" + git switch -c "$branch" + git config user.email "290192711+typescript-automation[bot]@users.noreply.github.com" + git config user.name "typescript-automation[bot]" + git config core.hooksPath /dev/null + git commit -m "Bump vscode-typescript to $EXTENSION_VERSION" + + basic_auth="$(node -e 'process.stdout.write(Buffer.from("x-access-token:" + process.env.GITHUB_APP_TOKEN).toString("base64"))')" + echo "::add-mask::$basic_auth" + git config --local http.https://github.com/.extraheader "AUTHORIZATION: basic ${basic_auth}" + + if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then + git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch" + existingCommit="$(git rev-parse "origin/$branch")" + existingParent="$(git rev-parse "origin/$branch^")" + if [ "$existingParent" != "$SOURCE_SHA" ] || ! git diff --quiet HEAD "$existingCommit"; then + echo "Existing branch $branch does not match this release bump." >&2 + exit 1 + fi + else + git push --set-upstream origin "$branch" + fi + + existingPr="$(gh pr list \ + --repo microsoft/TypeScript \ + --base main \ + --head "$branch" \ + --state open \ + --json url \ + --jq '.[0].url // empty')" + if [ -n "$existingPr" ]; then + echo "Pull request already exists: $existingPr" + else + gh pr create \ + --repo microsoft/TypeScript \ + --base main \ + --head "$branch" \ + --title "Bump vscode-typescript to $EXTENSION_VERSION" \ + --body "Updates the vscode-typescript extension to $EXTENSION_VERSION." + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a864147929cf..851b17462a5d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,7 @@ jobs: - uses: ./.github/actions/setup-go - run: npm ci - run: npx hereby typescript:release --forRelease --setPrerelease dev.0.0 + - run: npx hereby vscode-typescript:release --forRelease --vscodeTypescriptRelease extension: runs-on: ubuntu-latest @@ -331,6 +332,7 @@ jobs: - run: npm ci - run: go -C ./tools run ./cmd/checkmodpaths "$PWD" - run: npx hereby check:herebyfile + - run: npx hereby check:vsce-version - run: npx hereby check:scripts - run: npx hereby typescript:check-platforms diff --git a/.github/workflows/tag-vscode-typescript.yml b/.github/workflows/tag-vscode-typescript.yml new file mode 100644 index 0000000000000..cc29864bb62ba --- /dev/null +++ b/.github/workflows/tag-vscode-typescript.yml @@ -0,0 +1,116 @@ +name: Tag vscode-typescript release + +on: + pull_request_target: + types: [closed] + branches: [main] + paths: + - packages/vscode-typescript/package.json + +run-name: Tag vscode-typescript release + +concurrency: + group: tag-vscode-typescript-${{ github.event.pull_request.merge_commit_sha }} + cancel-in-progress: false + +permissions: + contents: read + id-token: write + +defaults: + run: + shell: bash + +jobs: + tag: + if: >- + github.repository == 'microsoft/TypeScript' && + github.event.pull_request.merged == true + runs-on: ubuntu-latest + environment: + name: azure + deployment: false + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + filter: blob:none + fetch-depth: 2 + persist-credentials: false + + - name: Check for extension version bump + id: version + run: | + set -euo pipefail + packagePath="packages/vscode-typescript/package.json" + previousVersion="$(git show "HEAD^:$packagePath" | jq -r '.version')" + version="$(jq -r '.version' "$packagePath")" + + if [ "$previousVersion" = "$version" ]; then + echo "$packagePath changed without changing its version." + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if ! [[ "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "Invalid extension version: $version" >&2 + exit 1 + fi + + lockVersion="$(jq -r '.packages["packages/vscode-typescript"].version' package-lock.json)" + if [ "$version" != "$lockVersion" ]; then + echo "package.json version $version does not match package-lock.json version $lockVersion." >&2 + exit 1 + fi + + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + if: steps.version.outputs.changed == 'true' + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + + - name: Create GitHub App token + if: steps.version.outputs.changed == 'true' + 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-contents: write + + - name: Create release tag + if: steps.version.outputs.changed == 'true' + env: + EXTENSION_VERSION: ${{ steps.version.outputs.version }} + GITHUB_APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + tag="vscode-typescript/v$EXTENSION_VERSION" + git config user.email "290192711+typescript-automation[bot]@users.noreply.github.com" + git config user.name "typescript-automation[bot]" + git config core.hooksPath /dev/null + + basic_auth="$(node -e 'process.stdout.write(Buffer.from("x-access-token:" + process.env.GITHUB_APP_TOKEN).toString("base64"))')" + echo "::add-mask::$basic_auth" + git config --local http.https://github.com/.extraheader "AUTHORIZATION: basic ${basic_auth}" + + if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null; then + git fetch origin "refs/tags/$tag:refs/tags/$tag" + existingCommit="$(git rev-list -n 1 "$tag")" + if [ "$existingCommit" != "$(git rev-parse HEAD)" ]; then + echo "Tag $tag already exists at $existingCommit." >&2 + exit 1 + fi + echo "Tag $tag already exists at the release commit." + exit 0 + fi + + git tag --annotate "$tag" --message "vscode-typescript $EXTENSION_VERSION" + git push origin "refs/tags/$tag" diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 381b13808b5e4..f2cb45e00c97e 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -27,6 +27,8 @@ const __filename = url.fileURLToPath(new URL(import.meta.url)); const __dirname = path.dirname(__filename); const isCI = !!process.env.CI || !!process.env.TF_BUILD; +const stableThreeComponentVersionPatternSource = String.raw`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`; +const stableThreeComponentVersionPattern = new RegExp(stableThreeComponentVersionPatternSource); /** * @typedef {{ @@ -109,6 +111,7 @@ const { values: rawOptions } = parseArgs({ setPrerelease: { type: "string" }, forRelease: { type: "boolean" }, + vscodeTypescriptRelease: { type: "boolean" }, race: { type: "boolean", default: parseEnvBoolean("RACE") }, noembed: { type: "boolean", default: parseEnvBoolean("NOEMBED") }, @@ -130,17 +133,21 @@ const options = /** @type {Options} */ (rawOptions); // Main publishes prerelease builds of the TypeScript package. const nativePreviewReleaseProfile = /** @type {"native-preview" | "typescript"} */ ("typescript"); const nativePreviewReleaseVersion = /** @type {string | undefined} */ (undefined); -const produceNativePreviewVsix = /** @type {boolean} */ (false); -const produceTypeScriptNightlyVsix = /** @type {boolean} */ (true); -const usePublishedPlatformPackagesForVsix = /** @type {boolean} */ (false); +const releaseVscodeTypescript = !!options.vscodeTypescriptRelease; +const produceNativePreviewVsix = releaseVscodeTypescript; +const produceTypeScriptNightlyVsix = !releaseVscodeTypescript; +const usePublishedPlatformPackagesForVsix = releaseVscodeTypescript; const produceAnyVsix = produceNativePreviewVsix || produceTypeScriptNightlyVsix; const publishAsTypescript = nativePreviewReleaseProfile === "typescript"; -if (options.forRelease && !options.setPrerelease && (!nativePreviewReleaseVersion || produceAnyVsix)) { +if (releaseVscodeTypescript && options.setPrerelease) { + throw new Error("vscode-typescript releases use the extension's package.json version and do not accept setPrerelease"); +} +if (!releaseVscodeTypescript && options.forRelease && !options.setPrerelease && (!nativePreviewReleaseVersion || produceAnyVsix)) { throw new Error("forRelease requires setPrerelease unless nativePreviewReleaseVersion is hardcoded and VSIX production is disabled"); } -if (usePublishedPlatformPackagesForVsix && !publishAsTypescript) { - throw new Error("usePublishedPlatformPackagesForVsix requires nativePreviewReleaseProfile to be 'typescript'"); +if (releaseVscodeTypescript && !publishAsTypescript) { + throw new Error("vscode-typescript releases require nativePreviewReleaseProfile to be 'typescript'"); } const defaultGoBuildTags = [ @@ -1398,6 +1405,71 @@ export const checkHerebyfile = task({ ]), }); +export const checkVsceVersion = task({ + name: "check:vsce-version", + description: "Checks that Azure release jobs use the repository's pinned vsce version.", + run: () => { + for (const validVersion of ["0.0.0", "1.2.3", "10.20.30"]) { + assert(stableThreeComponentVersionPattern.test(validVersion), `${validVersion} should be a valid stable version.`); + } + for (const invalidVersion of ["01.0.0", "1.02.0", "1.2.03", "1.2", "1.2.3-beta"]) { + assert(!stableThreeComponentVersionPattern.test(invalidVersion), `${invalidVersion} should not be a valid stable version.`); + } + + for ( + const [workflow, expectedCount] of [ + ["./.github/workflows/tag-vscode-typescript.yml", 1], + ["./tools/pipelines/vscode-typescript-build.yml", 1], + ["./tools/pipelines/vscode-typescript-publish.yml", 2], + ] + ) { + const activeLines = fs.readFileSync(workflow, "utf8").split(/\r?\n/).filter(line => !line.trimStart().startsWith("#")); + const validators = activeLines.filter(line => line.includes(`=~ ${stableThreeComponentVersionPatternSource} ]]`)); + if (validators.length !== expectedCount) { + throw new Error(`${workflow} must contain exactly ${expectedCount} active stable version validator(s).`); + } + } + + const packageJson = JSON.parse(fs.readFileSync("./packages/vscode-typescript/package.json", "utf8")); + const packageLock = JSON.parse(fs.readFileSync("./package-lock.json", "utf8")); + const version = packageJson.devDependencies?.["@vscode/vsce"]; + if (typeof version !== "string" || !stableThreeComponentVersionPattern.test(version)) { + throw new Error(`packages/vscode-typescript must pin @vscode/vsce to an exact version, got ${JSON.stringify(version)}.`); + } + + const workspaceVersion = packageLock.packages?.["packages/vscode-typescript"]?.devDependencies?.["@vscode/vsce"]; + const installedVersion = packageLock.packages?.["node_modules/@vscode/vsce"]?.version; + if (workspaceVersion !== version || installedVersion !== version) { + throw new Error( + `@vscode/vsce version mismatch: package.json=${version}, package-lock workspace=${workspaceVersion}, package-lock package=${installedVersion}.`, + ); + } + + const setupVsce = fs.readFileSync("./tools/pipelines/steps/setup-vsce.yml", "utf8"); + const activeSetupLines = setupVsce.split(/\r?\n/).filter(line => !line.trimStart().startsWith("#")); + const installCommand = `- bash: npm install --no-save @vscode/vsce@${version}`; + if (activeSetupLines.filter(line => line.trim() === installCommand).length !== 1) { + throw new Error(`tools/pipelines/steps/setup-vsce.yml must install exactly @vscode/vsce@${version}.`); + } + + for ( + const pipeline of [ + "./tools/pipelines/typescript-publish.yml", + "./tools/pipelines/vscode-typescript-publish.yml", + ] + ) { + const contents = fs.readFileSync(pipeline, "utf8"); + const activeLines = contents.split(/\r?\n/).filter(line => !line.trimStart().startsWith("#")); + const templateReferences = activeLines.filter( + line => line.trim() === "- template: /tools/pipelines/steps/setup-vsce.yml@self", + ); + if (templateReferences.length !== 1 || activeLines.some(line => line.includes("@vscode/vsce"))) { + throw new Error(`${pipeline} must use setup-vsce.yml exactly once and must not install @vscode/vsce directly.`); + } + } + }, +}); + const scriptTsconfigs = [ "./tools/scripts/tsc/tsconfig.json", "./tsc/internal/lsp/lsproto/_generate/tsconfig.json", @@ -1699,6 +1771,20 @@ const builtPublishedPlatformPackages = path.resolve("./built/published-platform- const builtSignTmp = path.resolve("./built/sign-tmp"); const publishedTypeScriptAliasPackageName = "@typescript/bundled-typescript"; const releasePackageEnv = { COREPACK_ENABLE_STRICT: "0" }; +const getReleasePackageRegistry = memoize(async () => { + const { stdout } = await runOutput("npm", ["config", "get", "registry"], { env: releasePackageEnv }); + return stdout.trim(); +}); + +const getVscodeTypeScriptExtensionPackageJson = memoize(() => JSON.parse(fs.readFileSync(path.join(extensionDir, "package.json"), "utf8"))); + +function getVscodeTypeScriptExtensionVersion() { + const version = getVscodeTypeScriptExtensionPackageJson().version; + if (typeof version !== "string" || !stableThreeComponentVersionPattern.test(version)) { + throw new Error(`packages/vscode-typescript/package.json must contain a stable three-component version, got ${JSON.stringify(version)}.`); + } + return version; +} const getSignTempDir = memoize(async () => { const dir = path.resolve(builtSignTmp); @@ -2072,8 +2158,8 @@ function nodeToGOARCH(arch, os) { } const getPlatforms = memoize(() => { - const publishTag = getPublishTag(); - let supportedPlatforms = publishAsTypescript && publishTag !== "next" + const publishTag = releaseVscodeTypescript ? undefined : getPublishTag(); + let supportedPlatforms = !releaseVscodeTypescript && publishAsTypescript && publishTag !== "next" ? platforms : platforms.filter(({ vsix }) => vsix); @@ -2649,9 +2735,8 @@ const getPublishedTypeScriptPackageJson = memoize(() => { function getPublishedTypeScriptVersion() { const version = getPublishedTypeScriptPackageJson().version; - const expectedVersion = getVersion(); - if (usePublishedPlatformPackagesForVsix && version !== expectedVersion) { - throw new Error(`usePublishedPlatformPackagesForVsix requires ${publishedTypeScriptAliasPackageName}'s installed version (${version}) to match release version ${expectedVersion}.`); + if (releaseVscodeTypescript && !stableThreeComponentVersionPattern.test(version)) { + throw new Error(`vscode-typescript releases require a stable three-component TypeScript version, got ${version}.`); } return version; } @@ -2707,17 +2792,36 @@ async function getPublishedPlatformPackageLibDirWorker(npmPackageName) { if (!lockEntry.resolved || typeof lockEntry.resolved !== "string") { throw new Error(`package-lock.json entry for ${npmPackageName}@${version} does not contain a tarball URL.`); } + if (!lockEntry.integrity || typeof lockEntry.integrity !== "string") { + throw new Error(`package-lock.json entry for ${npmPackageName}@${version} does not contain an integrity hash.`); + } - console.log(`Fetching ${npmPackageName}@${version} with npm.`); - const { stdout } = await runOutput("npm", ["pack", "--json", `${npmPackageName}@${version}`], { - cwd: tarballDestination, - env: releasePackageEnv, - }); - const [packed] = JSON.parse(stdout); - if (!packed.filename || typeof packed.filename !== "string") { - throw new Error(`npm pack ${npmPackageName}@${version} did not return a filename.`); + const resolved = new URL(lockEntry.resolved); + if (resolved.origin === "https://registry.npmjs.org") { + const registry = new URL(await getReleasePackageRegistry()); + resolved.protocol = registry.protocol; + resolved.host = registry.host; + resolved.pathname = path.posix.join(registry.pathname, resolved.pathname); } - await tar.x({ file: path.join(tarballDestination, packed.filename), cwd: dest, strip: 1 }); + + console.log(`Fetching locked ${npmPackageName}@${version} tarball from ${resolved}.`); + const response = await fetch(resolved); + if (!response.ok) { + throw new Error(`Failed to fetch ${npmPackageName}@${version}: ${response.status} ${response.statusText}.`); + } + const tarball = Buffer.from(await response.arrayBuffer()); + const integrityMatch = /^sha512-(.+)$/.exec(lockEntry.integrity); + if (!integrityMatch) { + throw new Error(`Unsupported integrity hash for ${npmPackageName}@${version}: ${lockEntry.integrity}.`); + } + const expectedIntegrity = Buffer.from(integrityMatch[1], "base64"); + const actualIntegrity = crypto.createHash("sha512").update(tarball).digest(); + if (expectedIntegrity.length !== actualIntegrity.length || !crypto.timingSafeEqual(expectedIntegrity, actualIntegrity)) { + throw new Error(`Integrity check failed for ${npmPackageName}@${version}.`); + } + const tarballPath = path.join(tarballDestination, path.basename(resolved.pathname)); + await fs.promises.writeFile(tarballPath, tarball); + await tar.x({ file: tarballPath, cwd: dest, strip: 1 }); if (!fs.existsSync(lib)) { throw new Error(`Published platform package ${npmPackageName}@${version} did not contain a lib directory.`); @@ -2747,18 +2851,23 @@ async function runPackVsixExtensions() { let version = "0.0.0"; if (options.forRelease) { - // No real semver prerelease versioning. - // https://code.visualstudio.com/api/working-with-extensions/publishing-extension#prerelease-extensions - assert(options.setPrerelease, "forRelease is true but setPrerelease is not set"); - const prerelease = options.setPrerelease; - assert(typeof prerelease === "string", "setPrerelease is not a string"); - // parse `dev..`. - const match = prerelease.match(/dev\.(\d+)\.(\d+)/); - if (!match) { - throw new Error(`Prerelease version should be in the form of dev.., but got ${prerelease}`); + if (releaseVscodeTypescript) { + version = getVscodeTypeScriptExtensionVersion(); + } + else { + // No real semver prerelease versioning. + // https://code.visualstudio.com/api/working-with-extensions/publishing-extension#prerelease-extensions + assert(options.setPrerelease, "forRelease is true but setPrerelease is not set"); + const prerelease = options.setPrerelease; + assert(typeof prerelease === "string", "setPrerelease is not a string"); + // parse `dev..`. + const match = prerelease.match(/dev\.(\d+)\.(\d+)/); + if (!match) { + throw new Error(`Prerelease version should be in the form of dev.., but got ${prerelease}`); + } + // Set version to `0..`. + version = `0.${match[1]}.${match[2]}`; } - // Set version to `0..`. - version = `0.${match[1]}.${match[2]}`; } console.log("Version:", version); @@ -2826,10 +2935,55 @@ async function runSignVsixExtensions() { }); } +async function runWriteVscodeTypeScriptReleaseManifest() { + const platforms = getPlatforms(); + const extensions = platforms.flatMap(({ extensions }) => extensions); + /** @type {Record} */ + const artifacts = {}; + for (const extension of extensions) { + for (const artifactPath of [extension.vsixPath, extension.vsixManifestPath, extension.vsixSignaturePath]) { + const filename = path.basename(artifactPath); + artifacts[filename] = { + sha256: crypto.createHash("sha256").update(await fs.promises.readFile(artifactPath)).digest("hex"), + }; + } + } + + const packageJson = getVscodeTypeScriptExtensionPackageJson(); + const manifest = { + extension: `${packageJson.publisher}.${packageJson.name}`, + extensionVersion: getVscodeTypeScriptExtensionVersion(), + bundledTypeScriptVersion: getPublishedTypeScriptVersion(), + signType: process.env.VSCODE_TYPESCRIPT_SIGN_TYPE, + sourceRef: process.env.BUILD_SOURCEBRANCH || process.env.GITHUB_REF || undefined, + sourceCommit: process.env.BUILD_SOURCEVERSION || process.env.GITHUB_SHA || undefined, + targets: extensions.map(({ vscodeTarget }) => vscodeTarget), + artifacts, + }; + await fs.promises.writeFile(path.join(builtVsix, "release-manifest.json"), JSON.stringify(manifest, undefined, 4) + "\n"); +} + +export const vscodeTypescriptRelease = task({ + name: "vscode-typescript:release", + hiddenFromTaskList: true, + run: async () => { + if (!options.forRelease || !releaseVscodeTypescript) { + throw new Error("vscode-typescript:release requires --forRelease and --vscodeTypescriptRelease"); + } + await runPackVsixExtensions(); + await runSignVsixExtensions(); + await runWriteVscodeTypeScriptReleaseManifest(); + await runCleanSignTempDirectory(); + }, +}); + export const nativePreviewRelease = task({ name: "typescript:release", hiddenFromTaskList: true, run: async () => { + if (releaseVscodeTypescript) { + throw new Error("typescript:release cannot be used with --vscodeTypescriptRelease; use vscode-typescript:release"); + } if (!options.forRelease || !options.setPrerelease && (!nativePreviewReleaseVersion || produceAnyVsix)) { throw new Error("typescript:release requires --forRelease and --setPrerelease flags, unless nativePreviewReleaseVersion is hardcoded and VSIX production is disabled. Example: npx hereby typescript:release --forRelease --setPrerelease=dev.1.0"); } diff --git a/package-lock.json b/package-lock.json index 6bfed937d972a..a98eff3e2f81a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5505,7 +5505,7 @@ }, "packages/vscode-typescript": { "name": "native-preview", - "version": "0.0.0", + "version": "1.0.0", "dependencies": { "@vscode/extension-telemetry": "^1.5.2", "vscode-languageclient": "^10.1.0", @@ -5514,7 +5514,7 @@ "devDependencies": { "@types/vscode": "~1.125.0", "@typescript/bundled-typescript": "npm:typescript@7.0.2", - "@vscode/vsce": "^3.9.2", + "@vscode/vsce": "3.9.2", "esbuild": "^0.28.2" }, "engines": { diff --git a/packages/vscode-typescript/package.json b/packages/vscode-typescript/package.json index 3be3c59bd7b72..5fbead2166487 100644 --- a/packages/vscode-typescript/package.json +++ b/packages/vscode-typescript/package.json @@ -6,7 +6,7 @@ "description": "%description%", "icon": "logo.png", "private": true, - "version": "0.0.0", + "version": "1.0.0", "type": "commonjs", "l10n": "./l10n", "repository": { @@ -385,7 +385,7 @@ "devDependencies": { "@types/vscode": "~1.125.0", "@typescript/bundled-typescript": "npm:typescript@7.0.2", - "@vscode/vsce": "^3.9.2", + "@vscode/vsce": "3.9.2", "esbuild": "^0.28.2" } } diff --git a/tools/pipelines/steps/setup-node-npm-ci.yml b/tools/pipelines/steps/setup-node-npm-ci.yml index c358c9301b8ef..82891ca1db3ee 100755 --- a/tools/pipelines/steps/setup-node-npm-ci.yml +++ b/tools/pipelines/steps/setup-node-npm-ci.yml @@ -6,15 +6,10 @@ steps: - bash: | cat > .npmrc << 'EOF' - registry=https://pkgs.dev.azure.com/devdiv/devdiv/_packaging/devdiv_PublicPackages/npm/registry/ + registry=https://packagefeedproxy.microsoft.io/npm/ EOF displayName: 'Set up .npmrc' - - task: npmAuthenticate@0 - inputs: - workingFile: .npmrc - displayName: 'Authenticate npm' - - pwsh: | npm install -g (Get-Content package.json | ConvertFrom-Json).packageManager npm --version diff --git a/tools/pipelines/steps/setup-vsce.yml b/tools/pipelines/steps/setup-vsce.yml new file mode 100644 index 0000000000000..afac1da9d23b5 --- /dev/null +++ b/tools/pipelines/steps/setup-vsce.yml @@ -0,0 +1,23 @@ +parameters: + - name: condition + type: string + default: succeeded() + +steps: + - task: NodeTool@0 + condition: ${{ parameters.condition }} + inputs: + versionSpec: 24.x + displayName: 'Install Node' + + - bash: | + cat > .npmrc << 'EOF' + registry=https://packagefeedproxy.microsoft.io/npm/ + EOF + npm init -y + condition: ${{ parameters.condition }} + displayName: 'Set up npm' + + - bash: npm install --no-save @vscode/vsce@3.9.2 + condition: ${{ parameters.condition }} + displayName: 'Install vsce' diff --git a/tools/pipelines/typescript-publish.yml b/tools/pipelines/typescript-publish.yml index b85898c865e99..07a6bb74fb855 100755 --- a/tools/pipelines/typescript-publish.yml +++ b/tools/pipelines/typescript-publish.yml @@ -181,29 +181,9 @@ extends: fi displayName: 'Check VSIX artifacts' - - task: NodeTool@0 - condition: and(succeeded(), eq(variables['HasVsix'], 'true')) - inputs: - versionSpec: 24.x - displayName: 'Install Node' - - - bash: | - cat > .npmrc << 'EOF' - registry=https://pkgs.dev.azure.com/devdiv/devdiv/_packaging/devdiv_PublicPackages/npm/registry/ - EOF - npm init -y - displayName: 'Set up .npmrc' - condition: and(succeeded(), eq(variables['HasVsix'], 'true')) - - - task: npmAuthenticate@0 - condition: and(succeeded(), eq(variables['HasVsix'], 'true')) - inputs: - workingFile: .npmrc - displayName: 'Authenticate npm' - - - bash: npm install @vscode/vsce@latest - displayName: 'Install vsce' - condition: and(succeeded(), eq(variables['HasVsix'], 'true')) + - template: /tools/pipelines/steps/setup-vsce.yml@self + parameters: + condition: and(succeeded(), eq(variables['HasVsix'], 'true')) - task: AzureCLI@2 displayName: 'Check Marketplace Auth' diff --git a/tools/pipelines/vscode-typescript-build.yml b/tools/pipelines/vscode-typescript-build.yml new file mode 100644 index 0000000000000..d373a2d943edf --- /dev/null +++ b/tools/pipelines/vscode-typescript-build.yml @@ -0,0 +1,161 @@ +trigger: + tags: + include: + - vscode-typescript/v* + +pr: none + +name: TypeScript-VS-Code-Extension-Release-Build-$(Date:yyyyMMdd)$(Rev:.r) +appendCommitMessageToRunName: false + +variables: + - name: TeamName + value: TypeScript + +resources: + repositories: + - repository: MicroBuildTemplate + type: git + name: 1ESPipelineTemplates/MicroBuildTemplate + ref: refs/tags/release + +extends: + template: azure-pipelines/MicroBuild.1ES.Official.yml@MicroBuildTemplate + + parameters: + settings: + networkIsolationPolicy: Permissive,CFSClean + sdl: + git: + submodules: false + fetchDepth: 0 + fetchTags: false + retryCount: 3 + sourceAnalysisPool: VSEngSS-MicroBuild2022-1ES + pool: + name: AzurePipelines-EO + image: 1ESPT-Ubuntu22.04 + os: linux + + stages: + - stage: Build + displayName: Build and sign vscode-typescript + jobs: + - job: Build + displayName: Build and sign vscode-typescript + timeoutInMinutes: 90 + + templateContext: + outputs: + - output: pipelineArtifact + targetPath: $(Build.ArtifactStagingDirectory)/vsix + artifactName: vsix + + steps: + - checkout: self + clean: true + submodules: false + fetchDepth: 0 + fetchFilter: blob:none + fetchTags: false + + - bash: | + set -euo pipefail + git fetch origin main --no-tags + if ! git merge-base --is-ancestor "$BUILD_SOURCEVERSION" refs/remotes/origin/main; then + echo "Release tags must point to commits already merged into main." >&2 + exit 1 + fi + + tag="${BUILD_SOURCEBRANCH#refs/tags/}" + case "$tag" in + vscode-typescript/v*) ;; + *) + echo "Expected a vscode-typescript/v* tag, got $BUILD_SOURCEBRANCH." >&2 + exit 1 + ;; + esac + + tagVersion="${tag#vscode-typescript/v}" + packageVersion="$(jq -r '.version' packages/vscode-typescript/package.json)" + lockVersion="$(jq -r '.packages["packages/vscode-typescript"].version' package-lock.json)" + if [ "$tagVersion" != "$packageVersion" ]; then + echo "Tag version $tagVersion does not match package version $packageVersion." >&2 + exit 1 + fi + if [ "$packageVersion" != "$lockVersion" ]; then + echo "package.json version $packageVersion does not match package-lock.json version $lockVersion." >&2 + exit 1 + fi + if ! [[ "$packageVersion" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "Extension version must be a stable three-component version." >&2 + exit 1 + fi + + echo "##vso[build.updatebuildnumber]vscode-typescript-$packageVersion" + displayName: Validate release tag + + - task: NuGetAuthenticate@1 + displayName: '🔩 NuGet Authenticate' + - task: UsePythonVersion@0 + displayName: Use Python 3.11 + inputs: + versionSpec: 3.11 + - task: UseDotNet@2 + displayName: Use .NET Core 3.1.x + inputs: + packageType: sdk + version: 3.1.x + - task: UseDotNet@2 + displayName: Use .NET Core SDK 8.0.x + inputs: + version: 8.0.x + - task: MicroBuildSigningPlugin@4 + displayName: '🔩 Install Signing Plugin' + inputs: + signType: real + azureSubscription: MicroBuild Signing Task (DevDiv) + ConnectedPMEServiceName: beb8cb23-b303-4c95-ab26-9e44bc958d39 + zipSources: false + env: + MicroBuildOutputFolderOverride: $(Agent.TempDirectory) + + - template: /tools/pipelines/steps/setup-node-npm-ci.yml@self + + - bash: npm test -w native-preview + displayName: Test extension + + - bash: npx hereby vscode-typescript:release --forRelease --vscodeTypescriptRelease + displayName: Build and sign extensions + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + VSCODE_TYPESCRIPT_SIGN_TYPE: real + + - bash: | + set -euo pipefail + shopt -s nullglob + signatureFiles=(built/vsix/*.signature.p7s) + if (( ${#signatureFiles[@]} == 0 )); then + echo "No VSIX signatures were produced." >&2 + exit 1 + fi + for signatureFile in "${signatureFiles[@]}"; do + manifestFile="${signatureFile%.signature.p7s}.manifest" + if cmp -s "$manifestFile" "$signatureFile"; then + echo "$signatureFile was not replaced with a real signature." >&2 + exit 1 + fi + openssl pkcs7 -inform DER -in "$signatureFile" -noout + done + echo "##vso[build.addbuildtag]vscode-typescript-release" + displayName: Validate release signatures + + - bash: | + set -euo pipefail + artifactDirectory="$(Build.ArtifactStagingDirectory)/vsix" + mkdir -p "$artifactDirectory" + cp -v built/vsix/*.vsix "$artifactDirectory" + cp -v built/vsix/*.manifest "$artifactDirectory" + cp -v built/vsix/*.signature.p7s "$artifactDirectory" + cp -v built/vsix/release-manifest.json "$artifactDirectory" + displayName: Stage release artifacts diff --git a/tools/pipelines/vscode-typescript-publish.yml b/tools/pipelines/vscode-typescript-publish.yml new file mode 100644 index 0000000000000..cf0db339acb82 --- /dev/null +++ b/tools/pipelines/vscode-typescript-publish.yml @@ -0,0 +1,332 @@ +trigger: none + +pr: none + +name: TypeScript-VS-Code-Extension-Release-Publish-$(Date:yyyyMMdd)$(Rev:.r) +appendCommitMessageToRunName: false + +parameters: + - name: dryRun + displayName: Dry run + type: boolean + default: false + +variables: + - name: TeamName + value: TypeScript + - name: TYPESCRIPT_AUTOMATION_GITHUB_APP_CLIENT_ID + value: Iv23li4GolzJSEp1mzHI + - name: TYPESCRIPT_AUTOMATION_GITHUB_APP_KEY_ID + value: https://jststeam-passwords.vault.azure.net/keys/typescript-automation + +resources: + repositories: + - repository: MicroBuildTemplate + type: git + name: 1ESPipelineTemplates/MicroBuildTemplate + ref: refs/tags/release + pipelines: + - pipeline: TypeScript_VS_Code_Extension_Release_Build + project: DevDiv + source: TypeScript VS Code Extension Release Build + trigger: + tags: + - vscode-typescript-release + +extends: + template: azure-pipelines/MicroBuild.1ES.Official.Publish.yml@MicroBuildTemplate + + parameters: + sdl: + sourceAnalysisPool: VSEngSS-MicroBuild2022-1ES + pool: + name: AzurePipelines-EO + image: 1ESPT-Ubuntu22.04 + os: linux + settings: + networkIsolationPolicy: Permissive,CFSClean + + stages: + - stage: Publish + displayName: Publish vscode-typescript + jobs: + - job: Validate + displayName: Validate vscode-typescript release + + templateContext: + inputs: + - input: pipelineArtifact + pipeline: TypeScript_VS_Code_Extension_Release_Build + artifactName: vsix + targetPath: $(Pipeline.Workspace)/vsix + + steps: + - checkout: none + + - bash: | + set -euo pipefail + artifactDirectory="$(Pipeline.Workspace)/vsix" + manifest="$artifactDirectory/release-manifest.json" + test -f "$manifest" + + extension="$(jq -r '.extension' "$manifest")" + version="$(jq -r '.extensionVersion' "$manifest")" + bundledTypeScriptVersion="$(jq -r '.bundledTypeScriptVersion' "$manifest")" + signType="$(jq -r '.signType' "$manifest")" + sourceRef="$(jq -r '.sourceRef' "$manifest")" + sourceCommit="$(jq -r '.sourceCommit' "$manifest")" + if [ "$extension" != "TypeScriptTeam.native-preview" ]; then + echo "Unexpected extension identity: $extension" >&2 + exit 1 + fi + if ! [[ "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "Invalid extension version: $version" >&2 + exit 1 + fi + if ! [[ "$bundledTypeScriptVersion" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "Invalid bundled TypeScript version: $bundledTypeScriptVersion" >&2 + exit 1 + fi + if [ "$signType" != "real" ]; then + echo "Refusing to publish artifacts signed with signType=$signType." >&2 + exit 1 + fi + if [ "$sourceRef" != "$RELEASE_SOURCE_BRANCH" ]; then + echo "Artifact source ref $sourceRef does not match triggering build ref $RELEASE_SOURCE_BRANCH." >&2 + exit 1 + fi + if [ "$sourceCommit" != "$RELEASE_SOURCE_COMMIT" ]; then + echo "Artifact source commit $sourceCommit does not match triggering build commit $RELEASE_SOURCE_COMMIT." >&2 + exit 1 + fi + case "$sourceRef" in + refs/tags/vscode-typescript/v"$version") ;; + *) + echo "Source ref $sourceRef does not match extension version $version." >&2 + exit 1 + ;; + esac + + while IFS=$'\t' read -r filename expectedHash; do + artifact="$artifactDirectory/$filename" + test -f "$artifact" + actualHash="$(sha256sum "$artifact" | cut -d' ' -f1)" + if [ "$actualHash" != "$expectedHash" ]; then + echo "Hash mismatch for $filename." >&2 + exit 1 + fi + done < <(jq -r '.artifacts | to_entries[] | [.key, .value.sha256] | @tsv' "$manifest") + + shopt -s nullglob + vsixFiles=("$artifactDirectory"/*.vsix) + if (( ${#vsixFiles[@]} == 0 )); then + echo "No VSIX artifacts found." >&2 + exit 1 + fi + for vsixFilePath in "${vsixFiles[@]}"; do + manifestFile="${vsixFilePath%.vsix}.manifest" + signatureFile="${vsixFilePath%.vsix}.signature.p7s" + for artifact in "$vsixFilePath" "$manifestFile" "$signatureFile"; do + filename="$(basename "$artifact")" + jq -e --arg filename "$filename" '.artifacts | has($filename)' "$manifest" >/dev/null + done + if cmp -s "$manifestFile" "$signatureFile"; then + echo "$signatureFile does not contain a real signature." >&2 + exit 1 + fi + openssl pkcs7 -inform DER -in "$signatureFile" -noout + + packageVersion="$(unzip -p "$vsixFilePath" extension/package.json | jq -r '.version')" + packageBundledTypeScriptVersion="$(unzip -p "$vsixFilePath" extension/package.json | jq -r '.bundledTypeScriptVersion')" + if [ "$packageVersion" != "$version" ] || [ "$packageBundledTypeScriptVersion" != "$bundledTypeScriptVersion" ]; then + echo "Version metadata mismatch in $(basename "$vsixFilePath")." >&2 + exit 1 + fi + done + targetCount="$(jq '.targets | length' "$manifest")" + if [ "${#vsixFiles[@]}" -ne "$targetCount" ]; then + echo "Expected $targetCount VSIXs, found ${#vsixFiles[@]}." >&2 + exit 1 + fi + displayName: Validate signed release artifacts + env: + RELEASE_SOURCE_BRANCH: $(resources.pipeline.TypeScript_VS_Code_Extension_Release_Build.sourceBranch) + RELEASE_SOURCE_COMMIT: $(resources.pipeline.TypeScript_VS_Code_Extension_Release_Build.sourceCommit) + + - ${{ if eq(parameters.dryRun, false) }}: + - job: Approve + displayName: Approve production publication + dependsOn: Validate + pool: server + timeoutInMinutes: 5760 + steps: + - task: ManualValidation@1 + timeoutInMinutes: 4320 + inputs: + notifyUsers: '[DevDiv]\JSTSteam' + approvers: '[DevDiv]\JSTSteam' + allowApproversToApproveTheirOwnRuns: true + instructions: | + Review the validated source tag, commit, extension version, + bundled TypeScript version, target list, and signing results. + Resume to publish the VSIXs to the Marketplace and GitHub Release. + onTimeout: reject + + - job: Publish + displayName: Publish vscode-typescript + dependsOn: Approve + + templateContext: + type: releaseJob + isProduction: true + inputs: + - input: pipelineArtifact + pipeline: TypeScript_VS_Code_Extension_Release_Build + artifactName: vsix + targetPath: $(Pipeline.Workspace)/vsix + + steps: + - checkout: none + + - template: /tools/pipelines/steps/setup-vsce.yml@self + + - task: AzureCLI@2 + displayName: Publish VSIXs to Marketplace + retryCountOnTaskFailure: 3 + inputs: + azureSubscription: TypeScript-VSMarketplacePublishAuth + scriptType: bash + scriptLocation: inlineScript + inlineScript: | + set -euo pipefail + shopt -s nullglob + for vsixFilePath in "$(Pipeline.Workspace)"/vsix/*.vsix; do + manifestFilePath="${vsixFilePath%.vsix}.manifest" + signatureFilePath="${vsixFilePath%.vsix}.signature.p7s" + npx vsce publish \ + --packagePath "$vsixFilePath" \ + --manifestPath "$manifestFilePath" \ + --signaturePath "$signatureFilePath" \ + --azure-credential \ + --skip-duplicate \ + --allow-all-proposed-apis + done + + - template: /tools/pipelines/steps/create-github-app-token.yml@self + parameters: + azureSubscription: TypeScript-DevDiv-KeyVault + repositories: TypeScript + permissions: contents:write + insertSteps: + - bash: | + set -euo pipefail + artifactDirectory="$(Pipeline.Workspace)/vsix" + manifest="$artifactDirectory/release-manifest.json" + version="$(jq -r '.extensionVersion' "$manifest")" + bundledTypeScriptVersion="$(jq -r '.bundledTypeScriptVersion' "$manifest")" + sourceCommit="$(jq -r '.sourceCommit' "$manifest")" + tag="vscode-typescript/v$version" + + shopt -s nullglob + vsixFiles=("$artifactDirectory"/*.vsix) + if (( ${#vsixFiles[@]} == 0 )); then + echo "No VSIX artifacts found." >&2 + exit 1 + fi + + existingAssetsDirectory="$(mktemp -d)" + trap 'rm -rf "$existingAssetsDirectory"' EXIT + + refreshExistingAssetNames() { + local output + if ! output="$( + gh release view "$tag" \ + --repo microsoft/TypeScript \ + --json assets \ + --jq '.assets[].name' + )"; then + echo "Could not list assets for GitHub Release $tag." >&2 + return 1 + fi + mapfile -t existingAssetNames <<< "$output" + } + + assetExists() { + local filename="$1" + local existingAssetName + for existingAssetName in "${existingAssetNames[@]}"; do + if [ "$existingAssetName" = "$filename" ]; then + return 0 + fi + done + return 1 + } + + verifyExistingAsset() { + local filename="$1" + local expectedHash="$2" + local existingHash + + gh release download "$tag" \ + --repo microsoft/TypeScript \ + --pattern "$filename" \ + --dir "$existingAssetsDirectory" \ + --clobber + existingHash="$(sha256sum "$existingAssetsDirectory/$filename" | cut -d' ' -f1)" + if [ "$existingHash" != "$expectedHash" ]; then + echo "Existing GitHub Release asset $filename does not match the release manifest." >&2 + return 1 + fi + echo "Existing GitHub Release asset $filename matches the release manifest." + } + + syncReleaseAssets() { + local vsixFilePath + local filename + local expectedHash + local -a missingVsixFiles=() + + refreshExistingAssetNames + for vsixFilePath in "${vsixFiles[@]}"; do + filename="$(basename "$vsixFilePath")" + expectedHash="$(jq -r --arg filename "$filename" '.artifacts[$filename].sha256 // empty' "$manifest")" + if [ -z "$expectedHash" ]; then + echo "No manifest hash found for $filename." >&2 + exit 1 + fi + + if assetExists "$filename"; then + verifyExistingAsset "$filename" "$expectedHash" + else + missingVsixFiles+=("$vsixFilePath") + fi + done + + for vsixFilePath in "${missingVsixFiles[@]}"; do + filename="$(basename "$vsixFilePath")" + expectedHash="$(jq -r --arg filename "$filename" '.artifacts[$filename].sha256' "$manifest")" + if ! gh release upload "$tag" "$vsixFilePath" --repo microsoft/TypeScript; then + echo "Upload of $filename failed; checking whether another run uploaded it." + refreshExistingAssetNames + if ! assetExists "$filename"; then + return 1 + fi + verifyExistingAsset "$filename" "$expectedHash" + fi + done + } + + if ! gh release view "$tag" --repo microsoft/TypeScript >/dev/null 2>&1; then + if ! gh release create "$tag" \ + --repo microsoft/TypeScript \ + --title "$tag" \ + --latest=false \ + --notes "Bundles TypeScript $bundledTypeScriptVersion from commit $sourceCommit."; then + echo "Creation of $tag failed; checking whether another run created it." + gh release view "$tag" --repo microsoft/TypeScript >/dev/null + fi + fi + syncReleaseAssets + displayName: Create GitHub Release + env: + GH_TOKEN: $(GH_TOKEN)