Extension Release #7
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Extension Release Workflow | |
| # | |
| # Prepares a VS Code extension release by: | |
| # 1. Validating the new version is greater than the current version in package.json | |
| # 2. Generating release notes from extension commits between two commits | |
| # 3. Updating the version in extension/package.json | |
| # 4. Creating a CHANGELOG.md entry | |
| # 5. Opening a draft PR with the changes | |
| name: Extension Release | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| release_version: | |
| description: 'New extension version (e.g., 1.0.8)' | |
| required: true | |
| type: string | |
| from_sha: | |
| description: 'Start commit SHA. Leave empty to use the latest stable Marketplace VSIX .version SHA.' | |
| required: false | |
| type: string | |
| default: '' | |
| to_sha: | |
| description: 'End commit SHA (defaults to latest commit on main if empty)' | |
| required: false | |
| type: string | |
| default: '' | |
| dry_run: | |
| description: 'Validate and generate the release files without pushing a branch or opening a PR' | |
| required: false | |
| type: boolean | |
| default: false | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| concurrency: | |
| group: extension-release | |
| cancel-in-progress: false | |
| jobs: | |
| prepare-release: | |
| name: Prepare Extension Release | |
| runs-on: ubuntu-latest | |
| # Dry-run is intentionally allowed in forks so maintainers can validate the | |
| # Marketplace baseline, changelog generation, and PR body without bot secrets. | |
| if: ${{ github.repository_owner == 'microsoft' || inputs.dry_run }} | |
| steps: | |
| - name: Check if user is authorized | |
| env: | |
| ACTOR: ${{ github.actor }} | |
| GH_TOKEN: ${{ github.token }} | |
| REPOSITORY: ${{ github.repository }} | |
| run: | | |
| set -eo pipefail | |
| echo "Checking if ${ACTOR} is authorized to run extension releases..." | |
| PERMISSION=$(gh api "repos/${REPOSITORY}/collaborators/${ACTOR}/permission" --jq '.permission') | |
| echo "User permission level: ${PERMISSION}" | |
| # `write` is the highest permission level granted to contributors on | |
| # microsoft/aspire — nobody holds `maintain`/`admin`, so gating on those | |
| # alone would make this workflow undispatchable by anyone. Accept `write` | |
| # and above, matching the repo's other contributor-gated workflows | |
| # (.github/workflows/apply-test-attributes.yml, backport.yml). | |
| if [[ "$PERMISSION" != "admin" && "$PERMISSION" != "maintain" && "$PERMISSION" != "write" ]]; then | |
| echo "❌ ERROR: User ${ACTOR} does not have sufficient permissions." | |
| echo "Required: 'write', 'maintain', or 'admin' permission level." | |
| echo "Current: '${PERMISSION}'" | |
| exit 1 | |
| fi | |
| echo "✓ User ${ACTOR} is authorized (permission: ${PERMISSION})" | |
| - name: Checkout Repository | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| ref: main | |
| fetch-depth: 0 # Full history for diff generation | |
| persist-credentials: false | |
| - name: Resolve commit range | |
| id: resolve-sha | |
| env: | |
| INPUT_FROM_SHA: ${{ inputs.from_sha }} | |
| INPUT_TO_SHA: ${{ inputs.to_sha }} | |
| run: | | |
| set -eo pipefail | |
| WORK_DIR="${RUNNER_TEMP}/extension-release/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" | |
| mkdir -p "$WORK_DIR" | |
| git fetch --no-tags origin main:refs/remotes/origin/main | |
| TO_SHA="$INPUT_TO_SHA" | |
| if [ -z "$TO_SHA" ]; then | |
| TO_SHA=$(git rev-parse origin/main) | |
| echo "No end SHA provided, using origin/main: $TO_SHA" | |
| fi | |
| FROM_SHA="$INPUT_FROM_SHA" | |
| FROM_SHA_SOURCE="input" | |
| BASE_VERSION="" | |
| if [ -z "$FROM_SHA" ]; then | |
| FROM_SHA_SOURCE="marketplace" | |
| echo "No start SHA provided. Resolving from the latest stable VS Code Marketplace release..." | |
| MARKETPLACE_QUERY=$(jq -n '{ | |
| filters: [ | |
| { | |
| criteria: [ | |
| { filterType: 7, value: "microsoft-aspire.aspire-vscode" } | |
| ], | |
| pageNumber: 1, | |
| pageSize: 1, | |
| sortBy: 0, | |
| sortOrder: 0 | |
| } | |
| ], | |
| assetTypes: ["Microsoft.VisualStudio.Services.VSIXPackage"], | |
| flags: 914 | |
| }') | |
| HTTP_CODE=$(curl -sS -o "$WORK_DIR/marketplace_response.json" -w "%{http_code}" --max-time 60 --retry 2 -X POST \ | |
| "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" \ | |
| -H "Content-Type: application/json" \ | |
| -H "Accept: application/json;api-version=7.2-preview.1" \ | |
| -d "$MARKETPLACE_QUERY") | |
| if [ "$HTTP_CODE" != "200" ]; then | |
| echo "❌ Marketplace query returned HTTP $HTTP_CODE" | |
| cat "$WORK_DIR/marketplace_response.json" | |
| exit 1 | |
| fi | |
| BASE_VERSION=$(jq -r '.results[0].extensions[0].versions[0].version // empty' "$WORK_DIR/marketplace_response.json") | |
| VSIX_URL=$(jq -r '.results[0].extensions[0].versions[0].files[]? | select(.assetType == "Microsoft.VisualStudio.Services.VSIXPackage") | .source' "$WORK_DIR/marketplace_response.json") | |
| if [ -z "$BASE_VERSION" ] || [ -z "$VSIX_URL" ]; then | |
| echo "❌ Could not find the latest Marketplace VSIX metadata for microsoft-aspire.aspire-vscode." | |
| cat "$WORK_DIR/marketplace_response.json" | |
| exit 1 | |
| fi | |
| echo "Latest Marketplace version: $BASE_VERSION" | |
| curl -fsSL --max-time 60 --retry 2 "$VSIX_URL" -o "$WORK_DIR/latest-marketplace.vsix" | |
| python3 - "$WORK_DIR/latest-marketplace.vsix" > "$WORK_DIR/marketplace_vsix.json" <<'PY' | |
| import json | |
| import sys | |
| import zipfile | |
| vsix_path = sys.argv[1] | |
| with zipfile.ZipFile(vsix_path) as archive: | |
| try: | |
| sha = archive.read("extension/.version").decode("utf-8").strip() | |
| except KeyError: | |
| raise SystemExit("The Marketplace VSIX does not contain extension/.version; pass from_sha explicitly.") | |
| try: | |
| package_json = json.loads(archive.read("extension/package.json").decode("utf-8")) | |
| version = package_json.get("version", "") | |
| except (KeyError, ValueError): | |
| # version is only used for a non-fatal mismatch warning, so a | |
| # missing or malformed package.json must not fail the workflow. | |
| version = "" | |
| print(json.dumps({"sha": sha, "packageVersion": version})) | |
| PY | |
| FROM_SHA=$(jq -r '.sha // empty' "$WORK_DIR/marketplace_vsix.json") | |
| VSIX_PACKAGE_VERSION=$(jq -r '.packageVersion // empty' "$WORK_DIR/marketplace_vsix.json") | |
| if [ "$VSIX_PACKAGE_VERSION" != "$BASE_VERSION" ]; then | |
| echo "⚠️ Marketplace metadata version ($BASE_VERSION) does not match VSIX package.json version ($VSIX_PACKAGE_VERSION)." | |
| fi | |
| if [[ ! "$FROM_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then | |
| echo "❌ extension/.version from Marketplace VSIX did not contain a full git SHA: '$FROM_SHA'" | |
| echo "Pass from_sha explicitly if you need to use a historical release without embedded commit metadata." | |
| exit 1 | |
| fi | |
| echo "Resolved Marketplace v${BASE_VERSION} to commit $FROM_SHA" | |
| fi | |
| # Resolve and validate both SHAs (accepts short or full SHAs) | |
| FROM_SHA=$(git rev-parse --verify "${FROM_SHA}^{commit}" 2>/dev/null) || { | |
| echo "❌ Start SHA does not exist in this checkout: $FROM_SHA" | |
| if [ "$FROM_SHA_SOURCE" = "marketplace" ]; then | |
| echo "The SHA came from the latest Marketplace VSIX .version file. Pass from_sha explicitly if the shipped commit is not reachable from main." | |
| fi | |
| exit 1 | |
| } | |
| TO_SHA=$(git rev-parse --verify "${TO_SHA}^{commit}" 2>/dev/null) || { | |
| echo "❌ End SHA does not exist: $TO_SHA" | |
| exit 1 | |
| } | |
| if ! git merge-base --is-ancestor "$FROM_SHA" "$TO_SHA"; then | |
| echo "❌ Invalid SHA range: from_sha must be an ancestor of to_sha." | |
| echo "from_sha: $FROM_SHA" | |
| echo "to_sha: $TO_SHA" | |
| echo "The supplied SHAs may be swapped or from unrelated histories." | |
| exit 1 | |
| fi | |
| echo "from_sha=$FROM_SHA" >> "$GITHUB_OUTPUT" | |
| echo "to_sha=$TO_SHA" >> "$GITHUB_OUTPUT" | |
| echo "from_sha_source=$FROM_SHA_SOURCE" >> "$GITHUB_OUTPUT" | |
| echo "base_version=$BASE_VERSION" >> "$GITHUB_OUTPUT" | |
| echo "✓ SHA range: ${FROM_SHA}..${TO_SHA}" | |
| - name: Validate version format | |
| env: | |
| INPUT_VERSION: ${{ inputs.release_version }} | |
| run: | | |
| set -eo pipefail | |
| VERSION="$INPUT_VERSION" | |
| if [[ ! "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then | |
| echo "❌ Invalid version format: $VERSION" | |
| echo "Expected format: major.minor.patch without leading zeros (e.g., 1.0.8 or 0.9.0)" | |
| exit 1 | |
| fi | |
| echo "✓ Version format is valid: $VERSION" | |
| - name: Check version is greater than current | |
| id: version-check | |
| env: | |
| INPUT_VERSION: ${{ inputs.release_version }} | |
| run: | | |
| set -eo pipefail | |
| CURRENT_VERSION=$(jq -r '.version' extension/package.json) | |
| NEW_VERSION="$INPUT_VERSION" | |
| echo "Current version: $CURRENT_VERSION" | |
| echo "New version: $NEW_VERSION" | |
| # Split versions into components | |
| IFS='.' read -r CUR_MAJOR CUR_MINOR CUR_PATCH <<< "$CURRENT_VERSION" | |
| IFS='.' read -r NEW_MAJOR NEW_MINOR NEW_PATCH <<< "$NEW_VERSION" | |
| # Compare versions | |
| IS_GREATER=false | |
| if [ "$NEW_MAJOR" -gt "$CUR_MAJOR" ]; then | |
| IS_GREATER=true | |
| elif [ "$NEW_MAJOR" -eq "$CUR_MAJOR" ]; then | |
| if [ "$NEW_MINOR" -gt "$CUR_MINOR" ]; then | |
| IS_GREATER=true | |
| elif [ "$NEW_MINOR" -eq "$CUR_MINOR" ]; then | |
| if [ "$NEW_PATCH" -gt "$CUR_PATCH" ]; then | |
| IS_GREATER=true | |
| fi | |
| fi | |
| fi | |
| if [ "$IS_GREATER" != "true" ]; then | |
| echo "❌ New version ($NEW_VERSION) must be greater than current version ($CURRENT_VERSION)" | |
| exit 1 | |
| fi | |
| echo "current_version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" | |
| echo "✓ Version bump: $CURRENT_VERSION → $NEW_VERSION" | |
| - name: Generate extension commit log | |
| id: generate-diff | |
| env: | |
| FROM_SHA: ${{ steps.resolve-sha.outputs.from_sha }} | |
| TO_SHA: ${{ steps.resolve-sha.outputs.to_sha }} | |
| run: | | |
| set -eo pipefail | |
| WORK_DIR="${RUNNER_TEMP}/extension-release/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" | |
| mkdir -p "$WORK_DIR" | |
| echo "Generating commit log for extension changes..." | |
| # Get commit messages for extension/ changes. Keep this focused on the | |
| # extension tree because the generated changelog is published to VS Code | |
| # users, not to repository maintainers. | |
| COMMITS=$(git log --format='%h%x09%s' --no-merges "${FROM_SHA}..${TO_SHA}" -- extension/ 2>/dev/null || echo "") | |
| if [ -z "$COMMITS" ]; then | |
| echo "⚠️ No extension-specific commits found in range. Fallback release notes will use the maintenance entry." | |
| fi | |
| # Write to a file so later steps can read it without multiline output handling. | |
| printf '%s\n' "$COMMITS" > "$WORK_DIR/commits.txt" | |
| COMMIT_COUNT=$(printf '%s\n' "$COMMITS" | awk 'NF { count++ } END { print count + 0 }') | |
| echo "commit_count=$COMMIT_COUNT" >> "$GITHUB_OUTPUT" | |
| echo "✓ Found $COMMIT_COUNT extension commits in range" | |
| - name: Generate release notes | |
| id: release-notes | |
| env: | |
| NEW_VERSION: ${{ inputs.release_version }} | |
| FROM_SHA: ${{ steps.resolve-sha.outputs.from_sha }} | |
| TO_SHA: ${{ steps.resolve-sha.outputs.to_sha }} | |
| BASE_VERSION: ${{ steps.resolve-sha.outputs.base_version }} | |
| run: | | |
| set -eo pipefail | |
| WORK_DIR="${RUNNER_TEMP}/extension-release/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" | |
| RELEASE_NOTES="$WORK_DIR/release_notes.md" | |
| : > "$RELEASE_NOTES" | |
| NOTE_COUNT=0 | |
| while IFS= read -r line; do | |
| if [ -n "$line" ]; then | |
| # Commit lines are emitted as: "<short-sha>\t<subject>". | |
| MSG=$(printf '%s\n' "$line" | cut -f2-) | |
| MSG=$(printf '%s\n' "$MSG" | sed -E 's/^[A-Za-z]+(\([^)]*\))?!?:[[:space:]]*//; s/[[:space:]]*\(#[0-9]+\)$//') | |
| MSG=$(printf '%s\n' "$MSG" | perl -0pe 's/\r//g; s/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]//g; s/!\[[^\]]*\]\([^)]+\)//g; s/<[^>\n]*>//g') | |
| case "$MSG" in | |
| ""|Merge\ *|merge\ *|Release\ *|release\ *|Bump\ *|bump\ *|Update\ package-lock*|Update\ yarn.lock*) | |
| continue | |
| ;; | |
| esac | |
| if [ "$NOTE_COUNT" -eq 0 ]; then | |
| echo "### Changes (auto-generated from commits)" >> "$RELEASE_NOTES" | |
| fi | |
| printf -- '- %s\n' "$MSG" >> "$RELEASE_NOTES" | |
| NOTE_COUNT=$((NOTE_COUNT + 1)) | |
| if [ "$NOTE_COUNT" -ge 8 ]; then | |
| break | |
| fi | |
| fi | |
| done < "$WORK_DIR/commits.txt" | |
| if [ "$NOTE_COUNT" -eq 0 ]; then | |
| { | |
| echo "### Maintenance" | |
| echo "- No user-facing extension changes were detected." | |
| } > "$RELEASE_NOTES" | |
| fi | |
| echo "✓ Release notes generated" | |
| RELEASE_NOTES_CONTENT=$(printf '%.8000s' "$(cat "$RELEASE_NOTES")") | |
| printf '%s\n' "$RELEASE_NOTES_CONTENT" > "$RELEASE_NOTES" | |
| # The changelog entry committed to the PR is a PLACEHOLDER, not the | |
| # deterministic commit list. A separate agentic workflow | |
| # (.github/workflows/extension-changelog.md) detects the marker comment | |
| # below, generates polished user-facing notes from the extension commit | |
| # range, and replaces this block on the PR branch via a safe output. This | |
| # keeps AI generation in an agentic workflow (read-only agent + safe | |
| # outputs) instead of invoking a model inline here. The deterministic | |
| # commit list ("$RELEASE_NOTES") is preserved in the PR body as a | |
| # human-reviewable fallback in case the agent never runs (e.g. on a fork). | |
| # | |
| # The marker carries the full from/to SHAs and the Marketplace base | |
| # version so the agent can validate them and recompute the same range. | |
| # BASE_VERSION may be empty when no Marketplace baseline was resolved. | |
| { | |
| echo "## v${NEW_VERSION}" | |
| echo "" | |
| echo "<!-- aspire-ext-changelog from=${FROM_SHA} to=${TO_SHA} base=${BASE_VERSION} -->" | |
| echo "_Release notes are being generated automatically and will replace this placeholder shortly. If this line is still here after the \`extension-changelog\` workflow runs, copy the deterministic commit list from the pull request description into this entry before merging._" | |
| } > "$WORK_DIR/changelog_entry.md" | |
| - name: Update package.json version | |
| env: | |
| NEW_VERSION: ${{ inputs.release_version }} | |
| run: | | |
| set -eo pipefail | |
| cd extension | |
| jq --arg v "$NEW_VERSION" '.version = $v' package.json > package.json.tmp | |
| mv package.json.tmp package.json | |
| echo "✓ Updated extension/package.json version to $NEW_VERSION" | |
| - name: Update or create CHANGELOG.md | |
| env: | |
| NEW_VERSION: ${{ inputs.release_version }} | |
| run: | | |
| set -eo pipefail | |
| WORK_DIR="${RUNNER_TEMP}/extension-release/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" | |
| CHANGELOG="extension/CHANGELOG.md" | |
| ENTRY=$(cat "$WORK_DIR/changelog_entry.md") | |
| if [ -f "$CHANGELOG" ]; then | |
| HEADER=$(head -1 "$CHANGELOG") | |
| if [[ "$HEADER" == \#* ]]; then | |
| { | |
| echo "$HEADER" | |
| echo "" | |
| echo "$ENTRY" | |
| echo "" | |
| # The changelog starts with a title followed by a blank line. The | |
| # new entry already ends with its own separator, so strip only the | |
| # leading blank lines from the old body before appending it. | |
| tail -n +2 "$CHANGELOG" | sed '/[^[:space:]]/,$!d' | |
| } > "${CHANGELOG}.tmp" | |
| else | |
| echo "⚠️ CHANGELOG.md does not start with a Markdown header. Prepending entry at the top." | |
| { | |
| echo "$ENTRY" | |
| echo "" | |
| cat "$CHANGELOG" | |
| } > "${CHANGELOG}.tmp" | |
| fi | |
| mv "${CHANGELOG}.tmp" "$CHANGELOG" | |
| else | |
| { | |
| echo "# Aspire VS Code Extension Changelog" | |
| echo "" | |
| echo "$ENTRY" | |
| } > "$CHANGELOG" | |
| fi | |
| echo "✓ Updated $CHANGELOG" | |
| echo "" | |
| echo "=== Changelog Entry ===" | |
| cat "$WORK_DIR/changelog_entry.md" | |
| echo "========================" | |
| - name: Build pull request body | |
| env: | |
| NEW_VERSION: ${{ inputs.release_version }} | |
| CURRENT_VERSION: ${{ steps.version-check.outputs.current_version }} | |
| FROM_SHA: ${{ steps.resolve-sha.outputs.from_sha }} | |
| TO_SHA: ${{ steps.resolve-sha.outputs.to_sha }} | |
| FROM_SHA_SOURCE: ${{ steps.resolve-sha.outputs.from_sha_source }} | |
| BASE_VERSION: ${{ steps.resolve-sha.outputs.base_version }} | |
| REPOSITORY: ${{ github.repository }} | |
| run: | | |
| set -eo pipefail | |
| WORK_DIR="${RUNNER_TEMP}/extension-release/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" | |
| RELEASE_NOTES=$(cat "$WORK_DIR/release_notes.md") | |
| { | |
| echo "## VS Code Extension Release v${NEW_VERSION}" | |
| echo "" | |
| echo "**Version bump:** ${CURRENT_VERSION} → ${NEW_VERSION}" | |
| echo "**Commit range:** [\`${FROM_SHA:0:7}\`](https://github.com/${REPOSITORY}/commit/${FROM_SHA})..[\`${TO_SHA:0:7}\`](https://github.com/${REPOSITORY}/commit/${TO_SHA})" | |
| if [ "$FROM_SHA_SOURCE" = "marketplace" ]; then | |
| echo "**Baseline:** VS Code Marketplace v${BASE_VERSION} (from the shipped VSIX \`extension/.version\`)" | |
| else | |
| echo "**Baseline:** explicitly supplied \`from_sha\`" | |
| fi | |
| echo "" | |
| echo "### Proposed release notes (deterministic fallback)" | |
| echo "" | |
| echo "The \`extension-changelog\` agentic workflow generates the final, polished \`extension/CHANGELOG.md\` entry and replaces the placeholder on this branch. The commit-derived list below is the human-reviewable fallback — if the agent does not run, paste it into \`extension/CHANGELOG.md\` before merging." | |
| echo "" | |
| printf '%s\n' "$RELEASE_NOTES" | |
| echo "" | |
| echo "### Release instructions" | |
| echo "" | |
| echo "1. Confirm \`extension/CHANGELOG.md\` is specific, user-facing, and free of internal-only changes. The \`extension-changelog\` agentic workflow replaces the placeholder entry with generated notes shortly after this PR is opened; if the placeholder is still present, fill it in from the deterministic fallback above before merging." | |
| echo "2. Get a signed Azure DevOps \`microsoft-aspire\` source build of the merge commit that published the \`aspire-vscode-extension\` artifact with exactly one \`.vsix\`, matching \`.manifest\`, and matching \`.signature.p7s\`:" | |
| echo " - **Stable release:** use the build that runs automatically on merge — it packages the extension as a stable (non-pre-release) VSIX." | |
| echo " - **Pre-release:** the automatic merge build is stable-only, so manually queue the [\`microsoft-aspire\`](https://dev.azure.com/dnceng/internal/_build?definitionId=1602) pipeline on the merge commit with \`Package VS Code Extension as Pre-Release=true\`, then use that build instead. The publish job fails if the VSIX pre-release flag does not match \`IsPrerelease\`." | |
| echo "3. Run Azure DevOps pipeline [\`release-publish-nuget\`](https://dev.azure.com/dnceng/internal/_build?definitionId=1600&_a=summary) and select that build under **Resources** → \`aspire-build\`." | |
| echo "4. For an extension-only release, use these parameters:" | |
| echo "" | |
| echo " | Parameter | Value |" | |
| echo " |-----------|-------|" | |
| echo " | \`ReleaseVersion\` | \`auto\` |" | |
| echo " | \`IsPrerelease\` | \`false\` for a stable release, \`true\` for a pre-release |" | |
| echo " | \`DryRun\` | \`false\` |" | |
| echo " | \`SkipNuGetPublish\` | \`true\` |" | |
| echo " | \`SkipChannelPromotion\` | \`true\` |" | |
| echo " | \`SkipWinGetPublish\` | \`true\` |" | |
| echo " | \`SkipHomebrewValidation\` | \`true\` |" | |
| echo " | \`SkipGitHubTasks\` | \`true\` |" | |
| echo " | \`SkipReleaseAssets\` | \`true\` |" | |
| echo " | \`SkipVSCodeExtensionPublish\` | \`false\` |" | |
| echo "" | |
| echo "5. For a full Aspire release, keep the normal NuGet/channel/GitHub task settings and additionally set \`SkipVSCodeExtensionPublish=false\`. For a pre-release extension, also use a source build queued with \`Package VS Code Extension as Pre-Release=true\` and set \`IsPrerelease=true\`." | |
| echo "6. To validate without publishing, run the same pipeline with \`DryRun=true\` and \`SkipVSCodeExtensionPublish=false\`; the release job validates the VSIX/manifest/signature triplet and Marketplace PAT but skips \`vsce publish\`." | |
| echo "" | |
| echo "> **Note:** This preparation workflow can also be tested from a fork with \`dry_run=true\`. Dry-run mode resolves the Marketplace baseline, generates the changelog entry, uploads the proposed files as a workflow artifact, and skips bot-token branch/PR creation." | |
| } > "$WORK_DIR/pr_body.md" | |
| - name: Dry-run summary | |
| if: ${{ inputs.dry_run }} | |
| env: | |
| FROM_SHA: ${{ steps.resolve-sha.outputs.from_sha }} | |
| TO_SHA: ${{ steps.resolve-sha.outputs.to_sha }} | |
| run: | | |
| set -eo pipefail | |
| WORK_DIR="${RUNNER_TEMP}/extension-release/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" | |
| # CHANGELOG.md may be newly created (untracked), and `git diff` skips | |
| # untracked files. Mark it intent-to-add so the preview diff includes | |
| # the changelog entry — the primary artifact a dry run is meant to show. | |
| git add -N extension/CHANGELOG.md | |
| git diff -- extension/package.json extension/CHANGELOG.md > "$WORK_DIR/proposed_changes.diff" | |
| { | |
| echo "## Extension release dry run" | |
| echo "" | |
| echo "- Commit range: \`${FROM_SHA}\`..\`${TO_SHA}\`" | |
| echo "- Proposed files are available in the \`extension-release-dry-run\` artifact." | |
| echo "" | |
| echo "### Changelog entry" | |
| echo "" | |
| cat "$WORK_DIR/changelog_entry.md" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Upload dry-run release artifacts | |
| if: ${{ inputs.dry_run }} | |
| uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 | |
| with: | |
| name: extension-release-dry-run | |
| path: | | |
| ${{ runner.temp }}/extension-release/${{ github.run_id }}-${{ github.run_attempt }}/changelog_entry.md | |
| ${{ runner.temp }}/extension-release/${{ github.run_id }}-${{ github.run_attempt }}/release_notes.md | |
| ${{ runner.temp }}/extension-release/${{ github.run_id }}-${{ github.run_attempt }}/pr_body.md | |
| ${{ runner.temp }}/extension-release/${{ github.run_id }}-${{ github.run_attempt }}/proposed_changes.diff | |
| - name: Generate GitHub App Token | |
| if: ${{ !inputs.dry_run }} | |
| id: app-token | |
| uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6 | |
| with: | |
| app-id: ${{ secrets.ASPIRE_BOT_APP_ID }} | |
| private-key: ${{ secrets.ASPIRE_BOT_PRIVATE_KEY }} | |
| - name: Create draft pull request | |
| if: ${{ !inputs.dry_run }} | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| NEW_VERSION: ${{ inputs.release_version }} | |
| REPOSITORY: ${{ github.repository }} | |
| SERVER_URL: ${{ github.server_url }} | |
| run: | | |
| set -eo pipefail | |
| WORK_DIR="${RUNNER_TEMP}/extension-release/${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" | |
| BRANCH_NAME="extension-release/v${NEW_VERSION}" | |
| # GitHub's Git HTTPS endpoint expects token credentials in the URL/credential | |
| # flow (the same pattern used by pr-docs-check.md and | |
| # release-update-support-mdx.md). Supplying a bearer extraHeader is not enough | |
| # for `git push`, which otherwise falls back to prompting for a username. | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| export GIT_TERMINAL_PROMPT=0 | |
| SERVER_URL_STRIPPED="${SERVER_URL#https://}" | |
| git remote set-url origin "https://x-access-token:${GH_TOKEN}@${SERVER_URL_STRIPPED}/${REPOSITORY}.git" | |
| # Create branch and commit | |
| git checkout -B "$BRANCH_NAME" | |
| git add extension/package.json extension/CHANGELOG.md | |
| git commit -m "Prepare VS Code extension release v${NEW_VERSION}" | |
| if git ls-remote --exit-code --heads origin "$BRANCH_NAME" >/dev/null 2>&1; then | |
| git fetch origin "$BRANCH_NAME:refs/remotes/origin/$BRANCH_NAME" | |
| fi | |
| git push --force-with-lease origin "$BRANCH_NAME" | |
| # Create or update draft PR. Capture the PR number directly from each | |
| # path (the update path already resolved it; `gh pr create` prints the | |
| # new PR's URL) instead of re-querying with `gh pr list` afterwards, | |
| # which can transiently return empty immediately after creation. | |
| EXISTING_PR=$(gh pr list --head "$BRANCH_NAME" --base main --json number --jq '.[0].number // empty' 2>/dev/null || true) | |
| if [ -n "$EXISTING_PR" ]; then | |
| gh pr edit "$EXISTING_PR" \ | |
| --title "Prepare VS Code extension release v${NEW_VERSION}" \ | |
| --body-file "$WORK_DIR/pr_body.md" | |
| PR_NUMBER="$EXISTING_PR" | |
| echo "✓ Updated existing draft PR #${PR_NUMBER} for extension release v${NEW_VERSION}" | |
| else | |
| # `gh pr create` prints the created PR URL (e.g. https://github.com/microsoft/aspire/pull/123) | |
| # on its last line; take the trailing path segment as the number. | |
| PR_URL=$(gh pr create \ | |
| --base main \ | |
| --head "$BRANCH_NAME" \ | |
| --title "Prepare VS Code extension release v${NEW_VERSION}" \ | |
| --body-file "$WORK_DIR/pr_body.md" \ | |
| --draft | tail -n1) | |
| PR_NUMBER="${PR_URL##*/}" | |
| echo "✓ Created draft PR #${PR_NUMBER} for extension release v${NEW_VERSION}" | |
| fi | |
| # Apply the trigger label using the GitHub App token — not the default | |
| # GITHUB_TOKEN — which is what lets the downstream `extension-changelog` | |
| # agentic workflow fire on the `labeled` event (workflows are not | |
| # triggered by actions performed with the implicit GITHUB_TOKEN). | |
| if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then | |
| echo "::error::Could not resolve the extension release PR number to apply the trigger label (got '${PR_NUMBER}')." | |
| exit 1 | |
| fi | |
| # Ensure the label exists before adding it (`--force` is idempotent: it | |
| # updates the label if it already exists and creates it otherwise). | |
| gh label create vscode-extension-release \ | |
| --color BFD4F2 \ | |
| --description "Bot-created VS Code extension release PR; triggers the extension-changelog agentic workflow." \ | |
| --force >/dev/null 2>&1 || true | |
| # Remove then re-add the label so a fresh `labeled` event always fires, | |
| # even on a re-run where the label is already present (adding a label | |
| # that already exists on the PR is a no-op that fires no event). On a | |
| # re-run the release workflow has just force-pushed a new placeholder, | |
| # so the downstream changelog workflow must run again. The remove is a | |
| # harmless no-op (with `|| true`) when the label isn't present yet, and | |
| # the resulting `unlabeled` event is ignored by extension-changelog | |
| # (which only triggers on `labeled`). | |
| gh pr edit "$PR_NUMBER" --remove-label vscode-extension-release >/dev/null 2>&1 || true | |
| gh pr edit "$PR_NUMBER" --add-label vscode-extension-release | |
| echo "✓ Applied 'vscode-extension-release' label to PR #${PR_NUMBER}" |