From a52063c559b1d78a69245b92489f383485769f94 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:45:21 -0700 Subject: [PATCH 01/12] Restore release management workflows --- .github/disabled-workflows/sync-branch.yaml | 103 ------------ .github/workflows/create-cherry-pick-pr.yml | 44 +++-- .../new-release-branch.yaml | 81 ++++++---- .../set-version.yaml | 111 ++++++++----- .github/workflows/sync-branch.yaml | 153 ++++++++++++++++++ Herebyfile.mjs | 2 +- tools/scripts/configure-release.mjs | 32 ++++ 7 files changed, 340 insertions(+), 186 deletions(-) delete mode 100644 .github/disabled-workflows/sync-branch.yaml rename .github/{disabled-workflows => workflows}/new-release-branch.yaml (62%) rename .github/{disabled-workflows => workflows}/set-version.yaml (51%) create mode 100644 .github/workflows/sync-branch.yaml create mode 100644 tools/scripts/configure-release.mjs diff --git a/.github/disabled-workflows/sync-branch.yaml b/.github/disabled-workflows/sync-branch.yaml deleted file mode 100644 index 4c19e1e67d60a..0000000000000 --- a/.github/disabled-workflows/sync-branch.yaml +++ /dev/null @@ -1,103 +0,0 @@ -name: Sync branch with master - -on: - workflow_dispatch: - inputs: - branch_name: - description: Release branch name to create - required: true - type: string - - # Inputs provided by the bot - distinct_id: - description: '(bot) A distinct ID' - required: false - default: '' - source_issue: - description: '(bot) The issue that triggered this workflow' - required: false - default: '' - requesting_user: - description: '(bot) The user who requested this workflow' - required: false - default: '' - status_comment: - description: '(bot) The comment to update with the status of this workflow' - required: false - default: '' - -run-name: ${{ github.workflow }}${{ inputs.distinct_id && format(' (bot run {0})', inputs.distinct_id) || '' }} - -permissions: - contents: read - id-token: write - -# Ensure scripts are run with pipefail. See: -# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#exit-codes-and-error-action-preference -defaults: - run: - shell: bash - -jobs: - build: - runs-on: ubuntu-latest - environment: - name: azure - deployment: false - - steps: - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: 'lts/*' - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - ref: ${{ inputs.branch_name }} - filter: blob:none # https://github.blog/2020-12-21-get-up-to-speed-with-partial-clone-and-shallow-clone/ - fetch-depth: 0 # Default is 1; need to set to 0 to get the benefits of blob:none. - persist-credentials: false - # required client_payload members: - # branch_name - the target branch - - run: | - git config user.email "290192711+typescript-automation[bot]@users.noreply.github.com" - git config user.name "typescript-automation[bot]" - git fetch origin main - git merge origin/main --no-ff - npm ci - npx hereby LKG - git add --force ./lib - git commit -m 'Update LKG' - - 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 - - name: Configure git for GitHub App token - shell: bash - env: - GITHUB_APP_TOKEN: ${{ steps.app-token.outputs.token }} - run: | - set -euo pipefail - 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}" - - run: git push - - - uses: microsoft/typescript-bot-test-triggerer/.github/actions/post-workflow-result@08bcbbf253fd6bc533dd8b9ab47a58582be57fbe # master - if: ${{ !cancelled() && inputs.distinct_id }} - with: - success_comment: "I've pulled main into ${{ inputs.branch_name }} for you." - failure_comment: 'I was unable merge main into ${{ inputs.branch_name }}.' - github_token: ${{ steps.app-token.outputs.token }} - distinct_id: ${{ inputs.distinct_id }} - source_issue: ${{ inputs.source_issue }} - requesting_user: ${{ inputs.requesting_user }} - status_comment: ${{ inputs.status_comment }} diff --git a/.github/workflows/create-cherry-pick-pr.yml b/.github/workflows/create-cherry-pick-pr.yml index dc24cc5b7b9e7..525f32163265f 100644 --- a/.github/workflows/create-cherry-pick-pr.yml +++ b/.github/workflows/create-cherry-pick-pr.yml @@ -56,6 +56,15 @@ jobs: filter: blob:none # https://github.blog/2020-12-21-get-up-to-speed-with-partial-clone-and-shallow-clone/ fetch-depth: 0 # Default is 1; need to set to 0 to get the benefits of blob:none. persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - uses: ./.github/actions/setup-go + - name: Check out target branch + env: + TARGET_BRANCH: ${{ inputs.target_branch }} + run: git switch --detach "origin/$TARGET_BRANCH" + - run: npm ci - uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 with: client-id: ${{ vars.AZURE_CLIENT_ID }} @@ -110,7 +119,9 @@ jobs: pull_number: +PR, }); - if (!pr.data.merge_commit_sha) throw new Error("No merge commit sha found"); + if (!pr.data.merged || !pr.data.merge_commit_sha) { + throw new Error(`PR #${PR} has not been merged`); + } const pickBranch = `cherry-pick/${PR}/${TARGET_BRANCH}`; @@ -118,7 +129,6 @@ jobs: await exec.exec("git", ["config", "user.email", "290192711+typescript-automation[bot]@users.noreply.github.com"]); await exec.exec("git", ["config", "user.name", "typescript-automation[bot]"]); - await exec.exec("git", ["switch", "--detach", `origin/${TARGET_BRANCH}`]); await exec.exec("git", ["switch", "-c", pickBranch]); let updatedBaselinesMessage = ""; @@ -127,29 +137,38 @@ jobs: } catch (e) { console.log(e); - // The cherry-pick failed. If all of the conflicts are in tsc/testdata/baselines, + // The cherry-pick failed. If all conflicts are compiler baselines, // try to run the tests and accept the new baselines. - await exec.exec("git", ["add", "tsc/testdata/baselines"]); - // This will fail if any other files were modified. - await exec.exec("git", ["-c", "core.editor=true", "cherry-pick", "--continue"]); + const conflictsOutput = await exec.getExecOutput("git", ["diff", "--name-only", "--diff-filter=U"]); + const conflictedFiles = conflictsOutput.stdout.trim().split("\n").filter(Boolean); + const baselineDirs = ["tsc/testdata/baselines", "tests/baselines"]; + if (!conflictedFiles.length || conflictedFiles.some(file => !baselineDirs.some(dir => file.startsWith(`${dir}/`)))) { + throw new Error(`Cherry-pick has non-baseline conflicts:\n${conflictedFiles.join("\n")}`); + } - await exec.exec("npm", ["ci"]); + await exec.exec("git", ["add", "--all", "--", ...conflictedFiles]); + await exec.exec("git", ["-c", "core.editor=true", "cherry-pick", "--continue"]); try { - await exec.exec("npm", ["test", "--", "--no-lint"]); + await exec.exec("npx", ["hereby", "test"]); } catch { // Expected to fail. } await exec.exec("npx", ["hereby", "baseline-accept"]); - await exec.exec("git", ["add", "tsc/testdata/baselines"]); - await exec.exec("git", ["commit", "-m", "Update baselines"]); + await exec.exec("npx", ["hereby", "test"]); + const existingBaselineDirs = baselineDirs.filter(dir => conflictedFiles.some(file => file.startsWith(`${dir}/`))); + await exec.exec("git", ["add", "--all", "--", ...existingBaselineDirs]); + const staged = await exec.getExecOutput("git", ["diff", "--cached", "--quiet"], { ignoreReturnCode: true }); + if (staged.exitCode !== 0) { + await exec.exec("git", ["commit", "-m", "Update baselines"]); + } updatedBaselinesMessage = " This involved updating baselines; please check the diff."; } - await exec.exec("git", ["push", "--force", "--set-upstream", "origin", pickBranch]); + await exec.exec("git", ["push", "--force-with-lease", "--set-upstream", "origin", pickBranch]); const existingPulls = await github.rest.pulls.list({ owner: context.repo.owner, @@ -180,11 +199,12 @@ jobs: assignees: ["DanielRosenwasser"], }); + const reviewers = ["DanielRosenwasser", REQUESTING_USER].filter(Boolean); await github.rest.pulls.requestReviewers({ owner: context.repo.owner, repo: context.repo.repo, pull_number: newPr.data.number, - reviewers: ["DanielRosenwasser", REQUESTING_USER], + reviewers, }); commentBody = `I've created #${newPr.data.number} for you.${updatedBaselinesMessage}`; diff --git a/.github/disabled-workflows/new-release-branch.yaml b/.github/workflows/new-release-branch.yaml similarity index 62% rename from .github/disabled-workflows/new-release-branch.yaml rename to .github/workflows/new-release-branch.yaml index 58bd6310c1603..c613f148348c5 100644 --- a/.github/disabled-workflows/new-release-branch.yaml +++ b/.github/workflows/new-release-branch.yaml @@ -1,4 +1,4 @@ -name: New Release Branch +name: New release branch on: workflow_dispatch: @@ -40,56 +40,74 @@ permissions: contents: read id-token: write -# Ensure scripts are run with pipefail. See: -# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#exit-codes-and-error-action-preference defaults: run: shell: bash jobs: - build: + create: + if: github.repository == 'microsoft/TypeScript' runs-on: ubuntu-latest environment: name: azure deployment: false steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - filter: blob:none # https://github.blog/2020-12-21-get-up-to-speed-with-partial-clone-and-shallow-clone/ - fetch-depth: 0 # Default is 1; need to set to 0 to get the benefits of blob:none. + ref: main + filter: blob:none + fetch-depth: 0 persist-credentials: false - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 'lts/*' - - run: | - npm --version - # corepack enable npm - npm install -g $(jq -r '.packageManager' < package.json) - npm --version - - env: + node-version: 24 + - uses: ./.github/actions/setup-go + - name: Configure release branch + env: BRANCH_NAME: ${{ inputs.branch_name }} PACKAGE_VERSION: ${{ inputs.package_version }} CORE_MAJOR_MINOR: ${{ inputs.core_major_minor }} run: | - git checkout -b "$BRANCH_NAME" - sed -i -e 's/"version": ".*"/"version": "'"$PACKAGE_VERSION"'"/g' package.json - sed -i -e 's/const versionMajorMinor = ".*"/const versionMajorMinor = "'"$CORE_MAJOR_MINOR"'"/g' src/compiler/corePublic.ts - sed -i -e 's/const versionMajorMinor = ".*"/const versionMajorMinor = "'"$CORE_MAJOR_MINOR"'"/g' tests/baselines/reference/api/typescript.d.ts - sed -i -e 's/const version\(: string\)\{0,1\} = .*;/const version = "'"$PACKAGE_VERSION"'" as string;/g' src/compiler/corePublic.ts - npm ci - npm install # update package-lock.json to ensure the version bump is included - npx hereby LKG - npm test - git diff - git add package.json package-lock.json - git add src/compiler/corePublic.ts - git add tests/baselines/reference/api/typescript.d.ts - git add --force ./lib + set -euo pipefail + if ! [[ "$BRANCH_NAME" =~ ^(release-.+|ts[0-9]+-release)$ ]]; then + echo "Release branch name must start with 'release-' or match 'ts-release'." >&2 + exit 1 + fi + if git show-ref --verify --quiet "refs/remotes/origin/$BRANCH_NAME"; then + echo "Branch $BRANCH_NAME already exists." >&2 + exit 1 + fi + + git switch -c "$BRANCH_NAME" + node tools/scripts/configure-release.mjs "$PACKAGE_VERSION" "$CORE_MAJOR_MINOR" + + mapfile -t changedFiles < <(git diff --name-only) + expectedFiles=("Herebyfile.mjs" "tsc/internal/core/version.go") + if [ "${changedFiles[*]}" != "${expectedFiles[*]}" ]; then + echo "Unexpected files in release configuration: ${changedFiles[*]}" >&2 + exit 1 + fi + - run: npm ci + - run: npx hereby validate --all + - name: Commit release configuration + env: + PACKAGE_VERSION: ${{ inputs.package_version }} + run: | + set -euo pipefail + git diff --check + mapfile -t changedFiles < <(git diff --name-only) + expectedFiles=("Herebyfile.mjs" "tsc/internal/core/version.go") + if [ "${changedFiles[*]}" != "${expectedFiles[*]}" ]; then + echo "Validation produced unexpected changes: ${changedFiles[*]}" >&2 + exit 1 + fi + git add Herebyfile.mjs tsc/internal/core/version.go git config user.email "290192711+typescript-automation[bot]@users.noreply.github.com" git config user.name "typescript-automation[bot]" - git commit -m "Bump version to $PACKAGE_VERSION and LKG" - - uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + git config core.hooksPath /dev/null + git commit -m "Bump version to $PACKAGE_VERSION" + - uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 with: client-id: ${{ vars.AZURE_CLIENT_ID }} tenant-id: ${{ vars.AZURE_TENANT_ID }} @@ -104,7 +122,6 @@ jobs: repositories: TypeScript permission-contents: write - name: Configure git for GitHub App token - shell: bash env: GITHUB_APP_TOKEN: ${{ steps.app-token.outputs.token }} run: | diff --git a/.github/disabled-workflows/set-version.yaml b/.github/workflows/set-version.yaml similarity index 51% rename from .github/disabled-workflows/set-version.yaml rename to .github/workflows/set-version.yaml index dd58152a1dcb6..bc40c6f2f06ec 100644 --- a/.github/disabled-workflows/set-version.yaml +++ b/.github/workflows/set-version.yaml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: branch_name: - description: Release branch name to create + description: Release branch to update required: true type: string package_version: @@ -40,59 +40,91 @@ permissions: contents: read id-token: write -# Ensure scripts are run with pipefail. See: -# https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#exit-codes-and-error-action-preference defaults: run: shell: bash jobs: - build: + update: + if: github.repository == 'microsoft/TypeScript' runs-on: ubuntu-latest environment: name: azure deployment: false + steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Validate release branch name + env: + BRANCH_NAME: ${{ inputs.branch_name }} + run: | + set -euo pipefail + if ! [[ "$BRANCH_NAME" =~ ^(release-.+|ts[0-9]+-release)$ ]]; then + echo "Release branch name must start with 'release-' or match 'ts-release'." >&2 + exit 1 + fi + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ inputs.branch_name }} + filter: blob:none + fetch-depth: 0 persist-credentials: false - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 'lts/*' - - run: | - npm --version - # corepack enable npm - npm install -g $(jq -r '.packageManager' < package.json) - npm --version - # notably, this is essentially the same script as `new-release-branch.yaml` (with fewer inputs), but it assumes the branch already exists - # do note that executing the transform below will prevent the `configurePrerelease` script from running on the source, as it makes the - # `version` identifier no longer match the regex it uses - # required client_payload members: - # branch_name - the target branch - # package_version - the full version string (eg, `3.9.1-rc` or `3.9.2`) - # core_major_minor - the major.minor pair associated with the desired package_version (eg, `3.9` for `3.9.3`) - - env: + node-version: 24 + - uses: ./.github/actions/setup-go + - run: npm ci + - name: Load release automation from main + run: | + set -euo pipefail + git fetch origin main + git show origin/main:tools/scripts/configure-release.mjs > "$RUNNER_TEMP/configure-release.mjs" + - name: Configure release version + id: configure + env: PACKAGE_VERSION: ${{ inputs.package_version }} CORE_MAJOR_MINOR: ${{ inputs.core_major_minor }} run: | - sed -i -e 's/"version": ".*"/"version": "'"$PACKAGE_VERSION"'"/g' package.json - sed -i -e 's/const versionMajorMinor = ".*"/const versionMajorMinor = "'"$CORE_MAJOR_MINOR"'"/g' src/compiler/corePublic.ts - sed -i -e 's/const versionMajorMinor = ".*"/const versionMajorMinor = "'"$CORE_MAJOR_MINOR"'"/g' tests/baselines/reference/api/typescript.d.ts - sed -i -e 's/const version\(: string\)\{0,1\} = .*;/const version = "'"$PACKAGE_VERSION"'" as string;/g' src/compiler/corePublic.ts - npm ci - npm install # update package-lock.json to ensure the version bump is included - npx hereby LKG - npm test - git diff - git add package.json package-lock.json - git add src/compiler/corePublic.ts - git add tests/baselines/reference/api/typescript.d.ts - git add --force ./lib + set -euo pipefail + node "$RUNNER_TEMP/configure-release.mjs" "$PACKAGE_VERSION" "$CORE_MAJOR_MINOR" + + mapfile -t changedFiles < <(git diff --name-only) + if [ "${#changedFiles[@]}" -eq 0 ]; then + echo "Version is already $PACKAGE_VERSION." + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + expectedFiles=("Herebyfile.mjs" "tsc/internal/core/version.go") + for changedFile in "${changedFiles[@]}"; do + if [[ ! " ${expectedFiles[*]} " =~ " $changedFile " ]]; then + echo "Unexpected file in release configuration: $changedFile" >&2 + exit 1 + fi + done + echo "changed=true" >> "$GITHUB_OUTPUT" + - if: steps.configure.outputs.changed == 'true' + run: npx hereby validate --all + - name: Commit release configuration + if: steps.configure.outputs.changed == 'true' + env: + PACKAGE_VERSION: ${{ inputs.package_version }} + run: | + set -euo pipefail + git diff --check + mapfile -t changedFiles < <(git diff --name-only) + expectedFiles=("Herebyfile.mjs" "tsc/internal/core/version.go") + for changedFile in "${changedFiles[@]}"; do + if [[ ! " ${expectedFiles[*]} " =~ " $changedFile " ]]; then + echo "Validation produced unexpected change: $changedFile" >&2 + exit 1 + fi + done + git add Herebyfile.mjs tsc/internal/core/version.go git config user.email "290192711+typescript-automation[bot]@users.noreply.github.com" git config user.name "typescript-automation[bot]" - git commit -m "Bump version to $PACKAGE_VERSION and LKG" - - uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + git config core.hooksPath /dev/null + git commit -m "Bump version to $PACKAGE_VERSION" + - uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 with: client-id: ${{ vars.AZURE_CLIENT_ID }} tenant-id: ${{ vars.AZURE_TENANT_ID }} @@ -107,7 +139,7 @@ jobs: repositories: TypeScript permission-contents: write - name: Configure git for GitHub App token - shell: bash + if: steps.configure.outputs.changed == 'true' env: GITHUB_APP_TOKEN: ${{ steps.app-token.outputs.token }} run: | @@ -115,13 +147,16 @@ jobs: 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}" - - run: git push + - if: steps.configure.outputs.changed == 'true' + env: + BRANCH_NAME: ${{ inputs.branch_name }} + run: git push origin "HEAD:$BRANCH_NAME" - uses: microsoft/typescript-bot-test-triggerer/.github/actions/post-workflow-result@08bcbbf253fd6bc533dd8b9ab47a58582be57fbe # master if: ${{ !cancelled() && inputs.distinct_id }} with: success_comment: "I've set the version of ${{ inputs.branch_name }} to ${{ inputs.package_version }} for you." - failure_comment: 'I was unable set the version.' + failure_comment: 'I was unable to set the version.' github_token: ${{ steps.app-token.outputs.token }} distinct_id: ${{ inputs.distinct_id }} source_issue: ${{ inputs.source_issue }} diff --git a/.github/workflows/sync-branch.yaml b/.github/workflows/sync-branch.yaml new file mode 100644 index 0000000000000..a39110e5a0533 --- /dev/null +++ b/.github/workflows/sync-branch.yaml @@ -0,0 +1,153 @@ +name: Sync release branch with main + +on: + workflow_dispatch: + inputs: + branch_name: + description: Release branch to update + required: true + type: string + + # Inputs provided by the bot + distinct_id: + description: '(bot) A distinct ID' + required: false + default: '' + source_issue: + description: '(bot) The issue that triggered this workflow' + required: false + default: '' + requesting_user: + description: '(bot) The user who requested this workflow' + required: false + default: '' + status_comment: + description: '(bot) The comment to update with the status of this workflow' + required: false + default: '' + +run-name: ${{ github.workflow }}${{ inputs.distinct_id && format(' (bot run {0})', inputs.distinct_id) || '' }} + +permissions: + contents: read + id-token: write + +defaults: + run: + shell: bash + +jobs: + sync: + if: github.repository == 'microsoft/TypeScript' + runs-on: ubuntu-latest + environment: + name: azure + deployment: false + + steps: + - name: Validate release branch name + env: + BRANCH_NAME: ${{ inputs.branch_name }} + run: | + set -euo pipefail + if ! [[ "$BRANCH_NAME" =~ ^(release-.+|ts[0-9]+-release)$ ]]; then + echo "Release branch name must start with 'release-' or match 'ts-release'." >&2 + exit 1 + fi + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.branch_name }} + filter: blob:none + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - uses: ./.github/actions/setup-go + - name: Merge main + env: + BRANCH_NAME: ${{ inputs.branch_name }} + run: | + set -euo pipefail + releaseVersion="$(node -e ' + const source = require("node:fs").readFileSync("tsc/internal/core/version.go", "utf8"); + const match = source.match(/var version = "([^"]+)"/); + if (!match) throw new Error("Unable to read the release version"); + process.stdout.write(match[1]); + ')" + 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 fetch origin main + git show origin/main:tools/scripts/configure-release.mjs > "$RUNNER_TEMP/configure-release.mjs" + + if ! git merge origin/main --no-ff --no-commit; then + mapfile -t conflictedFiles < <(git diff --name-only --diff-filter=U) + allowedConflicts=("Herebyfile.mjs" "tsc/internal/core/version.go") + for conflictedFile in "${conflictedFiles[@]}"; do + if [[ ! " ${allowedConflicts[*]} " =~ " $conflictedFile " ]]; then + echo "Merge conflict requires manual resolution: $conflictedFile" >&2 + exit 1 + fi + done + if [ "${#conflictedFiles[@]}" -eq 0 ]; then + echo "Merge failed without reporting conflicted files." >&2 + exit 1 + fi + git checkout --theirs -- "${conflictedFiles[@]}" + fi + + node "$RUNNER_TEMP/configure-release.mjs" "$releaseVersion" + git add Herebyfile.mjs tsc/internal/core/version.go + if git rev-parse --verify --quiet MERGE_HEAD >/dev/null; then + git commit --no-edit + elif ! git diff --cached --quiet; then + git commit -m "Restore release configuration after syncing main" + fi + - run: npm ci + - run: npx hereby validate --all + - name: Verify clean merge + run: | + set -euo pipefail + git diff --check + if ! git diff --quiet || ! git diff --cached --quiet; then + echo "Validation modified the worktree; refusing to push the merge." >&2 + git status --short + exit 1 + fi + - uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3.0.2 + 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 + - name: Configure git for GitHub App token + env: + GITHUB_APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + 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}" + - env: + BRANCH_NAME: ${{ inputs.branch_name }} + run: git push origin "HEAD:$BRANCH_NAME" + + - uses: microsoft/typescript-bot-test-triggerer/.github/actions/post-workflow-result@08bcbbf253fd6bc533dd8b9ab47a58582be57fbe # master + if: ${{ !cancelled() && inputs.distinct_id }} + with: + success_comment: "I've pulled main into ${{ inputs.branch_name }} for you." + failure_comment: 'I was unable to merge main into ${{ inputs.branch_name }}.' + github_token: ${{ steps.app-token.outputs.token }} + distinct_id: ${{ inputs.distinct_id }} + source_issue: ${{ inputs.source_issue }} + requesting_user: ${{ inputs.requesting_user }} + status_comment: ${{ inputs.status_comment }} diff --git a/Herebyfile.mjs b/Herebyfile.mjs index ce9ecf7fe3ec9..2168308d2aa6b 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -137,7 +137,7 @@ const nativePreviewReleaseProfile = /** @type {"native-preview" | "typescript"} const nativePreviewReleaseVersion = /** @type {string | undefined} */ (undefined); const releaseVscodeTypescript = !!options.vscodeTypescriptRelease; const produceNativePreviewVsix = releaseVscodeTypescript; -const produceTypeScriptNightlyVsix = !releaseVscodeTypescript; +const produceTypeScriptNightlyVsix = !nativePreviewReleaseVersion && !releaseVscodeTypescript; const usePublishedPlatformPackagesForVsix = releaseVscodeTypescript; const produceAnyVsix = produceNativePreviewVsix || produceTypeScriptNightlyVsix; const publishAsTypescript = nativePreviewReleaseProfile === "typescript"; diff --git a/tools/scripts/configure-release.mjs b/tools/scripts/configure-release.mjs new file mode 100644 index 0000000000000..d923face254c5 --- /dev/null +++ b/tools/scripts/configure-release.mjs @@ -0,0 +1,32 @@ +import fs from "node:fs"; + +const [version, expectedMajorMinor] = process.argv.slice(2); + +if (!version || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(version)) { + throw new Error("Usage: node tools/scripts/configure-release.mjs [expected-major.minor]"); +} + +const majorMinor = version.split(".", 2).join("."); +if (expectedMajorMinor && expectedMajorMinor !== majorMinor) { + throw new Error(`Version ${version} has major.minor ${majorMinor}, not ${expectedMajorMinor}`); +} + +updateFile( + "tsc/internal/core/version.go", + /var version = "[^"]+"/g, + `var version = "${version}"`, +); +updateFile( + "Herebyfile.mjs", + /const nativePreviewReleaseVersion = \/\*\* @type \{string \| undefined\} \*\/ \([^)]*\);/g, + `const nativePreviewReleaseVersion = /** @type {string | undefined} */ ("${version}");`, +); + +function updateFile(path, pattern, replacement) { + const source = fs.readFileSync(path, "utf8"); + const matches = source.match(pattern); + if (matches?.length !== 1) { + throw new Error(`Expected exactly one release version declaration in ${path}, found ${matches?.length ?? 0}`); + } + fs.writeFileSync(path, source.replace(pattern, replacement)); +} From b01e3c980fd98d8d7e0be9f9347b0a2298495828 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:30:30 -0700 Subject: [PATCH 02/12] Fix release version validation --- Herebyfile.mjs | 5 +++- tools/scripts/configure-release.mjs | 4 +++- tools/scripts/semver.mjs | 17 ++++++++++++++ tools/scripts/semver.test.mjs | 36 +++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 tools/scripts/semver.mjs create mode 100644 tools/scripts/semver.test.mjs diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 2168308d2aa6b..6e5eaaf5cae5f 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -1224,7 +1224,10 @@ export const testBenchmarks = task({ async function runTestTools() { const command = gotestsum("tools"); - await run(command[0], [...command.slice(1), "./..."], { env: goTestEnv, cwd: path.join(__dirname, "tools") }); + await Promise.all([ + run(command[0], [...command.slice(1), "./..."], { env: goTestEnv, cwd: path.join(__dirname, "tools") }), + run("node", ["--test", "./scripts/semver.test.mjs"], { cwd: path.join(__dirname, "tools") }), + ]); } async function runTestAPI() { diff --git a/tools/scripts/configure-release.mjs b/tools/scripts/configure-release.mjs index d923face254c5..69dde24cd6ad3 100644 --- a/tools/scripts/configure-release.mjs +++ b/tools/scripts/configure-release.mjs @@ -1,8 +1,10 @@ import fs from "node:fs"; +import { isSemVer } from "./semver.mjs"; + const [version, expectedMajorMinor] = process.argv.slice(2); -if (!version || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(version)) { +if (!version || !isSemVer(version)) { throw new Error("Usage: node tools/scripts/configure-release.mjs [expected-major.minor]"); } diff --git a/tools/scripts/semver.mjs b/tools/scripts/semver.mjs new file mode 100644 index 0000000000000..e162e434382cf --- /dev/null +++ b/tools/scripts/semver.mjs @@ -0,0 +1,17 @@ +const numericIdentifier = String.raw`(?:0|[1-9]\d*)`; +const nonNumericIdentifier = String.raw`(?:\d*[A-Za-z-][0-9A-Za-z-]*)`; +const prereleaseIdentifier = String.raw`(?:${numericIdentifier}|${nonNumericIdentifier})`; +const prerelease = String.raw`${prereleaseIdentifier}(?:\.${prereleaseIdentifier})*`; +const build = String.raw`[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*`; +const semverPattern = new RegExp( + String.raw`^(${numericIdentifier})\.(${numericIdentifier})\.(${numericIdentifier})(?:-${prerelease})?(?:\+${build})?$`, +); +const maxUint32 = 0xffff_ffffn; + +/** + * @param {string} value + */ +export function isSemVer(value) { + const match = semverPattern.exec(value); + return match !== null && match.slice(1, 4).every(component => BigInt(component) <= maxUint32); +} diff --git a/tools/scripts/semver.test.mjs b/tools/scripts/semver.test.mjs new file mode 100644 index 0000000000000..2916ab63e29f3 --- /dev/null +++ b/tools/scripts/semver.test.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { isSemVer } from "./semver.mjs"; + +test("valid SemVer versions", () => { + for ( + const version of [ + "7.0.3", + "7.0.3-alpha", + "7.0.3-alpha.0", + "7.0.3-alpha.0+build.1", + "7.0.3+build.1", + "4294967295.0.0", + ] + ) { + assert.equal(isSemVer(version), true, version); + } +}); + +test("invalid SemVer versions", () => { + for ( + const version of [ + "7.0", + "07.0.3", + "7.00.3", + "7.0.03", + "7.0.3-01", + "7.0.3-alpha..1", + "7.0.3+build..1", + "4294967296.0.0", + ] + ) { + assert.equal(isSemVer(version), false, version); + } +}); From c83870e0da4ed7c081a86ed61dec0220be24af1d Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:43:14 -0700 Subject: [PATCH 03/12] Keep release configuration script self-contained --- Herebyfile.mjs | 2 +- tools/scripts/configure-release.mjs | 62 ++++++++++++++++------- tools/scripts/configure-release.test.mjs | 63 ++++++++++++++++++++++++ tools/scripts/semver.mjs | 17 ------- tools/scripts/semver.test.mjs | 36 -------------- 5 files changed, 108 insertions(+), 72 deletions(-) create mode 100644 tools/scripts/configure-release.test.mjs delete mode 100644 tools/scripts/semver.mjs delete mode 100644 tools/scripts/semver.test.mjs diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 6e5eaaf5cae5f..4aca988bcbd6e 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -1226,7 +1226,7 @@ async function runTestTools() { const command = gotestsum("tools"); await Promise.all([ run(command[0], [...command.slice(1), "./..."], { env: goTestEnv, cwd: path.join(__dirname, "tools") }), - run("node", ["--test", "./scripts/semver.test.mjs"], { cwd: path.join(__dirname, "tools") }), + run("node", ["--test", "./scripts/configure-release.test.mjs"], { cwd: path.join(__dirname, "tools") }), ]); } diff --git a/tools/scripts/configure-release.mjs b/tools/scripts/configure-release.mjs index 69dde24cd6ad3..634f542d88ab8 100644 --- a/tools/scripts/configure-release.mjs +++ b/tools/scripts/configure-release.mjs @@ -1,28 +1,54 @@ import fs from "node:fs"; +import { pathToFileURL } from "node:url"; -import { isSemVer } from "./semver.mjs"; - -const [version, expectedMajorMinor] = process.argv.slice(2); +const numericIdentifier = String.raw`(?:0|[1-9]\d*)`; +const nonNumericIdentifier = String.raw`(?:\d*[A-Za-z-][0-9A-Za-z-]*)`; +const prereleaseIdentifier = String.raw`(?:${numericIdentifier}|${nonNumericIdentifier})`; +const prerelease = String.raw`${prereleaseIdentifier}(?:\.${prereleaseIdentifier})*`; +const build = String.raw`[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*`; +const semverPattern = new RegExp( + String.raw`^(${numericIdentifier})\.(${numericIdentifier})\.(${numericIdentifier})(?:-${prerelease})?(?:\+${build})?$`, +); +const maxUint32 = 0xffff_ffffn; -if (!version || !isSemVer(version)) { - throw new Error("Usage: node tools/scripts/configure-release.mjs [expected-major.minor]"); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + configureRelease(process.argv.slice(2)); } -const majorMinor = version.split(".", 2).join("."); -if (expectedMajorMinor && expectedMajorMinor !== majorMinor) { - throw new Error(`Version ${version} has major.minor ${majorMinor}, not ${expectedMajorMinor}`); +/** + * @param {string} value + */ +export function isSemVer(value) { + const match = semverPattern.exec(value); + return match !== null && match.slice(1, 4).every(component => BigInt(component) <= maxUint32); } -updateFile( - "tsc/internal/core/version.go", - /var version = "[^"]+"/g, - `var version = "${version}"`, -); -updateFile( - "Herebyfile.mjs", - /const nativePreviewReleaseVersion = \/\*\* @type \{string \| undefined\} \*\/ \([^)]*\);/g, - `const nativePreviewReleaseVersion = /** @type {string | undefined} */ ("${version}");`, -); +/** + * @param {string[]} args + */ +function configureRelease(args) { + const [version, expectedMajorMinor] = args; + + if (!version || !isSemVer(version)) { + throw new Error("Usage: node tools/scripts/configure-release.mjs [expected-major.minor]"); + } + + const majorMinor = version.split(".", 2).join("."); + if (expectedMajorMinor && expectedMajorMinor !== majorMinor) { + throw new Error(`Version ${version} has major.minor ${majorMinor}, not ${expectedMajorMinor}`); + } + + updateFile( + "tsc/internal/core/version.go", + /var version = "[^"]+"/g, + `var version = "${version}"`, + ); + updateFile( + "Herebyfile.mjs", + /const nativePreviewReleaseVersion = \/\*\* @type \{string \| undefined\} \*\/ \([^)]*\);/g, + `const nativePreviewReleaseVersion = /** @type {string | undefined} */ ("${version}");`, + ); +} function updateFile(path, pattern, replacement) { const source = fs.readFileSync(path, "utf8"); diff --git a/tools/scripts/configure-release.test.mjs b/tools/scripts/configure-release.test.mjs new file mode 100644 index 0000000000000..128665e716100 --- /dev/null +++ b/tools/scripts/configure-release.test.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { isSemVer } from "./configure-release.mjs"; + +test("valid SemVer versions", () => { + for ( + const version of [ + "7.0.3", + "7.0.3-alpha", + "7.0.3-alpha.0", + "7.0.3-alpha.0+build.1", + "7.0.3+build.1", + "4294967295.0.0", + ] + ) { + assert.equal(isSemVer(version), true, version); + } +}); + +test("invalid SemVer versions", () => { + for ( + const version of [ + "7.0", + "07.0.3", + "7.00.3", + "7.0.03", + "7.0.3-01", + "7.0.3-alpha..1", + "7.0.3+build..1", + "4294967296.0.0", + ] + ) { + assert.equal(isSemVer(version), false, version); + } +}); + +test("copied release configuration script is self-contained", () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "configure-release-")); + try { + const script = path.join(temp, "configure-release.mjs"); + fs.copyFileSync(fileURLToPath(new URL("./configure-release.mjs", import.meta.url)), script); + fs.mkdirSync(path.join(temp, "tsc/internal/core"), { recursive: true }); + fs.writeFileSync(path.join(temp, "tsc/internal/core/version.go"), 'package core\n\nvar version = "0.0.0"\n'); + fs.writeFileSync( + path.join(temp, "Herebyfile.mjs"), + "const nativePreviewReleaseVersion = /** @type {string | undefined} */ (process.env.NATIVE_PREVIEW_RELEASE_VERSION);\n", + ); + + execFileSync(process.execPath, [script, "7.0.3+build.1", "7.0"], { cwd: temp }); + + assert.match(fs.readFileSync(path.join(temp, "tsc/internal/core/version.go"), "utf8"), /var version = "7\.0\.3\+build\.1"/); + assert.match(fs.readFileSync(path.join(temp, "Herebyfile.mjs"), "utf8"), /\("7\.0\.3\+build\.1"\)/); + } + finally { + fs.rmSync(temp, { recursive: true, force: true }); + } +}); diff --git a/tools/scripts/semver.mjs b/tools/scripts/semver.mjs deleted file mode 100644 index e162e434382cf..0000000000000 --- a/tools/scripts/semver.mjs +++ /dev/null @@ -1,17 +0,0 @@ -const numericIdentifier = String.raw`(?:0|[1-9]\d*)`; -const nonNumericIdentifier = String.raw`(?:\d*[A-Za-z-][0-9A-Za-z-]*)`; -const prereleaseIdentifier = String.raw`(?:${numericIdentifier}|${nonNumericIdentifier})`; -const prerelease = String.raw`${prereleaseIdentifier}(?:\.${prereleaseIdentifier})*`; -const build = String.raw`[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*`; -const semverPattern = new RegExp( - String.raw`^(${numericIdentifier})\.(${numericIdentifier})\.(${numericIdentifier})(?:-${prerelease})?(?:\+${build})?$`, -); -const maxUint32 = 0xffff_ffffn; - -/** - * @param {string} value - */ -export function isSemVer(value) { - const match = semverPattern.exec(value); - return match !== null && match.slice(1, 4).every(component => BigInt(component) <= maxUint32); -} diff --git a/tools/scripts/semver.test.mjs b/tools/scripts/semver.test.mjs deleted file mode 100644 index 2916ab63e29f3..0000000000000 --- a/tools/scripts/semver.test.mjs +++ /dev/null @@ -1,36 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { isSemVer } from "./semver.mjs"; - -test("valid SemVer versions", () => { - for ( - const version of [ - "7.0.3", - "7.0.3-alpha", - "7.0.3-alpha.0", - "7.0.3-alpha.0+build.1", - "7.0.3+build.1", - "4294967295.0.0", - ] - ) { - assert.equal(isSemVer(version), true, version); - } -}); - -test("invalid SemVer versions", () => { - for ( - const version of [ - "7.0", - "07.0.3", - "7.00.3", - "7.0.03", - "7.0.3-01", - "7.0.3-alpha..1", - "7.0.3+build..1", - "4294967296.0.0", - ] - ) { - assert.equal(isSemVer(version), false, version); - } -}); From dd95364893a671f7bb947c53453e95e8f252fc3d Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:52:07 -0700 Subject: [PATCH 04/12] Remove release script tests --- Herebyfile.mjs | 5 +- tools/scripts/configure-release.mjs | 52 ++++++++----------- tools/scripts/configure-release.test.mjs | 63 ------------------------ 3 files changed, 22 insertions(+), 98 deletions(-) delete mode 100644 tools/scripts/configure-release.test.mjs diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 4aca988bcbd6e..2168308d2aa6b 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -1224,10 +1224,7 @@ export const testBenchmarks = task({ async function runTestTools() { const command = gotestsum("tools"); - await Promise.all([ - run(command[0], [...command.slice(1), "./..."], { env: goTestEnv, cwd: path.join(__dirname, "tools") }), - run("node", ["--test", "./scripts/configure-release.test.mjs"], { cwd: path.join(__dirname, "tools") }), - ]); + await run(command[0], [...command.slice(1), "./..."], { env: goTestEnv, cwd: path.join(__dirname, "tools") }); } async function runTestAPI() { diff --git a/tools/scripts/configure-release.mjs b/tools/scripts/configure-release.mjs index 634f542d88ab8..da87918e8cfad 100644 --- a/tools/scripts/configure-release.mjs +++ b/tools/scripts/configure-release.mjs @@ -1,5 +1,4 @@ import fs from "node:fs"; -import { pathToFileURL } from "node:url"; const numericIdentifier = String.raw`(?:0|[1-9]\d*)`; const nonNumericIdentifier = String.raw`(?:\d*[A-Za-z-][0-9A-Za-z-]*)`; @@ -11,45 +10,36 @@ const semverPattern = new RegExp( ); const maxUint32 = 0xffff_ffffn; -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - configureRelease(process.argv.slice(2)); +const [version, expectedMajorMinor] = process.argv.slice(2); + +if (!version || !isSemVer(version)) { + throw new Error("Usage: node tools/scripts/configure-release.mjs [expected-major.minor]"); +} + +const majorMinor = version.split(".", 2).join("."); +if (expectedMajorMinor && expectedMajorMinor !== majorMinor) { + throw new Error(`Version ${version} has major.minor ${majorMinor}, not ${expectedMajorMinor}`); } +updateFile( + "tsc/internal/core/version.go", + /var version = "[^"]+"/g, + `var version = "${version}"`, +); +updateFile( + "Herebyfile.mjs", + /const nativePreviewReleaseVersion = \/\*\* @type \{string \| undefined\} \*\/ \([^)]*\);/g, + `const nativePreviewReleaseVersion = /** @type {string | undefined} */ ("${version}");`, +); + /** * @param {string} value */ -export function isSemVer(value) { +function isSemVer(value) { const match = semverPattern.exec(value); return match !== null && match.slice(1, 4).every(component => BigInt(component) <= maxUint32); } -/** - * @param {string[]} args - */ -function configureRelease(args) { - const [version, expectedMajorMinor] = args; - - if (!version || !isSemVer(version)) { - throw new Error("Usage: node tools/scripts/configure-release.mjs [expected-major.minor]"); - } - - const majorMinor = version.split(".", 2).join("."); - if (expectedMajorMinor && expectedMajorMinor !== majorMinor) { - throw new Error(`Version ${version} has major.minor ${majorMinor}, not ${expectedMajorMinor}`); - } - - updateFile( - "tsc/internal/core/version.go", - /var version = "[^"]+"/g, - `var version = "${version}"`, - ); - updateFile( - "Herebyfile.mjs", - /const nativePreviewReleaseVersion = \/\*\* @type \{string \| undefined\} \*\/ \([^)]*\);/g, - `const nativePreviewReleaseVersion = /** @type {string | undefined} */ ("${version}");`, - ); -} - function updateFile(path, pattern, replacement) { const source = fs.readFileSync(path, "utf8"); const matches = source.match(pattern); diff --git a/tools/scripts/configure-release.test.mjs b/tools/scripts/configure-release.test.mjs deleted file mode 100644 index 128665e716100..0000000000000 --- a/tools/scripts/configure-release.test.mjs +++ /dev/null @@ -1,63 +0,0 @@ -import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { test } from "node:test"; -import { fileURLToPath } from "node:url"; - -import { isSemVer } from "./configure-release.mjs"; - -test("valid SemVer versions", () => { - for ( - const version of [ - "7.0.3", - "7.0.3-alpha", - "7.0.3-alpha.0", - "7.0.3-alpha.0+build.1", - "7.0.3+build.1", - "4294967295.0.0", - ] - ) { - assert.equal(isSemVer(version), true, version); - } -}); - -test("invalid SemVer versions", () => { - for ( - const version of [ - "7.0", - "07.0.3", - "7.00.3", - "7.0.03", - "7.0.3-01", - "7.0.3-alpha..1", - "7.0.3+build..1", - "4294967296.0.0", - ] - ) { - assert.equal(isSemVer(version), false, version); - } -}); - -test("copied release configuration script is self-contained", () => { - const temp = fs.mkdtempSync(path.join(os.tmpdir(), "configure-release-")); - try { - const script = path.join(temp, "configure-release.mjs"); - fs.copyFileSync(fileURLToPath(new URL("./configure-release.mjs", import.meta.url)), script); - fs.mkdirSync(path.join(temp, "tsc/internal/core"), { recursive: true }); - fs.writeFileSync(path.join(temp, "tsc/internal/core/version.go"), 'package core\n\nvar version = "0.0.0"\n'); - fs.writeFileSync( - path.join(temp, "Herebyfile.mjs"), - "const nativePreviewReleaseVersion = /** @type {string | undefined} */ (process.env.NATIVE_PREVIEW_RELEASE_VERSION);\n", - ); - - execFileSync(process.execPath, [script, "7.0.3+build.1", "7.0"], { cwd: temp }); - - assert.match(fs.readFileSync(path.join(temp, "tsc/internal/core/version.go"), "utf8"), /var version = "7\.0\.3\+build\.1"/); - assert.match(fs.readFileSync(path.join(temp, "Herebyfile.mjs"), "utf8"), /\("7\.0\.3\+build\.1"\)/); - } - finally { - fs.rmSync(temp, { recursive: true, force: true }); - } -}); From 4120c9626899579a6ae28c45c32b5eb719926b96 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:08:27 -0700 Subject: [PATCH 05/12] Finalize TypeScript npm releases --- tools/pipelines/typescript-build.yml | 20 ++- tools/pipelines/typescript-publish.yml | 221 +++++++++++++++++++++++++ 2 files changed, 239 insertions(+), 2 deletions(-) diff --git a/tools/pipelines/typescript-build.yml b/tools/pipelines/typescript-build.yml index 519789aff3dc2..50b0134f6d14c 100755 --- a/tools/pipelines/typescript-build.yml +++ b/tools/pipelines/typescript-build.yml @@ -103,7 +103,7 @@ extends: displayName: '🔩 Install Signing Plugin' inputs: ${{ if eq(parameters.signType, 'auto') }}: - ${{ if eq(variables['Build.Reason'], 'Schedule') }}: + ${{ if or(eq(variables['Build.Reason'], 'Schedule'), startsWith(variables['Build.SourceBranch'], 'refs/heads/release-'), and(startsWith(variables['Build.SourceBranch'], 'refs/heads/ts'), endsWith(variables['Build.SourceBranch'], '-release'))) }}: signType: real ${{ else }}: signType: test @@ -111,7 +111,7 @@ extends: signType: ${{ parameters.signType }} # azureSubscription AKA ConnectedServiceName azureSubscription: 'MicroBuild Signing Task (DevDiv)' - ${{ if or(and(eq(parameters.signType, 'auto'), eq(variables['Build.Reason'], 'Schedule')), eq(parameters.signType, 'real')) }}: + ${{ if or(and(eq(parameters.signType, 'auto'), or(eq(variables['Build.Reason'], 'Schedule'), startsWith(variables['Build.SourceBranch'], 'refs/heads/release-'), and(startsWith(variables['Build.SourceBranch'], 'refs/heads/ts'), endsWith(variables['Build.SourceBranch'], '-release')))), eq(parameters.signType, 'real')) }}: # From "nonwindowspmeservicename" in https://dev.azure.com/devdiv/1ESPipelineTemplates/_git/MicroBuildTemplate?path=/azure-pipelines/Stages/Stage.yml ConnectedPMEServiceName: beb8cb23-b303-4c95-ab26-9e44bc958d39 # We do this ourselves. @@ -148,6 +148,22 @@ extends: # Needed for ESRP SYSTEM_ACCESSTOKEN: $(System.AccessToken) + - bash: | + set -euo pipefail + manifest="built/npm/publish-manifest.json" + mainPackage="$(jq -r 'if .stages[1] | length == 1 then .stages[1][0].filename else empty end' "$manifest")" + if [ -z "$mainPackage" ]; then + echo "Expected exactly one main npm package." >&2 + exit 1 + fi + version="$(tar -xOf "built/npm/$mainPackage" package/package.json | jq -r '.version')" + if [ -z "$version" ] || [ "$version" = "null" ]; then + echo "Unable to read the TypeScript package version." >&2 + exit 1 + fi + echo "##vso[build.updatebuildnumber]TypeScript-Build-$version" + displayName: 'Set versioned build name' + - bash: | set -euo pipefail mkdir $(Build.ArtifactStagingDirectory)/npm diff --git a/tools/pipelines/typescript-publish.yml b/tools/pipelines/typescript-publish.yml index 07a6bb74fb855..45356382b0953 100755 --- a/tools/pipelines/typescript-publish.yml +++ b/tools/pipelines/typescript-publish.yml @@ -16,6 +16,10 @@ variables: # For MicroBuild telemetry - 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: @@ -91,6 +95,48 @@ extends: echo "Expected exactly 2 npm publish stages, found $stageCount." >&2 exit 1 fi + + mainPackage="$(jq -r 'if .stages[1] | length == 1 then .stages[1][0].filename else empty end' "$manifest")" + if [ -z "$mainPackage" ]; then + echo "Expected exactly one main npm package." >&2 + exit 1 + fi + mainPackageJson="$(tar -xOf "$src/$mainPackage" package/package.json)" + version="$(jq -r '.version' <<< "$mainPackageJson")" + npmTag="$(jq -r '.publishConfig.tag' <<< "$mainPackageJson")" + if [ -z "$version" ] || [ "$version" = "null" ]; then + echo "Unable to read the TypeScript package version." >&2 + exit 1 + fi + case "$npmTag" in + latest | next | beta | rc) ;; + *) + echo "Unexpected npm publish tag: $npmTag" >&2 + exit 1 + ;; + esac + if [ "$npmTag" = "next" ]; then + if [ "$RELEASE_SOURCE_BRANCH" != "refs/heads/main" ]; then + echo "Refusing to publish npm tag $npmTag from $RELEASE_SOURCE_BRANCH." >&2 + exit 1 + fi + elif ! [[ "$RELEASE_SOURCE_BRANCH" =~ ^refs/heads/(release-.+|ts[0-9]+-release)$ ]]; then + echo "Refusing to publish npm tag $npmTag from $RELEASE_SOURCE_BRANCH." >&2 + exit 1 + fi + + while IFS= read -r filename; do + packageVersion="$(tar -xOf "$src/$filename" package/package.json | jq -r '.version')" + if [ "$packageVersion" != "$version" ]; then + echo "Package $filename has version $packageVersion, expected $version." >&2 + exit 1 + fi + done < <(jq -r '.stages[][] | .filename' "$manifest") + + echo "##vso[build.updatebuildnumber]TypeScript-Publish-$version" + echo "##vso[task.setvariable variable=packageVersion;isOutput=true]$version" + echo "##vso[task.setvariable variable=npmTag;isOutput=true]$npmTag" + jq -r '.stages[0][].filename' "$manifest" | xargs -I {} cp -v "$src/{}" "$platformDst/" jq -r '.stages[1][].filename' "$manifest" | xargs -I {} cp -v "$src/{}" "$mainDst/" @@ -101,7 +147,10 @@ extends: echo "Expected 1 main package, found $mainCount." >&2 exit 1 fi + name: releaseMetadata displayName: 'Stage npm artifacts' + env: + RELEASE_SOURCE_BRANCH: $(resources.pipeline.TypeScript_Release_Build.sourceBranch) - stage: Publish_npm displayName: Publish npm packages @@ -220,3 +269,175 @@ extends: echo "Publishing $vsixFilePath with $manifestFilePath and $signatureFilePath" npx vsce publish --packagePath "$vsixFilePath" --manifestPath "$manifestFilePath" --signaturePath "$signatureFilePath" --azure-credential --skip-duplicate --allow-all-proposed-apis done + + - stage: Finalize_release + displayName: Create TypeScript GitHub Release + dependsOn: + - Prepare + - Publish_npm + condition: and(succeeded(), eq(${{ parameters.dryRun }}, False), ne(dependencies.Prepare.outputs['Stage_npm.releaseMetadata.npmTag'], 'next')) + variables: + packageVersion: $[stageDependencies.Prepare.Stage_npm.outputs['releaseMetadata.packageVersion']] + npmTag: $[stageDependencies.Prepare.Stage_npm.outputs['releaseMetadata.npmTag']] + + jobs: + - job: Finalize_release + displayName: Create TypeScript tag and GitHub Release + + templateContext: + type: releaseJob + isProduction: true + inputs: + - input: pipelineArtifact + pipeline: 'TypeScript_Release_Build' + artifactName: 'npm' + targetPath: '$(Pipeline.Workspace)/npm' + + steps: + - checkout: none + + - 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)/npm" + manifest="$artifactDirectory/publish-manifest.json" + + case "$NPM_TAG" in + latest) + if ! [[ "$PACKAGE_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "Stable package version is not valid SemVer: $PACKAGE_VERSION" >&2 + exit 1 + fi + tag="v$PACKAGE_VERSION" + title="TypeScript $PACKAGE_VERSION" + prerelease=false + ;; + beta | rc) + if ! [[ "$PACKAGE_VERSION" =~ ^((0|[1-9][0-9]*)\.(0|[1-9][0-9]*))\.(0|[1-9][0-9]*)-"$NPM_TAG"([.-].*)?$ ]]; then + echo "Package version $PACKAGE_VERSION does not match npm tag $NPM_TAG." >&2 + exit 1 + fi + tag="v${BASH_REMATCH[1]}-$NPM_TAG" + if [ "$NPM_TAG" = "beta" ]; then + title="TypeScript ${BASH_REMATCH[1]} Beta" + else + title="TypeScript ${BASH_REMATCH[1]}.${BASH_REMATCH[4]} RC" + fi + prerelease=true + ;; + *) + echo "Refusing to create a GitHub Release for npm tag $NPM_TAG." >&2 + exit 1 + ;; + esac + + existingTagCommit="$(gh api "repos/microsoft/TypeScript/git/ref/tags/$tag" --jq '.object.sha' 2>/dev/null || true)" + if [ -n "$existingTagCommit" ]; then + if [ "$existingTagCommit" != "$RELEASE_SOURCE_COMMIT" ]; then + echo "Tag $tag points to $existingTagCommit, expected $RELEASE_SOURCE_COMMIT." >&2 + exit 1 + fi + else + if ! gh api repos/microsoft/TypeScript/git/refs \ + --method POST \ + --field ref="refs/tags/$tag" \ + --field sha="$RELEASE_SOURCE_COMMIT"; then + echo "Creation of $tag failed; checking whether another run created it." + existingTagCommit="$(gh api "repos/microsoft/TypeScript/git/ref/tags/$tag" --jq '.object.sha')" + if [ "$existingTagCommit" != "$RELEASE_SOURCE_COMMIT" ]; then + echo "Tag $tag points to $existingTagCommit, expected $RELEASE_SOURCE_COMMIT." >&2 + exit 1 + fi + fi + fi + + notes="Downloads are available on [npm](https://www.npmjs.com/package/typescript/v/$PACKAGE_VERSION)." + if ! gh release view "$tag" --repo microsoft/TypeScript >/dev/null 2>&1; then + releaseArgs=( + "$tag" + --repo microsoft/TypeScript + --title "$title" + --notes "$notes" + --target "$RELEASE_SOURCE_COMMIT" + ) + if [ "$prerelease" = "true" ]; then + releaseArgs+=(--prerelease --latest=false) + else + releaseArgs+=(--latest) + fi + if ! gh release create "${releaseArgs[@]}"; then + echo "Creation of GitHub Release $tag failed; checking whether another run created it." + gh release view "$tag" --repo microsoft/TypeScript >/dev/null + fi + fi + + existingAssetsDirectory="$(mktemp -d)" + trap 'rm -rf "$existingAssetsDirectory"' EXIT + + refreshExistingAssetNames() { + local output + output="$( + gh release view "$tag" \ + --repo microsoft/TypeScript \ + --json assets \ + --jq '.assets[].name' + )" + 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 packagePath="$1" + local filename + local expectedHash + local existingHash + filename="$(basename "$packagePath")" + expectedHash="$(sha256sum "$packagePath" | cut -d' ' -f1)" + 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 published package." >&2 + exit 1 + fi + } + + refreshExistingAssetNames + while IFS= read -r filename; do + packagePath="$artifactDirectory/$filename" + test -f "$packagePath" + if assetExists "$filename"; then + verifyExistingAsset "$packagePath" + elif ! gh release upload "$tag" "$packagePath" --repo microsoft/TypeScript; then + echo "Upload of $filename failed; checking whether another run uploaded it." + refreshExistingAssetNames + if ! assetExists "$filename"; then + exit 1 + fi + verifyExistingAsset "$packagePath" + fi + done < <(jq -r '.stages[][] | .filename' "$manifest") + displayName: Create TypeScript GitHub Release + env: + GH_TOKEN: $(GH_TOKEN) + PACKAGE_VERSION: $(packageVersion) + NPM_TAG: $(npmTag) + RELEASE_SOURCE_COMMIT: $(resources.pipeline.TypeScript_Release_Build.sourceCommit) From 49df791bd78aedbc3bc1599e9fb6af2afe2c4d33 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:41:52 -0700 Subject: [PATCH 06/12] Simplify release pipeline configuration --- tools/pipelines/typescript-build.yml | 39 ++------------------------ tools/pipelines/typescript-publish.yml | 1 - 2 files changed, 3 insertions(+), 37 deletions(-) diff --git a/tools/pipelines/typescript-build.yml b/tools/pipelines/typescript-build.yml index 50b0134f6d14c..e21f7cc7a593c 100755 --- a/tools/pipelines/typescript-build.yml +++ b/tools/pipelines/typescript-build.yml @@ -11,16 +11,6 @@ schedules: name: $(date:yyyyMMdd)$(rev:.r) appendCommitMessageToRunName: false -parameters: - - name: signType - displayName: Sign type - type: string - default: auto - values: - - auto - - real - - test - variables: # For MicroBuild telemetry - name: TeamName @@ -102,18 +92,11 @@ extends: - task: MicroBuildSigningPlugin@4 displayName: '🔩 Install Signing Plugin' inputs: - ${{ if eq(parameters.signType, 'auto') }}: - ${{ if or(eq(variables['Build.Reason'], 'Schedule'), startsWith(variables['Build.SourceBranch'], 'refs/heads/release-'), and(startsWith(variables['Build.SourceBranch'], 'refs/heads/ts'), endsWith(variables['Build.SourceBranch'], '-release'))) }}: - signType: real - ${{ else }}: - signType: test - ${{ else }}: - signType: ${{ parameters.signType }} + signType: real # azureSubscription AKA ConnectedServiceName azureSubscription: 'MicroBuild Signing Task (DevDiv)' - ${{ if or(and(eq(parameters.signType, 'auto'), or(eq(variables['Build.Reason'], 'Schedule'), startsWith(variables['Build.SourceBranch'], 'refs/heads/release-'), and(startsWith(variables['Build.SourceBranch'], 'refs/heads/ts'), endsWith(variables['Build.SourceBranch'], '-release')))), eq(parameters.signType, 'real')) }}: - # From "nonwindowspmeservicename" in https://dev.azure.com/devdiv/1ESPipelineTemplates/_git/MicroBuildTemplate?path=/azure-pipelines/Stages/Stage.yml - ConnectedPMEServiceName: beb8cb23-b303-4c95-ab26-9e44bc958d39 + # From "nonwindowspmeservicename" in https://dev.azure.com/devdiv/1ESPipelineTemplates/_git/MicroBuildTemplate?path=/azure-pipelines/Stages/Stage.yml + ConnectedPMEServiceName: beb8cb23-b303-4c95-ab26-9e44bc958d39 # We do this ourselves. zipSources: false env: @@ -148,22 +131,6 @@ extends: # Needed for ESRP SYSTEM_ACCESSTOKEN: $(System.AccessToken) - - bash: | - set -euo pipefail - manifest="built/npm/publish-manifest.json" - mainPackage="$(jq -r 'if .stages[1] | length == 1 then .stages[1][0].filename else empty end' "$manifest")" - if [ -z "$mainPackage" ]; then - echo "Expected exactly one main npm package." >&2 - exit 1 - fi - version="$(tar -xOf "built/npm/$mainPackage" package/package.json | jq -r '.version')" - if [ -z "$version" ] || [ "$version" = "null" ]; then - echo "Unable to read the TypeScript package version." >&2 - exit 1 - fi - echo "##vso[build.updatebuildnumber]TypeScript-Build-$version" - displayName: 'Set versioned build name' - - bash: | set -euo pipefail mkdir $(Build.ArtifactStagingDirectory)/npm diff --git a/tools/pipelines/typescript-publish.yml b/tools/pipelines/typescript-publish.yml index 45356382b0953..83569d77fbe0a 100755 --- a/tools/pipelines/typescript-publish.yml +++ b/tools/pipelines/typescript-publish.yml @@ -133,7 +133,6 @@ extends: fi done < <(jq -r '.stages[][] | .filename' "$manifest") - echo "##vso[build.updatebuildnumber]TypeScript-Publish-$version" echo "##vso[task.setvariable variable=packageVersion;isOutput=true]$version" echo "##vso[task.setvariable variable=npmTag;isOutput=true]$npmTag" From 1144890da5f1607bdd039edc6277a97439f6f54d Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:52:26 -0700 Subject: [PATCH 07/12] Link publisher runs to release builds --- tools/pipelines/typescript-publish.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tools/pipelines/typescript-publish.yml b/tools/pipelines/typescript-publish.yml index 83569d77fbe0a..b4a2619fb05d5 100755 --- a/tools/pipelines/typescript-publish.yml +++ b/tools/pipelines/typescript-publish.yml @@ -81,6 +81,17 @@ extends: steps: - checkout: none + - bash: | + set -euo pipefail + if [ -z "$RELEASE_BUILD_RUN_NAME" ]; then + echo "Unable to read the source build run name." >&2 + exit 1 + fi + echo "##vso[build.updatebuildnumber]$RELEASE_BUILD_RUN_NAME-publish" + displayName: 'Set publisher run name' + env: + RELEASE_BUILD_RUN_NAME: $(resources.pipeline.TypeScript_Release_Build.runName) + - bash: | set -euo pipefail From 1c3a64e755da34bfc4d598305692009078ced325 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:38:19 -0700 Subject: [PATCH 08/12] Support downstream package builds --- Herebyfile.mjs | 72 +++++++++++++++++++++++++--- tools/pipelines/typescript-build.yml | 3 ++ 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 2168308d2aa6b..6ee803a5d32be 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -113,6 +113,7 @@ const { values: rawOptions } = parseArgs({ setPrerelease: { type: "string" }, forRelease: { type: "boolean" }, + respectGoEnv: { type: "boolean" }, vscodeTypescriptRelease: { type: "boolean" }, race: { type: "boolean", default: parseEnvBoolean("RACE") }, @@ -148,6 +149,12 @@ if (releaseVscodeTypescript && options.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 (options.respectGoEnv && options.forRelease) { + throw new Error("respectGoEnv cannot be combined with forRelease"); +} +if (options.respectGoEnv && options.setPrerelease) { + throw new Error("respectGoEnv requires the version declared in the source"); +} if (releaseVscodeTypescript && !publishAsTypescript) { throw new Error("vscode-typescript releases require nativePreviewReleaseProfile to be 'typescript'"); } @@ -297,7 +304,7 @@ function getReleaseBuildFlags(versionOverride) { function buildTsc(opts) { opts ||= {}; const out = opts.out ?? path.resolve("./built/local/tsc" + (process.platform === "win32" ? ".exe" : "")); - const env = { ...goBuildEnv, ...opts.env }; + const env = { ...(options.respectGoEnv ? {} : goBuildEnv), ...opts.env }; return run("go", ["build", ...goBuildFlags, ...(opts.extraFlags ?? []), ...goBuildTags("noembed"), "-o", out, "./cmd/tsc"], { signal: opts.abortSignal, env, @@ -2387,7 +2394,7 @@ function stripConditionsFromValue(value) { export const buildNativePreviewPackages = task({ name: "typescript:build", - hiddenFromTaskList: true, + description: "Builds TypeScript npm packages for the current platform. Pass --respectGoEnv to preserve caller-provided Go build settings.", run: runBuildNativePreviewPackages, }); @@ -2434,8 +2441,10 @@ async function runBuildNativePreviewPackages() { } stripSourceConditions(inputPackageJson); - const { stdout: gitHead } = await runOutput("git", ["rev-parse", "HEAD"]); - inputPackageJson.gitHead = gitHead.trim(); + if (fs.existsSync(".git")) { + const { stdout: gitHead } = await runOutput("git", ["rev-parse", "HEAD"]); + inputPackageJson.gitHead = gitHead.trim(); + } inputPackageJson.publishConfig = { access: "public", tag: getPublishTag(), @@ -2492,7 +2501,9 @@ async function runBuildNativePreviewPackages() { throw new Error(`Found external imports in .d.ts files:\n${importErrors.map(e => " " + e).join("\n")}`); } - const extraFlags = getReleaseBuildFlags(options.setPrerelease || nativePreviewReleaseVersion ? getVersion() : undefined); + const extraFlags = options.respectGoEnv + ? [] + : getReleaseBuildFlags(options.setPrerelease || nativePreviewReleaseVersion ? getVersion() : undefined); const platformBuilders = platforms.map(({ npmDir, npmPackageName, nodeOs, nodeArch, goos, goarch }) => async () => { const packageJson = { @@ -2528,7 +2539,11 @@ async function runBuildNativePreviewPackages() { const exeName = nativePreviewExeName(nodeOs); await buildTsc({ out: publishAsTypescript ? path.join(out, exeName) : out, - env: { GOOS: goos, GOARCH: goarch, GOARM: "6", CGO_ENABLED: "0" }, + env: { + GOOS: goos, + GOARCH: goarch, + ...(options.respectGoEnv ? {} : { GOARM: "6", CGO_ENABLED: "0" }), + }, extraFlags, }); }); @@ -2546,6 +2561,51 @@ async function runBuildNativePreviewPackages() { } } +/** + * @param {ReturnType} platforms + */ +async function testNativePreviewPackage(platforms) { + const hostPlatform = platforms.find(({ nodeOs, nodeArch }) => nodeOs === process.platform && nodeArch === process.arch); + assert(hostPlatform, `No package was built for the host platform ${process.platform}-${process.arch}`); + + const testRoot = path.resolve("built/package-test"); + const nodeModules = path.join(testRoot, "node_modules"); + const mainPackageDir = path.join(nodeModules, ...mainNativePreviewPackage.npmPackageName.split("/")); + const platformPackageDir = path.join(nodeModules, ...hostPlatform.npmPackageName.split("/")); + const sourceFile = path.join(testRoot, "index.ts"); + + await rimraf(testRoot); + try { + await cpRecursive(mainNativePreviewPackage.npmDir, mainPackageDir); + await cpRecursive(hostPlatform.npmDir, platformPackageDir); + await fs.promises.writeFile(sourceFile, 'export const value: string = "value";\n'); + + const binName = publishAsTypescript ? "tsc" : "tsgo"; + const binPath = path.join(mainPackageDir, "bin", binName); + const { stdout: versionOutput } = await runOutput(process.execPath, [binPath, "--version"]); + assert(versionOutput.includes(getVersion()), `Expected version output to contain ${getVersion()}, got ${versionOutput.trim()}`); + + const { stdout: listFilesOutput } = await runOutput(process.execPath, [binPath, "--noEmit", "--listFiles", sourceFile]); + assert(!listFilesOutput.includes("bundled:///"), "Packaged compiler listed an embedded library path"); + + const expectedLib = path.resolve(platformPackageDir, "lib", "lib.es5.d.ts"); + const listedFiles = listFilesOutput + .split(/\r?\n/) + .filter(Boolean) + .map(file => path.resolve(file)); + assert(listedFiles.includes(expectedLib), `Expected packaged compiler to list ${expectedLib}`); + } + finally { + await rimraf(testRoot); + } +} + +export const testNativePreviewPackageTask = task({ + name: "typescript:test-package", + description: "Tests the TypeScript npm package for the current platform.", + run: () => testNativePreviewPackage(getPlatforms()), +}); + export const signNativePreviewPackages = task({ name: "typescript:sign", hiddenFromTaskList: true, diff --git a/tools/pipelines/typescript-build.yml b/tools/pipelines/typescript-build.yml index e21f7cc7a593c..8c0c5b262b2fd 100755 --- a/tools/pipelines/typescript-build.yml +++ b/tools/pipelines/typescript-build.yml @@ -113,6 +113,9 @@ extends: - bash: npx hereby typescript:build --forRelease --setPrerelease dev.$(Build.BuildNumber) displayName: 'Build packages' + - bash: npx hereby typescript:test-package --forRelease --setPrerelease dev.$(Build.BuildNumber) + displayName: 'Test packages' + - bash: npx hereby typescript:sign --forRelease --setPrerelease dev.$(Build.BuildNumber) displayName: 'Sign packages' env: From aae2b5b0781241c361a5ed0d18a75e5c8e7eca62 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:22:28 -0700 Subject: [PATCH 09/12] Address release workflow review feedback --- Herebyfile.mjs | 5 +++-- tools/scripts/configure-release.mjs | 15 ++++++++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 6ee803a5d32be..7e449b3c4990d 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -1793,7 +1793,7 @@ function getPublishTag() { } const match = version.match(/-(dev|beta|rc)(?:[.-]|$)/); if (match?.[1]) return match[1] === "dev" ? "next" : match[1]; - if (version === nativePreviewReleaseVersion) return "latest"; + if (version === nativePreviewReleaseVersion && stableThreeComponentVersionPattern.test(version)) return "latest"; throw new Error(`Refusing to publish 'typescript' with the latest tag from non-release version ${version}.`); } return "latest"; @@ -2583,7 +2583,7 @@ async function testNativePreviewPackage(platforms) { const binName = publishAsTypescript ? "tsc" : "tsgo"; const binPath = path.join(mainPackageDir, "bin", binName); const { stdout: versionOutput } = await runOutput(process.execPath, [binPath, "--version"]); - assert(versionOutput.includes(getVersion()), `Expected version output to contain ${getVersion()}, got ${versionOutput.trim()}`); + assert.equal(versionOutput.trim(), `Version ${getVersion()}`); const { stdout: listFilesOutput } = await runOutput(process.execPath, [binPath, "--noEmit", "--listFiles", sourceFile]); assert(!listFilesOutput.includes("bundled:///"), "Packaged compiler listed an embedded library path"); @@ -2603,6 +2603,7 @@ async function testNativePreviewPackage(platforms) { export const testNativePreviewPackageTask = task({ name: "typescript:test-package", description: "Tests the TypeScript npm package for the current platform.", + dependencies: options.forRelease ? undefined : [buildNativePreviewPackages], run: () => testNativePreviewPackage(getPlatforms()), }); diff --git a/tools/scripts/configure-release.mjs b/tools/scripts/configure-release.mjs index da87918e8cfad..d7b86269f85fa 100644 --- a/tools/scripts/configure-release.mjs +++ b/tools/scripts/configure-release.mjs @@ -4,16 +4,17 @@ const numericIdentifier = String.raw`(?:0|[1-9]\d*)`; const nonNumericIdentifier = String.raw`(?:\d*[A-Za-z-][0-9A-Za-z-]*)`; const prereleaseIdentifier = String.raw`(?:${numericIdentifier}|${nonNumericIdentifier})`; const prerelease = String.raw`${prereleaseIdentifier}(?:\.${prereleaseIdentifier})*`; -const build = String.raw`[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*`; -const semverPattern = new RegExp( - String.raw`^(${numericIdentifier})\.(${numericIdentifier})\.(${numericIdentifier})(?:-${prerelease})?(?:\+${build})?$`, +const releaseVersionPattern = new RegExp( + String.raw`^(${numericIdentifier})\.(${numericIdentifier})\.(${numericIdentifier})(?:-(?:beta|rc)(?:\.${prerelease})?)?$`, ); const maxUint32 = 0xffff_ffffn; const [version, expectedMajorMinor] = process.argv.slice(2); -if (!version || !isSemVer(version)) { - throw new Error("Usage: node tools/scripts/configure-release.mjs [expected-major.minor]"); +if (!version || !isReleaseVersion(version)) { + throw new Error( + "Usage: node tools/scripts/configure-release.mjs [expected-major.minor]", + ); } const majorMinor = version.split(".", 2).join("."); @@ -35,8 +36,8 @@ updateFile( /** * @param {string} value */ -function isSemVer(value) { - const match = semverPattern.exec(value); +function isReleaseVersion(value) { + const match = releaseVersionPattern.exec(value); return match !== null && match.slice(1, 4).every(component => BigInt(component) <= maxUint32); } From 3a0320b61c1475ca09e930fa2ba63f5055df5184 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:51:47 -0700 Subject: [PATCH 10/12] Use versioned release build names --- tools/pipelines/typescript-build.yml | 49 +++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/tools/pipelines/typescript-build.yml b/tools/pipelines/typescript-build.yml index 8c0c5b262b2fd..b7a19bed245a0 100755 --- a/tools/pipelines/typescript-build.yml +++ b/tools/pipelines/typescript-build.yml @@ -8,10 +8,12 @@ schedules: include: - main -name: $(date:yyyyMMdd)$(rev:.r) +name: $(Date:yyyyMMdd).$(runRevision) appendCommitMessageToRunName: false variables: + - name: runRevision + value: $[counter(format('{0:yyyyMMdd}', pipeline.startTime), 1)] # For MicroBuild telemetry - name: TeamName value: TypeScript @@ -134,6 +136,51 @@ extends: # Needed for ESRP SYSTEM_ACCESSTOKEN: $(System.AccessToken) + - bash: | + set -euo pipefail + + if ! [[ "$INITIAL_BUILD_NUMBER" =~ ^[0-9]{8}\.[1-9][0-9]*$ ]]; then + echo "Unexpected initial build number: $INITIAL_BUILD_NUMBER" >&2 + exit 1 + fi + + manifest="built/npm/publish-manifest.json" + mainPackage="$(jq -r 'if .stages[1] | length == 1 then .stages[1][0].filename else empty end' "$manifest")" + if [ -z "$mainPackage" ]; then + echo "Expected exactly one main npm package." >&2 + exit 1 + fi + + mainPackageJson="$(tar -xOf "built/npm/$mainPackage" package/package.json)" + version="$(jq -r '.version' <<< "$mainPackageJson")" + npmTag="$(jq -r '.publishConfig.tag' <<< "$mainPackageJson")" + if [ -z "$version" ] || [ "$version" = "null" ]; then + echo "Unable to read the TypeScript package version." >&2 + exit 1 + fi + + case "$npmTag" in + next) + if [[ "$version" != *"-dev.$INITIAL_BUILD_NUMBER" ]]; then + echo "Nightly package version $version does not contain build number $INITIAL_BUILD_NUMBER." >&2 + exit 1 + fi + finalBuildNumber="$version" + ;; + latest | beta | rc) + finalBuildNumber="${version}_${INITIAL_BUILD_NUMBER}" + ;; + *) + echo "Unexpected npm publish tag: $npmTag" >&2 + exit 1 + ;; + esac + + echo "##vso[build.updatebuildnumber]$finalBuildNumber" + displayName: 'Set versioned build name' + env: + INITIAL_BUILD_NUMBER: $(Build.BuildNumber) + - bash: | set -euo pipefail mkdir $(Build.ArtifactStagingDirectory)/npm From 3ec91c39faf0203325889e0bce25235b7fced54e Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:05:52 -0700 Subject: [PATCH 11/12] Set release build names before building --- Herebyfile.mjs | 13 ++++++ tools/pipelines/typescript-build.yml | 68 ++++++++++++++-------------- 2 files changed, 46 insertions(+), 35 deletions(-) diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 7e449b3c4990d..f894d1f6daf17 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -1803,6 +1803,7 @@ const extensionDir = path.resolve("./packages/vscode-typescript"); const nightlyExtensionDir = path.resolve("./packages/vscode-typescript-nightly"); const builtNpm = path.resolve("./built/npm"); const builtVsix = path.resolve("./built/vsix"); +const typeScriptReleaseInfoPath = path.resolve("./built/typescript-release-info.json"); const builtPublishedPlatformPackages = path.resolve("./built/published-platform-packages"); const builtSignTmp = path.resolve("./built/sign-tmp"); const publishedTypeScriptAliasPackageName = "@typescript/bundled-typescript"; @@ -2398,6 +2399,18 @@ export const buildNativePreviewPackages = task({ run: runBuildNativePreviewPackages, }); +export const writeTypeScriptReleaseInfo = task({ + name: "typescript:release-info", + hiddenFromTaskList: true, + run: async () => { + await fs.promises.mkdir(path.dirname(typeScriptReleaseInfoPath), { recursive: true }); + await fs.promises.writeFile( + typeScriptReleaseInfoPath, + JSON.stringify({ version: getVersion(), npmTag: getPublishTag() }, undefined, 4) + "\n", + ); + }, +}); + async function runBuildNativePreviewPackages() { if (usePublishedPlatformPackagesForVsix) { checkPublishedPlatformPackagesForVsix(); diff --git a/tools/pipelines/typescript-build.yml b/tools/pipelines/typescript-build.yml index b7a19bed245a0..84dc67d9af0ae 100755 --- a/tools/pipelines/typescript-build.yml +++ b/tools/pipelines/typescript-build.yml @@ -110,31 +110,6 @@ extends: fetchDepth: 1 fetchTags: false - template: /tools/pipelines/steps/setup-node-npm-ci.yml@self - - template: /tools/pipelines/steps/setup-go.yml@self - - - bash: npx hereby typescript:build --forRelease --setPrerelease dev.$(Build.BuildNumber) - displayName: 'Build packages' - - - bash: npx hereby typescript:test-package --forRelease --setPrerelease dev.$(Build.BuildNumber) - displayName: 'Test packages' - - - bash: npx hereby typescript:sign --forRelease --setPrerelease dev.$(Build.BuildNumber) - displayName: 'Sign packages' - env: - # Needed for ESRP - SYSTEM_ACCESSTOKEN: $(System.AccessToken) - - - bash: npx hereby typescript:pack --forRelease --setPrerelease dev.$(Build.BuildNumber) - displayName: 'Pack packages' - - - bash: npx hereby vscode-typescript:pack --forRelease --setPrerelease dev.$(Build.BuildNumber) - displayName: 'Pack extensions' - - - bash: npx hereby vscode-typescript:sign --forRelease --setPrerelease dev.$(Build.BuildNumber) - displayName: 'Sign extensions' - env: - # Needed for ESRP - SYSTEM_ACCESSTOKEN: $(System.AccessToken) - bash: | set -euo pipefail @@ -144,18 +119,15 @@ extends: exit 1 fi - manifest="built/npm/publish-manifest.json" - mainPackage="$(jq -r 'if .stages[1] | length == 1 then .stages[1][0].filename else empty end' "$manifest")" - if [ -z "$mainPackage" ]; then - echo "Expected exactly one main npm package." >&2 - exit 1 - fi + echo "##vso[task.setvariable variable=initialBuildNumber]$INITIAL_BUILD_NUMBER" - mainPackageJson="$(tar -xOf "built/npm/$mainPackage" package/package.json)" - version="$(jq -r '.version' <<< "$mainPackageJson")" - npmTag="$(jq -r '.publishConfig.tag' <<< "$mainPackageJson")" + npx hereby typescript:release-info --forRelease --setPrerelease "dev.$INITIAL_BUILD_NUMBER" + + releaseInfo="built/typescript-release-info.json" + version="$(jq -r '.version' "$releaseInfo")" + npmTag="$(jq -r '.npmTag' "$releaseInfo")" if [ -z "$version" ] || [ "$version" = "null" ]; then - echo "Unable to read the TypeScript package version." >&2 + echo "Unable to determine the TypeScript package version." >&2 exit 1 fi @@ -181,6 +153,32 @@ extends: env: INITIAL_BUILD_NUMBER: $(Build.BuildNumber) + - template: /tools/pipelines/steps/setup-go.yml@self + + - bash: npx hereby typescript:build --forRelease --setPrerelease dev.$(initialBuildNumber) + displayName: 'Build packages' + + - bash: npx hereby typescript:test-package --forRelease --setPrerelease dev.$(initialBuildNumber) + displayName: 'Test packages' + + - bash: npx hereby typescript:sign --forRelease --setPrerelease dev.$(initialBuildNumber) + displayName: 'Sign packages' + env: + # Needed for ESRP + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + + - bash: npx hereby typescript:pack --forRelease --setPrerelease dev.$(initialBuildNumber) + displayName: 'Pack packages' + + - bash: npx hereby vscode-typescript:pack --forRelease --setPrerelease dev.$(initialBuildNumber) + displayName: 'Pack extensions' + + - bash: npx hereby vscode-typescript:sign --forRelease --setPrerelease dev.$(initialBuildNumber) + displayName: 'Sign extensions' + env: + # Needed for ESRP + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + - bash: | set -euo pipefail mkdir $(Build.ArtifactStagingDirectory)/npm From 62c353ef9f6797c2c2d2ed94cf4395038a1ad6bc Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:20:49 -0700 Subject: [PATCH 12/12] Enforce canonical release versions --- tools/pipelines/typescript-publish.yml | 44 ++++++++++++++++++++------ tools/scripts/configure-release.mjs | 21 ++++++++---- 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/tools/pipelines/typescript-publish.yml b/tools/pipelines/typescript-publish.yml index b4a2619fb05d5..92a035cc3d927 100755 --- a/tools/pipelines/typescript-publish.yml +++ b/tools/pipelines/typescript-publish.yml @@ -119,8 +119,27 @@ extends: echo "Unable to read the TypeScript package version." >&2 exit 1 fi + versionComponent='(0|[1-9][0-9]*)' case "$npmTag" in - latest | next | beta | rc) ;; + next) ;; + latest) + if ! [[ "$version" =~ ^$versionComponent\.$versionComponent\.(2|[3-9]|[1-9][0-9]+)$ ]]; then + echo "Stable package version must have patch version 2 or greater: $version" >&2 + exit 1 + fi + ;; + beta) + if ! [[ "$version" =~ ^$versionComponent\.$versionComponent\.0-beta$ ]]; then + echo "Beta package version must have the form major.minor.0-beta: $version" >&2 + exit 1 + fi + ;; + rc) + if ! [[ "$version" =~ ^$versionComponent\.$versionComponent\.1-rc$ ]]; then + echo "RC package version must have the form major.minor.1-rc: $version" >&2 + exit 1 + fi + ;; *) echo "Unexpected npm publish tag: $npmTag" >&2 exit 1 @@ -319,25 +338,30 @@ extends: case "$NPM_TAG" in latest) - if ! [[ "$PACKAGE_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then - echo "Stable package version is not valid SemVer: $PACKAGE_VERSION" >&2 + if ! [[ "$PACKAGE_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(2|[3-9]|[1-9][0-9]+)$ ]]; then + echo "Stable package version must have patch version 2 or greater: $PACKAGE_VERSION" >&2 exit 1 fi tag="v$PACKAGE_VERSION" title="TypeScript $PACKAGE_VERSION" prerelease=false ;; - beta | rc) - if ! [[ "$PACKAGE_VERSION" =~ ^((0|[1-9][0-9]*)\.(0|[1-9][0-9]*))\.(0|[1-9][0-9]*)-"$NPM_TAG"([.-].*)?$ ]]; then - echo "Package version $PACKAGE_VERSION does not match npm tag $NPM_TAG." >&2 + beta) + if ! [[ "$PACKAGE_VERSION" =~ ^((0|[1-9][0-9]*)\.(0|[1-9][0-9]*))\.0-beta$ ]]; then + echo "Beta package version must have the form major.minor.0-beta: $PACKAGE_VERSION" >&2 exit 1 fi tag="v${BASH_REMATCH[1]}-$NPM_TAG" - if [ "$NPM_TAG" = "beta" ]; then - title="TypeScript ${BASH_REMATCH[1]} Beta" - else - title="TypeScript ${BASH_REMATCH[1]}.${BASH_REMATCH[4]} RC" + title="TypeScript ${BASH_REMATCH[1]} Beta" + prerelease=true + ;; + rc) + if ! [[ "$PACKAGE_VERSION" =~ ^((0|[1-9][0-9]*)\.(0|[1-9][0-9]*))\.1-rc$ ]]; then + echo "RC package version must have the form major.minor.1-rc: $PACKAGE_VERSION" >&2 + exit 1 fi + tag="v${BASH_REMATCH[1]}-$NPM_TAG" + title="TypeScript ${BASH_REMATCH[1]}.1 RC" prerelease=true ;; *) diff --git a/tools/scripts/configure-release.mjs b/tools/scripts/configure-release.mjs index d7b86269f85fa..c839f517ca18a 100644 --- a/tools/scripts/configure-release.mjs +++ b/tools/scripts/configure-release.mjs @@ -1,11 +1,8 @@ import fs from "node:fs"; const numericIdentifier = String.raw`(?:0|[1-9]\d*)`; -const nonNumericIdentifier = String.raw`(?:\d*[A-Za-z-][0-9A-Za-z-]*)`; -const prereleaseIdentifier = String.raw`(?:${numericIdentifier}|${nonNumericIdentifier})`; -const prerelease = String.raw`${prereleaseIdentifier}(?:\.${prereleaseIdentifier})*`; const releaseVersionPattern = new RegExp( - String.raw`^(${numericIdentifier})\.(${numericIdentifier})\.(${numericIdentifier})(?:-(?:beta|rc)(?:\.${prerelease})?)?$`, + String.raw`^(${numericIdentifier})\.(${numericIdentifier})\.(${numericIdentifier})(?:-(beta|rc))?$`, ); const maxUint32 = 0xffff_ffffn; @@ -13,7 +10,7 @@ const [version, expectedMajorMinor] = process.argv.slice(2); if (!version || !isReleaseVersion(version)) { throw new Error( - "Usage: node tools/scripts/configure-release.mjs [expected-major.minor]", + "Usage: node tools/scripts/configure-release.mjs = 2)> [expected-major.minor]", ); } @@ -38,7 +35,19 @@ updateFile( */ function isReleaseVersion(value) { const match = releaseVersionPattern.exec(value); - return match !== null && match.slice(1, 4).every(component => BigInt(component) <= maxUint32); + if (match === null || !match.slice(1, 4).every(component => BigInt(component) <= maxUint32)) { + return false; + } + + const patch = BigInt(match[3]); + switch (match[4]) { + case "beta": + return patch === 0n; + case "rc": + return patch === 1n; + default: + return patch >= 2n; + } } function updateFile(path, pattern, replacement) {