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..f894d1f6daf17 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") }, @@ -137,7 +138,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"; @@ -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, @@ -1786,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"; @@ -1796,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"; @@ -2387,10 +2395,22 @@ 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, }); +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(); @@ -2434,8 +2454,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 +2514,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 +2552,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 +2574,52 @@ 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.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"); + + 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.", + dependencies: options.forRelease ? undefined : [buildNativePreviewPackages], + 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 519789aff3dc2..84dc67d9af0ae 100755 --- a/tools/pipelines/typescript-build.yml +++ b/tools/pipelines/typescript-build.yml @@ -8,20 +8,12 @@ schedules: include: - main -name: $(date:yyyyMMdd)$(rev:.r) +name: $(Date:yyyyMMdd).$(runRevision) appendCommitMessageToRunName: false -parameters: - - name: signType - displayName: Sign type - type: string - default: auto - values: - - auto - - real - - test - variables: + - name: runRevision + value: $[counter(format('{0:yyyyMMdd}', pipeline.startTime), 1)] # For MicroBuild telemetry - name: TeamName value: TypeScript @@ -102,18 +94,11 @@ extends: - task: MicroBuildSigningPlugin@4 displayName: '🔩 Install Signing Plugin' inputs: - ${{ if eq(parameters.signType, 'auto') }}: - ${{ if eq(variables['Build.Reason'], 'Schedule') }}: - 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'), eq(variables['Build.Reason'], 'Schedule')), 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: @@ -125,24 +110,70 @@ extends: fetchDepth: 1 fetchTags: false - template: /tools/pipelines/steps/setup-node-npm-ci.yml@self + + - 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 + + echo "##vso[task.setvariable variable=initialBuildNumber]$INITIAL_BUILD_NUMBER" + + 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 determine 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) + - template: /tools/pipelines/steps/setup-go.yml@self - - bash: npx hereby typescript:build --forRelease --setPrerelease dev.$(Build.BuildNumber) + - bash: npx hereby typescript:build --forRelease --setPrerelease dev.$(initialBuildNumber) displayName: 'Build packages' - - bash: npx hereby typescript:sign --forRelease --setPrerelease dev.$(Build.BuildNumber) + - 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.$(Build.BuildNumber) + - bash: npx hereby typescript:pack --forRelease --setPrerelease dev.$(initialBuildNumber) displayName: 'Pack packages' - - bash: npx hereby vscode-typescript:pack --forRelease --setPrerelease dev.$(Build.BuildNumber) + - bash: npx hereby vscode-typescript:pack --forRelease --setPrerelease dev.$(initialBuildNumber) displayName: 'Pack extensions' - - bash: npx hereby vscode-typescript:sign --forRelease --setPrerelease dev.$(Build.BuildNumber) + - bash: npx hereby vscode-typescript:sign --forRelease --setPrerelease dev.$(initialBuildNumber) displayName: 'Sign extensions' env: # Needed for ESRP diff --git a/tools/pipelines/typescript-publish.yml b/tools/pipelines/typescript-publish.yml index 07a6bb74fb855..92a035cc3d927 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: @@ -77,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 @@ -91,6 +106,66 @@ 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 + versionComponent='(0|[1-9][0-9]*)' + case "$npmTag" in + 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 + ;; + 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[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 +176,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 +298,180 @@ 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]*)\.(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) + 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" + 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 + ;; + *) + 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) diff --git a/tools/scripts/configure-release.mjs b/tools/scripts/configure-release.mjs new file mode 100644 index 0000000000000..c839f517ca18a --- /dev/null +++ b/tools/scripts/configure-release.mjs @@ -0,0 +1,60 @@ +import fs from "node:fs"; + +const numericIdentifier = String.raw`(?:0|[1-9]\d*)`; +const releaseVersionPattern = new RegExp( + String.raw`^(${numericIdentifier})\.(${numericIdentifier})\.(${numericIdentifier})(?:-(beta|rc))?$`, +); +const maxUint32 = 0xffff_ffffn; + +const [version, expectedMajorMinor] = process.argv.slice(2); + +if (!version || !isReleaseVersion(version)) { + throw new Error( + "Usage: node tools/scripts/configure-release.mjs = 2)> [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 + */ +function isReleaseVersion(value) { + const match = releaseVersionPattern.exec(value); + 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) { + 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)); +}