From 18bd1fefa7f3adbb4cd376c0082f196b6c82cb72 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:08:11 -0700 Subject: [PATCH 1/8] Publish packages to npm through ESRP in Azure Pipelines Packages need to be published to npm through ESRP rather than with an npm token stored in GitHub Actions. ESRP can only be used from an Azure Pipelines release job, so changesets can no longer publish packages itself. Add a release pipeline that runs on every push to v2. Like the existing single-package release pipelines, it publishes the package versions changed by the latest commit, which for changesets is the merged "Version Packages" PR, and does nothing otherwise. Packages are published one ESRP release per level of the workspace dependency graph, so a failed release never leaves a published package depending on an unpublished version. A CI job checks that the pipeline defines enough stages for the current graph. All stages run in one job, so retrying a partially failed run would resubmit packages that already succeeded, which ESRP rejects. Before each stage, skip any versions that are already on npm. --- .github/workflows/CI.yml | 11 ++ .../azure-pipelines/pack-changed-packages.mjs | 181 ++++++++++++++++++ build/azure-pipelines/release-helper.mjs | 77 ++++++++ build/azure-pipelines/release.yml | 144 ++++++++++++++ 4 files changed, 413 insertions(+) create mode 100644 build/azure-pipelines/pack-changed-packages.mjs create mode 100644 build/azure-pipelines/release-helper.mjs create mode 100644 build/azure-pipelines/release.yml diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index e7259cd6c95c..8ffcb13d0115 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -102,12 +102,23 @@ jobs: # git checkout origin/v2 -- pnpm changeset status --since=origin/v2 + release-pipeline: + name: release pipeline + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 'lts/*' + - run: node build/azure-pipelines/pack-changed-packages.mjs --check-stage-capacity + required: runs-on: ubuntu-latest if: ${{ always() }} needs: - tests - changesets + - release-pipeline steps: - name: Check required jobs diff --git a/build/azure-pipelines/pack-changed-packages.mjs b/build/azure-pipelines/pack-changed-packages.mjs new file mode 100644 index 000000000000..abd608b7791c --- /dev/null +++ b/build/azure-pipelines/pack-changed-packages.mjs @@ -0,0 +1,181 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const root = process.cwd(); +const releasePipelinePath = path.join(root, "build/azure-pipelines/release.yml"); +const checkStageCapacity = process.argv.includes("--check-stage-capacity"); +const outDir = process.argv.find(arg => !arg.startsWith("--") && arg !== process.argv[0] && arg !== process.argv[1]) || process.env.BUILD_ARTIFACTSTAGINGDIRECTORY; + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function collectWorkspacePackages() { + const packages = new Map(); + for (const dir of fs.readdirSync(path.join(root, "packages"))) { + const packageJsonPath = path.join(root, "packages", dir, "package.json"); + if (!fs.existsSync(packageJsonPath)) continue; + + const packageJson = readJson(packageJsonPath); + if (!packageJson.name || packageJson.private) continue; + + packages.set(packageJson.name, { + dir: path.relative(root, path.dirname(packageJsonPath)), + packageJson, + }); + } + return packages; +} + +function dependenciesOnPackedPackages(pkg, packedNames) { + const dependencies = new Set(); + for (const section of ["dependencies", "peerDependencies", "optionalDependencies"]) { + for (const dependency of Object.keys(pkg.packageJson[section] || {})) { + if (packedNames.has(dependency)) dependencies.add(dependency); + } + } + return dependencies; +} + +function computeDepths(packages, packageNames) { + const packageNameSet = new Set(packageNames); + const memo = new Map(); + const visiting = new Set(); + + function depth(name) { + if (memo.has(name)) return memo.get(name); + if (visiting.has(name)) throw new Error(`Workspace dependency cycle involving ${name}`); + + visiting.add(name); + const deps = dependenciesOnPackedPackages(packages.get(name), packageNameSet); + const value = deps.size === 0 ? 0 : 1 + Math.max(...[...deps].map(depth)); + visiting.delete(name); + memo.set(name, value); + return value; + } + + for (const name of packageNames) depth(name); + return memo; +} + +function validateReleasePipelineStageCapacity(packages) { + const allPackageNames = [...packages.keys()]; + const maxRequiredDepth = Math.max(0, ...computeDepths(packages, allPackageNames).values()); + const releaseYaml = fs.readFileSync(releasePipelinePath, "utf8"); + const stageNumbers = [...releaseYaml.matchAll(/^\s+-\s+index:\s+(\d+)\s*$/gm)] + .map(match => Number(match[1])); + const maxAvailableStage = Math.max(-1, ...stageNumbers); + + if (maxRequiredDepth !== maxAvailableStage) { + throw new Error(`Workspace publish graph requires stages 0-${maxRequiredDepth}, but ${releasePipelinePath} defines stages 0-${maxAvailableStage}`); + } + + console.log(`Workspace publish graph requires stages 0-${maxRequiredDepth}; release pipeline defines stages 0-${maxAvailableStage}.`); + return maxAvailableStage + 1; +} + +const packages = collectWorkspacePackages(); +const maxStages = validateReleasePipelineStageCapacity(packages); + +if (checkStageCapacity) { + process.exit(0); +} + +if (!outDir) { + throw new Error("Usage: node build/azure-pipelines/pack-changed-packages.mjs "); +} + +// PRs are always squash merged, so a version bump from the "Version Packages" +// PR is always in the tip commit of its own push. +function readPreviousPackageJson(pkg) { + try { + return JSON.parse(execFileSync("git", ["show", `HEAD~1:${pkg.dir}/package.json`], { encoding: "utf8" })); + } + catch { + return undefined; + } +} + +function versionChanged(pkg) { + const previous = readPreviousPackageJson(pkg); + return previous?.version !== pkg.packageJson.version; +} + +function findChangedPackageNames() { + const releaseBranch = `refs/heads/${readJson(path.join(root, ".changeset/config.json")).baseBranch}`; + const sourceBranch = process.env.BUILD_SOURCEBRANCH; + if (sourceBranch !== undefined && sourceBranch !== releaseBranch) { + console.log(`Building ${sourceBranch}, not ${releaseBranch}; not staging any packages.`); + return []; + } + + return [...packages.values()] + .filter(versionChanged) + .map(pkg => pkg.packageJson.name); +} + +const changedPackageNames = findChangedPackageNames(); + +let packed = []; +if (changedPackageNames.length) { + const packedDir = path.join(outDir, "packed"); + fs.mkdirSync(packedDir, { recursive: true }); + + const filters = changedPackageNames.flatMap(name => ["--filter", name]); + const output = execFileSync("pnpm", ["-r", ...filters, "pack", "--pack-destination", packedDir, "--json"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }); + const parsed = output.trim() ? JSON.parse(output) : []; + packed = Array.isArray(parsed) ? parsed : [parsed]; + + for (const packedPackage of packed) { + packedPackage.filename = path.join(packedDir, path.basename(packedPackage.filename)); + } +} + +packed = packed.filter(pkg => packages.has(pkg.name)); +const packedNames = packed.map(pkg => pkg.name); +const packedByName = new Map(packed.map(pkg => [pkg.name, pkg])); +const depths = computeDepths(packages, packedNames); +const manifest = { stages: Array.from({ length: maxStages }, () => []) }; +for (let i = 0; i < maxStages; i++) { + fs.mkdirSync(path.join(outDir, `stage-${i}`), { recursive: true }); +} + +for (const name of packedNames) { + const depth = depths.get(name); + if (depth >= maxStages) { + throw new Error(`${name} requires publish stage ${depth}, but max stage is ${maxStages - 1}`); + } + + const packedPackage = packedByName.get(name); + const filename = path.basename(packedPackage.filename); + const stageDir = path.join(outDir, `stage-${depth}`); + fs.mkdirSync(stageDir, { recursive: true }); + fs.copyFileSync(packedPackage.filename, path.join(stageDir, filename)); + manifest.stages[depth].push({ + name, + version: packedPackage.version, + filename, + }); +} + +fs.writeFileSync(path.join(outDir, "publish-manifest.json"), JSON.stringify(manifest, undefined, 2) + "\n"); +fs.copyFileSync(path.join(root, "build/azure-pipelines/release-helper.mjs"), path.join(outDir, "release-helper.mjs")); +fs.rmSync(path.join(outDir, "packed"), { recursive: true, force: true }); + +for (let i = 0; i < maxStages; i++) { + if (!manifest.stages[i].length) { + fs.writeFileSync(path.join(outDir, `stage-${i}`, ".empty"), ""); + } + console.log(`Stage ${i}: ${manifest.stages[i].map(pkg => `${pkg.name}@${pkg.version}`).join(", ") || "(empty)"}`); +} + +const shouldPublish = packedNames.length > 0; +console.log(`##vso[task.setvariable variable=ShouldPublish]${shouldPublish}`); +console.log(`##vso[task.setvariable variable=ShouldPublish;isOutput=true]${shouldPublish}`); +if (shouldPublish && process.env.BUILD_BUILDNUMBER) { + console.log(`##vso[build.updatebuildnumber]${process.env.BUILD_BUILDNUMBER}-release`); +} diff --git a/build/azure-pipelines/release-helper.mjs b/build/azure-pipelines/release-helper.mjs new file mode 100644 index 000000000000..89406d0bc681 --- /dev/null +++ b/build/azure-pipelines/release-helper.mjs @@ -0,0 +1,77 @@ +// Release jobs have no repository checkout, so this script is copied into the +// pipeline artifact next to publish-manifest.json and must only use Node +// built-ins. +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const artifactDir = path.dirname(fileURLToPath(import.meta.url)); +const manifest = JSON.parse(fs.readFileSync(path.join(artifactDir, "publish-manifest.json"), "utf8")); + +function setVariable(name, value) { + console.log(`##vso[task.setvariable variable=${name}]${value}`); +} + +function warn(message) { + console.log(`##vso[task.logissue type=warning]${message}`); +} + +function spec(pkg) { + return `${pkg.name}@${pkg.version}`; +} + +function stagePackages(stage) { + const packages = manifest.stages[stage]; + if (!packages) throw new Error(`Unknown publish stage ${stage}`); + return packages; +} + +// Unlike packuments, the per-version endpoint is not cached by npm's CDN, so +// it reflects newly published versions immediately. +async function isOnNpm(pkg) { + const url = `https://registry.npmjs.org/${encodeURIComponent(pkg.name)}/${encodeURIComponent(pkg.version)}`; + let response; + try { + response = await fetch(url, { headers: { Accept: "application/json" } }); + } + catch (error) { + throw new Error(`GET ${url}: ${error.cause?.message ?? error.message}`); + } + if (response.status === 404) return false; + if (!response.ok) throw new Error(`GET ${url}: ${response.status} ${response.statusText}`); + return true; +} + +// ESRP fails when asked to publish a version that already exists, so drop +// packages that an earlier attempt of this run already published. +async function prepareStage(stage) { + let pending = 0; + for (const pkg of stagePackages(stage)) { + let published = false; + try { + published = await isOnNpm(pkg); + } + catch (error) { + warn(`Could not check whether ${spec(pkg)} is on npm; publishing it. ${error.message}`); + } + + if (published) { + console.log(`${spec(pkg)} is already on npm; not publishing it again.`); + fs.rmSync(path.join(artifactDir, `stage-${stage}`, pkg.filename), { force: true }); + } + else { + console.log(`${spec(pkg)} will be published.`); + pending++; + } + } + setVariable(`PublishStage${stage}`, pending > 0); +} + +const [command, ...args] = process.argv.slice(2); +switch (command) { + case "prepare-stage": + await prepareStage(Number(args[0])); + break; + default: + throw new Error(`Unknown command ${command}`); +} diff --git a/build/azure-pipelines/release.yml b/build/azure-pipelines/release.yml new file mode 100644 index 000000000000..d32fd532bfc9 --- /dev/null +++ b/build/azure-pipelines/release.yml @@ -0,0 +1,144 @@ +name: $(date:yyyyMMdd).$(rev:r) + +trigger: + branches: + include: + - v2 +pr: none + +variables: + TeamName: TypeScript + Codeql.Enabled: true + ReleaseOwners: "jabaile@microsoft.com" + ReleaseApprovers: "alexchi@microsoft.com" + NpmArtifactName: npm-packages + +parameters: + - name: publishStages + type: object + default: + - index: 0 + - index: 1 + +resources: + repositories: + - repository: MicroBuildTemplate + type: git + name: 1ESPipelineTemplates/MicroBuildTemplate + ref: refs/tags/release + +extends: + template: azure-pipelines/MicroBuild.1ES.Official.Publish.yml@MicroBuildTemplate + parameters: + pool: + name: AzurePipelines-EO + image: 1ESPT-Ubuntu22.04 + os: linux + sdl: + sourceAnalysisPool: VSEng-MicroBuildVSStable + binskim: + scanOutputDirectoryOnly: true + settings: + networkIsolationPolicy: Permissive,CFSClean + stages: + - stage: Build + displayName: Build + jobs: + - job: Build + displayName: Build and pack changed packages + templateContext: + mb: + # This pipeline uses the publish template so the package publish + # stages can run. Do not inject publish authorization into the + # normal build job. + publish: + enabled: false + signing: + enabled: false + sdl: + codeSignValidation: + enabled: false + outputParentDirectory: "$(Build.ArtifactStagingDirectory)" + outputs: + - output: pipelineArtifact + condition: and(succeeded(), eq(variables['ShouldPublish'], 'true')) + targetPath: "$(Build.ArtifactStagingDirectory)/$(NpmArtifactName)" + artifactName: $(NpmArtifactName) + steps: + - checkout: self + fetchDepth: 2 + + - task: UseNode@1 + inputs: + version: "22.x" + displayName: "Install Node.js" + + - script: | + set -euo pipefail + cat > .npmrc << 'EOF' + registry=https://pkgs.dev.azure.com/devdiv/devdiv/_packaging/devdiv_PublicPackages/npm/registry/ + EOF + displayName: "Set up .npmrc" + + - task: npmAuthenticate@0 + inputs: + workingFile: .npmrc + displayName: "Authenticate npm" + + - script: | + set -euo pipefail + # Global installs ignore the project .npmrc, so point npm at it + # explicitly to install from the authenticated feed. + npm install -g --userconfig "$PWD/.npmrc" "$(node -p "require('./package.json').packageManager.split('+')[0]")" + pnpm --version + displayName: "Install package manager" + + - script: pnpm install + displayName: "Install dependencies" + + - script: pnpm bootstrap + displayName: "Bootstrap packages" + + - script: pnpm build + displayName: "Build packages" + + - script: node build/azure-pipelines/pack-changed-packages.mjs "$(Build.ArtifactStagingDirectory)/$(NpmArtifactName)" + name: packChangedPackages + displayName: "Pack changed packages" + + - stage: Publish + displayName: Publish to npmjs + dependsOn: Build + condition: and(succeeded(), eq(dependencies.Build.outputs['Build.packChangedPackages.ShouldPublish'], 'true')) + jobs: + - job: Publish + displayName: Publish npm packages + templateContext: + type: releaseJob + isProduction: true + inputs: + - input: pipelineArtifact + artifactName: $(NpmArtifactName) + targetPath: $(Pipeline.Workspace)/$(NpmArtifactName) + steps: + - checkout: none + + - task: UseNode@1 + inputs: + version: "22.x" + displayName: "Install Node.js" + + - ${{ each publishStage in parameters.publishStages }}: + - script: node "$(Pipeline.Workspace)/$(NpmArtifactName)/release-helper.mjs" prepare-stage ${{ publishStage.index }} + displayName: "Check stage ${{ publishStage.index }} packages" + + - template: MicroBuild.Publish.yml@MicroBuildTemplate + parameters: + condition: and(succeeded(), eq(variables['PublishStage${{ publishStage.index }}'], 'true')) + intent: "PackageDistribution" + contentType: "npm" + contentSource: "Folder" + folderLocation: "$(Pipeline.Workspace)/$(NpmArtifactName)/stage-${{ publishStage.index }}" + waitForReleaseCompletion: true + owners: "$(ReleaseOwners)" + approvers: "$(ReleaseApprovers)" From b965538497ef74d0d7c695bffe2def886827035e Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:08:11 -0700 Subject: [PATCH 2/8] Stop publishing to npm from GitHub Actions The release pipeline now publishes packages through ESRP, so changesets/action only needs to open the "Version Packages" PR. --- .github/workflows/publish-packages.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 52df2ce4538b..93ab8449e898 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -65,6 +65,3 @@ jobs: - uses: changesets/action@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2 with: github-token: ${{ steps.app-token.outputs.token }} - publish-script: pnpm ci:publish - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} From 03f239216501f17fe7381b71284d24367b683791 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:08:11 -0700 Subject: [PATCH 3/8] Create git tags and GitHub releases after publishing When changesets/action published packages itself, it also pushed a name@version tag and created a GitHub release with the changelog entry for each package. Publishing through ESRP dropped both. Recreate them from the publish manifest once publishing finishes, so a release looks the same as before. This reuses the GitHub App token template from tslib's release pipeline, since release jobs have no checkout to run the token CLI from. --- .../azure-pipelines/pack-changed-packages.mjs | 16 + build/azure-pipelines/release-helper.mjs | 73 ++ build/azure-pipelines/release.yml | 40 ++ .../steps/create-github-app-token.yml | 622 ++++++++++++++++++ 4 files changed, 751 insertions(+) create mode 100644 build/azure-pipelines/steps/create-github-app-token.yml diff --git a/build/azure-pipelines/pack-changed-packages.mjs b/build/azure-pipelines/pack-changed-packages.mjs index abd608b7791c..00081402d4a2 100644 --- a/build/azure-pipelines/pack-changed-packages.mjs +++ b/build/azure-pipelines/pack-changed-packages.mjs @@ -102,6 +102,21 @@ function versionChanged(pkg) { return previous?.version !== pkg.packageJson.version; } +// Match changesets/action, which uses the package's CHANGELOG.md section for +// the version as the GitHub release body. +function readReleaseNotes(pkg) { + const changelogPath = path.join(root, pkg.dir, "CHANGELOG.md"); + if (!fs.existsSync(changelogPath)) return ""; + + const lines = fs.readFileSync(changelogPath, "utf8").split(/\r?\n/); + const start = lines.findIndex(line => line.trim() === `## ${pkg.packageJson.version}`); + if (start === -1) return ""; + + let end = lines.findIndex((line, i) => i > start && /^##?\s/.test(line)); + if (end === -1) end = lines.length; + return lines.slice(start + 1, end).join("\n").trim(); +} + function findChangedPackageNames() { const releaseBranch = `refs/heads/${readJson(path.join(root, ".changeset/config.json")).baseBranch}`; const sourceBranch = process.env.BUILD_SOURCEBRANCH; @@ -159,6 +174,7 @@ for (const name of packedNames) { name, version: packedPackage.version, filename, + releaseNotes: readReleaseNotes(packages.get(name)), }); } diff --git a/build/azure-pipelines/release-helper.mjs b/build/azure-pipelines/release-helper.mjs index 89406d0bc681..2543dda23e38 100644 --- a/build/azure-pipelines/release-helper.mjs +++ b/build/azure-pipelines/release-helper.mjs @@ -67,11 +67,84 @@ async function prepareStage(stage) { setVariable(`PublishStage${stage}`, pending > 0); } +function requireEnv(name) { + const value = process.env[name]; + if (!value) throw new Error(`${name} is required.`); + return value; +} + +async function githubRequest(route, method = "GET", body) { + const repository = requireEnv("GITHUB_REPOSITORY"); + const response = await fetch(`https://api.github.com/repos/${repository}/${route}`, { + method, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${requireEnv("GH_TOKEN")}`, + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (method === "GET" && response.status === 404) return undefined; + if (!response.ok) { + throw new Error(`${method} ${route}: ${response.status} ${await response.text()}`); + } + return response.json(); +} + +async function ensureTag(tag, commit) { + const ref = await githubRequest(`git/ref/tags/${tag}`); + if (!ref) { + await githubRequest("git/refs", "POST", { ref: `refs/tags/${tag}`, sha: commit }); + console.log(`Created tag ${tag}.`); + return; + } + + let object = ref.object; + while (object.type === "tag") { + object = (await githubRequest(`git/tags/${object.sha}`)).object; + } + if (object.type !== "commit" || object.sha !== commit) { + throw new Error(`Tag ${tag} already exists but does not point to ${commit}.`); + } + console.log(`Tag ${tag} already exists.`); +} + +async function ensureRelease(tag, pkg) { + const release = await githubRequest(`releases/tags/${tag}`); + if (release) { + console.log(`GitHub release ${tag} already exists: ${release.html_url}`); + return; + } + + const created = await githubRequest("releases", "POST", { + tag_name: tag, + name: tag, + body: pkg.releaseNotes, + prerelease: pkg.version.includes("-"), + }); + console.log(`Created GitHub release: ${created.html_url}`); +} + +// Recreate what changesets/action does after `changeset publish`: a +// name@version tag and a GitHub release for every published package. +async function finalize() { + const commit = requireEnv("RELEASE_SOURCE_COMMIT"); + for (const pkg of manifest.stages.flat()) { + const tag = spec(pkg); + await ensureTag(tag, commit); + await ensureRelease(tag, pkg); + } +} + const [command, ...args] = process.argv.slice(2); switch (command) { case "prepare-stage": await prepareStage(Number(args[0])); break; + case "finalize": + await finalize(); + break; default: throw new Error(`Unknown command ${command}`); } diff --git a/build/azure-pipelines/release.yml b/build/azure-pipelines/release.yml index d32fd532bfc9..f5763812302d 100644 --- a/build/azure-pipelines/release.yml +++ b/build/azure-pipelines/release.yml @@ -12,6 +12,9 @@ variables: ReleaseOwners: "jabaile@microsoft.com" ReleaseApprovers: "alexchi@microsoft.com" NpmArtifactName: npm-packages + GitHubRepository: microsoft/TypeScript-Website + TYPESCRIPT_AUTOMATION_GITHUB_APP_CLIENT_ID: Iv23li4GolzJSEp1mzHI + TYPESCRIPT_AUTOMATION_GITHUB_APP_KEY_ID: https://jststeam-passwords.vault.azure.net/keys/typescript-automation parameters: - name: publishStages @@ -142,3 +145,40 @@ extends: waitForReleaseCompletion: true owners: "$(ReleaseOwners)" approvers: "$(ReleaseApprovers)" + + - stage: Finalize + displayName: Create tags and GitHub releases + dependsOn: + - Build + - Publish + condition: and(succeeded(), eq(dependencies.Build.outputs['Build.packChangedPackages.ShouldPublish'], 'true')) + jobs: + - job: CreateGitHubReleases + displayName: Create tags and GitHub releases + templateContext: + type: releaseJob + isProduction: true + inputs: + - input: pipelineArtifact + artifactName: $(NpmArtifactName) + targetPath: $(Pipeline.Workspace)/$(NpmArtifactName) + steps: + - checkout: none + + - task: UseNode@1 + inputs: + version: "22.x" + displayName: "Install Node.js" + + - template: /build/azure-pipelines/steps/create-github-app-token.yml@self + parameters: + azureSubscription: TypeScript-DevDiv-KeyVault + repositories: TypeScript-Website + permissions: contents:write + insertSteps: + - script: node "$(Pipeline.Workspace)/$(NpmArtifactName)/release-helper.mjs" finalize + displayName: Create tags and GitHub releases + env: + GH_TOKEN: $(GH_TOKEN) + GITHUB_REPOSITORY: $(GitHubRepository) + RELEASE_SOURCE_COMMIT: $(Build.SourceVersion) diff --git a/build/azure-pipelines/steps/create-github-app-token.yml b/build/azure-pipelines/steps/create-github-app-token.yml new file mode 100644 index 000000000000..1f374fd0c989 --- /dev/null +++ b/build/azure-pipelines/steps/create-github-app-token.yml @@ -0,0 +1,622 @@ +# Step template for creating a GitHub App token via Azure Key Vault. +# This template can be used in release jobs where checkout is not available. +# The CLI script from microsoft/create-github-app-token-via-key-vault is inlined below. +# +# The token is created and stored in an Azure Pipelines variable (default: GH_TOKEN). +# A revocation step runs at the end with condition: always(). +# To use the token, add your steps between this template's create and revoke steps +# by setting insertSteps. + +parameters: + - name: azureSubscription + type: string + default: 'TypeScript Public CI' + - name: owner + type: string + default: 'microsoft' + - name: repositories + type: string + - name: permissions + type: string + - name: tokenVariable + type: string + default: 'GH_TOKEN' + - name: condition + type: string + default: succeeded() + - name: revokeCondition + type: string + default: always() + - name: insertSteps + type: stepList + default: [] + +steps: + - task: AzureCLI@2 + displayName: Create GitHub App token + condition: ${{ parameters.condition }} + inputs: + azureSubscription: ${{ parameters.azureSubscription }} + scriptType: pscore + scriptLocation: inlineScript + inlineScript: | + @' + "use strict"; + + // src/api.ts + var defaultRefreshWindowMs = 5 * 60 * 1e3; + var defaultGitHubApiUrl = "https://api.github.com"; + var transientRetryCount = 3; + var GitHubRequestError = class extends Error { + status; + constructor(message, status) { + super(message); + this.status = status; + } + }; + function assertValue(value, message) { + if (!value) { + throw new Error(message); + } + return value; + } + function base64url(value) { + return Buffer.from(value).toString("base64url"); + } + async function sleep(ms) { + await new Promise((resolve) => setTimeout(resolve, ms)); + } + function isRetryableError(error) { + return error instanceof GitHubRequestError ? error.status >= 500 : error instanceof TypeError; + } + async function retryTransient(operation) { + for (let attempt = 0; ; attempt++) { + try { + return await operation(); + } catch (error) { + if (attempt >= transientRetryCount || !isRetryableError(error)) { + throw error; + } + await sleep(2 ** attempt * 1e3); + } + } + } + function splitRepositoryNames(repositories) { + if (Array.isArray(repositories)) { + return repositories.map((repo) => `${repo}`.trim()).filter(Boolean); + } + if (typeof repositories === "string") { + return repositories.split(/[,\n]/).map((repo) => repo.trim()).filter(Boolean); + } + return []; + } + function stableObject(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return value; + } + return Object.fromEntries( + Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, stableObject(entry)]) + ); + } + function githubHeaders(token, json = false) { + return { + "Accept": "application/vnd.github+json", + "Authorization": `Bearer ${token}`, + ...json ? { "Content-Type": "application/json" } : {}, + "X-GitHub-Api-Version": "2022-11-28" + }; + } + function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); + } + function requiredIntegerProperty(value, property, failureMessage) { + const propertyValue = isRecord(value) ? value[property] : void 0; + if (typeof propertyValue !== "number" || !Number.isInteger(propertyValue)) { + throw new Error(failureMessage); + } + return propertyValue; + } + function requiredStringProperty(value, property, failureMessage) { + const propertyValue = isRecord(value) ? value[property] : void 0; + if (typeof propertyValue !== "string" || !propertyValue) { + throw new Error(failureMessage); + } + return propertyValue; + } + function validatePermissionName(key) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + throw new Error(`Invalid permission name: ${key}`); + } + } + function validatePermissionLevel(key, level) { + if (level !== "read" && level !== "write" && level !== "admin") { + throw new Error(`Invalid permission level for ${key}: ${level}`); + } + return level; + } + function validatePermissions(value) { + if (value === void 0) { + return void 0; + } + if (!isRecord(value)) { + throw new Error("permissions must be an object"); + } + const permissions = {}; + for (const [key, level] of Object.entries(value)) { + validatePermissionName(key); + permissions[key] = validatePermissionLevel(key, level); + } + return Object.keys(permissions).length === 0 ? void 0 : permissions; + } + async function requestJson(url, init, failureMessage) { + const response = await fetch(url, init); + const body = await response.text(); + if (!response.ok) { + throw new GitHubRequestError( + `${failureMessage}: ${response.status} ${response.statusText}: ${body}`, + response.status + ); + } + try { + return JSON.parse(body); + } catch { + throw new Error(`${failureMessage}: GitHub returned invalid JSON`); + } + } + async function requestNoContent(url, init, failureMessage) { + const response = await fetch(url, init); + if (!response.ok) { + const body = await response.text(); + throw new GitHubRequestError( + `${failureMessage}: ${response.status} ${response.statusText}: ${body}`, + response.status + ); + } + } + function parseRepositoryInput(input) { + const parts = input.split("/"); + if (parts.length === 1 && parts[0]) { + return { input, name: parts[0] }; + } + if (parts.length === 2 && parts[0] && parts[1]) { + return { input, owner: parts[0], name: parts[1] }; + } + throw new Error(`Invalid repository '${input}'. Expected 'repository' or 'owner/repository'.`); + } + function normalizeRepositoryTarget(owner, repositories, defaultOwner) { + const parsedRepositories = repositories.map(parseRepositoryInput); + const repositoryOwner = parsedRepositories.find((repository) => repository.owner)?.owner; + const parsedOwner = owner || defaultOwner || repositoryOwner; + if (!parsedOwner) { + throw new Error("owner is required when repositories are provided"); + } + const mismatchedRepository = parsedRepositories.find( + (repository) => repository.owner && repository.owner.toLowerCase() !== parsedOwner.toLowerCase() + ); + if (mismatchedRepository) { + throw new Error( + `Repository '${mismatchedRepository.input}' includes owner '${mismatchedRepository.owner}', which does not match the resolved owner '${parsedOwner}'.` + ); + } + return { + owner: parsedOwner, + repositories: parsedRepositories.map((repository) => repository.name) + }; + } + function resolveInstallationTarget(options, defaultOwner) { + const repositories = splitRepositoryNames(options.repositories ?? options.repositoryNames); + if (options.enterprise) { + if (options.owner || repositories.length > 0) { + throw new Error("Cannot use 'enterprise' with 'owner' or 'repositories'"); + } + return { type: "enterprise", enterprise: options.enterprise }; + } + const owner = assertValue(options.owner ?? defaultOwner, "owner is required to discover installation ID"); + if (repositories.length === 0) { + return { type: "owner", owner }; + } + return { type: "repository", owner, repositories }; + } + function createGitHubAppAuth(options) { + assertValue(options.appClientId, "appClientId is required"); + assertValue(options.signer, "signer is required"); + const appClientId = options.appClientId; + const signer = options.signer; + const defaultOwner = options.defaultOwner; + const refreshWindowMs = options.refreshWindowMs ?? defaultRefreshWindowMs; + const githubApiUrl = options.githubApiUrl ?? defaultGitHubApiUrl; + const installationCache = /* @__PURE__ */ new Map(); + const tokenCache = /* @__PURE__ */ new Map(); + async function createJwt() { + const now = Math.floor(Date.now() / 1e3); + const iat = now - 60; + const exp = now + 9 * 60; + const header = base64url(JSON.stringify({ typ: "JWT", alg: "RS256" })); + const payload = base64url(JSON.stringify({ iat, exp, iss: appClientId })); + const signingInput = `${header}.${payload}`; + const signature = await signer(signingInput); + return `${signingInput}.${signature}`; + } + async function discoverInstallation(target) { + const cacheKey = JSON.stringify(target); + const cached = installationCache.get(cacheKey); + if (cached !== void 0) { + return cached; + } + const jwt = await createJwt(); + let installation; + switch (target.type) { + case "enterprise": + installation = await requestJson( + `${githubApiUrl}/enterprises/${target.enterprise}/installation`, + { headers: githubHeaders(jwt) }, + "Could not discover GitHub App installation ID" + ); + break; + case "owner": + try { + installation = await requestJson( + `${githubApiUrl}/orgs/${target.owner}/installation`, + { headers: githubHeaders(jwt) }, + "Could not discover GitHub App installation ID" + ); + } catch (error) { + if (!(error instanceof GitHubRequestError) || error.status !== 404) { + throw error; + } + installation = await requestJson( + `${githubApiUrl}/users/${target.owner}/installation`, + { headers: githubHeaders(jwt) }, + "Could not discover GitHub App installation ID" + ); + } + break; + case "repository": + installation = await requestJson( + `${githubApiUrl}/repos/${target.owner}/${assertValue(target.repositories[0], "repository is required")}/installation`, + { headers: githubHeaders(jwt) }, + "Could not discover GitHub App installation ID" + ); + break; + } + const result = { + id: requiredIntegerProperty(installation, "id", "GitHub did not return an installation ID"), + appSlug: requiredStringProperty(installation, "app_slug", "GitHub did not return an App slug") + }; + installationCache.set(cacheKey, result); + return result; + } + async function getInstallationToken(options2) { + const target = resolveInstallationTarget(options2, defaultOwner); + const permissions = validatePermissions(options2.permissions); + return retryTransient(async () => { + const installation = await discoverInstallation(target); + const repositories = target.type === "repository" ? target.repositories : []; + const cacheKey = JSON.stringify({ + installationId: installation.id, + repositories: [...repositories].sort(), + permissions: stableObject(permissions) + }); + const cached = tokenCache.get(cacheKey); + if (cached && Date.now() < new Date(cached.expiresAt).getTime() - refreshWindowMs) { + return cached; + } + const jwt = await createJwt(); + const body = { + ...repositories.length > 0 ? { repositories } : {}, + ...permissions ? { permissions } : {} + }; + const token = await requestJson( + `${githubApiUrl}/app/installations/${installation.id}/access_tokens`, + { + method: "POST", + headers: githubHeaders(jwt, true), + body: JSON.stringify(body) + }, + "Could not create GitHub App installation token" + ); + const result = { + token: requiredStringProperty(token, "token", "GitHub did not return an installation token"), + expiresAt: requiredStringProperty( + token, + "expires_at", + "GitHub did not return an installation token expiration" + ), + installationId: installation.id, + appSlug: installation.appSlug, + repositories, + permissions: isRecord(token) && isRecord(token["permissions"]) ? token["permissions"] : permissions ?? {} + }; + tokenCache.set(cacheKey, result); + return result; + }); + } + async function getToken(options2) { + return (await getInstallationToken(options2)).token; + } + async function revokeToken(token) { + await requestNoContent( + `${githubApiUrl}/installation/token`, + { + method: "DELETE", + headers: githubHeaders(token) + }, + "Could not revoke GitHub App installation token" + ); + } + return { + getInstallationToken, + getToken, + revokeToken + }; + } + + // src/azureCliSigner.ts + var import_node_child_process = require("node:child_process"); + var import_node_crypto = require("node:crypto"); + var cachedAzCommand; + function isRecord2(value) { + return typeof value === "object" && value !== null; + } + function base64ToBase64url(value) { + return value.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); + } + function commandExists(command) { + try { + (0, import_node_child_process.execFileSync)("where.exe", [command], { + encoding: "utf8", + stdio: ["ignore", "ignore", "ignore"] + }); + return true; + } catch { + return false; + } + } + function azCommand() { + if (cachedAzCommand) { + return cachedAzCommand; + } + if (process.platform !== "win32") { + cachedAzCommand = { command: "az", argsPrefix: [] }; + return cachedAzCommand; + } + for (const command of ["az.exe", "az.cmd", "az.bat", "az"]) { + if (commandExists(command)) { + cachedAzCommand = command.endsWith(".exe") ? { command, argsPrefix: [] } : { command: process.env["ComSpec"] || "cmd.exe", argsPrefix: ["/d", "/s", "/c", command] }; + return cachedAzCommand; + } + } + throw new Error("Azure CLI (`az`) was not found on PATH"); + } + function signDigest(keyId, digest) { + const { command, argsPrefix } = azCommand(); + try { + return (0, import_node_child_process.execFileSync)(command, [ + ...argsPrefix, + "keyvault", + "key", + "sign", + "--id", + keyId, + "--algorithm", + "RS256", + "--digest", + digest, + "--query", + "signature", + "--output", + "tsv", + "--only-show-errors" + ], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"] + }).trim(); + } catch (error) { + if (isRecord2(error)) { + if (error["code"] === "ENOENT") { + throw new Error("Azure CLI (`az`) was not found on PATH"); + } + const errorStderr = error["stderr"]; + const stderr = typeof errorStderr === "string" ? errorStderr.trim() : ""; + if (typeof error["status"] === "number") { + throw new Error( + `Azure Key Vault signing failed with exit code ${error["status"]}${stderr ? `: ${stderr}` : ""}` + ); + } + } + throw new Error("Azure Key Vault signing failed"); + } + } + function createAzureCliKeyVaultSigner(keyId) { + if (!keyId) { + throw new Error("keyId is required"); + } + return async (signingInput) => { + const digest = (0, import_node_crypto.createHash)("sha256").update(signingInput).digest("base64"); + const signature = signDigest(keyId, digest); + if (!signature) { + throw new Error("Azure Key Vault did not return a signature"); + } + return base64ToBase64url(signature); + }; + } + + // src/proxy.ts + var proxyEnvironmentKeys = [ + "https_proxy", + "HTTPS_PROXY", + "http_proxy", + "HTTP_PROXY" + ]; + function proxyEnvironmentConfigured() { + return proxyEnvironmentKeys.some((key) => process.env[key]); + } + function nativeProxySupportEnabled() { + return process.env["NODE_USE_ENV_PROXY"] === "1"; + } + function ensureNativeProxySupport() { + if (!proxyEnvironmentConfigured() || nativeProxySupportEnabled()) { + return; + } + throw new Error( + "A proxy environment variable is set, but Node.js native proxy support is not enabled. Set NODE_USE_ENV_PROXY=1 before running this tool." + ); + } + + // src/cli.ts + function requiredEnv(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} must be set`); + } + return value; + } + function getAppClientId() { + const appClientId = process.env["APP_CLIENT_ID"]; + if (!appClientId) { + throw new Error("APP_CLIENT_ID must be set"); + } + return appClientId; + } + function validatePermissionName2(key) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { + throw new Error(`Invalid permission name: ${key}`); + } + } + function validatePermissionLevel2(key, level) { + if (level !== "read" && level !== "write" && level !== "admin") { + throw new Error(`Invalid permission level for ${key}: ${level}`); + } + return level; + } + function parsePermissions(value) { + if (!value) { + return void 0; + } + const permissions = {}; + for (const entry of splitRepositoryNames(value)) { + const parts = entry.split(":"); + if (parts.length !== 2) { + throw new Error(`Permission entry must include an explicit level: ${entry}`); + } + const key = parts[0]?.trim(); + const rawLevel = parts[1]?.trim(); + if (!key) { + throw new Error(`Permission entry must include a permission name: ${entry}`); + } + validatePermissionName2(key); + if (Object.hasOwn(permissions, key)) { + throw new Error(`Duplicate permission: ${key}`); + } + permissions[key] = validatePermissionLevel2(key, rawLevel); + } + return Object.keys(permissions).length === 0 ? void 0 : permissions; + } + function validateVariableName(name, envName) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { + throw new Error(`${envName} must be an environment-style variable name`); + } + } + function parseOutputMode(value) { + const output = (value || "stdout").trim().toLowerCase(); + if (output === "azure" || output === "azure-pipelines" || output === "stdout") { + return output; + } + throw new Error('OUTPUT must be "azure", "azure-pipelines", or "stdout"'); + } + function getTokenOptions() { + const enterprise = process.env["ENTERPRISE"]; + const owner = process.env["OWNER"]; + const repositories = splitRepositoryNames(process.env["REPOSITORIES"]); + const permissions = parsePermissions(process.env["PERMISSIONS"]); + if (enterprise) { + if (owner || repositories.length > 0) { + throw new Error("Cannot use ENTERPRISE with OWNER or REPOSITORIES"); + } + return { enterprise, permissions }; + } + if (repositories.length > 0) { + return { ...normalizeRepositoryTarget(owner, repositories, void 0), permissions }; + } + if (owner) { + return { owner, permissions }; + } + throw new Error("OWNER, REPOSITORIES, or ENTERPRISE must be set"); + } + function writeAzurePipelinesOutput(installationToken) { + const variableName = requiredEnv("AZURE_TOKEN_VARIABLE"); + validateVariableName(variableName, "AZURE_TOKEN_VARIABLE"); + process.stdout.write(`##vso[task.setvariable variable=${variableName};isSecret=true]${installationToken.token} + `); + } + function writeOutput(installationToken, output) { + switch (output) { + case "azure": + case "azure-pipelines": + writeAzurePipelinesOutput(installationToken); + break; + case "stdout": + process.stdout.write(`${installationToken.token} + `); + break; + } + } + function reportError(error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`error: ${message}`); + } + async function main() { + ensureNativeProxySupport(); + const githubApiUrl = process.env["GITHUB_API_URL"] || "https://api.github.com"; + const revokeTokenValue = process.env["REVOKE_TOKEN"]; + if (revokeTokenValue) { + const response = await fetch(`${githubApiUrl}/installation/token`, { + method: "DELETE", + headers: { + "Accept": "application/vnd.github+json", + "Authorization": `Bearer ${revokeTokenValue}`, + "X-GitHub-Api-Version": "2022-11-28" + } + }); + if (!response.ok) { + const body = await response.text(); + throw new Error(`Could not revoke token: ${response.status} ${response.statusText}: ${body}`); + } + return; + } + const githubAuth = createGitHubAppAuth({ + appClientId: getAppClientId(), + signer: createAzureCliKeyVaultSigner(requiredEnv("KEY_ID")), + githubApiUrl + }); + const installationToken = await githubAuth.getInstallationToken(getTokenOptions()); + writeOutput(installationToken, parseOutputMode(process.env["OUTPUT"])); + } + void main().catch((error) => { + reportError(error); + process.exitCode = 1; + }); + '@ | Set-Content -LiteralPath "$(Agent.TempDirectory)/create-github-app-token.cjs" -Encoding utf8NoBOM + node "$(Agent.TempDirectory)/create-github-app-token.cjs" + env: + APP_CLIENT_ID: $(TYPESCRIPT_AUTOMATION_GITHUB_APP_CLIENT_ID) + KEY_ID: $(TYPESCRIPT_AUTOMATION_GITHUB_APP_KEY_ID) + OWNER: ${{ parameters.owner }} + REPOSITORIES: ${{ parameters.repositories }} + PERMISSIONS: ${{ parameters.permissions }} + OUTPUT: azure-pipelines + AZURE_TOKEN_VARIABLE: ${{ parameters.tokenVariable }} + + - ${{ each step in parameters.insertSteps }}: + - ${{ step }} + + - task: PowerShell@2 + displayName: Revoke GitHub App token + condition: ${{ parameters.revokeCondition }} + continueOnError: true + inputs: + targetType: inline + pwsh: true + script: node "$(Agent.TempDirectory)/create-github-app-token.cjs" + env: + REVOKE_TOKEN: $(${{ parameters.tokenVariable }}) From 0c59f5501df5075e96116482a02e40562b762c22 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:47:30 -0700 Subject: [PATCH 4/8] Skip the site's bootstrap in the release pipeline The release pipeline runs under network isolation that blocks registry.npmjs.org, and the typescriptlang-org bootstrap fetches TypeScript's dist-tags from there, so the Build stage would fail. None of the published packages depend on the site, so bootstrap everything else. --- build/azure-pipelines/release.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/build/azure-pipelines/release.yml b/build/azure-pipelines/release.yml index f5763812302d..0e5fc4d9fca8 100644 --- a/build/azure-pipelines/release.yml +++ b/build/azure-pipelines/release.yml @@ -99,7 +99,9 @@ extends: - script: pnpm install displayName: "Install dependencies" - - script: pnpm bootstrap + # The site's bootstrap fetches from registry.npmjs.org, which network + # isolation blocks, and no published package needs it. + - script: pnpm --filter "./packages/*" --filter "!typescriptlang-org" run bootstrap displayName: "Bootstrap packages" - script: pnpm build From b82187318167da964bb6c72a5b4f136a65b477b0 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:47:37 -0700 Subject: [PATCH 5/8] Don't check npm before publishing each stage The check was meant to skip versions that an earlier attempt already published, so that rerunning a partially failed run wouldn't resubmit them. But the release job's network isolation blocks registry.npmjs.org, so the check could never succeed, and it isn't needed anyway: ESRP succeeds for versions that are already on npm. Have the build report which stages have packages instead, so the publish job no longer needs Node or the network. --- .../azure-pipelines/pack-changed-packages.mjs | 4 +- build/azure-pipelines/release-helper.mjs | 58 ------------------- build/azure-pipelines/release.yml | 14 ++--- 3 files changed, 7 insertions(+), 69 deletions(-) diff --git a/build/azure-pipelines/pack-changed-packages.mjs b/build/azure-pipelines/pack-changed-packages.mjs index 00081402d4a2..4119930d5f54 100644 --- a/build/azure-pipelines/pack-changed-packages.mjs +++ b/build/azure-pipelines/pack-changed-packages.mjs @@ -183,10 +183,8 @@ fs.copyFileSync(path.join(root, "build/azure-pipelines/release-helper.mjs"), pat fs.rmSync(path.join(outDir, "packed"), { recursive: true, force: true }); for (let i = 0; i < maxStages; i++) { - if (!manifest.stages[i].length) { - fs.writeFileSync(path.join(outDir, `stage-${i}`, ".empty"), ""); - } console.log(`Stage ${i}: ${manifest.stages[i].map(pkg => `${pkg.name}@${pkg.version}`).join(", ") || "(empty)"}`); + console.log(`##vso[task.setvariable variable=PublishStage${i};isOutput=true]${manifest.stages[i].length > 0}`); } const shouldPublish = packedNames.length > 0; diff --git a/build/azure-pipelines/release-helper.mjs b/build/azure-pipelines/release-helper.mjs index 2543dda23e38..8651cd1dbbb2 100644 --- a/build/azure-pipelines/release-helper.mjs +++ b/build/azure-pipelines/release-helper.mjs @@ -8,65 +8,10 @@ import { fileURLToPath } from "node:url"; const artifactDir = path.dirname(fileURLToPath(import.meta.url)); const manifest = JSON.parse(fs.readFileSync(path.join(artifactDir, "publish-manifest.json"), "utf8")); -function setVariable(name, value) { - console.log(`##vso[task.setvariable variable=${name}]${value}`); -} - -function warn(message) { - console.log(`##vso[task.logissue type=warning]${message}`); -} - function spec(pkg) { return `${pkg.name}@${pkg.version}`; } -function stagePackages(stage) { - const packages = manifest.stages[stage]; - if (!packages) throw new Error(`Unknown publish stage ${stage}`); - return packages; -} - -// Unlike packuments, the per-version endpoint is not cached by npm's CDN, so -// it reflects newly published versions immediately. -async function isOnNpm(pkg) { - const url = `https://registry.npmjs.org/${encodeURIComponent(pkg.name)}/${encodeURIComponent(pkg.version)}`; - let response; - try { - response = await fetch(url, { headers: { Accept: "application/json" } }); - } - catch (error) { - throw new Error(`GET ${url}: ${error.cause?.message ?? error.message}`); - } - if (response.status === 404) return false; - if (!response.ok) throw new Error(`GET ${url}: ${response.status} ${response.statusText}`); - return true; -} - -// ESRP fails when asked to publish a version that already exists, so drop -// packages that an earlier attempt of this run already published. -async function prepareStage(stage) { - let pending = 0; - for (const pkg of stagePackages(stage)) { - let published = false; - try { - published = await isOnNpm(pkg); - } - catch (error) { - warn(`Could not check whether ${spec(pkg)} is on npm; publishing it. ${error.message}`); - } - - if (published) { - console.log(`${spec(pkg)} is already on npm; not publishing it again.`); - fs.rmSync(path.join(artifactDir, `stage-${stage}`, pkg.filename), { force: true }); - } - else { - console.log(`${spec(pkg)} will be published.`); - pending++; - } - } - setVariable(`PublishStage${stage}`, pending > 0); -} - function requireEnv(name) { const value = process.env[name]; if (!value) throw new Error(`${name} is required.`); @@ -139,9 +84,6 @@ async function finalize() { const [command, ...args] = process.argv.slice(2); switch (command) { - case "prepare-stage": - await prepareStage(Number(args[0])); - break; case "finalize": await finalize(); break; diff --git a/build/azure-pipelines/release.yml b/build/azure-pipelines/release.yml index 0e5fc4d9fca8..68f77c5e0d71 100644 --- a/build/azure-pipelines/release.yml +++ b/build/azure-pipelines/release.yml @@ -118,6 +118,10 @@ extends: jobs: - job: Publish displayName: Publish npm packages + variables: + - ${{ each publishStage in parameters.publishStages }}: + - name: PublishStage${{ publishStage.index }} + value: $[stageDependencies.Build.Build.outputs['packChangedPackages.PublishStage${{ publishStage.index }}']] templateContext: type: releaseJob isProduction: true @@ -128,15 +132,9 @@ extends: steps: - checkout: none - - task: UseNode@1 - inputs: - version: "22.x" - displayName: "Install Node.js" - + # ESRP succeeds for versions that are already on npm, so rerunning + # this job after a partial failure is safe. - ${{ each publishStage in parameters.publishStages }}: - - script: node "$(Pipeline.Workspace)/$(NpmArtifactName)/release-helper.mjs" prepare-stage ${{ publishStage.index }} - displayName: "Check stage ${{ publishStage.index }} packages" - - template: MicroBuild.Publish.yml@MicroBuildTemplate parameters: condition: and(succeeded(), eq(variables['PublishStage${{ publishStage.index }}'], 'true')) From 13220c0ebbca442228b8a9394eb561ab130d92c0 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:24:46 -0700 Subject: [PATCH 6/8] Let changesets/action create tags and GitHub releases Upstream's publish script ran `changeset git-tag` before publishing, and changesets/action pushed the tags it reported and created a GitHub release for each. The action can still do that without publishing, so run `changeset git-tag` as its publish script instead of recreating the same thing in the release pipeline. This replaces the Finalize stage, its helper script and the GitHub App token template. Tags and releases now appear when the "Version Packages" PR is merged rather than after ESRP finishes. --- .github/workflows/publish-packages.yml | 2 + .../azure-pipelines/pack-changed-packages.mjs | 18 - build/azure-pipelines/release-helper.mjs | 92 --- build/azure-pipelines/release.yml | 40 -- .../steps/create-github-app-token.yml | 622 ------------------ package.json | 1 - 6 files changed, 2 insertions(+), 773 deletions(-) delete mode 100644 build/azure-pipelines/release-helper.mjs delete mode 100644 build/azure-pipelines/steps/create-github-app-token.yml diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 93ab8449e898..a4f1545c2470 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -65,3 +65,5 @@ jobs: - uses: changesets/action@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2.1.2 with: github-token: ${{ steps.app-token.outputs.token }} + # Packages are published to npm by build/azure-pipelines/release.yml. + publish-script: pnpm changeset git-tag diff --git a/build/azure-pipelines/pack-changed-packages.mjs b/build/azure-pipelines/pack-changed-packages.mjs index 4119930d5f54..05ca67738351 100644 --- a/build/azure-pipelines/pack-changed-packages.mjs +++ b/build/azure-pipelines/pack-changed-packages.mjs @@ -102,21 +102,6 @@ function versionChanged(pkg) { return previous?.version !== pkg.packageJson.version; } -// Match changesets/action, which uses the package's CHANGELOG.md section for -// the version as the GitHub release body. -function readReleaseNotes(pkg) { - const changelogPath = path.join(root, pkg.dir, "CHANGELOG.md"); - if (!fs.existsSync(changelogPath)) return ""; - - const lines = fs.readFileSync(changelogPath, "utf8").split(/\r?\n/); - const start = lines.findIndex(line => line.trim() === `## ${pkg.packageJson.version}`); - if (start === -1) return ""; - - let end = lines.findIndex((line, i) => i > start && /^##?\s/.test(line)); - if (end === -1) end = lines.length; - return lines.slice(start + 1, end).join("\n").trim(); -} - function findChangedPackageNames() { const releaseBranch = `refs/heads/${readJson(path.join(root, ".changeset/config.json")).baseBranch}`; const sourceBranch = process.env.BUILD_SOURCEBRANCH; @@ -174,12 +159,9 @@ for (const name of packedNames) { name, version: packedPackage.version, filename, - releaseNotes: readReleaseNotes(packages.get(name)), }); } -fs.writeFileSync(path.join(outDir, "publish-manifest.json"), JSON.stringify(manifest, undefined, 2) + "\n"); -fs.copyFileSync(path.join(root, "build/azure-pipelines/release-helper.mjs"), path.join(outDir, "release-helper.mjs")); fs.rmSync(path.join(outDir, "packed"), { recursive: true, force: true }); for (let i = 0; i < maxStages; i++) { diff --git a/build/azure-pipelines/release-helper.mjs b/build/azure-pipelines/release-helper.mjs deleted file mode 100644 index 8651cd1dbbb2..000000000000 --- a/build/azure-pipelines/release-helper.mjs +++ /dev/null @@ -1,92 +0,0 @@ -// Release jobs have no repository checkout, so this script is copied into the -// pipeline artifact next to publish-manifest.json and must only use Node -// built-ins. -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const artifactDir = path.dirname(fileURLToPath(import.meta.url)); -const manifest = JSON.parse(fs.readFileSync(path.join(artifactDir, "publish-manifest.json"), "utf8")); - -function spec(pkg) { - return `${pkg.name}@${pkg.version}`; -} - -function requireEnv(name) { - const value = process.env[name]; - if (!value) throw new Error(`${name} is required.`); - return value; -} - -async function githubRequest(route, method = "GET", body) { - const repository = requireEnv("GITHUB_REPOSITORY"); - const response = await fetch(`https://api.github.com/repos/${repository}/${route}`, { - method, - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${requireEnv("GH_TOKEN")}`, - "Content-Type": "application/json", - "X-GitHub-Api-Version": "2022-11-28", - }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - if (method === "GET" && response.status === 404) return undefined; - if (!response.ok) { - throw new Error(`${method} ${route}: ${response.status} ${await response.text()}`); - } - return response.json(); -} - -async function ensureTag(tag, commit) { - const ref = await githubRequest(`git/ref/tags/${tag}`); - if (!ref) { - await githubRequest("git/refs", "POST", { ref: `refs/tags/${tag}`, sha: commit }); - console.log(`Created tag ${tag}.`); - return; - } - - let object = ref.object; - while (object.type === "tag") { - object = (await githubRequest(`git/tags/${object.sha}`)).object; - } - if (object.type !== "commit" || object.sha !== commit) { - throw new Error(`Tag ${tag} already exists but does not point to ${commit}.`); - } - console.log(`Tag ${tag} already exists.`); -} - -async function ensureRelease(tag, pkg) { - const release = await githubRequest(`releases/tags/${tag}`); - if (release) { - console.log(`GitHub release ${tag} already exists: ${release.html_url}`); - return; - } - - const created = await githubRequest("releases", "POST", { - tag_name: tag, - name: tag, - body: pkg.releaseNotes, - prerelease: pkg.version.includes("-"), - }); - console.log(`Created GitHub release: ${created.html_url}`); -} - -// Recreate what changesets/action does after `changeset publish`: a -// name@version tag and a GitHub release for every published package. -async function finalize() { - const commit = requireEnv("RELEASE_SOURCE_COMMIT"); - for (const pkg of manifest.stages.flat()) { - const tag = spec(pkg); - await ensureTag(tag, commit); - await ensureRelease(tag, pkg); - } -} - -const [command, ...args] = process.argv.slice(2); -switch (command) { - case "finalize": - await finalize(); - break; - default: - throw new Error(`Unknown command ${command}`); -} diff --git a/build/azure-pipelines/release.yml b/build/azure-pipelines/release.yml index 68f77c5e0d71..02402249d00b 100644 --- a/build/azure-pipelines/release.yml +++ b/build/azure-pipelines/release.yml @@ -12,9 +12,6 @@ variables: ReleaseOwners: "jabaile@microsoft.com" ReleaseApprovers: "alexchi@microsoft.com" NpmArtifactName: npm-packages - GitHubRepository: microsoft/TypeScript-Website - TYPESCRIPT_AUTOMATION_GITHUB_APP_CLIENT_ID: Iv23li4GolzJSEp1mzHI - TYPESCRIPT_AUTOMATION_GITHUB_APP_KEY_ID: https://jststeam-passwords.vault.azure.net/keys/typescript-automation parameters: - name: publishStages @@ -145,40 +142,3 @@ extends: waitForReleaseCompletion: true owners: "$(ReleaseOwners)" approvers: "$(ReleaseApprovers)" - - - stage: Finalize - displayName: Create tags and GitHub releases - dependsOn: - - Build - - Publish - condition: and(succeeded(), eq(dependencies.Build.outputs['Build.packChangedPackages.ShouldPublish'], 'true')) - jobs: - - job: CreateGitHubReleases - displayName: Create tags and GitHub releases - templateContext: - type: releaseJob - isProduction: true - inputs: - - input: pipelineArtifact - artifactName: $(NpmArtifactName) - targetPath: $(Pipeline.Workspace)/$(NpmArtifactName) - steps: - - checkout: none - - - task: UseNode@1 - inputs: - version: "22.x" - displayName: "Install Node.js" - - - template: /build/azure-pipelines/steps/create-github-app-token.yml@self - parameters: - azureSubscription: TypeScript-DevDiv-KeyVault - repositories: TypeScript-Website - permissions: contents:write - insertSteps: - - script: node "$(Pipeline.Workspace)/$(NpmArtifactName)/release-helper.mjs" finalize - displayName: Create tags and GitHub releases - env: - GH_TOKEN: $(GH_TOKEN) - GITHUB_REPOSITORY: $(GitHubRepository) - RELEASE_SOURCE_COMMIT: $(Build.SourceVersion) diff --git a/build/azure-pipelines/steps/create-github-app-token.yml b/build/azure-pipelines/steps/create-github-app-token.yml deleted file mode 100644 index 1f374fd0c989..000000000000 --- a/build/azure-pipelines/steps/create-github-app-token.yml +++ /dev/null @@ -1,622 +0,0 @@ -# Step template for creating a GitHub App token via Azure Key Vault. -# This template can be used in release jobs where checkout is not available. -# The CLI script from microsoft/create-github-app-token-via-key-vault is inlined below. -# -# The token is created and stored in an Azure Pipelines variable (default: GH_TOKEN). -# A revocation step runs at the end with condition: always(). -# To use the token, add your steps between this template's create and revoke steps -# by setting insertSteps. - -parameters: - - name: azureSubscription - type: string - default: 'TypeScript Public CI' - - name: owner - type: string - default: 'microsoft' - - name: repositories - type: string - - name: permissions - type: string - - name: tokenVariable - type: string - default: 'GH_TOKEN' - - name: condition - type: string - default: succeeded() - - name: revokeCondition - type: string - default: always() - - name: insertSteps - type: stepList - default: [] - -steps: - - task: AzureCLI@2 - displayName: Create GitHub App token - condition: ${{ parameters.condition }} - inputs: - azureSubscription: ${{ parameters.azureSubscription }} - scriptType: pscore - scriptLocation: inlineScript - inlineScript: | - @' - "use strict"; - - // src/api.ts - var defaultRefreshWindowMs = 5 * 60 * 1e3; - var defaultGitHubApiUrl = "https://api.github.com"; - var transientRetryCount = 3; - var GitHubRequestError = class extends Error { - status; - constructor(message, status) { - super(message); - this.status = status; - } - }; - function assertValue(value, message) { - if (!value) { - throw new Error(message); - } - return value; - } - function base64url(value) { - return Buffer.from(value).toString("base64url"); - } - async function sleep(ms) { - await new Promise((resolve) => setTimeout(resolve, ms)); - } - function isRetryableError(error) { - return error instanceof GitHubRequestError ? error.status >= 500 : error instanceof TypeError; - } - async function retryTransient(operation) { - for (let attempt = 0; ; attempt++) { - try { - return await operation(); - } catch (error) { - if (attempt >= transientRetryCount || !isRetryableError(error)) { - throw error; - } - await sleep(2 ** attempt * 1e3); - } - } - } - function splitRepositoryNames(repositories) { - if (Array.isArray(repositories)) { - return repositories.map((repo) => `${repo}`.trim()).filter(Boolean); - } - if (typeof repositories === "string") { - return repositories.split(/[,\n]/).map((repo) => repo.trim()).filter(Boolean); - } - return []; - } - function stableObject(value) { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return value; - } - return Object.fromEntries( - Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, stableObject(entry)]) - ); - } - function githubHeaders(token, json = false) { - return { - "Accept": "application/vnd.github+json", - "Authorization": `Bearer ${token}`, - ...json ? { "Content-Type": "application/json" } : {}, - "X-GitHub-Api-Version": "2022-11-28" - }; - } - function isRecord(value) { - return typeof value === "object" && value !== null && !Array.isArray(value); - } - function requiredIntegerProperty(value, property, failureMessage) { - const propertyValue = isRecord(value) ? value[property] : void 0; - if (typeof propertyValue !== "number" || !Number.isInteger(propertyValue)) { - throw new Error(failureMessage); - } - return propertyValue; - } - function requiredStringProperty(value, property, failureMessage) { - const propertyValue = isRecord(value) ? value[property] : void 0; - if (typeof propertyValue !== "string" || !propertyValue) { - throw new Error(failureMessage); - } - return propertyValue; - } - function validatePermissionName(key) { - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { - throw new Error(`Invalid permission name: ${key}`); - } - } - function validatePermissionLevel(key, level) { - if (level !== "read" && level !== "write" && level !== "admin") { - throw new Error(`Invalid permission level for ${key}: ${level}`); - } - return level; - } - function validatePermissions(value) { - if (value === void 0) { - return void 0; - } - if (!isRecord(value)) { - throw new Error("permissions must be an object"); - } - const permissions = {}; - for (const [key, level] of Object.entries(value)) { - validatePermissionName(key); - permissions[key] = validatePermissionLevel(key, level); - } - return Object.keys(permissions).length === 0 ? void 0 : permissions; - } - async function requestJson(url, init, failureMessage) { - const response = await fetch(url, init); - const body = await response.text(); - if (!response.ok) { - throw new GitHubRequestError( - `${failureMessage}: ${response.status} ${response.statusText}: ${body}`, - response.status - ); - } - try { - return JSON.parse(body); - } catch { - throw new Error(`${failureMessage}: GitHub returned invalid JSON`); - } - } - async function requestNoContent(url, init, failureMessage) { - const response = await fetch(url, init); - if (!response.ok) { - const body = await response.text(); - throw new GitHubRequestError( - `${failureMessage}: ${response.status} ${response.statusText}: ${body}`, - response.status - ); - } - } - function parseRepositoryInput(input) { - const parts = input.split("/"); - if (parts.length === 1 && parts[0]) { - return { input, name: parts[0] }; - } - if (parts.length === 2 && parts[0] && parts[1]) { - return { input, owner: parts[0], name: parts[1] }; - } - throw new Error(`Invalid repository '${input}'. Expected 'repository' or 'owner/repository'.`); - } - function normalizeRepositoryTarget(owner, repositories, defaultOwner) { - const parsedRepositories = repositories.map(parseRepositoryInput); - const repositoryOwner = parsedRepositories.find((repository) => repository.owner)?.owner; - const parsedOwner = owner || defaultOwner || repositoryOwner; - if (!parsedOwner) { - throw new Error("owner is required when repositories are provided"); - } - const mismatchedRepository = parsedRepositories.find( - (repository) => repository.owner && repository.owner.toLowerCase() !== parsedOwner.toLowerCase() - ); - if (mismatchedRepository) { - throw new Error( - `Repository '${mismatchedRepository.input}' includes owner '${mismatchedRepository.owner}', which does not match the resolved owner '${parsedOwner}'.` - ); - } - return { - owner: parsedOwner, - repositories: parsedRepositories.map((repository) => repository.name) - }; - } - function resolveInstallationTarget(options, defaultOwner) { - const repositories = splitRepositoryNames(options.repositories ?? options.repositoryNames); - if (options.enterprise) { - if (options.owner || repositories.length > 0) { - throw new Error("Cannot use 'enterprise' with 'owner' or 'repositories'"); - } - return { type: "enterprise", enterprise: options.enterprise }; - } - const owner = assertValue(options.owner ?? defaultOwner, "owner is required to discover installation ID"); - if (repositories.length === 0) { - return { type: "owner", owner }; - } - return { type: "repository", owner, repositories }; - } - function createGitHubAppAuth(options) { - assertValue(options.appClientId, "appClientId is required"); - assertValue(options.signer, "signer is required"); - const appClientId = options.appClientId; - const signer = options.signer; - const defaultOwner = options.defaultOwner; - const refreshWindowMs = options.refreshWindowMs ?? defaultRefreshWindowMs; - const githubApiUrl = options.githubApiUrl ?? defaultGitHubApiUrl; - const installationCache = /* @__PURE__ */ new Map(); - const tokenCache = /* @__PURE__ */ new Map(); - async function createJwt() { - const now = Math.floor(Date.now() / 1e3); - const iat = now - 60; - const exp = now + 9 * 60; - const header = base64url(JSON.stringify({ typ: "JWT", alg: "RS256" })); - const payload = base64url(JSON.stringify({ iat, exp, iss: appClientId })); - const signingInput = `${header}.${payload}`; - const signature = await signer(signingInput); - return `${signingInput}.${signature}`; - } - async function discoverInstallation(target) { - const cacheKey = JSON.stringify(target); - const cached = installationCache.get(cacheKey); - if (cached !== void 0) { - return cached; - } - const jwt = await createJwt(); - let installation; - switch (target.type) { - case "enterprise": - installation = await requestJson( - `${githubApiUrl}/enterprises/${target.enterprise}/installation`, - { headers: githubHeaders(jwt) }, - "Could not discover GitHub App installation ID" - ); - break; - case "owner": - try { - installation = await requestJson( - `${githubApiUrl}/orgs/${target.owner}/installation`, - { headers: githubHeaders(jwt) }, - "Could not discover GitHub App installation ID" - ); - } catch (error) { - if (!(error instanceof GitHubRequestError) || error.status !== 404) { - throw error; - } - installation = await requestJson( - `${githubApiUrl}/users/${target.owner}/installation`, - { headers: githubHeaders(jwt) }, - "Could not discover GitHub App installation ID" - ); - } - break; - case "repository": - installation = await requestJson( - `${githubApiUrl}/repos/${target.owner}/${assertValue(target.repositories[0], "repository is required")}/installation`, - { headers: githubHeaders(jwt) }, - "Could not discover GitHub App installation ID" - ); - break; - } - const result = { - id: requiredIntegerProperty(installation, "id", "GitHub did not return an installation ID"), - appSlug: requiredStringProperty(installation, "app_slug", "GitHub did not return an App slug") - }; - installationCache.set(cacheKey, result); - return result; - } - async function getInstallationToken(options2) { - const target = resolveInstallationTarget(options2, defaultOwner); - const permissions = validatePermissions(options2.permissions); - return retryTransient(async () => { - const installation = await discoverInstallation(target); - const repositories = target.type === "repository" ? target.repositories : []; - const cacheKey = JSON.stringify({ - installationId: installation.id, - repositories: [...repositories].sort(), - permissions: stableObject(permissions) - }); - const cached = tokenCache.get(cacheKey); - if (cached && Date.now() < new Date(cached.expiresAt).getTime() - refreshWindowMs) { - return cached; - } - const jwt = await createJwt(); - const body = { - ...repositories.length > 0 ? { repositories } : {}, - ...permissions ? { permissions } : {} - }; - const token = await requestJson( - `${githubApiUrl}/app/installations/${installation.id}/access_tokens`, - { - method: "POST", - headers: githubHeaders(jwt, true), - body: JSON.stringify(body) - }, - "Could not create GitHub App installation token" - ); - const result = { - token: requiredStringProperty(token, "token", "GitHub did not return an installation token"), - expiresAt: requiredStringProperty( - token, - "expires_at", - "GitHub did not return an installation token expiration" - ), - installationId: installation.id, - appSlug: installation.appSlug, - repositories, - permissions: isRecord(token) && isRecord(token["permissions"]) ? token["permissions"] : permissions ?? {} - }; - tokenCache.set(cacheKey, result); - return result; - }); - } - async function getToken(options2) { - return (await getInstallationToken(options2)).token; - } - async function revokeToken(token) { - await requestNoContent( - `${githubApiUrl}/installation/token`, - { - method: "DELETE", - headers: githubHeaders(token) - }, - "Could not revoke GitHub App installation token" - ); - } - return { - getInstallationToken, - getToken, - revokeToken - }; - } - - // src/azureCliSigner.ts - var import_node_child_process = require("node:child_process"); - var import_node_crypto = require("node:crypto"); - var cachedAzCommand; - function isRecord2(value) { - return typeof value === "object" && value !== null; - } - function base64ToBase64url(value) { - return value.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); - } - function commandExists(command) { - try { - (0, import_node_child_process.execFileSync)("where.exe", [command], { - encoding: "utf8", - stdio: ["ignore", "ignore", "ignore"] - }); - return true; - } catch { - return false; - } - } - function azCommand() { - if (cachedAzCommand) { - return cachedAzCommand; - } - if (process.platform !== "win32") { - cachedAzCommand = { command: "az", argsPrefix: [] }; - return cachedAzCommand; - } - for (const command of ["az.exe", "az.cmd", "az.bat", "az"]) { - if (commandExists(command)) { - cachedAzCommand = command.endsWith(".exe") ? { command, argsPrefix: [] } : { command: process.env["ComSpec"] || "cmd.exe", argsPrefix: ["/d", "/s", "/c", command] }; - return cachedAzCommand; - } - } - throw new Error("Azure CLI (`az`) was not found on PATH"); - } - function signDigest(keyId, digest) { - const { command, argsPrefix } = azCommand(); - try { - return (0, import_node_child_process.execFileSync)(command, [ - ...argsPrefix, - "keyvault", - "key", - "sign", - "--id", - keyId, - "--algorithm", - "RS256", - "--digest", - digest, - "--query", - "signature", - "--output", - "tsv", - "--only-show-errors" - ], { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"] - }).trim(); - } catch (error) { - if (isRecord2(error)) { - if (error["code"] === "ENOENT") { - throw new Error("Azure CLI (`az`) was not found on PATH"); - } - const errorStderr = error["stderr"]; - const stderr = typeof errorStderr === "string" ? errorStderr.trim() : ""; - if (typeof error["status"] === "number") { - throw new Error( - `Azure Key Vault signing failed with exit code ${error["status"]}${stderr ? `: ${stderr}` : ""}` - ); - } - } - throw new Error("Azure Key Vault signing failed"); - } - } - function createAzureCliKeyVaultSigner(keyId) { - if (!keyId) { - throw new Error("keyId is required"); - } - return async (signingInput) => { - const digest = (0, import_node_crypto.createHash)("sha256").update(signingInput).digest("base64"); - const signature = signDigest(keyId, digest); - if (!signature) { - throw new Error("Azure Key Vault did not return a signature"); - } - return base64ToBase64url(signature); - }; - } - - // src/proxy.ts - var proxyEnvironmentKeys = [ - "https_proxy", - "HTTPS_PROXY", - "http_proxy", - "HTTP_PROXY" - ]; - function proxyEnvironmentConfigured() { - return proxyEnvironmentKeys.some((key) => process.env[key]); - } - function nativeProxySupportEnabled() { - return process.env["NODE_USE_ENV_PROXY"] === "1"; - } - function ensureNativeProxySupport() { - if (!proxyEnvironmentConfigured() || nativeProxySupportEnabled()) { - return; - } - throw new Error( - "A proxy environment variable is set, but Node.js native proxy support is not enabled. Set NODE_USE_ENV_PROXY=1 before running this tool." - ); - } - - // src/cli.ts - function requiredEnv(name) { - const value = process.env[name]; - if (!value) { - throw new Error(`${name} must be set`); - } - return value; - } - function getAppClientId() { - const appClientId = process.env["APP_CLIENT_ID"]; - if (!appClientId) { - throw new Error("APP_CLIENT_ID must be set"); - } - return appClientId; - } - function validatePermissionName2(key) { - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { - throw new Error(`Invalid permission name: ${key}`); - } - } - function validatePermissionLevel2(key, level) { - if (level !== "read" && level !== "write" && level !== "admin") { - throw new Error(`Invalid permission level for ${key}: ${level}`); - } - return level; - } - function parsePermissions(value) { - if (!value) { - return void 0; - } - const permissions = {}; - for (const entry of splitRepositoryNames(value)) { - const parts = entry.split(":"); - if (parts.length !== 2) { - throw new Error(`Permission entry must include an explicit level: ${entry}`); - } - const key = parts[0]?.trim(); - const rawLevel = parts[1]?.trim(); - if (!key) { - throw new Error(`Permission entry must include a permission name: ${entry}`); - } - validatePermissionName2(key); - if (Object.hasOwn(permissions, key)) { - throw new Error(`Duplicate permission: ${key}`); - } - permissions[key] = validatePermissionLevel2(key, rawLevel); - } - return Object.keys(permissions).length === 0 ? void 0 : permissions; - } - function validateVariableName(name, envName) { - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { - throw new Error(`${envName} must be an environment-style variable name`); - } - } - function parseOutputMode(value) { - const output = (value || "stdout").trim().toLowerCase(); - if (output === "azure" || output === "azure-pipelines" || output === "stdout") { - return output; - } - throw new Error('OUTPUT must be "azure", "azure-pipelines", or "stdout"'); - } - function getTokenOptions() { - const enterprise = process.env["ENTERPRISE"]; - const owner = process.env["OWNER"]; - const repositories = splitRepositoryNames(process.env["REPOSITORIES"]); - const permissions = parsePermissions(process.env["PERMISSIONS"]); - if (enterprise) { - if (owner || repositories.length > 0) { - throw new Error("Cannot use ENTERPRISE with OWNER or REPOSITORIES"); - } - return { enterprise, permissions }; - } - if (repositories.length > 0) { - return { ...normalizeRepositoryTarget(owner, repositories, void 0), permissions }; - } - if (owner) { - return { owner, permissions }; - } - throw new Error("OWNER, REPOSITORIES, or ENTERPRISE must be set"); - } - function writeAzurePipelinesOutput(installationToken) { - const variableName = requiredEnv("AZURE_TOKEN_VARIABLE"); - validateVariableName(variableName, "AZURE_TOKEN_VARIABLE"); - process.stdout.write(`##vso[task.setvariable variable=${variableName};isSecret=true]${installationToken.token} - `); - } - function writeOutput(installationToken, output) { - switch (output) { - case "azure": - case "azure-pipelines": - writeAzurePipelinesOutput(installationToken); - break; - case "stdout": - process.stdout.write(`${installationToken.token} - `); - break; - } - } - function reportError(error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`error: ${message}`); - } - async function main() { - ensureNativeProxySupport(); - const githubApiUrl = process.env["GITHUB_API_URL"] || "https://api.github.com"; - const revokeTokenValue = process.env["REVOKE_TOKEN"]; - if (revokeTokenValue) { - const response = await fetch(`${githubApiUrl}/installation/token`, { - method: "DELETE", - headers: { - "Accept": "application/vnd.github+json", - "Authorization": `Bearer ${revokeTokenValue}`, - "X-GitHub-Api-Version": "2022-11-28" - } - }); - if (!response.ok) { - const body = await response.text(); - throw new Error(`Could not revoke token: ${response.status} ${response.statusText}: ${body}`); - } - return; - } - const githubAuth = createGitHubAppAuth({ - appClientId: getAppClientId(), - signer: createAzureCliKeyVaultSigner(requiredEnv("KEY_ID")), - githubApiUrl - }); - const installationToken = await githubAuth.getInstallationToken(getTokenOptions()); - writeOutput(installationToken, parseOutputMode(process.env["OUTPUT"])); - } - void main().catch((error) => { - reportError(error); - process.exitCode = 1; - }); - '@ | Set-Content -LiteralPath "$(Agent.TempDirectory)/create-github-app-token.cjs" -Encoding utf8NoBOM - node "$(Agent.TempDirectory)/create-github-app-token.cjs" - env: - APP_CLIENT_ID: $(TYPESCRIPT_AUTOMATION_GITHUB_APP_CLIENT_ID) - KEY_ID: $(TYPESCRIPT_AUTOMATION_GITHUB_APP_KEY_ID) - OWNER: ${{ parameters.owner }} - REPOSITORIES: ${{ parameters.repositories }} - PERMISSIONS: ${{ parameters.permissions }} - OUTPUT: azure-pipelines - AZURE_TOKEN_VARIABLE: ${{ parameters.tokenVariable }} - - - ${{ each step in parameters.insertSteps }}: - - ${{ step }} - - - task: PowerShell@2 - displayName: Revoke GitHub App token - condition: ${{ parameters.revokeCondition }} - continueOnError: true - inputs: - targetType: inline - pwsh: true - script: node "$(Agent.TempDirectory)/create-github-app-token.cjs" - env: - REVOKE_TOKEN: $(${{ parameters.tokenVariable }}) diff --git a/package.json b/package.json index 200cf3cd8336..1ec3211ab872 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,6 @@ ] }, "scripts": { - "ci:publish": "pnpm changeset git-tag && pnpm publish -r", "bootstrap": "pnpm -r run bootstrap && BOOTSTRAPPING=true pnpm -r run build", "start": "concurrently -p \"[{name}]\" -n \"BUILD,SITE\" -c \"bgBlue.bold,bgMagenta.bold\" \"node watcher.js\" \"pnpm run --filter=typescriptlang-org start\"", "build": "pnpm run --filter=!typescriptlang-org build", From 5262c0b998e50078887e1a625bf7e4ef53f979fe Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:56:50 -0700 Subject: [PATCH 7/8] Patch @typescript/vfs to test the release pipeline Changesets also patches sandbox and twoslash, which depend on vfs, so the first release exercises both publish levels. --- .changeset/esrp-publish.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/esrp-publish.md diff --git a/.changeset/esrp-publish.md b/.changeset/esrp-publish.md new file mode 100644 index 000000000000..a78966c9fabf --- /dev/null +++ b/.changeset/esrp-publish.md @@ -0,0 +1,5 @@ +--- +"@typescript/vfs": patch +--- + +Publish through ESRP in Azure Pipelines. From a6a91bf82ad534a1b122c92db88ad30dd88a0624 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:58:39 -0700 Subject: [PATCH 8/8] Reword the test changeset --- .changeset/esrp-publish.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/esrp-publish.md b/.changeset/esrp-publish.md index a78966c9fabf..ca1cc77f02c3 100644 --- a/.changeset/esrp-publish.md +++ b/.changeset/esrp-publish.md @@ -2,4 +2,4 @@ "@typescript/vfs": patch --- -Publish through ESRP in Azure Pipelines. +Switch to new release pipeline