diff --git a/common/config/azure-pipelines/npm-post-publish.yaml b/common/config/azure-pipelines/npm-post-publish.yaml new file mode 100644 index 0000000000..61be013516 --- /dev/null +++ b/common/config/azure-pipelines/npm-post-publish.yaml @@ -0,0 +1,221 @@ +parameters: + - name: delayMinutes + displayName: 'Minutes to wait for packages to propagate before running' + type: number + default: 5 + +name: 'Post-publish $(Date:yyyyMMdd).$(Rev:r) (triggered by $(resources.triggeringAlias))' + +variables: + - name: FORCE_COLOR + value: 1 + +# This pipeline is triggered only by pipeline resources (npm publish pipelines), +# not by CI pushes or PR builds. +trigger: none +pr: none + +resources: + pipelines: + - pipeline: npmPublish + source: 'rushstack NPM Publish' + trigger: + enabled: true + branches: + include: + - refs/heads/main + - pipeline: npmPublishRush + source: 'rushstack NPM Publish (rush)' + trigger: + enabled: true + branches: + include: + - refs/heads/main + repositories: + - repository: 1esPipelines + type: git + name: 1ESPipelineTemplates/1ESPipelineTemplates + ref: refs/tags/release + - repository: rushstackWebsites + type: github + name: microsoft/rushstack-websites + endpoint: GitHubProjects + ref: refs/heads/main + +extends: + template: v1/1ES.Official.PipelineTemplate.yml@1esPipelines + parameters: + sdl: + sourceRepositoriesToScan: + exclude: + - repository: rushstackWebsites + pool: + name: Azure-Pipelines-1ESPT-ExDShared + os: windows + stages: + # ────────────────────────────────────────────────────────────────────────── + # Stage 0: Wait for packages to propagate to the npm registry + # ────────────────────────────────────────────────────────────────────────── + - stage: WaitForPropagation + displayName: 'Wait for npm propagation' + jobs: + - job: + displayName: 'Delay' + pool: server + timeoutInMinutes: 120 + steps: + - task: Delay@1 + displayName: 'Wait ${{ parameters.delayMinutes }} minute(s)' + inputs: + delayForMinutes: '${{ parameters.delayMinutes }}' + + # ────────────────────────────────────────────────────────────────────────── + # Stage 1: Bump decoupled local dependencies + # ────────────────────────────────────────────────────────────────────────── + - stage: BumpDecoupledDeps + displayName: 'Bump decoupled local dependencies' + dependsOn: WaitForPropagation + variables: + BranchName: 'automated/bump-decoupled-deps' + CommitMessage: 'chore: bump decoupled local dependencies' + jobs: + - job: + displayName: 'Bump decoupled dependencies and create PR' + pool: + name: publish-rushstack + os: linux + steps: + - checkout: self + persistCredentials: true + + - template: /common/config/azure-pipelines/templates/install-node.yaml@self + + - script: 'git config --local user.email rushbot@users.noreply.github.com' + displayName: 'git config email' + + - script: 'git config --local user.name Rushbot' + displayName: 'git config name' + + - script: 'node common/scripts/install-run-rush.js install --to repo-toolbox' + displayName: 'Rush Install' + + - script: 'node common/scripts/install-run-rush.js build --to repo-toolbox --verbose' + displayName: 'Rush Build (repo-toolbox)' + + - script: 'node repo-scripts/repo-toolbox/lib-commonjs/start.js bump-decoupled-local-dependencies' + displayName: 'Bump decoupled local dependencies' + + - script: 'node common/scripts/install-run-rush.js update' + displayName: 'Rush Update' + + - bash: | + set -e + + if git diff --quiet; then + echo "No changes detected. Skipping commit and PR." + echo "##vso[task.setvariable variable=HasChanges]false" + exit 0 + fi + + echo "##vso[task.setvariable variable=HasChanges]true" + + git checkout -B $(BranchName) + git add --all + git commit -m "$(CommitMessage)" + displayName: 'Commit dependency changes' + + - bash: | + set -e + + node common/scripts/install-run-rush.js change \ + --bulk \ + --bump-type none \ + --commit-message "chore: generate change files for decoupled dependency bump" + displayName: 'Generate change files' + condition: and(succeeded(), eq(variables.HasChanges, 'true')) + + - template: /common/config/azure-pipelines/templates/push-and-create-github-pr.yaml@self + parameters: + BranchName: $(BranchName) + PrTitle: $(CommitMessage) + PrDescription: 'Automated PR to bump decoupled local dependencies to the latest published versions.' + + # ────────────────────────────────────────────────────────────────────────── + # Stage 2: Update API documentation on rushstack-websites + # ────────────────────────────────────────────────────────────────────────── + - stage: UpdateApiDocs + displayName: 'Update API documentation' + dependsOn: WaitForPropagation + variables: + BranchName: 'automated/update-api-docs' + CommitMessage: 'docs: update API documentation' + jobs: + - job: + displayName: 'Update API docs and create PR' + pool: + name: publish-rushstack + os: linux + steps: + - checkout: rushstackWebsites + persistCredentials: true + + - template: /common/config/azure-pipelines/templates/install-node.yaml@self + parameters: + NodeMajorVersion: 24 + + # Build the custom Docusaurus plugin for api-documenter in the + # rushstack-websites repo. + - script: 'node common/scripts/install-run-rush.js install' + displayName: 'Rush Install (rushstack-websites)' + + - script: 'node common/scripts/install-run-rush.js build --to-except api.rushstack.io --verbose' + displayName: 'Rush Build to-except api.rushstack.io (rushstack-websites)' + + # Download the api artifact from the triggering publish pipeline. + # AzDO automatically resolves which pipeline resource triggered this run. + - task: DownloadPipelineArtifact@2 + displayName: 'Download API review files' + inputs: + source: specific + project: GitHubProjects + pipeline: 'rushstack NPM Publish' + runVersion: latest + artifact: api + path: $(Pipeline.Workspace)/api + + # Run api-documenter with the Docusaurus plugin from the + # api.rushstack.io project directory so it picks up + # config/api-documenter.json. + - script: 'npx @microsoft/api-documenter@latest generate --input-folder $(Pipeline.Workspace)/api --output-folder ./docs/pages' + displayName: 'Generate API documentation' + workingDirectory: websites/api.rushstack.io + + # Update the API docs folder in rushstack-websites and commit. + - bash: | + set -e + + git config --local user.email rushbot@users.noreply.github.com + git config --local user.name Rushbot + + # Move the generated nav data file to the expected location. + mv websites/api.rushstack.io/docs/api_nav.json websites/api.rushstack.io/data/api_nav.json + + # Check for changes (tracked and untracked) + if git diff --quiet && [ -z "$(git ls-files --others --exclude-standard)" ]; then + echo "No API documentation changes detected." + echo "##vso[task.setvariable variable=HasChanges]false" + exit 0 + fi + + echo "##vso[task.setvariable variable=HasChanges]true" + + git checkout -B $(BranchName) + git add --all + git commit -m "$(CommitMessage)" + displayName: 'Update API docs and commit' + + - template: /common/config/azure-pipelines/templates/push-and-create-github-pr.yaml@self + parameters: + BranchName: $(BranchName) + PrTitle: $(CommitMessage) + PrDescription: 'Automated PR to update API reference documentation from the latest published packages.' diff --git a/common/config/azure-pipelines/templates/push-and-create-github-pr.yaml b/common/config/azure-pipelines/templates/push-and-create-github-pr.yaml new file mode 100644 index 0000000000..2aa09ac32d --- /dev/null +++ b/common/config/azure-pipelines/templates/push-and-create-github-pr.yaml @@ -0,0 +1,119 @@ +parameters: + - name: BranchName + type: string + - name: PrTitle + type: string + - name: PrDescription + type: string + default: '' + - name: TargetBranch + type: string + default: 'main' + - name: HasChangesVariableName + type: string + default: 'HasChanges' + - name: WorkingDirectory + type: string + default: '$(Build.SourcesDirectory)' + +steps: + # Force-push the branch. This is safe because the branch (e.g. "automated/bump-decoupled-deps") + # is exclusively owned by this pipeline and is never manually committed to. + - bash: | + set -e + git push origin ${{ parameters.BranchName }} --force + displayName: 'Push branch' + condition: and(succeeded(), eq(variables['${{ parameters.HasChangesVariableName }}'], 'true')) + workingDirectory: ${{ parameters.WorkingDirectory }} + + - bash: | + set -e + + # ── Resolve the GitHub owner/repo from the git remote URL ── + # Handles both HTTPS (https://github.com/owner/repo.git) and SSH (git@github.com:owner/repo.git) URLs. + REPO_SLUG=$(git remote get-url origin | sed -E 's#.*github\.com[:/](.+/[^.]+)(\.git)?$#\1#') + echo "Repository: ${REPO_SLUG}" + OWNER=$(echo "${REPO_SLUG}" | cut -d/ -f1) + + # ── Extract credentials from the AzDO-managed git config ── + # When "persistCredentials: true" is set on the checkout step, AzDO injects an + # "http..extraheader" git config entry containing an "AUTHORIZATION: basic " + # header for the GitHub service connection. We reuse this for GitHub API calls so that + # no additional secrets or PATs need to be configured. + AUTH_HEADER=$(git config --get-regexp 'http\..*\.extraheader' | head -1 | sed 's/^[^ ]* //') + if [ -z "$AUTH_HEADER" ]; then + echo "##[error]Could not extract authorization header from git config. Ensure persistCredentials is enabled on the checkout step." + exit 1 + fi + + # ── Write credentials to a temporary curl config file ── + # This avoids passing the auth token as a command-line argument, which would be + # visible in process listings (e.g. "ps aux") and could leak into logs. + CURL_CONFIG=$(mktemp) + trap 'rm -f "$CURL_CONFIG"' EXIT + echo "-H \"${AUTH_HEADER}\"" > "$CURL_CONFIG" + echo '-H "Accept: application/vnd.github+json"' >> "$CURL_CONFIG" + + API_BASE="https://api.github.com/repos/${REPO_SLUG}" + + # ── GitHub API helper ── + # Calls the GitHub API using the temporary curl config file for auth headers. + # On success (2xx), prints the response body to stdout. + # On failure, prints the HTTP status and error body to stderr and returns non-zero. + github_api() { + local RESPONSE HTTP_CODE BODY + RESPONSE=$(curl -s -w "\n%{http_code}" -K "$CURL_CONFIG" "$@") + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | sed '$d') + + if [[ "$HTTP_CODE" -ge 200 && "$HTTP_CODE" -lt 300 ]]; then + echo "$BODY" + else + echo "##[error]GitHub API returned HTTP ${HTTP_CODE}:" >&2 + echo "$BODY" >&2 + return 1 + fi + } + + # ── Check for an existing open PR from this branch ── + # The GitHub "List pull requests" API filters by "head=OWNER:BRANCH" to find any + # open PR already targeting this branch. If one exists, we update it instead of + # creating a duplicate. + EXISTING_PR=$(github_api \ + "${API_BASE}/pulls?head=${OWNER}:${{ parameters.BranchName }}&state=open" \ + | jq '.[0].number // empty') + + if [ -n "$EXISTING_PR" ]; then + # ── Update existing PR ── + # Only the description is updated; the title is left as-is since the branch was + # already force-pushed with the new commits above. + echo "Updating existing PR #${EXISTING_PR}" + github_api -X PATCH \ + "${API_BASE}/pulls/${EXISTING_PR}" \ + -d "$(jq -n --arg body "$PR_BODY" '{body: $body}')" > /dev/null + else + # ── Create new PR ── + # jq --arg safely handles JSON escaping of the title and body, so special + # characters (quotes, newlines, etc.) in the parameter values are safe. + echo "Creating new PR" + github_api -X POST \ + "${API_BASE}/pulls" \ + -d "$(jq -n \ + --arg title "$PR_TITLE" \ + --arg body "$PR_BODY" \ + --arg head "${{ parameters.BranchName }}" \ + --arg base "${{ parameters.TargetBranch }}" \ + '{title: $title, body: $body, head: $head, base: $base}')" > /dev/null + fi + displayName: 'Create or update GitHub PR' + condition: and(succeeded(), eq(variables['${{ parameters.HasChangesVariableName }}'], 'true')) + # Pass PR title and description as environment variables rather than using + # ${{ }} template expansion inside the script. Template expansion would + # substitute the raw string into the Bash source code, which breaks if the + # value contains quotes or other shell metacharacters. Environment variables + # are set by the AzDO agent outside of the shell, so they are safe regardless + # of content. + workingDirectory: ${{ parameters.WorkingDirectory }} + env: + PR_TITLE: ${{ parameters.PrTitle }} + PR_BODY: ${{ parameters.PrDescription }}