diff --git a/.github/actions/vscode/check-ci-status/action.yml b/.github/actions/vscode/check-ci-status/action.yml new file mode 100644 index 0000000..6a0b3a6 --- /dev/null +++ b/.github/actions/vscode/check-ci-status/action.yml @@ -0,0 +1,100 @@ +name: Check CI Status +description: > + Verifies that CI checks passed for a given commit SHA before promotion. + Fails if any required check did not succeed. + +inputs: + commit-sha: + description: 'Commit SHA to check CI status for' + required: true + token: + description: 'GitHub token with repo read access' + required: true + required-checks: + description: > + Comma-separated list of check names that must have succeeded. + If empty, all non-skipped check-runs must have conclusion "success". + required: false + default: '' + +runs: + using: composite + steps: + - name: Verify CI checks passed + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + COMMIT_SHA: ${{ inputs.commit-sha }} + REQUIRED_CHECKS: ${{ inputs.required-checks }} + REPO: ${{ github.repository }} + run: | + echo "Checking CI status for commit $COMMIT_SHA in $REPO..." + + # Fetch all check-runs for the commit (paginate up to 100) + CHECK_RUNS=$(gh api \ + "repos/$REPO/commits/$COMMIT_SHA/check-runs" \ + --paginate \ + --jq '.check_runs[] | {name: .name, status: .status, conclusion: .conclusion}' \ + 2>&1) + + if [ -z "$CHECK_RUNS" ]; then + echo "No check-runs found for commit $COMMIT_SHA" + echo "Cannot verify CI status — failing to prevent untested promotion" + exit 1 + fi + + echo "Check-runs found:" + echo "$CHECK_RUNS" | jq -r '" \(.name): status=\(.status) conclusion=\(.conclusion)"' + + FAILED=0 + + if [ -n "$REQUIRED_CHECKS" ]; then + # Only validate the specified checks + IFS=',' read -ra CHECKS <<< "$REQUIRED_CHECKS" + for CHECK in "${CHECKS[@]}"; do + CHECK=$(echo "$CHECK" | xargs) # trim whitespace + CONCLUSION=$(echo "$CHECK_RUNS" | jq -r --arg name "$CHECK" \ + 'select(.name == $name) | .conclusion' | head -1) + if [ "$CONCLUSION" != "success" ]; then + echo "FAIL: required check '$CHECK' has conclusion '$CONCLUSION' (expected 'success')" + FAILED=1 + else + echo "PASS: required check '$CHECK' succeeded" + fi + done + else + # Validate all non-skipped check-runs + while IFS= read -r RUN; do + NAME=$(echo "$RUN" | jq -r '.name') + STATUS=$(echo "$RUN" | jq -r '.status') + CONCLUSION=$(echo "$RUN" | jq -r '.conclusion') + + # Skip queued/in-progress (treat as not-yet-run, which is a failure) + if [ "$STATUS" != "completed" ]; then + echo "FAIL: check '$NAME' is not completed (status=$STATUS)" + FAILED=1 + continue + fi + + # Allow skipped checks (neutral conclusion) + if [ "$CONCLUSION" = "skipped" ] || [ "$CONCLUSION" = "neutral" ]; then + echo "SKIP: check '$NAME' was skipped — ignoring" + continue + fi + + if [ "$CONCLUSION" != "success" ]; then + echo "FAIL: check '$NAME' has conclusion '$CONCLUSION'" + FAILED=1 + fi + done < <(echo "$CHECK_RUNS" | jq -c '.') + fi + + if [ "$FAILED" -eq 1 ]; then + echo "" + echo "CI quality gate FAILED for commit $COMMIT_SHA" + echo "Promotion blocked. Fix failing checks before retrying." + exit 1 + fi + + echo "" + echo "CI quality gate PASSED for commit $COMMIT_SHA" diff --git a/.github/actions/vscode/publish-vsix/action.yml b/.github/actions/vscode/publish-vsix/action.yml new file mode 100644 index 0000000..9ac4c80 --- /dev/null +++ b/.github/actions/vscode/publish-vsix/action.yml @@ -0,0 +1,147 @@ +name: "Publish VSIX" +description: "Publishes VSIX files to a marketplace with dry-run support" + +inputs: + vsix-path: + description: "Path to the VSIX file to publish" + required: true + publish-tool: + description: "Publishing tool to use" + required: true + pre-release: + description: "Publish as pre-release version" + required: false + default: "false" + dry-run: + description: "Run in dry-run mode" + required: false + default: "false" + +runs: + using: composite + steps: + - name: Validate inputs + shell: bash + run: | + # Validate VSIX path exists + if [ ! -f "${{ inputs.vsix-path }}" ]; then + echo "❌ Error: VSIX file not found at ${{ inputs.vsix-path }}" + exit 1 + fi + + # Validate VSIX file extension + if [[ ! "${{ inputs.vsix-path }}" =~ \.vsix$ ]]; then + echo "❌ Error: File must have .vsix extension" + exit 1 + fi + + # Validate publish tool + if [[ ! "${{ inputs.publish-tool }}" =~ ^(ovsx|vsce)$ ]]; then + echo "❌ Error: Invalid publish tool: ${{ inputs.publish-tool }}" + exit 1 + fi + + echo "✅ Input validation passed" + + - name: Audit publish attempt + shell: bash + run: | + # Create audit log entry + AUDIT_LOG="/tmp/publish_audit.log" + TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + ACTOR="${{ github.actor }}" + REPO="${{ github.repository }}" + RUN_ID="${{ github.run_id }}" + WORKFLOW="${{ github.workflow }}" + + # Get file info for audit + FILE_SIZE=$(stat -c%s "${{ inputs.vsix-path }}" 2>/dev/null || stat -f%z "${{ inputs.vsix-path }}" 2>/dev/null || echo "unknown") + FILE_HASH=$(sha256sum "${{ inputs.vsix-path }}" 2>/dev/null | cut -d' ' -f1 || echo "unknown") + + # Log audit information + echo "[$TIMESTAMP] PUBLISH_ATTEMPT: actor=$ACTOR, repo=$REPO, run_id=$RUN_ID, workflow=$WORKFLOW, tool=${{ inputs.publish-tool }}, file=${{ inputs.vsix-path }}, size=$FILE_SIZE, hash=$FILE_HASH, pre_release=${{ inputs.pre-release }}, dry_run=${{ inputs.dry-run }}" >> "$AUDIT_LOG" + + # Also log to GitHub Actions output for visibility + echo "🔍 AUDIT: Publish attempt logged - $TIMESTAMP" + echo " Actor: $ACTOR" + echo " Repository: $REPO" + echo " Run ID: $RUN_ID" + echo " Workflow: $WORKFLOW" + echo " Tool: ${{ inputs.publish-tool }}" + echo " File: ${{ inputs.vsix-path }}" + echo " Size: $FILE_SIZE bytes" + echo " Hash: $FILE_HASH" + echo " Pre-release: ${{ inputs.pre-release }}" + echo " Dry-run: ${{ inputs.dry-run }}" + + - name: Publish VSIX + shell: bash + run: | + echo "Publishing ${{ inputs.vsix-path }}" + + # Calculate marketplace name based on publish tool + if [ "${{ inputs.publish-tool }}" = "ovsx" ]; then + MARKETPLACE_NAME="Open VSX Registry" + TOKEN_ENV="OVSX_PAT" + else + MARKETPLACE_NAME="Visual Studio Marketplace" + TOKEN_ENV="VSCE_PERSONAL_ACCESS_TOKEN" + fi + + PRE_RELEASE_FLAG="" + if [ "${{ inputs.pre-release }}" = "true" ]; then + PRE_RELEASE_FLAG="--pre-release" + echo "Would publish as pre-release version" + fi + + # Mask token in logs for security + TOKEN_MASK="***" + + if [ "${{ inputs.dry-run }}" = "true" ]; then + echo "🔍 DRY RUN MODE - Would publish to $MARKETPLACE_NAME:" + echo " VSIX: ${{ inputs.vsix-path }}" + echo " Pre-release: ${{ inputs.pre-release }}" + + if [ "${{ inputs.publish-tool }}" = "ovsx" ]; then + echo " Command: npx ovsx publish \"${{ inputs.vsix-path }}\" -p $TOKEN_MASK $PRE_RELEASE_FLAG" + else + echo " Command: npx @vscode/vsce publish --packagePath \"${{ inputs.vsix-path }}\" --skip-duplicate $PRE_RELEASE_FLAG" + fi + echo "✅ Dry run completed - no actual publish performed" + else + echo "Publishing VSIX: ${{ inputs.vsix-path }}" + + # Verify token is available + if [ -z "${!TOKEN_ENV}" ]; then + echo "❌ Error: $TOKEN_ENV environment variable is not set" + exit 1 + fi + + if [ "${{ inputs.publish-tool }}" = "vsce" ]; then + export VSCE_PAT="${!TOKEN_ENV}" # ensure the expected env var is set + npx @vscode/vsce publish --packagePath "${{ inputs.vsix-path }}" --skip-duplicate $PRE_RELEASE_FLAG + else + npx ovsx publish "${{ inputs.vsix-path }}" -p "${!TOKEN_ENV}" --skip-duplicate $PRE_RELEASE_FLAG + fi + + echo "✅ Successfully published to $MARKETPLACE_NAME" + fi + + - name: Audit publish result + shell: bash + if: inputs.dry-run != 'true' + run: | + # Log the result of the publish attempt + AUDIT_LOG="/tmp/publish_audit.log" + TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + ACTOR="${{ github.actor }}" + REPO="${{ github.repository }}" + RUN_ID="${{ github.run_id }}" + + if [ $? -eq 0 ]; then + echo "[$TIMESTAMP] PUBLISH_SUCCESS: actor=$ACTOR, repo=$REPO, run_id=$RUN_ID, tool=${{ inputs.publish-tool }}, file=${{ inputs.vsix-path }}" >> "$AUDIT_LOG" + echo "✅ AUDIT: Publish successful - $TIMESTAMP" + else + echo "[$TIMESTAMP] PUBLISH_FAILURE: actor=$ACTOR, repo=$REPO, run_id=$RUN_ID, tool=${{ inputs.publish-tool }}, file=${{ inputs.vsix-path }}" >> "$AUDIT_LOG" + echo "❌ AUDIT: Publish failed - $TIMESTAMP" + fi diff --git a/.github/workflows/vscode-ci-template.yml b/.github/workflows/vscode-ci-template.yml new file mode 100644 index 0000000..0b65f48 --- /dev/null +++ b/.github/workflows/vscode-ci-template.yml @@ -0,0 +1,215 @@ +name: CI + +# Reusable CI workflow template for VS Code extension repositories +# +# Usage from consuming repository: +# jobs: +# ci: +# uses: salesforcecli/github-workflows/.github/workflows/vscode/ci-template.yml@feat/add-vscode-extension-ci +# with: +# lint-command: 'npm run lint' +# compile-command: 'npm run compile' +# test-command: 'npm run test' +# test-coverage-command: 'npm run test:coverage' +# +# Features: +# - Tests across multiple OS (Ubuntu, Windows) +# - Tests across Node.js versions (lts/-1, lts/*, current) +# - Coverage collection and reporting +# - Parallel test execution +# - Artifact upload for coverage reports + +on: + workflow_call: + inputs: + lint-command: + description: 'Command to run linting' + required: false + default: 'npm run lint' + type: string + compile-command: + description: 'Command to compile' + required: false + default: 'npm run compile' + type: string + test-command: + description: 'Command to run tests (without coverage)' + required: false + default: 'npm run test' + type: string + test-coverage-command: + description: 'Command to run tests with coverage' + required: false + default: 'npm run test:coverage' + type: string + coverage-report-command: + description: 'Command to merge coverage reports' + required: false + default: 'npm run test:coverage:report' + type: string + workflow_dispatch: + inputs: + lint-command: + description: 'Command to run linting' + required: false + default: 'npm run lint' + type: string + +# Add explicit permissions for security +permissions: + contents: read + pull-requests: read + actions: read + +jobs: + test: + name: Test + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + node-version: ['lts/-1', 'lts/*', 'current'] + fail-fast: false + + runs-on: ${{ matrix.os }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: false + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install dependencies + uses: salesforcecli/github-workflows/.github/actions/npmInstallWithRetries@feat/add-vscode-extension-ci + + - name: Run linting + run: ${{ inputs.lint-command }} + + - name: Compile project + run: ${{ inputs.compile-command }} + + - name: Run tests with coverage (lts/current) + if: ${{ matrix.node-version != 'lts/-1' }} + run: ${{ inputs.test-coverage-command }} + + - name: Run tests (lts/-1, no coverage) + if: ${{ matrix.node-version == 'lts/-1' }} + env: + # Old-LTS defaults to ~4 GB old-space, which is too low for heavy stdlib suites. + # Keep this scoped to lts/-1 and non-coverage runs only. + NODE_OPTIONS: --max-old-space-size=6144 + run: ${{ inputs.test-command }} + + - name: Merge coverage reports + if: ${{ matrix.node-version != 'lts/-1' }} + run: ${{ inputs.coverage-report-command }} + + - name: Determine Node Label + id: node-label + shell: bash + env: + NODE_VERSION: ${{ matrix.node-version }} + run: | + if [ "$NODE_VERSION" = "lts/*" ]; then + echo "value=lts" >> $GITHUB_OUTPUT + elif [ "$NODE_VERSION" = "lts/-1" ]; then + echo "value=lts-1" >> $GITHUB_OUTPUT + elif [ "$NODE_VERSION" = "current" ]; then + echo "value=current" >> $GITHUB_OUTPUT + else + echo "value=$NODE_VERSION" >> $GITHUB_OUTPUT + fi + + - name: Upload coverage report + if: ${{ matrix.node-version != 'lts/-1' }} + uses: actions/upload-artifact@v7 + with: + name: coverage-report-${{ matrix.os }}-${{ steps.node-label.outputs.value }} + path: ./coverage + + test-quality: + name: Test Quality + needs: test + strategy: + matrix: + os: [ubuntu-latest] + node-version: ['lts/*'] + fail-fast: false + + runs-on: ${{ matrix.os }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: false + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install dependencies + uses: salesforcecli/github-workflows/.github/actions/npmInstallWithRetries@feat/add-vscode-extension-ci + + - name: Run quality tests + run: npm run test:quality + + package: + name: Package + needs: test + if: ${{ needs.test.result == 'success' }} + uses: salesforcecli/github-workflows/.github/workflows/vscode-package.yml@feat/add-vscode-extension-ci + with: + branch: ${{ github.head_ref || github.ref_name }} + artifact-name: vsix-packages + dry-run: false + + ci-complete: + name: CI Complete + runs-on: ubuntu-latest + needs: [test, package] + if: always() + steps: + - name: Check all jobs result + env: + TEST_RESULT: ${{ needs.test.result }} + PACKAGE_RESULT: ${{ needs.package.result }} + run: | + if [[ "$TEST_RESULT" != "success" ]]; then + echo "Test job(s) failed" + exit 1 + fi + if [[ "$PACKAGE_RESULT" != "success" ]]; then + echo "Package job failed" + exit 1 + fi + echo "All jobs succeeded" + + slack-notify: + name: CI Failed Notification + needs: [test, package] + runs-on: ubuntu-latest + if: always() && github.event_name == 'push' && (needs.test.result == 'failure' || needs.package.result == 'failure') + steps: + - name: Notify Slack + uses: slackapi/slack-github-action@v3.0.3 + with: + payload: | + { + "text": "❌ CI Pipeline Failed", + "event": "CI workflow failed, run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", + "repo": "${{ github.repository }}", + "test_result": "${{ needs.test.result }}", + "package_result": "${{ needs.package.result }}", + "branch": "${{ github.ref_name }}", + "commit": "${{ github.sha }}" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.IDEE_MAIN_SLACK_WEBHOOK }} diff --git a/.github/workflows/vscode-manual-publish.yml b/.github/workflows/vscode-manual-publish.yml new file mode 100644 index 0000000..9c5596d --- /dev/null +++ b/.github/workflows/vscode-manual-publish.yml @@ -0,0 +1,898 @@ +name: Manual Publish VS Code Extension + +# Reusable workflow for manually publishing a VS Code extension to the marketplace. +# Supports two source paths: +# - A nightly GitHub Release (version-tag path — normal workflow) +# - A branch CI build artifact (source-run-id path — out-of-band, bypass mandatory) +# +# Usage from consuming repository: +# jobs: +# manual-publish: +# uses: salesforcecli/github-workflows/.github/workflows/vscode-manual-publish.yml@main +# with: +# extension-name: 'apex-lsp-vscode-extension' +# vsix-name-pattern: 'apex-language-server-extension-*.vsix' +# version-tag: 'apex-language-server-extension-v0.5.3-nightly.20260301' # OR source-run-id +# slot: 'pre-release' # or 'stable' +# registries: 'all' # or 'vsce' or 'ovsx' +# exclude-web-vsix: 'true' +# extensions-root: 'packages' +# dry-run: 'false' +# secrets: inherit +# +# Requirements - calling repository must have: +# - Secrets: IDEE_GH_TOKEN, VSCE_PERSONAL_ACCESS_TOKEN, IDEE_OVSX_PAT +# - Action: ./.github/actions/npm-install-with-retries +# - Action: ./.github/actions/check-ci-status +# - Action: ./.github/actions/repackage-vsix-stable +# - Action: ./.github/actions/publish-vsix +# - Environment: manual-publish-gate (with required reviewers for approval) + +on: + workflow_call: + inputs: + extension-name: + description: 'Extension name (e.g., "apex-lsp-vscode-extension")' + required: true + type: string + vsix-name-pattern: + description: 'Pattern to match VSIX files (e.g., "apex-language-server-extension-*.vsix")' + required: true + type: string + version-tag: + description: '[Tag path] Nightly git tag to publish from (e.g., apex-language-server-extension-v0.5.3-nightly.20260301). Mutually exclusive with source-run-id.' + required: false + default: '' + type: string + source-run-id: + description: '[Run path] GitHub Actions run ID whose VSIX artifact to publish. Mutually exclusive with version-tag. REQUIRES skip-quality-checks=true and confirm-bypass=BYPASS.' + required: false + default: '' + type: string + slot: + description: 'Marketplace slot to publish to (pre-release or stable)' + required: true + type: string + registries: + description: 'Registries to publish to (all, vsce, ovsx)' + required: false + default: 'all' + type: string + target-stable-version: + description: 'Optional stable version override (e.g., 0.6.1). Must be valid semver with EVEN minor. Only applies when slot is stable. Required when using source-run-id with slot stable.' + required: false + default: '' + type: string + skip-quality-checks: + description: 'Skip CI quality gate. REQUIRED when using source-run-id.' + required: false + default: 'false' + type: string + confirm-bypass: + description: 'Type BYPASS to confirm skipping CI quality gate (required when skip-quality-checks is true)' + required: false + default: '' + type: string + exclude-web-vsix: + description: 'Exclude *-web-* VSIX files' + required: false + default: 'false' + type: string + extensions-root: + description: 'Root directory for extensions (default: packages)' + required: false + default: 'packages' + type: string + vsix-artifact-name: + description: 'Name of the VSIX artifact in run artifacts (default: vsix-packages)' + required: false + default: 'vsix-packages' + type: string + required-ci-checks: + description: 'Comma-separated list of required CI check names (default: "CI Complete, Package / Package, Test Quality (ubuntu-latest, lts/*)")' + required: false + default: 'CI Complete, Package / Package, Test Quality (ubuntu-latest, lts/*)' + type: string + dry-run: + description: 'Run in dry-run mode (no actual publishing or tagging)' + required: false + default: 'false' + type: string + workflow_dispatch: + inputs: + extension-name: + description: 'Extension name (e.g., "apex-lsp-vscode-extension")' + required: true + type: string + vsix-name-pattern: + description: 'Pattern to match VSIX files (e.g., "apex-language-server-extension-*.vsix")' + required: true + type: string + version-tag: + description: '[Tag path] Nightly git tag to publish from' + required: false + default: '' + type: string + source-run-id: + description: '[Run path] GitHub Actions run ID' + required: false + default: '' + type: string + slot: + description: 'Marketplace slot (pre-release or stable)' + required: true + default: 'pre-release' + type: choice + options: + - pre-release + - stable + registries: + description: 'Registries to publish to' + required: false + default: 'all' + type: choice + options: + - all + - vsce + - ovsx + target-stable-version: + description: 'Optional stable version override' + required: false + default: '' + type: string + skip-quality-checks: + description: 'Skip CI quality gate' + required: false + default: 'false' + type: choice + options: + - 'false' + - 'true' + confirm-bypass: + description: 'Type BYPASS to confirm bypass' + required: false + default: '' + type: string + exclude-web-vsix: + description: 'Exclude *-web-* VSIX files' + required: false + default: 'false' + type: choice + options: + - 'false' + - 'true' + extensions-root: + description: 'Root directory for extensions' + required: false + default: 'packages' + type: string + dry-run: + description: 'Dry-run mode' + required: false + default: 'false' + type: choice + options: + - 'false' + - 'true' + +concurrency: + group: manual-publish + cancel-in-progress: false + +permissions: + contents: write + packages: write + actions: read + +jobs: + # ── prepare ──────────────────────────────────────────────────────────────── + # Validates all inputs, resolves source VSIX and version information, + # uploads VSIX as artifact for downstream jobs. + prepare: + runs-on: ubuntu-latest + outputs: + source-type: ${{ steps.resolve.outputs.source-type }} + prerelease-version: ${{ steps.resolve.outputs.prerelease-version }} + stable-version: ${{ steps.resolve.outputs.stable-version }} + prerelease-flag: ${{ steps.resolve.outputs.prerelease-flag }} + commit-sha: ${{ steps.resolve.outputs.commit-sha }} + ci-commit-sha: ${{ steps.ci-commit.outputs.ci-commit-sha }} + steps: + - name: Validate inputs — mutual exclusivity and bypass rules + run: | + VERSION_TAG="${{ inputs.version-tag }}" + SOURCE_RUN_ID="${{ inputs.source-run-id }}" + SKIP_QC="${{ inputs.skip-quality-checks }}" + BYPASS="${{ inputs.confirm-bypass }}" + + # Exactly one source must be provided + if [ -z "$VERSION_TAG" ] && [ -z "$SOURCE_RUN_ID" ]; then + echo "ERROR: Provide either version-tag or source-run-id." + exit 1 + fi + if [ -n "$VERSION_TAG" ] && [ -n "$SOURCE_RUN_ID" ]; then + echo "ERROR: version-tag and source-run-id are mutually exclusive." + exit 1 + fi + + # Run path requires bypass (branch CI profiles differ from main) + if [ -n "$SOURCE_RUN_ID" ]; then + if [ "$SKIP_QC" != "true" ]; then + echo "ERROR: source-run-id path requires skip-quality-checks=true (branch CI profiles differ from main)." + exit 1 + fi + fi + + # Bypass requires confirmation + if [ "$SKIP_QC" = "true" ]; then + if [ "$BYPASS" != "BYPASS" ]; then + echo "ERROR: skip-quality-checks is true but confirm-bypass is not 'BYPASS'." + exit 1 + fi + echo "WARNING: CI quality gate bypassed by ${{ github.actor }} for run ${{ github.run_id }}" + fi + + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.IDEE_GH_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22.x' + + - name: Install dependencies + uses: ./.github/actions/npm-install-with-retries + + - name: Resolve source and compute versions + id: resolve + env: + GH_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + VERSION_TAG: ${{ inputs.version-tag }} + SOURCE_RUN_ID: ${{ inputs.source-run-id }} + SLOT: ${{ inputs.slot }} + TARGET_STABLE: ${{ inputs.target-stable-version }} + VSIX_NAME_PATTERN: ${{ inputs.vsix-name-pattern }} + EXCLUDE_WEB_VSIX: ${{ inputs.exclude-web-vsix }} + EXTENSION_NAME: ${{ inputs.extension-name }} + VSIX_ARTIFACT_NAME: ${{ inputs.vsix-artifact-name }} + run: | + mkdir -p ./vsix-artifacts + + # ── Resolve VSIX and pre-release version ───────────────────────── + if [ -n "$VERSION_TAG" ]; then + echo "Source: nightly GH release tag ($VERSION_TAG)" + echo "source-type=tag" >> $GITHUB_OUTPUT + + # Get commit SHA from tag + git fetch --tags origin + COMMIT_SHA=$(git rev-list -n 1 "$VERSION_TAG" 2>/dev/null || echo "") + if [ -z "$COMMIT_SHA" ]; then + echo "ERROR: Could not resolve commit SHA for tag: $VERSION_TAG" + exit 1 + fi + echo "commit-sha=$COMMIT_SHA" >> $GITHUB_OUTPUT + + # Extract pre-release version from tag name + PRERELEASE_VERSION=$(echo "$VERSION_TAG" | grep -oP '\d+\.\d+\.\d+' | head -1) + if [ -z "$PRERELEASE_VERSION" ]; then + echo "ERROR: Could not extract semver from tag: $VERSION_TAG" + exit 1 + fi + + # Download VSIX from GH release + gh release download "$VERSION_TAG" \ + --pattern "$VSIX_NAME_PATTERN" \ + --dir ./vsix-artifacts \ + --repo "${{ github.repository }}" + + else + echo "Source: branch CI run ($SOURCE_RUN_ID)" + echo "source-type=run" >> $GITHUB_OUTPUT + + # Get commit SHA from the run + COMMIT_SHA=$(gh api "repos/${{ github.repository }}/actions/runs/$SOURCE_RUN_ID" \ + --jq '.head_sha') + if [ -z "$COMMIT_SHA" ]; then + echo "ERROR: Could not resolve head_sha for run $SOURCE_RUN_ID" + exit 1 + fi + echo "commit-sha=$COMMIT_SHA" >> $GITHUB_OUTPUT + + # Download VSIX from run artifact + gh run download "$SOURCE_RUN_ID" \ + --name "$VSIX_ARTIFACT_NAME" \ + --dir ./vsix-artifacts \ + --repo "${{ github.repository }}" 2>/dev/null || \ + gh run download "$SOURCE_RUN_ID" \ + --dir ./vsix-artifacts \ + --repo "${{ github.repository }}" + + # Find VSIX, excluding web variants if requested + if [ "$EXCLUDE_WEB_VSIX" = "true" ]; then + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" ! -name '*-web-*' | head -1) + else + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" | head -1) + fi + + if [ -z "$VSIX_FILE" ]; then + echo "ERROR: No VSIX found in run $SOURCE_RUN_ID artifacts" + exit 1 + fi + + # Extract pre-release version from the VSIX's package.json + PRERELEASE_VERSION=$(unzip -p "$VSIX_FILE" extension/package.json | \ + node -e "let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>console.log(JSON.parse(d).version));") + if [ -z "$PRERELEASE_VERSION" ]; then + echo "ERROR: Could not extract version from VSIX package.json" + exit 1 + fi + echo "Extracted version from VSIX: $PRERELEASE_VERSION" + fi + + echo "prerelease-version=$PRERELEASE_VERSION" >> $GITHUB_OUTPUT + + # Find VSIX, excluding web variants if requested + if [ "$EXCLUDE_WEB_VSIX" = "true" ]; then + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" ! -name '*-web-*' | head -1) + else + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" | head -1) + fi + + if [ -z "$VSIX_FILE" ]; then + echo "ERROR: No VSIX found under ./vsix-artifacts" + exit 1 + fi + + # Check if VSIX is packaged as pre-release (extension.vsixmanifest) + VSIX_PRERELEASE_PACKAGED=$(unzip -p "$VSIX_FILE" extension.vsixmanifest | \ + { grep -q 'Microsoft.VisualStudio.Code.PreRelease" Value="true"' && echo true || echo false; }) + echo "VSIX pre-release (extension.vsixmanifest): $VSIX_PRERELEASE_PACKAGED ($VSIX_FILE)" + + # ── Stable slot: determine and validate stable version ──────────── + if [ "$SLOT" != "stable" ]; then + echo "stable-version=" >> $GITHUB_OUTPUT + if [ "$VSIX_PRERELEASE_PACKAGED" != "true" ]; then + echo "ERROR: Pre-release marketplace slot requires a VSIX built with vsce package --pre-release." + echo "This artifact is missing Microsoft.VisualStudio.Code.PreRelease Value=\"true\" in extension.vsixmanifest." + exit 1 + fi + echo "prerelease-flag=true" >> $GITHUB_OUTPUT + exit 0 + fi + + git fetch --tags origin + + if [ -n "$TARGET_STABLE" ]; then + # User-supplied override — safeguards + + # 1. Valid semver + VALID=$(node -e "const semver = require('semver'); console.log(semver.valid('$TARGET_STABLE') ? 'yes' : 'no');") + if [ "$VALID" != "yes" ]; then + echo "ERROR: target-stable-version '$TARGET_STABLE' is not valid semver" + exit 1 + fi + + # 2. Minor must be even + MINOR=$(node -e "const semver = require('semver'); console.log(semver.minor('$TARGET_STABLE'));") + if [ $(( MINOR % 2 )) -ne 0 ]; then + echo "ERROR: target-stable-version '$TARGET_STABLE' has odd minor ($MINOR). Stable versions require even minor." + exit 1 + fi + + # 3. Must be >= auto-computed floor (no downgrade) + AUTO_STABLE=$(node -e "const semver = require('semver'); console.log(semver.inc('$PRERELEASE_VERSION', 'minor'));") + IS_GTE=$(node -e "const semver = require('semver'); console.log(semver.gte('$TARGET_STABLE', '$AUTO_STABLE') ? 'yes' : 'no');") + if [ "$IS_GTE" != "yes" ]; then + echo "ERROR: target-stable-version '$TARGET_STABLE' is below the floor '$AUTO_STABLE' for pre-release series $PRERELEASE_VERSION." + exit 1 + fi + + # 4. Not already published + EXISTING="marketplace-stable-${EXTENSION_NAME}-v${TARGET_STABLE}" + if git tag --list "$EXISTING" | grep -q .; then + echo "ERROR: Stable version '$TARGET_STABLE' already published ($EXISTING exists)." + exit 1 + fi + + STABLE_VERSION="$TARGET_STABLE" + echo "Stable version (user override): $STABLE_VERSION" + else + # Auto-compute via semver minor bump + if [ -n "$(echo "$SOURCE_RUN_ID")" ] && [ -z "$TARGET_STABLE" ] && [ "$SLOT" = "stable" ]; then + echo "ERROR: target-stable-version is required when using source-run-id with slot=stable." + exit 1 + fi + + STABLE_VERSION=$(node -e "const semver = require('semver'); console.log(semver.inc('$PRERELEASE_VERSION', 'minor'));") + if [ -z "$STABLE_VERSION" ] || [ "$STABLE_VERSION" = "null" ]; then + echo "ERROR: Could not compute stable version from $PRERELEASE_VERSION" + exit 1 + fi + + # Not already published + EXISTING="marketplace-stable-${EXTENSION_NAME}-v${STABLE_VERSION}" + if git tag --list "$EXISTING" | grep -q .; then + echo "ERROR: Stable version '$STABLE_VERSION' (auto-computed from $PRERELEASE_VERSION) already published." + exit 1 + fi + + echo "Stable version (auto-computed): $PRERELEASE_VERSION → $STABLE_VERSION" + fi + + echo "stable-version=$STABLE_VERSION" >> $GITHUB_OUTPUT + echo "prerelease-flag=false" >> $GITHUB_OUTPUT + + # Walk first-parent chain to find nearest ancestor with successful CI + - name: Resolve nearest CI-tested ancestor + id: ci-commit + if: inputs.skip-quality-checks != 'true' + env: + GH_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + REPO: ${{ github.repository }} + START_SHA: ${{ steps.resolve.outputs.commit-sha }} + run: | + MAX_DEPTH=20 + SHA="$START_SHA" + CI_SHA="" + + for i in $(seq 1 "$MAX_DEPTH"); do + CONCLUSION=$(gh api "repos/$REPO/commits/$SHA/check-runs" --paginate \ + --jq '[.check_runs[] | select(.name == "CI Complete")] | .[0].conclusion // empty' 2>/dev/null || echo "") + if [ "$CONCLUSION" = "success" ]; then + CI_SHA="$SHA" + echo "Found CI-tested ancestor at depth $((i - 1)): $CI_SHA" + break + fi + echo " $SHA: no successful 'CI Complete' (conclusion='${CONCLUSION:-none}') — walking to parent" + PARENT=$(git rev-parse "${SHA}^" 2>/dev/null || echo "") + if [ -z "$PARENT" ]; then + echo "Reached root of history without a CI-tested commit." + break + fi + SHA="$PARENT" + done + + if [ -z "$CI_SHA" ]; then + echo "ERROR: No ancestor with a successful 'CI Complete' check found within $MAX_DEPTH commits of $START_SHA." + echo "Cannot verify CI status — failing to prevent untested promotion. Use skip-quality-checks=true to bypass." + exit 1 + fi + echo "ci-commit-sha=$CI_SHA" >> "$GITHUB_OUTPUT" + + - name: Upload VSIX for downstream jobs + uses: actions/upload-artifact@v7 + with: + name: source-vsix + path: ./vsix-artifacts/*.vsix + + # ── preview ──────────────────────────────────────────────────────────────── + # Summarises every planned action — shown to the reviewer in the gate step. + preview: + needs: prepare + runs-on: ubuntu-latest + steps: + - name: Summarise planned actions + env: + SOURCE_TYPE: ${{ needs.prepare.outputs.source-type }} + PRERELEASE_VERSION: ${{ needs.prepare.outputs.prerelease-version }} + STABLE_VERSION: ${{ needs.prepare.outputs.stable-version }} + COMMIT_SHA: ${{ needs.prepare.outputs.commit-sha }} + PRERELEASE_FLAG: ${{ needs.prepare.outputs.prerelease-flag }} + EXTENSION_NAME: ${{ inputs.extension-name }} + VSIX_NAME_PATTERN: ${{ inputs.vsix-name-pattern }} + run: | + SLOT="${{ inputs.slot }}" + DRY_RUN="${{ inputs.dry-run }}" + TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + if [ "$SLOT" = "stable" ]; then + TRACKING_TAG="marketplace-stable-${EXTENSION_NAME}-v${STABLE_VERSION}" + BASE_NAME=$(echo "$VSIX_NAME_PATTERN" | sed 's/\*\.vsix$//') + GH_RELEASE_TAG="${BASE_NAME}v${STABLE_VERSION}" + VSIX_NAME="${BASE_NAME}${STABLE_VERSION}.vsix" + SLOT_LABEL="stable (pre-release=false)" + else + TRACKING_TAG="marketplace-prerelease-${EXTENSION_NAME}-v${PRERELEASE_VERSION}" + GH_RELEASE_TAG="(none — pre-release slot)" + VSIX_NAME="original nightly VSIX (no repackaging)" + SLOT_LABEL="pre-release (pre-release=true)" + fi + + if [ "$SOURCE_TYPE" = "run" ]; then + SOURCE_LABEL="Branch CI run ${{ inputs.source-run-id }}" + else + SOURCE_LABEL="Nightly GH release tag ${{ inputs.version-tag }}" + fi + + if [ "${{ inputs.registries }}" = "all" ]; then + REGISTRY_LABEL="VS Code Marketplace (vsce) + Open VSX (ovsx)" + else + REGISTRY_LABEL="${{ inputs.registries }}" + fi + + echo "╔══════════════════════════════════════════════════════════════════╗" + echo "║ MANUAL PUBLISH — PLANNED ACTIONS ║" + echo "╚══════════════════════════════════════════════════════════════════╝" + echo "" + echo " Timestamp : $TIMESTAMP" + echo " Initiated by : ${{ github.actor }}" + echo " Dry-run : $DRY_RUN" + echo "" + echo "── Source ──────────────────────────────────────────────────────────" + echo " Type : $SOURCE_LABEL" + echo " Commit SHA : $COMMIT_SHA" + echo " Pre-release ver : $PRERELEASE_VERSION" + echo "" + echo "── Publish target ──────────────────────────────────────────────────" + echo " Extension : ${{ inputs.extension-name }}" + echo " Slot : $SLOT_LABEL" + if [ "$SLOT" = "stable" ]; then + echo " Stable version : $STABLE_VERSION" + echo " VSIX (repacked) : $VSIX_NAME" + else + echo " VSIX : $VSIX_NAME" + fi + echo " Registries : $REGISTRY_LABEL" + echo "" + echo "── Side effects ─────────────────────────────────────────────────────" + echo " Tracking tag : $TRACKING_TAG → $COMMIT_SHA (pushed to origin)" + if [ "$SLOT" = "stable" ]; then + echo " GitHub release : $GH_RELEASE_TAG (created with repacked VSIX as asset)" + fi + echo "" + echo "── Quality gate ─────────────────────────────────────────────────────" + if [ "${{ inputs.skip-quality-checks }}" = "true" ]; then + echo " ⚠️ BYPASSED by ${{ github.actor }} (confirm-bypass=BYPASS)" + else + echo " ✅ Will run against commit $COMMIT_SHA" + fi + echo "" + if [ "$DRY_RUN" = "true" ]; then + echo " ℹ️ DRY-RUN MODE — no publishing, tagging, or releases will occur." + fi + echo "════════════════════════════════════════════════════════════════════" + + # ── quality-gate ─────────────────────────────────────────────────────────── + quality-gate: + needs: [prepare, preview] + if: inputs.skip-quality-checks != 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Check CI status + uses: ./.github/actions/check-ci-status + with: + commit-sha: ${{ needs.prepare.outputs.ci-commit-sha }} + token: ${{ secrets.IDEE_GH_TOKEN }} + required-checks: ${{ inputs.required-ci-checks }} + + # ── gate ─────────────────────────────────────────────────────────────────── + gate: + needs: [prepare, preview, quality-gate] + if: always() && needs.prepare.result == 'success' && needs.preview.result == 'success' && (needs.quality-gate.result == 'success' || needs.quality-gate.result == 'skipped') + runs-on: ubuntu-latest + environment: manual-publish-gate + steps: + - name: Approved — proceeding with publish + run: echo "Manual publish approved by ${{ github.actor }} at $(date -u +"%Y-%m-%dT%H:%M:%SZ")" + + # ── repackage ────────────────────────────────────────────────────────────── + repackage: + needs: [prepare, gate] + if: always() && needs.prepare.result == 'success' && needs.gate.result == 'success' + runs-on: ubuntu-latest + outputs: + vsix-path: ${{ steps.resolve-vsix.outputs.vsix-path }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22.x' + + - name: Download source VSIX + uses: actions/download-artifact@v8 + with: + name: source-vsix + path: ./vsix-artifacts + + - name: Locate source VSIX + id: source-vsix + env: + VSIX_NAME_PATTERN: ${{ inputs.vsix-name-pattern }} + EXCLUDE_WEB_VSIX: ${{ inputs.exclude-web-vsix }} + run: | + if [ "$EXCLUDE_WEB_VSIX" = "true" ]; then + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" ! -name '*-web-*' | head -1) + else + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" | head -1) + fi + + if [ -z "$VSIX_FILE" ]; then + echo "ERROR: No VSIX found in artifact" + exit 1 + fi + echo "vsix-file=$VSIX_FILE" >> $GITHUB_OUTPUT + + - name: Repackage for stable + id: repack + if: inputs.slot == 'stable' + uses: ./.github/actions/repackage-vsix-stable + with: + source-vsix-path: ${{ steps.source-vsix.outputs.vsix-file }} + prerelease-version: ${{ needs.prepare.outputs.prerelease-version }} + stable-version: ${{ needs.prepare.outputs.stable-version }} + + - name: Resolve publish-ready VSIX path + id: resolve-vsix + run: | + if [ "${{ inputs.slot }}" = "stable" ]; then + echo "vsix-path=${{ steps.repack.outputs.vsix-path }}" >> $GITHUB_OUTPUT + else + echo "vsix-path=${{ steps.source-vsix.outputs.vsix-file }}" >> $GITHUB_OUTPUT + fi + + - name: Upload publish-ready VSIX + uses: actions/upload-artifact@v7 + with: + name: publish-vsix + path: ${{ steps.resolve-vsix.outputs.vsix-path }} + + # ── publish ──────────────────────────────────────────────────────────────── + publish: + needs: [prepare, repackage] + if: always() && needs.prepare.result == 'success' && needs.repackage.result == 'success' + runs-on: ubuntu-latest + strategy: + matrix: + include: ${{ fromJson(inputs.registries == 'vsce' && '[{"registry":"vsce","publish-tool":"vsce"}]' || inputs.registries == 'ovsx' && '[{"registry":"ovsx","publish-tool":"ovsx"}]' || '[{"registry":"vsce","publish-tool":"vsce"},{"registry":"ovsx","publish-tool":"ovsx"}]') }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22.x' + + - name: Install dependencies + uses: ./.github/actions/npm-install-with-retries + + - name: Download VSIX + uses: actions/download-artifact@v8 + with: + name: publish-vsix + path: ./vsix-artifacts + + - name: Locate VSIX + env: + VSIX_NAME_PATTERN: ${{ inputs.vsix-name-pattern }} + EXCLUDE_WEB_VSIX: ${{ inputs.exclude-web-vsix }} + run: | + if [ "$EXCLUDE_WEB_VSIX" = "true" ]; then + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" ! -name '*-web-*' | head -1) + else + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" | head -1) + fi + echo "VSIX_PATH=$VSIX_FILE" >> $GITHUB_ENV + + - name: Publish to ${{ matrix.registry }} + uses: ./.github/actions/publish-vsix + with: + vsix-path: ${{ env.VSIX_PATH }} + publish-tool: ${{ matrix.publish-tool }} + pre-release: ${{ needs.prepare.outputs.prerelease-flag }} + dry-run: ${{ inputs.dry-run || 'false' }} + env: + VSCE_PERSONAL_ACCESS_TOKEN: ${{ secrets.VSCE_PERSONAL_ACCESS_TOKEN }} + OVSX_PAT: ${{ secrets.IDEE_OVSX_PAT }} + + # ── create-github-release ────────────────────────────────────────────────── + create-github-release: + needs: [prepare, repackage, publish] + if: always() && inputs.slot == 'stable' && needs.publish.result == 'success' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.IDEE_GH_TOKEN }} + + - name: Download VSIX + uses: actions/download-artifact@v8 + with: + name: publish-vsix + path: ./vsix-artifacts + + - name: Create GitHub release for stable version + env: + GH_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + STABLE_VERSION: ${{ needs.prepare.outputs.stable-version }} + PRERELEASE_VERSION: ${{ needs.prepare.outputs.prerelease-version }} + SOURCE_TYPE: ${{ needs.prepare.outputs.source-type }} + DRY_RUN: ${{ inputs.dry-run || 'false' }} + VSIX_NAME_PATTERN: ${{ inputs.vsix-name-pattern }} + EXCLUDE_WEB_VSIX: ${{ inputs.exclude-web-vsix }} + run: | + if [ "$EXCLUDE_WEB_VSIX" = "true" ]; then + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" ! -name '*-web-*' | head -1) + else + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" | head -1) + fi + + BASE_NAME=$(basename "$VSIX_FILE" .vsix | sed -E 's/-[0-9]+\.[0-9]+\.[0-9]+$//') + RELEASE_TAG="${BASE_NAME}-v${STABLE_VERSION}" + RELEASE_TITLE="${BASE_NAME} v${STABLE_VERSION} (stable)" + + if [ "$SOURCE_TYPE" = "run" ]; then + SOURCE_DESC="branch CI run ${{ inputs.source-run-id }}" + else + SOURCE_DESC="nightly tag ${{ inputs.version-tag }}" + fi + + RELEASE_NOTES="## Stable Release v${STABLE_VERSION} + + Manually promoted from pre-release v${PRERELEASE_VERSION} (source: ${SOURCE_DESC}) by ${{ github.actor }} (run ${{ github.run_id }}). + + This is a stable release. Install from the VS Code Marketplace or Open VSX Registry." + + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: Would create GitHub release $RELEASE_TAG with $VSIX_FILE" + else + if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" &>/dev/null; then + EXISTING_ASSETS=$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json assets --jq '.assets | length') + if [ "$EXISTING_ASSETS" -gt 0 ]; then + echo "⏭️ GitHub release $RELEASE_TAG already exists with assets — skipping (idempotent rerun)" + else + echo "GitHub release $RELEASE_TAG exists but has no assets — uploading VSIX" + gh release upload "$RELEASE_TAG" "$VSIX_FILE" --repo "${{ github.repository }}" + echo "VSIX uploaded to existing release: $RELEASE_TAG" + fi + else + gh release create "$RELEASE_TAG" \ + "$VSIX_FILE" \ + --title "$RELEASE_TITLE" \ + --notes "$RELEASE_NOTES" \ + --repo "${{ github.repository }}" + echo "GitHub release created: $RELEASE_TAG" + fi + fi + + # ── tag-published ────────────────────────────────────────────────────────── + tag-published: + needs: [prepare, publish, create-github-release] + if: always() && needs.publish.result == 'success' && (inputs.slot == 'pre-release' || needs.create-github-release.result == 'success') + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.IDEE_GH_TOKEN }} + + - name: Create tracking tag + env: + SLOT: ${{ inputs.slot }} + PRERELEASE_VERSION: ${{ needs.prepare.outputs.prerelease-version }} + STABLE_VERSION: ${{ needs.prepare.outputs.stable-version }} + COMMIT_SHA: ${{ needs.prepare.outputs.commit-sha }} + DRY_RUN: ${{ inputs.dry-run || 'false' }} + GH_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + EXTENSION_NAME: ${{ inputs.extension-name }} + run: | + if [ "$SLOT" = "stable" ]; then + TRACKING_TAG="marketplace-stable-${EXTENSION_NAME}-v${STABLE_VERSION}" + else + TRACKING_TAG="marketplace-prerelease-${EXTENSION_NAME}-v${PRERELEASE_VERSION}" + fi + + echo "Creating tracking tag: $TRACKING_TAG → $COMMIT_SHA" + + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: Would create and push tag $TRACKING_TAG pointing to $COMMIT_SHA" + else + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git remote set-url origin https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git + git fetch --tags origin + if git tag --list "$TRACKING_TAG" | grep -q .; then + echo "⏭️ Tracking tag $TRACKING_TAG already exists — skipping (idempotent rerun)" + else + git tag "$TRACKING_TAG" "$COMMIT_SHA" + git push origin "$TRACKING_TAG" + echo "Tracking tag pushed: $TRACKING_TAG" + fi + fi + + # ── commit-stable-version ──────────────────────────────────────────────── + commit-stable-version: + needs: [prepare, publish, create-github-release, tag-published] + if: needs.publish.result == 'success' && inputs.slot == 'stable' + runs-on: ubuntu-latest + steps: + - name: Checkout main + uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + token: ${{ secrets.IDEE_GH_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22.x' + + - name: Commit stable version bump to main + env: + STABLE_VERSION: ${{ needs.prepare.outputs.stable-version }} + DRY_RUN: ${{ inputs.dry-run || 'false' }} + GH_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + EXTENSION_NAME: ${{ inputs.extension-name }} + EXTENSIONS_ROOT: ${{ inputs.extensions-root }} + run: | + set -euo pipefail + PKG_DIR="${EXTENSIONS_ROOT}/${EXTENSION_NAME}" + CURRENT_VERSION=$(node -p "require('./$PKG_DIR/package.json').version") + echo "main version: $CURRENT_VERSION -> stable version: $STABLE_VERSION" + + if [ -z "$STABLE_VERSION" ]; then + echo "Error: stable-version is empty but slot is stable" + exit 1 + fi + + # Monotonic guard: never move main backwards or sideways + IS_GT=$(STABLE_VERSION="$STABLE_VERSION" CURRENT_VERSION="$CURRENT_VERSION" node -e ' + const parse = v => String(v).split("-")[0].split(".").map(Number); + const a = parse(process.env.STABLE_VERSION); + const b = parse(process.env.CURRENT_VERSION); + let gt = false; + for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) { gt = a[i] > b[i]; break; } + } + console.log(gt ? "yes" : "no"); + ') + if [ "$IS_GT" != "yes" ]; then + echo "Skipping commit-back: main ($CURRENT_VERSION) is already >= stable ($STABLE_VERSION)" + exit 0 + fi + + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: Would set $PKG_DIR to $STABLE_VERSION and commit to main" + exit 0 + fi + + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" + + ( cd "$PKG_DIR" && npm version "$STABLE_VERSION" --no-git-tag-version ) + + npm install --package-lock-only --ignore-scripts + + git add "$PKG_DIR/package.json" package-lock.json + if git diff --staged --quiet; then + echo "No version change to commit - skipping (idempotent rerun)" + exit 0 + fi + + git commit -m "chore: set stable version $STABLE_VERSION [skip ci]" + + echo "Pushing stable version bump to main..." + if ! git push origin HEAD:main; then + echo "Push failed, attempting fetch+rebase and retry..." + git fetch origin main + git rebase origin/main + if ! git push origin HEAD:main; then + echo "Error: Push failed after rebase. Check branch protection rules." + exit 1 + fi + fi + echo "main package.json set to $STABLE_VERSION" diff --git a/.github/workflows/vscode-package.yml b/.github/workflows/vscode-package.yml new file mode 100644 index 0000000..497f9db --- /dev/null +++ b/.github/workflows/vscode-package.yml @@ -0,0 +1,249 @@ +name: Package + +# Reusable workflow for packaging VS Code extensions into VSIX files +# +# Usage from consuming repository: +# jobs: +# package: +# uses: salesforcecli/github-workflows/.github/workflows/vscode/package.yml@feat/add-vscode-extension-ci +# with: +# branch: main +# pre-release: true + +on: + workflow_call: + inputs: + node-version: + description: 'Node.js version to use' + required: false + default: '22.x' + type: string + branch: + description: 'Branch to package from' + required: false + default: 'main' + type: string + artifact-name: + description: 'Name for the VSIX artifacts (base name or pre-calculated: vsix-packages-{run_number}-{mode})' + required: false + default: 'vsix-packages' + type: string + dry-run: + description: 'Run in dry-run mode' + required: false + default: 'false' + type: string + pre-release: + description: 'Indicates if this is a pre-release version' + required: false + default: 'false' + type: string + extensions-root: + description: 'Root directory for extensions (default: packages)' + required: false + default: 'packages' + type: string + outputs: + artifact-name: + description: 'The calculated artifact name' + value: ${{ jobs.package.outputs.artifact-name }} + workflow_dispatch: + inputs: + node-version: + description: 'Node.js version to use' + required: false + default: '22.x' + type: string + branch: + description: 'Branch to package from' + required: false + default: 'main' + type: string + dry-run: + description: 'Run in dry-run mode' + required: false + default: 'false' + type: string + pre-release: + description: 'Indicates if this is a pre-release version' + required: false + default: 'false' + type: string + +# Add explicit permissions for security +permissions: + contents: read + actions: read + +jobs: + package: + name: Package + runs-on: ubuntu-latest + outputs: + artifact-name: ${{ steps.calc-artifact-name.outputs.artifact-name }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ inputs.branch || github.head_ref || github.ref }} + + - name: Setup Node.js ${{ inputs.node-version || '22.x' }} + uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.node-version || '22.x' }} + + - name: Install dependencies + uses: salesforcecli/github-workflows/.github/actions/npmInstallWithRetries@feat/add-vscode-extension-ci + + # Universal + web-target VSIXs are defined in packages/apex-lsp-vscode-extension (Wireit: package + package-web). + - name: Package packages + env: + EXTENSIONS_ROOT: ${{ inputs.extensions-root || 'packages' }} + run: | + if [ "${{ inputs.pre-release }}" = "true" ]; then + npm run package:packages:prerelease + else + npm run package:packages + fi + + - name: Generate MD5 checksums + id: md5-checksums + env: + EXTENSIONS_ROOT: ${{ inputs.extensions-root || 'packages' }} + run: | + echo "Generating MD5 checksums for VSIX files..." + + # Universal + web-target VSIX under packages/ + VSIX_FILES=$(find "$EXTENSIONS_ROOT" -name "*.vsix" -type f) + + if [ -z "$VSIX_FILES" ]; then + echo "No VSIX files found to generate checksums for" + echo "checksums_generated=false" >> $GITHUB_OUTPUT + exit 0 + fi + + # Create checksums directory structure + CHECKSUMS_FILE="checksums.md5" + CHECKSUMS_JSON_FILE="checksums.json" + > "$CHECKSUMS_FILE" # Create/clear checksums file + > "$CHECKSUMS_JSON_FILE" # Create/clear JSON file + echo "[" > "$CHECKSUMS_JSON_FILE" + + FIRST=true + # Generate MD5 checksums for each VSIX file + while IFS= read -r vsix_file; do + if [ -f "$vsix_file" ]; then + # Generate MD5 checksum + MD5_HASH=$(md5sum "$vsix_file" | cut -d' ' -f1) + + # Get relative path for display + RELATIVE_PATH=$(echo "$vsix_file" | sed "s|^$EXTENSIONS_ROOT/||") + + # Get file size + FILE_SIZE=$(stat -c%s "$vsix_file" 2>/dev/null || stat -f%z "$vsix_file" 2>/dev/null || echo "0") + + # Create individual .md5 file alongside VSIX file + MD5_FILE="${vsix_file}.md5" + echo "$MD5_HASH $(basename "$vsix_file")" > "$MD5_FILE" + + # Add to combined checksums file + echo "$MD5_HASH $RELATIVE_PATH" >> "$CHECKSUMS_FILE" + + # Add to JSON file for workflow summary + if [ "$FIRST" = true ]; then + FIRST=false + else + echo "," >> "$CHECKSUMS_JSON_FILE" + fi + echo " {\"file\":\"$RELATIVE_PATH\",\"md5\":\"$MD5_HASH\",\"size\":\"$FILE_SIZE\"}" >> "$CHECKSUMS_JSON_FILE" + + echo "Generated MD5 for: $RELATIVE_PATH" + echo " MD5: $MD5_HASH" + echo " Size: $FILE_SIZE bytes" + fi + done <<< "$VSIX_FILES" + + echo "]" >> "$CHECKSUMS_JSON_FILE" + + # Move combined checksums files to packages root for artifact upload + mv "$CHECKSUMS_FILE" "$EXTENSIONS_ROOT/checksums.md5" + mv "$CHECKSUMS_JSON_FILE" "$EXTENSIONS_ROOT/checksums.json" + + echo "checksums_generated=true" >> $GITHUB_OUTPUT + echo "checksums_file=$EXTENSIONS_ROOT/checksums.json" >> $GITHUB_OUTPUT + + - name: Calculate artifact name + id: calc-artifact-name + run: | + BASE_NAME="${{ inputs.artifact-name }}" + RUN_NUMBER="${{ github.run_number }}" + IS_DRY_RUN="${{ inputs.dry-run }}" + + # Check if already suffixed + if [[ "$BASE_NAME" =~ -dry-run$ ]] || [[ "$BASE_NAME" =~ -release$ ]]; then + ARTIFACT_NAME="$BASE_NAME" + else + if [ "$IS_DRY_RUN" = "true" ]; then + ARTIFACT_NAME="${BASE_NAME}-${RUN_NUMBER}-dry-run" + else + ARTIFACT_NAME="${BASE_NAME}-${RUN_NUMBER}-release" + fi + fi + + echo "artifact-name=$ARTIFACT_NAME" >> $GITHUB_OUTPUT + echo "Artifact name: $ARTIFACT_NAME" + + - name: Upload VSIX artifacts + id: upload + uses: actions/upload-artifact@v7 + env: + EXTENSIONS_ROOT: ${{ inputs.extensions-root || 'packages' }} + with: + name: ${{ steps.calc-artifact-name.outputs.artifact-name }} + path: | + ${{ inputs.extensions-root || 'packages' }}/**/*.vsix + ${{ inputs.extensions-root || 'packages' }}/**/*.vsix.md5 + ${{ inputs.extensions-root || 'packages' }}/checksums.md5 + ${{ inputs.extensions-root || 'packages' }}/checksums.json + retention-days: 5 + + - name: List VSIX files + env: + EXTENSIONS_ROOT: ${{ inputs.extensions-root || 'packages' }} + run: | + echo "VSIX files created:" + find "$EXTENSIONS_ROOT" -name "*.vsix" -exec ls -la {} \; + + - name: Add MD5 checksums to workflow summary + if: steps.md5-checksums.outputs.checksums_generated == 'true' + run: | + echo "## MD5 Checksums" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "MD5 checksums for all VSIX extension files:" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Extension | MD5 Checksum | Size |" >> $GITHUB_STEP_SUMMARY + echo "|-----------|-------------|------|" >> $GITHUB_STEP_SUMMARY + + # Read checksums from JSON file and format table + CHECKSUMS_FILE="${{ steps.md5-checksums.outputs.checksums_file }}" + + if [ -f "$CHECKSUMS_FILE" ]; then + # Use node to parse JSON and format table + node -e " + const fs = require('fs'); + const checksums = JSON.parse(fs.readFileSync('$CHECKSUMS_FILE', 'utf8')); + checksums.forEach(item => { + const file = item.file || 'unknown'; + const md5 = item.md5 || 'unknown'; + const size = item.size || '0'; + const sizeFormatted = size !== '0' && size !== 'unknown' ? (parseInt(size) / 1024).toFixed(2) + ' KB' : 'unknown'; + console.log(\`|\${file} |\` + md5 + \` |\${sizeFormatted}|\`); + }); + " >> $GITHUB_STEP_SUMMARY + else + echo "| No checksums available | - | - |" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Note:** Individual \`.md5\` files are available alongside each VSIX file in the artifacts." >> $GITHUB_STEP_SUMMARY + echo "A combined \`checksums.md5\` file and \`checksums.json\` file are also included in the artifacts." >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/vscode-promote-prerelease.yml b/.github/workflows/vscode-promote-prerelease.yml new file mode 100644 index 0000000..de466ca --- /dev/null +++ b/.github/workflows/vscode-promote-prerelease.yml @@ -0,0 +1,288 @@ +name: Promote Nightly to Pre-release + +# Promotes a vetted nightly build to pre-release on VS Code Marketplace and Open VSX. +# +# Usage: +# jobs: +# promote: +# uses: salesforcecli/github-workflows/.github/workflows/vscode-promote-prerelease.yml@feat/add-vscode-extension-ci +# with: +# extension-name: 'apex-lsp-vscode-extension' +# min-tag-age-days: '7' +# vsix-name-pattern: 'apex-language-server-extension-*.vsix' # optional, default: *.vsix +# exclude-web-vsix: 'true' # optional, default: false +# dry-run: 'false' +# secrets: inherit +# +# Requirements - calling repository must have: +# - Nightly tags matching *-nightly.* (e.g., v1.2.3-nightly.20260629) +# - GitHub releases for each nightly tag with VSIX file(s) attached +# - Passing CI checks on nightly commits +# - Secrets: IDEE_GH_TOKEN, VSCE_PERSONAL_ACCESS_TOKEN, IDEE_OVSX_PAT +# +# Workflow: finds oldest unpromoted nightly ≥ min-tag-age-days → verifies CI → +# downloads VSIX → publishes to both marketplaces → creates tracking tag + +on: + workflow_call: + inputs: + min-tag-age-days: + description: "Minimum nightly age in days before eligible for promotion" + required: false + default: "7" + type: string + extension-name: + description: "Extension name for tracking tag (e.g., 'apex-lsp-vscode-extension')" + required: true + type: string + vsix-name-pattern: + description: "Pattern to match VSIX files in release (e.g., 'apex-language-server-extension-*.vsix')" + required: false + default: "*.vsix" + type: string + exclude-web-vsix: + description: "Exclude *-web-* VSIX files from promotion" + required: false + default: "false" + type: string + dry-run: + description: "Run in dry-run mode (no actual publishing or tagging)" + required: false + default: "false" + type: string + workflow_dispatch: + inputs: + min-tag-age-days: + description: "Minimum nightly age in days before eligible for promotion (default: 7)" + required: false + default: "7" + type: string + extension-name: + description: "Extension name for tracking tag (e.g., 'apex-lsp-vscode-extension')" + required: true + type: string + vsix-name-pattern: + description: "Pattern to match VSIX files in release (e.g., 'apex-language-server-extension-*.vsix')" + required: false + default: "*.vsix" + type: string + exclude-web-vsix: + description: "Exclude *-web-* VSIX files from promotion" + required: false + default: "false" + type: choice + options: + - "false" + - "true" + dry-run: + description: "Run in dry-run mode (no actual publishing or tagging)" + required: false + default: "false" + type: choice + options: + - "false" + - "true" + +concurrency: + group: promote-prerelease + cancel-in-progress: false + +permissions: + contents: write + packages: write + actions: read + +jobs: + find-nightly-candidate: + runs-on: ubuntu-latest + outputs: + commit-sha: ${{ steps.find.outputs.commit-sha }} + nightly-tag: ${{ steps.find.outputs.nightly-tag }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.IDEE_GH_TOKEN }} + + - name: Find eligible nightly + id: find + env: + MIN_TAG_AGE_DAYS: ${{ inputs.min-tag-age-days || '7' }} + run: | + echo "Finding nightly tags older than $MIN_TAG_AGE_DAYS days..." + + # Get all nightly tags sorted by date (newest first) + NIGHTLY_TAGS=$(git tag -l '*-nightly.*' --sort=-creatordate) + + if [ -z "$NIGHTLY_TAGS" ]; then + echo "No nightly tags found" + echo "nightly-tag=" >> $GITHUB_OUTPUT + echo "commit-sha=" >> $GITHUB_OUTPUT + exit 0 + fi + + # Calculate cutoff timestamp (MIN_TAG_AGE_DAYS ago) + CUTOFF_TIMESTAMP=$(date -u -d "$MIN_TAG_AGE_DAYS days ago" +%s 2>/dev/null || date -u -v-${MIN_TAG_AGE_DAYS}d +%s) + + # Find first tag older than cutoff that hasn't been promoted + SELECTED_TAG="" + SELECTED_SHA="" + + while IFS= read -r tag; do + [ -z "$tag" ] && continue + + # Get tag creation timestamp + TAG_TIMESTAMP=$(git log -1 --format=%ct "$tag" 2>/dev/null) + [ -z "$TAG_TIMESTAMP" ] && continue + + # Check if tag is old enough + if [ "$TAG_TIMESTAMP" -lt "$CUTOFF_TIMESTAMP" ]; then + # Check if already promoted (has corresponding marketplace-prerelease tag) + VERSION=$(echo "$tag" | grep -oP '\d+\.\d+\.\d+' | head -1) + PROMO_TAG="marketplace-prerelease-*-v${VERSION}" + + if ! git tag -l "$PROMO_TAG" | grep -q .; then + SELECTED_TAG="$tag" + SELECTED_SHA=$(git rev-list -n 1 "$tag") + break + fi + fi + done <<< "$NIGHTLY_TAGS" + + if [ -n "$SELECTED_TAG" ]; then + echo "Found eligible nightly: $SELECTED_TAG (SHA: $SELECTED_SHA)" + echo "nightly-tag=$SELECTED_TAG" >> $GITHUB_OUTPUT + echo "commit-sha=$SELECTED_SHA" >> $GITHUB_OUTPUT + else + echo "No eligible nightly found" + echo "nightly-tag=" >> $GITHUB_OUTPUT + echo "commit-sha=" >> $GITHUB_OUTPUT + fi + + - name: Fail if no candidate found + if: steps.find.outputs.nightly-tag == '' + run: | + echo "No eligible nightly candidate found. Nothing to promote this week." + exit 1 + + quality-gate: + needs: find-nightly-candidate + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Check CI status for candidate commit + uses: salesforcecli/github-workflows/.github/actions/vscode/check-ci-status@feat/add-vscode-extension-ci + with: + commit-sha: ${{ needs.find-nightly-candidate.outputs.commit-sha }} + token: ${{ secrets.IDEE_GH_TOKEN }} + + publish: + needs: [find-nightly-candidate, quality-gate] + runs-on: ubuntu-latest + strategy: + matrix: + include: + - registry: vsce + publish-tool: vsce + - registry: ovsx + publish-tool: ovsx + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + token: ${{ secrets.IDEE_GH_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "22.x" + + - name: Install dependencies + uses: salesforcecli/github-workflows/.github/actions/npmInstallWithRetries@feat/add-vscode-extension-ci + + - name: Download VSIX from nightly GitHub release + env: + GH_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + NIGHTLY_TAG: ${{ needs.find-nightly-candidate.outputs.nightly-tag }} + VSIX_PATTERN: ${{ inputs.vsix-name-pattern || '*.vsix' }} + EXCLUDE_WEB: ${{ inputs.exclude-web-vsix || 'false' }} + run: | + mkdir -p ./vsix-artifacts + echo "Downloading VSIX from release: $NIGHTLY_TAG" + gh release download "$NIGHTLY_TAG" \ + --pattern "*.vsix" \ + --dir ./vsix-artifacts \ + --repo "${{ github.repository }}" + + # Find VSIX matching the pattern + if [ "$EXCLUDE_WEB" = "true" ]; then + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_PATTERN" ! -name '*-web-*' | head -1) + else + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_PATTERN" | head -1) + fi + + if [ -z "$VSIX_FILE" ]; then + echo "No VSIX found matching pattern '$VSIX_PATTERN' in release $NIGHTLY_TAG" + exit 1 + fi + echo "Found VSIX: $VSIX_FILE" + echo "VSIX_PATH=$VSIX_FILE" >> $GITHUB_ENV + + - name: Publish to ${{ matrix.registry }} + uses: salesforcecli/github-workflows/.github/actions/vscode/publish-vsix@feat/add-vscode-extension-ci + with: + vsix-path: ${{ env.VSIX_PATH }} + publish-tool: ${{ matrix.publish-tool }} + pre-release: "true" + dry-run: ${{ inputs.dry-run || 'false' }} + env: + VSCE_PERSONAL_ACCESS_TOKEN: ${{ secrets.VSCE_PERSONAL_ACCESS_TOKEN }} + OVSX_PAT: ${{ secrets.IDEE_OVSX_PAT }} + + tag-promoted: + needs: [find-nightly-candidate, publish] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.IDEE_GH_TOKEN }} + + - name: Create marketplace-prerelease tracking tag + env: + NIGHTLY_TAG: ${{ needs.find-nightly-candidate.outputs.nightly-tag }} + EXTENSION_NAME: ${{ inputs.extension-name }} + DRY_RUN: ${{ inputs.dry-run || 'false' }} + GH_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + run: | + # Extract version from the nightly tag (format: ...-v-nightly.*) + VERSION=$(echo "$NIGHTLY_TAG" | grep -oP '\d+\.\d+\.\d+' | head -1) + if [ -z "$VERSION" ]; then + echo "Could not extract version from tag: $NIGHTLY_TAG" + exit 1 + fi + + TRACKING_TAG="marketplace-prerelease-${EXTENSION_NAME}-v${VERSION}" + COMMIT_SHA="${{ needs.find-nightly-candidate.outputs.commit-sha }}" + + echo "Creating tracking tag: $TRACKING_TAG → $COMMIT_SHA" + + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: Would create and push tag $TRACKING_TAG" + else + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git remote set-url origin https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git + git fetch --tags origin + if git tag --list "$TRACKING_TAG" | grep -q .; then + echo "⏭️ Tracking tag $TRACKING_TAG already exists — skipping (idempotent rerun)" + else + git tag "$TRACKING_TAG" "$COMMIT_SHA" + git push origin "$TRACKING_TAG" + echo "Tracking tag pushed: $TRACKING_TAG" + fi + fi diff --git a/.github/workflows/vscode-promote-stable.yml b/.github/workflows/vscode-promote-stable.yml new file mode 100644 index 0000000..6fb26f2 --- /dev/null +++ b/.github/workflows/vscode-promote-stable.yml @@ -0,0 +1,517 @@ +name: Promote Pre-release to Stable + +# Reusable workflow for promoting a pre-release VS Code extension to stable. +# +# Usage from consuming repository: +# jobs: +# promote: +# uses: salesforcecli/github-workflows/.github/workflows/vscode-promote-stable.yml@main +# with: +# extension-name: 'apex-lsp-vscode-extension' +# vsix-name-pattern: 'apex-language-server-extension-*.vsix' +# exclude-web-vsix: 'true' +# extensions-root: 'packages' +# dry-run: 'false' +# secrets: inherit +# +# Requirements - calling repository must have: +# - marketplace-prerelease-* tracking tags +# - Nightly tags matching v{version}-nightly.* pattern +# - GitHub releases for each nightly tag with VSIX file(s) attached +# - Passing CI checks (gate, tag-published) +# - Secrets: IDEE_GH_TOKEN, VSCE_PERSONAL_ACCESS_TOKEN, IDEE_OVSX_PAT +# - Action: ./.github/actions/repackage-vsix-stable +# - Action: ./.github/actions/publish-vsix +# - Action: ./.github/actions/npm-install-with-retries +# +# Workflow: finds latest pre-release → verifies quality gate → repackages VSIX +# → publishes to both marketplaces → creates GitHub release → tags stable +# → commits stable version back to main + +on: + workflow_call: + inputs: + extension-name: + description: 'Extension name for tracking tags (e.g., "apex-lsp-vscode-extension")' + required: true + type: string + vsix-name-pattern: + description: 'Pattern to match VSIX files in release (e.g., "apex-language-server-extension-*.vsix")' + required: false + default: '*.vsix' + type: string + exclude-web-vsix: + description: 'Exclude *-web-* VSIX files from promotion' + required: false + default: 'false' + type: string + extensions-root: + description: 'Root directory for extensions (default: packages)' + required: false + default: 'packages' + type: string + dry-run: + description: 'Run in dry-run mode (no actual publishing or tagging)' + required: false + default: 'false' + type: string + workflow_dispatch: + inputs: + extension-name: + description: 'Extension name for tracking tags (e.g., "apex-lsp-vscode-extension")' + required: true + type: string + vsix-name-pattern: + description: 'Pattern to match VSIX files in release (e.g., "apex-language-server-extension-*.vsix")' + required: false + default: '*.vsix' + type: string + exclude-web-vsix: + description: 'Exclude *-web-* VSIX files from promotion' + required: false + default: 'false' + type: choice + options: + - 'false' + - 'true' + extensions-root: + description: 'Root directory for extensions (default: packages)' + required: false + default: 'packages' + type: string + dry-run: + description: 'Run in dry-run mode (no actual publishing or tagging)' + required: false + default: 'false' + type: choice + options: + - 'false' + - 'true' + +concurrency: + group: promote-stable + cancel-in-progress: false + +permissions: + contents: write + packages: write + actions: read + +jobs: + find-prerelease-candidate: + runs-on: ubuntu-latest + outputs: + prerelease-version: ${{ steps.find.outputs.prerelease-version }} + stable-version: ${{ steps.find.outputs.stable-version }} + nightly-tag: ${{ steps.find.outputs.nightly-tag }} + commit-sha: ${{ steps.find.outputs.commit-sha }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.IDEE_GH_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22.x' + + - name: Install dependencies + uses: ./.github/actions/npm-install-with-retries + + - name: Find latest pre-release candidate and compute stable version + id: find + env: + GH_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + EXTENSION_NAME: ${{ inputs.extension-name }} + run: | + # Find the latest marketplace-prerelease-* tracking tag + git fetch --tags origin + + PRERELEASE_TAG=$(git tag --list "marketplace-prerelease-${EXTENSION_NAME}-v*" \ + --sort=-version:refname | head -1) + + if [ -z "$PRERELEASE_TAG" ]; then + echo "No marketplace-prerelease-${EXTENSION_NAME}-v* tracking tags found. Nothing to promote." + exit 1 + fi + + echo "Found pre-release tracking tag: $PRERELEASE_TAG" + + # Extract version from tag (format: marketplace-prerelease--v) + PRERELEASE_VERSION=$(echo "$PRERELEASE_TAG" | grep -oP '\d+\.\d+\.\d+' | head -1) + if [ -z "$PRERELEASE_VERSION" ]; then + echo "Could not extract version from tag: $PRERELEASE_TAG" + exit 1 + fi + + echo "Pre-release version: $PRERELEASE_VERSION" + + # Compute stable version via semver minor bump using Node.js/semver + STABLE_VERSION=$(node -e "const semver = require('semver'); console.log(semver.inc('$PRERELEASE_VERSION', 'minor'));") + if [ -z "$STABLE_VERSION" ] || [ "$STABLE_VERSION" = "null" ]; then + echo "Could not compute stable version from $PRERELEASE_VERSION" + exit 1 + fi + + echo "Computed stable version: $STABLE_VERSION" + + # Check if stable was already published (floor check) + STABLE_TRACKING_TAG="marketplace-stable-${EXTENSION_NAME}-v${STABLE_VERSION}" + if git tag --list "$STABLE_TRACKING_TAG" | grep -q .; then + echo "Stable version $STABLE_VERSION already published ($STABLE_TRACKING_TAG exists). Nothing to do." + exit 1 + fi + + # Find the nightly tag that corresponds to this pre-release version + # It was the nightly that had version $PRERELEASE_VERSION + NIGHTLY_TAG=$(git tag --list "v${PRERELEASE_VERSION}-nightly*" \ + --sort=-version:refname | head -1) + + if [ -z "$NIGHTLY_TAG" ]; then + echo "Could not find nightly tag for version $PRERELEASE_VERSION" + exit 1 + fi + + echo "Corresponding nightly tag: $NIGHTLY_TAG" + + # Get commit SHA from the nightly tag + COMMIT_SHA=$(git rev-list -n 1 "$NIGHTLY_TAG") + echo "Commit SHA: $COMMIT_SHA" + + echo "prerelease-version=$PRERELEASE_VERSION" >> $GITHUB_OUTPUT + echo "stable-version=$STABLE_VERSION" >> $GITHUB_OUTPUT + echo "nightly-tag=$NIGHTLY_TAG" >> $GITHUB_OUTPUT + echo "commit-sha=$COMMIT_SHA" >> $GITHUB_OUTPUT + + quality-gate: + needs: find-prerelease-candidate + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Check CI status for candidate commit + uses: ./.github/actions/check-ci-status + with: + commit-sha: ${{ needs.find-prerelease-candidate.outputs.commit-sha }} + token: ${{ secrets.IDEE_GH_TOKEN }} + # Validate only the publish-pipeline checks that gate the candidate's quality. + # Without this, the action validates every check-run on the commit — including + # this workflow's own `quality-gate` job, whose superseded historical runs can + # carry a stale `failure` conclusion and block all future promotions. + # `gate` is the Manual Publish aggregate gate; `tag-published` confirms the + # nightly/pre-release artifact was successfully published for this commit. + required-checks: 'gate,tag-published' + + repackage: + needs: [find-prerelease-candidate, quality-gate] + runs-on: ubuntu-latest + outputs: + stable-vsix-path: ${{ steps.repack.outputs.vsix-path }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22.x' + + - name: Download VSIX from nightly GitHub release + env: + GH_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + NIGHTLY_TAG: ${{ needs.find-prerelease-candidate.outputs.nightly-tag }} + VSIX_NAME_PATTERN: ${{ inputs.vsix-name-pattern }} + EXCLUDE_WEB_VSIX: ${{ inputs.exclude-web-vsix }} + run: | + mkdir -p ./vsix-artifacts + echo "Downloading VSIX from release: $NIGHTLY_TAG" + gh release download "$NIGHTLY_TAG" \ + --pattern "$VSIX_NAME_PATTERN" \ + --dir ./vsix-artifacts \ + --repo "${{ github.repository }}" + + # Find VSIX, excluding web variants if requested + if [ "$EXCLUDE_WEB_VSIX" = "true" ]; then + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" ! -name '*-web-*' | head -1) + else + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" | head -1) + fi + + if [ -z "$VSIX_FILE" ]; then + echo "No VSIX found in release $NIGHTLY_TAG" + exit 1 + fi + echo "VSIX_FILE=$VSIX_FILE" >> $GITHUB_ENV + + - name: Repackage VSIX with stable version + id: repack + uses: ./.github/actions/repackage-vsix-stable + with: + source-vsix-path: ${{ env.VSIX_FILE }} + prerelease-version: ${{ needs.find-prerelease-candidate.outputs.prerelease-version }} + stable-version: ${{ needs.find-prerelease-candidate.outputs.stable-version }} + + - name: Upload stable VSIX as artifact + uses: actions/upload-artifact@v7 + with: + name: stable-vsix + path: ${{ steps.repack.outputs.vsix-path }} + + publish: + needs: [find-prerelease-candidate, repackage] + runs-on: ubuntu-latest + strategy: + matrix: + include: + - registry: vsce + publish-tool: vsce + - registry: ovsx + publish-tool: ovsx + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22.x' + + - name: Install dependencies + uses: ./.github/actions/npm-install-with-retries + + - name: Download stable VSIX artifact + uses: actions/download-artifact@v8 + with: + name: stable-vsix + path: ./vsix-artifacts + + - name: Locate stable VSIX + env: + VSIX_NAME_PATTERN: ${{ inputs.vsix-name-pattern }} + EXCLUDE_WEB_VSIX: ${{ inputs.exclude-web-vsix }} + run: | + # Find VSIX, excluding web variants if requested + if [ "$EXCLUDE_WEB_VSIX" = "true" ]; then + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" ! -name '*-web-*' | head -1) + else + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" | head -1) + fi + + if [ -z "$VSIX_FILE" ]; then + echo "No stable VSIX found in artifact" + exit 1 + fi + echo "VSIX_PATH=$VSIX_FILE" >> $GITHUB_ENV + + - name: Publish to ${{ matrix.registry }} + uses: ./.github/actions/publish-vsix + with: + vsix-path: ${{ env.VSIX_PATH }} + publish-tool: ${{ matrix.publish-tool }} + pre-release: 'false' + dry-run: ${{ inputs.dry-run || 'false' }} + env: + VSCE_PERSONAL_ACCESS_TOKEN: ${{ secrets.VSCE_PERSONAL_ACCESS_TOKEN }} + OVSX_PAT: ${{ secrets.IDEE_OVSX_PAT }} + + create-github-release: + needs: [find-prerelease-candidate, repackage, publish] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.IDEE_GH_TOKEN }} + + - name: Download stable VSIX artifact + uses: actions/download-artifact@v8 + with: + name: stable-vsix + path: ./vsix-artifacts + + - name: Create GitHub release for stable version + env: + GH_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + STABLE_VERSION: ${{ needs.find-prerelease-candidate.outputs.stable-version }} + PRERELEASE_VERSION: ${{ needs.find-prerelease-candidate.outputs.prerelease-version }} + NIGHTLY_TAG: ${{ needs.find-prerelease-candidate.outputs.nightly-tag }} + DRY_RUN: ${{ inputs.dry-run || 'false' }} + VSIX_NAME_PATTERN: ${{ inputs.vsix-name-pattern }} + EXCLUDE_WEB_VSIX: ${{ inputs.exclude-web-vsix }} + run: | + # Find VSIX, excluding web variants if requested + if [ "$EXCLUDE_WEB_VSIX" = "true" ]; then + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" ! -name '*-web-*' | head -1) + else + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" | head -1) + fi + + # Extract base name for release tag (remove version and .vsix) + # e.g., apex-language-server-extension-1.2.3.vsix -> apex-language-server-extension + BASE_NAME=$(basename "$VSIX_FILE" .vsix | sed -E 's/-[0-9]+\.[0-9]+\.[0-9]+$//') + RELEASE_TAG="${BASE_NAME}-v${STABLE_VERSION}" + RELEASE_TITLE="${BASE_NAME} v${STABLE_VERSION} (stable)" + RELEASE_NOTES="## Stable Release v${STABLE_VERSION} + + Promoted from pre-release v${PRERELEASE_VERSION} (nightly: ${NIGHTLY_TAG}). + + This is a stable release. Install from the VS Code Marketplace or Open VSX Registry." + + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: Would create GitHub release $RELEASE_TAG with $VSIX_FILE" + else + # Idempotent: check if release already exists + if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" &>/dev/null; then + EXISTING_ASSETS=$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json assets --jq '.assets | length') + if [ "$EXISTING_ASSETS" -gt 0 ]; then + echo "⏭️ GitHub release $RELEASE_TAG already exists with assets — skipping (idempotent rerun)" + else + echo "GitHub release $RELEASE_TAG exists but has no assets — uploading VSIX" + gh release upload "$RELEASE_TAG" "$VSIX_FILE" --repo "${{ github.repository }}" + echo "VSIX uploaded to existing release: $RELEASE_TAG" + fi + else + gh release create "$RELEASE_TAG" \ + "$VSIX_FILE" \ + --title "$RELEASE_TITLE" \ + --notes "$RELEASE_NOTES" \ + --repo "${{ github.repository }}" + echo "GitHub release created: $RELEASE_TAG" + fi + fi + + tag-stable: + needs: [find-prerelease-candidate, create-github-release] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.IDEE_GH_TOKEN }} + + - name: Create marketplace-stable tracking tag + env: + STABLE_VERSION: ${{ needs.find-prerelease-candidate.outputs.stable-version }} + COMMIT_SHA: ${{ needs.find-prerelease-candidate.outputs.commit-sha }} + DRY_RUN: ${{ inputs.dry-run || 'false' }} + GH_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + EXTENSION_NAME: ${{ inputs.extension-name }} + run: | + TRACKING_TAG="marketplace-stable-${EXTENSION_NAME}-v${STABLE_VERSION}" + + echo "Creating tracking tag: $TRACKING_TAG → $COMMIT_SHA" + + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: Would create and push tag $TRACKING_TAG" + else + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git remote set-url origin https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git + git fetch --tags origin + if git tag --list "$TRACKING_TAG" | grep -q .; then + echo "⏭️ Tracking tag $TRACKING_TAG already exists — skipping (idempotent rerun)" + else + git tag "$TRACKING_TAG" "$COMMIT_SHA" + git push origin "$TRACKING_TAG" + echo "Tracking tag pushed: $TRACKING_TAG" + fi + fi + + # ── commit-stable-version ──────────────────────────────────────────────── + # Advance main's package.json to the published stable version so the next + # nightly derives its version from the stable line. With the odd/even minor + # scheme, an even-minor stable (e.g. 0.6.0) makes the next nightly bump to the + # next odd minor (0.7.0). Without this, main stays on the old nightly line and + # the marketplace version diverges from the repo. Idempotent and monotonic: + # only bumps when stable-version is strictly greater than main's current + # version, so reruns and out-of-order promotions never move main backwards. + commit-stable-version: + needs: [find-prerelease-candidate, create-github-release, tag-stable] + runs-on: ubuntu-latest + steps: + - name: Checkout main + uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + token: ${{ secrets.IDEE_GH_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22.x' + + - name: Commit stable version bump to main + env: + STABLE_VERSION: ${{ needs.find-prerelease-candidate.outputs.stable-version }} + DRY_RUN: ${{ inputs.dry-run || 'false' }} + GH_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + EXTENSION_NAME: ${{ inputs.extension-name }} + EXTENSIONS_ROOT: ${{ inputs.extensions-root }} + run: | + set -euo pipefail + PKG_DIR="${EXTENSIONS_ROOT}/${EXTENSION_NAME}" + CURRENT_VERSION=$(node -p "require('./$PKG_DIR/package.json').version") + echo "main version: $CURRENT_VERSION -> stable version: $STABLE_VERSION" + + # Monotonic guard: never move main backwards or sideways. + # Self-contained semver compare (this job installs no node_modules, so + # `require('semver')` is unavailable). Read versions from env to avoid + # interpolating into the script body, and compare X.Y.Z numerically. + IS_GT=$(STABLE_VERSION="$STABLE_VERSION" CURRENT_VERSION="$CURRENT_VERSION" node -e ' + const parse = v => String(v).split("-")[0].split(".").map(Number); + const a = parse(process.env.STABLE_VERSION); + const b = parse(process.env.CURRENT_VERSION); + let gt = false; + for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) { gt = a[i] > b[i]; break; } + } + console.log(gt ? "yes" : "no"); + ') + if [ "$IS_GT" != "yes" ]; then + echo "Skipping commit-back: main ($CURRENT_VERSION) is already >= stable ($STABLE_VERSION)" + exit 0 + fi + + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: Would set $PKG_DIR to $STABLE_VERSION and commit to main" + exit 0 + fi + + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" + + ( cd "$PKG_DIR" && npm version "$STABLE_VERSION" --no-git-tag-version ) + + # Keep the root lockfile's workspace entry in sync with package.json. + # --package-lock-only rewrites package-lock.json from the manifests + # without installing node_modules, so the lockfile never drifts behind + # the committed version. This commit carries [skip ci], so nothing + # downstream re-derives the lockfile — it must be correct here. + npm install --package-lock-only --ignore-scripts + + git add "$PKG_DIR/package.json" package-lock.json + if git diff --staged --quiet; then + echo "No version change to commit - skipping (idempotent rerun)" + exit 0 + fi + + git commit -m "chore: set stable version $STABLE_VERSION [skip ci]" + + echo "Pushing stable version bump to main..." + if ! git push origin HEAD:main; then + echo "Push failed, attempting fetch+rebase and retry..." + git fetch origin main + git rebase origin/main + if ! git push origin HEAD:main; then + echo "Error: Push failed after rebase. Check branch protection rules." + exit 1 + fi + fi + echo "main package.json set to $STABLE_VERSION" diff --git a/.github/workflows/vscode-publish-extensions.yml b/.github/workflows/vscode-publish-extensions.yml new file mode 100644 index 0000000..a8c074a --- /dev/null +++ b/.github/workflows/vscode-publish-extensions.yml @@ -0,0 +1,1155 @@ +name: Publish VS Code Extensions + +# Reusable workflow for building, versioning, and publishing VS Code extensions +# +# Usage from consuming repository: +# jobs: +# publish: +# uses: salesforcecli/github-workflows/.github/workflows/vscode/publish-extensions.yml@feat/add-vscode-extension-ci +# with: +# extensions: changed # or 'all' or specific extension names +# registries: all # or 'vsce' or 'ovsx' +# pre-release: true # true for nightly/pre-release, false for stable +# dry-run: false +# secrets: inherit +# +# Requirements: +# - Requires jq for JSON manipulation (pre-installed on GitHub runners) +# - Requires secrets: IDEE_GH_TOKEN, VSCE_PERSONAL_ACCESS_TOKEN, IDEE_OVSX_PAT +# +# Features: +# - Auto-detects changed extensions +# - Smart version bumping (even/odd minor for stable/pre-release) +# - Conventional commit analysis +# - GitHub release creation with VSIX artifacts +# - Marketplace publishing (can be skipped for nightly by setting registries appropriately) + +on: + workflow_call: + inputs: + branch: + description: 'Branch to release from' + required: false + default: 'main' + type: string + extensions: + description: 'Extensions to release (all, changed, or comma-separated extension names)' + required: false + default: 'changed' + type: string + registries: + description: 'Registries to publish to (all, vsce, ovsx)' + required: false + default: 'all' + type: string + available-extensions: + description: 'Available VS Code extensions' + required: false + type: string + dry-run: + description: 'Run in dry-run mode (no actual publishing)' + required: false + default: 'false' + type: string + pre-release: + description: 'Publish as pre-release version' + required: false + default: 'true' + type: string + + version-bump: + description: 'Version bump type (auto, patch, minor, major)' + required: false + default: 'auto' + type: string + extensions-root: + description: 'Root directory for extensions (default: packages)' + required: false + default: 'packages' + type: string + exclude-web-vsix: + description: 'Exclude *-web-* VSIX files from publishing and releases' + required: false + default: 'false' + type: string + +# Add explicit permissions for security +permissions: + contents: write # Needed for version bumps and releases + packages: write # Needed for publishing to registries + actions: read + +jobs: + display-release-plan: + runs-on: ubuntu-latest + if: inputs.extensions != '' + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.branch || github.ref }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22.x' + + - name: Install dependencies + uses: salesforcecli/github-workflows/.github/actions/npmInstallWithRetries@feat/add-vscode-extension-ci + + - name: Display Extension Release Plan + env: + BRANCH: ${{ inputs.branch || github.ref_name }} + BUILD_TYPE: ${{ github.event_name }} + IS_NIGHTLY: 'true' + VERSION_BUMP: ${{ inputs.version-bump }} + REGISTRIES: ${{ inputs.registries }} + PRE_RELEASE: 'true' + SELECTED_EXTENSIONS: ${{ inputs.extensions }} + EXTENSIONS_ROOT: ${{ inputs.extensions-root || 'packages' }} + run: | + echo "=== EXTENSION RELEASE PLAN ===" + echo "Branch: $BRANCH" + echo "Build type: $BUILD_TYPE" + echo "Is nightly: $IS_NIGHTLY" + echo "Version bump type: $VERSION_BUMP" + echo "Registries: $REGISTRIES" + echo "Pre-release: $PRE_RELEASE" + echo "Dry run mode: ENABLED" + echo "" + echo "Extensions to release: $SELECTED_EXTENSIONS" + echo "" + + IFS=',' read -ra extensions <<< "$SELECTED_EXTENSIONS" + for ext in "${extensions[@]}"; do + [ -z "$ext" ] && continue + + package_json="$EXTENSIONS_ROOT/$ext/package.json" + if [ ! -f "$package_json" ]; then + echo "Extension: $ext (package.json not found)" + continue + fi + + current_version=$(jq -r '.version' "$package_json") + publisher=$(jq -r '.publisher // "N/A"' "$package_json") + + echo "Extension: $ext" + echo " Current version: $current_version" + echo " Publisher: $publisher" + + if [ "$IS_NIGHTLY" = "true" ]; then + echo " Version strategy: Nightly build (odd minor + nightly timestamp)" + elif [ "$PRE_RELEASE" = "true" ]; then + echo " Version strategy: Pre-release (odd minor version)" + else + echo " Version strategy: Stable release (even minor version)" + fi + + echo " Would create GitHub release" + echo " Would package VSIX" + + case "$REGISTRIES" in + marketplace) echo " Would publish to: VS Code Marketplace only" ;; + openvsx) echo " Would publish to: Open VSX only" ;; + all) echo " Would publish to: VS Code Marketplace and Open VSX" ;; + esac + echo "" + done + + echo "=== END OF RELEASE PLAN ===" + + bump-versions: + needs: [] + runs-on: ubuntu-latest + if: inputs.extensions != '' + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + token: ${{ secrets.IDEE_GH_TOKEN }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22.x' + + - name: Install dependencies + uses: salesforcecli/github-workflows/.github/actions/npmInstallWithRetries@feat/add-vscode-extension-ci + + - name: Bump versions and tag for selected extensions + env: + VERSION_BUMP: ${{ inputs.version-bump }} + SELECTED_EXTENSIONS: ${{ inputs.extensions }} + PRE_RELEASE: ${{ inputs.pre-release || github.event.inputs.pre-release || 'false' }} + IS_NIGHTLY: 'true' + IS_PROMOTION: 'false' + BRANCH: ${{ inputs.branch || github.ref_name }} + EXTENSIONS_ROOT: ${{ inputs.extensions-root || 'packages' }} + run: | + set -e + + # Function to parse semantic version + parse_version() { + local version=$1 + echo "$version" | awk -F. '{print $1, $2, $3}' + } + + # Function to calculate new version based on strategy + calculate_new_version() { + local current=$1 + local bump_type=$2 + local is_nightly=$3 + local is_promotion=$4 + local is_prerelease=$5 + + read -r major minor patch <<< $(parse_version "$current") + + if [ "$is_nightly" = "true" ]; then + # Nightly: enforce odd minor versions + case "$bump_type" in + major) + # Breaking change: new major, start at first odd minor + echo "$((major + 1)).1.0" + ;; + minor) + # New feature: skip to next odd minor + if [ $((minor % 2)) -eq 0 ]; then + echo "$major.$((minor + 1)).0" + else + echo "$major.$((minor + 2)).0" + fi + ;; + *) + # Patch/auto: ensure odd minor then increment patch + if [ $((minor % 2)) -eq 0 ]; then + echo "$major.$((minor + 1)).0" + else + echo "$major.$minor.$((patch + 1))" + fi + ;; + esac + elif [ "$is_promotion" = "true" ]; then + # Promotion: bump from odd (nightly) to even (stable) + if [ $((minor % 2)) -eq 1 ]; then + echo "$major.$((minor + 1)).0" + else + echo >&2 "Warning: Current version has even minor, expected odd for promotion" + echo "$major.$((minor + 2)).0" + fi + else + # Regular build + case "$bump_type" in + patch) + echo "$major.$minor.$((patch + 1))" + ;; + minor) + if [ "$is_prerelease" = "true" ]; then + # Pre-release: ensure odd minor + if [ $((minor % 2)) -eq 0 ]; then + echo "$major.$((minor + 1)).0" + else + echo "$major.$((minor + 2)).0" + fi + else + # Stable: ensure even minor + if [ $((minor % 2)) -eq 1 ]; then + echo "$major.$((minor + 1)).0" + else + echo "$major.$((minor + 2)).0" + fi + fi + ;; + major) + if [ "$is_prerelease" = "true" ]; then + echo "$((major + 1)).1.0" + else + echo "$((major + 1)).0.0" + fi + ;; + auto|*) + echo "$major.$minor.$((patch + 1))" + ;; + esac + fi + } + + # Function to create git tag + create_git_tag() { + local package_name=$1 + local version=$2 + local is_prerelease=$3 + local is_nightly=$4 + + local tag_name + if [ "$is_nightly" = "true" ]; then + # Nightly format: v{version}-nightly[.branch].{date} + local nightly_date=$(date -u +%Y%m%d) + local branch_suffix="" + if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ]; then + branch_suffix=".$(echo "$BRANCH" | tr '/' '-')" + fi + tag_name="v${version}-nightly${branch_suffix}.${nightly_date}" + else + # Regular format: {package}-v{version}[-pre-release] + if [ "$is_prerelease" = "true" ]; then + tag_name="${package_name}-v${version}-pre-release" + else + tag_name="${package_name}-v${version}" + fi + fi + + # Check if tag already exists (idempotency) + if git rev-parse "$tag_name" >/dev/null 2>&1 || git ls-remote --tags origin "$tag_name" 2>/dev/null | grep -q "$tag_name"; then + echo "⏭️ Tag $tag_name already exists — skipping (idempotent rerun)" + return 0 + fi + + echo "Creating tag $tag_name..." + git tag "$tag_name" + echo "✅ Tag created: $tag_name" + } + + # Main version bump logic + echo "=== Version Bumping ===" + echo "Strategy: $VERSION_BUMP" + echo "Pre-release: $PRE_RELEASE" + echo "Is nightly: $IS_NIGHTLY" + echo "Is promotion: $IS_PROMOTION" + echo "" + + IFS=',' read -ra extensions <<< "$SELECTED_EXTENSIONS" + for ext in "${extensions[@]}"; do + ext=$(echo "$ext" | xargs) # Trim whitespace + [ -z "$ext" ] && continue + + package_json="$EXTENSIONS_ROOT/$ext/package.json" + if [ ! -f "$package_json" ]; then + echo "⚠️ Skipping $ext: package.json not found" + continue + fi + + # Read current version and package name + current_version=$(jq -r '.version' "$package_json") + package_name=$(jq -r '.name' "$package_json") + + # Calculate new version + new_version=$(calculate_new_version "$current_version" "$VERSION_BUMP" "$IS_NIGHTLY" "$IS_PROMOTION" "$PRE_RELEASE") + + echo "📦 $ext" + echo " Current: $current_version" + echo " New: $new_version" + + # Update package.json + jq --arg ver "$new_version" '.version = $ver' "$package_json" > "${package_json}.tmp" + mv "${package_json}.tmp" "$package_json" + + # Create git tag + create_git_tag "$package_name" "$new_version" "$PRE_RELEASE" "$IS_NIGHTLY" + + echo "" + done + + echo "✅ Version bumping complete" + + - name: Validate GitHub authentication + env: + GITHUB_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + run: | + # Validate that required tokens are present + if [ -z "$GITHUB_TOKEN" ]; then + echo "❌ Error: GITHUB_TOKEN is not set" + exit 1 + fi + + # Test GitHub CLI authentication + if ! gh auth status >/dev/null 2>&1; then + echo "❌ Error: GitHub CLI authentication failed" + exit 1 + fi + + echo "✅ GitHub authentication validated" + + # If the branch push succeeds but tag push fails, do NOT re-run this job. + # Manually push the missing tags: git push origin --tags + - name: Commit version bumps with tags + env: + # Ensure GitHub CLI has proper authentication + GITHUB_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + DRY_RUN: ${{ inputs.dry-run || github.event.inputs.dry-run || 'false' }} + run: | + if [ "$DRY_RUN" = "true" ]; then + echo "🔄 DRY RUN: Would commit and push version bumps..." + echo "📋 DRY RUN: Changes that would be committed:" + git status --porcelain + echo "📋 DRY RUN: Tags that would be pushed:" + git tag --list | tail -10 || echo "No tags found" + echo "✅ DRY RUN: Would commit version bumps and push tags" + else + echo "🔄 Committing version bumps..." + + # Configure git for the action + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + + # Configure git to use the PAT for authentication + git remote set-url origin https://x-access-token:${{ secrets.IDEE_GH_TOKEN }}@github.com/${{ github.repository }}.git + + # Add all changes + # Note: git add . respects .gitignore, so ignored files won't be added + # This is intentional - Wireit output files in test fixtures should remain ignored + git add . + + if git diff --staged --quiet; then + # Nothing to stage — check whether the bump was already committed to remote + # (idempotent rerun: version bumper ran, committed, pushed, then a later step failed) + git fetch origin ${{ inputs.branch || github.ref_name }} + REMOTE_MSG=$(git log -1 --format='%s' origin/${{ inputs.branch || github.ref_name }}) + if echo "$REMOTE_MSG" | grep -q "chore: bump versions for release"; then + echo "⏭️ Version bump already committed to remote — skipping commit (idempotent rerun)" + else + echo "❌ Error: No staged changes and no prior bump commit found on remote. Version bumper may have failed silently." + exit 1 + fi + else + # Create commit with version bump message + git commit -m "chore: bump versions for release [skip ci]" + + # Push version bumps — retry once with rebase on non-fast-forward + echo "Pushing version bumps to ${{ inputs.branch || github.ref_name }}..." + if ! git push origin HEAD:${{ inputs.branch || github.ref_name }}; then + echo "⚠️ Push failed, attempting fetch+rebase and retry..." + git fetch origin + git rebase origin/${{ inputs.branch || github.ref_name }} + if ! git push origin HEAD:${{ inputs.branch || github.ref_name }}; then + echo "❌ Error: Push failed after rebase. Check branch protection rules." + exit 1 + fi + fi + fi + + # Push all tags — tolerate already-existing tags (do not fail the job) + echo "Pushing tags..." + if ! git push origin --tags; then + echo "⚠️ Warning: Some tags may already exist on remote. Continuing." + fi + + echo "✅ Version bumps and tags pushed successfully" + fi + + calculate-artifact-name: + runs-on: ubuntu-latest + outputs: + artifact-name: ${{ steps.calc.outputs.artifact-name }} + steps: + - name: Calculate artifact name + id: calc + run: | + BASE_NAME="vsix-packages" + RUN_NUMBER="${{ github.run_number }}" + IS_DRY_RUN="${{ inputs.dry-run || github.event.inputs.dry-run || 'false' }}" + + # Check if already suffixed + if [[ "$BASE_NAME" =~ -dry-run$ ]] || [[ "$BASE_NAME" =~ -release$ ]]; then + ARTIFACT_NAME="$BASE_NAME" + else + if [ "$IS_DRY_RUN" = "true" ]; then + ARTIFACT_NAME="${BASE_NAME}-${RUN_NUMBER}-dry-run" + else + ARTIFACT_NAME="${BASE_NAME}-${RUN_NUMBER}-release" + fi + fi + + echo "artifact-name=$ARTIFACT_NAME" >> $GITHUB_OUTPUT + echo "Artifact name: $ARTIFACT_NAME" + + package: + needs: [bump-versions, calculate-artifact-name] + uses: salesforcecli/github-workflows/.github/workflows/vscode-package.yml@feat/add-vscode-extension-ci + with: + branch: ${{ inputs.branch || github.ref_name }} + artifact-name: ${{ needs.calculate-artifact-name.outputs.artifact-name }} + dry-run: ${{ inputs.dry-run || github.event.inputs.dry-run || 'false' }} + pre-release: ${{ inputs.pre-release || github.event.inputs.pre-release || 'false' }} + extensions-root: ${{ inputs.extensions-root || 'packages' }} + + determine-publish-matrix: + needs: [calculate-artifact-name] + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.matrix.outputs.matrix }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22.x' + + - name: Install dependencies + uses: salesforcecli/github-workflows/.github/actions/npmInstallWithRetries@feat/add-vscode-extension-ci + + - name: Determine publish matrix + id: matrix + env: + REGISTRIES: ${{ inputs.registries }} + SELECTED_EXTENSIONS: ${{ inputs.extensions }} + IS_NIGHTLY: 'true' + EXTENSIONS_ROOT: ${{ inputs.extensions-root || 'packages' }} + run: | + # Skip marketplace publishing for nightly builds + if [ "$IS_NIGHTLY" = "true" ]; then + echo "Nightly build detected - skipping marketplace publishing" + echo 'matrix=[]' >> $GITHUB_OUTPUT + else + # Parse extensions + IFS=',' read -ra extensions <<< "$SELECTED_EXTENSIONS" + + # Determine registries + if [ "$REGISTRIES" = "all" ]; then + registries=("vsce" "ovsx") + else + IFS=',' read -ra registries <<< "$REGISTRIES" + fi + + # Build matrix JSON + matrix_entries=() + for ext in "${extensions[@]}"; do + [ -z "$ext" ] && continue + + # Read package name from package.json to build VSIX pattern + package_json="$EXTENSIONS_ROOT/$ext/package.json" + if [ -f "$package_json" ]; then + package_name=$(jq -r '.name' "$package_json") + vsix_pattern="*${package_name}*-[0-9]*.vsix" + else + # Fallback to extension directory name + vsix_pattern="*${ext}*.vsix" + fi + + for registry in "${registries[@]}"; do + [ -z "$registry" ] && continue + + case "$registry" in + vsce) marketplace="VS Code Marketplace" ;; + ovsx) marketplace="Open VSX Registry" ;; + *) marketplace="$registry" ;; + esac + + entry=$(jq -n \ + --arg reg "$registry" \ + --arg pattern "$vsix_pattern" \ + --arg market "$marketplace" \ + '{registry: $reg, vsix_pattern: $pattern, marketplace: $market}') + + matrix_entries+=("$entry") + done + done + + # Combine into matrix JSON + if [ ${#matrix_entries[@]} -eq 0 ]; then + matrix_json='{"include":[]}' + else + matrix_json=$(printf '%s\n' "${matrix_entries[@]}" | jq -s '{include: .}') + fi + + echo "matrix=$matrix_json" >> $GITHUB_OUTPUT + fi + + publish-skipped-notice: + name: Publish Skipped Notice + needs: + [ + bump-versions, + package, + calculate-artifact-name, + determine-publish-matrix, + ] + runs-on: ubuntu-latest + if: needs.determine-publish-matrix.outputs.matrix == '[]' || needs.determine-publish-matrix.outputs.matrix == '' + steps: + - name: Log skipped publish + run: | + echo "ℹ️ Marketplace publishing skipped for this build type" + echo " Reason: Nightly builds only create GitHub releases, not marketplace publishes" + echo " Extensions would be published: ${{ inputs.extensions }}" + echo " Registries configured: ${{ inputs.registries }}" + + publish: + needs: + [ + bump-versions, + package, + calculate-artifact-name, + determine-publish-matrix, + ] + runs-on: ubuntu-latest + if: needs.determine-publish-matrix.outputs.matrix != '[]' && needs.determine-publish-matrix.outputs.matrix != '' + strategy: + matrix: + include: ${{ fromJson(needs.determine-publish-matrix.outputs.matrix) }} + steps: + - name: Audit release attempt + shell: bash + run: | + # Create audit log entry for release attempt + AUDIT_LOG="/tmp/release_audit.log" + TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + ACTOR="${{ github.actor }}" + REPO="${{ github.repository }}" + RUN_ID="${{ github.run_id }}" + WORKFLOW="${{ github.workflow }}" + BRANCH="${{ inputs.branch || github.ref_name }}" + + # Log audit information + echo "[$TIMESTAMP] RELEASE_ATTEMPT: actor=$ACTOR, repo=$REPO, run_id=$RUN_ID, workflow=$WORKFLOW, branch=$BRANCH, registry=${{ matrix.registry }}, marketplace=${{ matrix.marketplace }}, dry_run=${{ inputs.dry-run || github.event.inputs.dry-run || 'false' }}" >> "$AUDIT_LOG" + + # Also log to GitHub Actions output for visibility + echo "🔍 AUDIT: Release attempt logged - $TIMESTAMP" + echo " Actor: $ACTOR" + echo " Repository: $REPO" + echo " Run ID: $RUN_ID" + echo " Workflow: $WORKFLOW" + echo " Branch: $BRANCH" + echo " Registry: ${{ matrix.registry }}" + echo " Marketplace: ${{ matrix.marketplace }}" + echo " Dry-run: ${{ inputs.dry-run || github.event.inputs.dry-run || 'false' }}" + + - name: Checkout + uses: actions/checkout@v6 + with: + token: ${{ secrets.IDEE_GH_TOKEN }} + ref: ${{ inputs.branch || github.ref }} + + - name: Download VSIX artifacts + uses: actions/download-artifact@v8 + with: + name: ${{ needs.calculate-artifact-name.outputs.artifact-name }} + path: ./vsix-artifacts + + - name: List downloaded artifacts + run: | + echo "=== DEBUG: Downloaded Artifacts ===" + echo "Artifact name: ${{ needs.calculate-artifact-name.outputs.artifact-name }}" + echo "Download path: ./vsix-artifacts" + echo "" + + if [ -d "./vsix-artifacts" ]; then + echo "Directory exists. Contents:" + ls -la ./vsix-artifacts/ + echo "" + + echo "VSIX files found:" + find ./vsix-artifacts -name "*.vsix" -exec ls -la {} \; + echo "" + + echo "Total VSIX files: $(find ./vsix-artifacts -name "*.vsix" | wc -l)" + else + echo "❌ Directory ./vsix-artifacts does not exist!" + fi + echo "=== END DEBUG ===" + + - name: Find VSIX file for publishing + id: find_vsix + env: + EXCLUDE_WEB: ${{ inputs.exclude-web-vsix || 'false' }} + run: | + ARTIFACTS_DIR="./vsix-artifacts" + VSIX_PATTERN="${{ matrix.vsix_pattern }}" + + # Find VSIX, optionally excluding *-web-* files + if [ "$EXCLUDE_WEB" = "true" ]; then + VSIX_FILE=$(find "$ARTIFACTS_DIR" -type f -name "$VSIX_PATTERN" ! -name '*-web-*' | head -1) + else + VSIX_FILE=$(find "$ARTIFACTS_DIR" -name "$VSIX_PATTERN" | head -1) + fi + + if [ -z "$VSIX_FILE" ]; then + echo "❌ No VSIX file found matching pattern: $VSIX_PATTERN" + echo "Searching in: $ARTIFACTS_DIR" + echo "Available files:" + find "$ARTIFACTS_DIR" -name "*.vsix" -exec ls -la {} \; + exit 1 + fi + + echo "vsix_file=$VSIX_FILE" >> $GITHUB_OUTPUT + echo "Found VSIX file: $VSIX_FILE" + + - name: Publish to ${{ matrix.marketplace }} + uses: salesforcecli/github-workflows/.github/actions/vscode/publish-vsix@feat/add-vscode-extension-ci + env: + # Pass tokens as environment variables for better security + VSCE_PERSONAL_ACCESS_TOKEN: ${{ matrix.registry == 'vsce' && secrets.VSCE_PERSONAL_ACCESS_TOKEN || '' }} + OVSX_PAT: ${{ matrix.registry == 'ovsx' && secrets.IDEE_OVSX_PAT || '' }} + with: + vsix-path: ${{ steps.find_vsix.outputs.vsix_file }} + publish-tool: ${{ matrix.registry }} + pre-release: ${{ inputs.pre-release || github.event.inputs.pre-release || 'false' }} + dry-run: ${{ inputs.dry-run || github.event.inputs.dry-run || 'false' }} + + - name: Audit release result + shell: bash + if: inputs.dry-run != 'true' && github.event.inputs.dry-run != 'true' + run: | + # Log the result of the release attempt + AUDIT_LOG="/tmp/release_audit.log" + TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + ACTOR="${{ github.actor }}" + REPO="${{ github.repository }}" + RUN_ID="${{ github.run_id }}" + BRANCH="${{ inputs.branch || github.ref_name }}" + + if [ $? -eq 0 ]; then + echo "[$TIMESTAMP] RELEASE_SUCCESS: actor=$ACTOR, repo=$REPO, run_id=$RUN_ID, branch=$BRANCH, registry=${{ matrix.registry }}, marketplace=${{ matrix.marketplace }}" >> "$AUDIT_LOG" + echo "✅ AUDIT: Release successful - $TIMESTAMP" + else + echo "[$TIMESTAMP] RELEASE_FAILURE: actor=$ACTOR, repo=$REPO, run_id=$RUN_ID, branch=$BRANCH, registry=${{ matrix.registry }}, marketplace=${{ matrix.marketplace }}" >> "$AUDIT_LOG" + echo "❌ AUDIT: Release failed - $TIMESTAMP" + fi + + create-github-releases: + name: Create GitHub Releases + needs: [package, calculate-artifact-name] + runs-on: ubuntu-latest + if: needs.package.result == 'success' && inputs.extensions != '' && github.event_name != 'pull_request' + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.branch || github.ref }} + token: ${{ secrets.IDEE_GH_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22.x' + + - name: Install dependencies + uses: salesforcecli/github-workflows/.github/actions/npmInstallWithRetries@feat/add-vscode-extension-ci + + - name: Download VSIX artifacts + uses: actions/download-artifact@v8 + with: + name: ${{ needs.calculate-artifact-name.outputs.artifact-name }} + path: ./vsix-artifacts + + - name: Create GitHub releases + env: + GITHUB_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + SELECTED_EXTENSIONS: ${{ inputs.extensions }} + IS_NIGHTLY: 'true' + PRE_RELEASE: 'true' + VERSION_BUMP: ${{ inputs.version-bump }} + DRY_RUN: ${{ inputs.dry-run || github.event.inputs.dry-run || 'false' }} + BRANCH: ${{ inputs.branch || github.ref_name }} + VSIX_ARTIFACTS_PATH: ./vsix-artifacts + EXTENSIONS_ROOT: ${{ inputs.extensions-root || 'packages' }} + EXCLUDE_WEB: ${{ inputs.exclude-web-vsix || 'false' }} + run: | + echo "Mode: $([ "$DRY_RUN" = "true" ] && echo "DRY RUN" || echo "LIVE")" + echo "Creating GitHub releases..." + + IFS=',' read -ra extensions <<< "$SELECTED_EXTENSIONS" + + for ext in "${extensions[@]}"; do + [ -z "$ext" ] && continue + + package_json="$EXTENSIONS_ROOT/$ext/package.json" + if [ ! -f "$package_json" ]; then + echo "⚠️ Skipping $ext: package.json not found" + continue + fi + + current_version=$(jq -r '.version' "$package_json") + package_name=$(jq -r '.name' "$package_json") + echo "Processing extension: $ext (package: $package_name, version: $current_version)" + + # Find VSIX files using the package name from package.json + vsix_pattern="*${package_name}*.vsix" + + vsix_files=("$VSIX_ARTIFACTS_PATH"/$ext/$vsix_pattern) + if [ ! -f "${vsix_files[0]}" ]; then + echo "⚠️ No VSIX files found for $ext" + continue + fi + + # Optionally filter out *-web-* builds + if [ "${EXCLUDE_WEB:-false}" = "true" ]; then + filtered_files=() + for file in "${vsix_files[@]}"; do + if [[ ! "$(basename "$file")" =~ -web- ]]; then + filtered_files+=("$file") + fi + done + else + filtered_files=("${vsix_files[@]}") + fi + + # Create release tag + release_tag="v$current_version" + release_title="$ext v$current_version" + + if [ "$IS_NIGHTLY" = "true" ]; then + nightly_date=$(date -u +%Y%m%d) + branch_suffix="" + [ "$BRANCH" != "main" ] && branch_suffix=".${BRANCH//\//-}" + release_tag="v${current_version}-nightly${branch_suffix}.${nightly_date}" + release_title="$ext v$current_version (Nightly $BRANCH $nightly_date)" + fi + + if [ "$DRY_RUN" = "true" ]; then + echo "✅ DRY RUN: Would create release $release_tag with ${#filtered_files[@]} VSIX files" + else + # Check if release exists + if gh release view "$release_tag" --repo "$GITHUB_REPOSITORY" > /dev/null 2>&1; then + echo "⏭️ Release $release_tag already exists - skipping" + else + # Generate release notes + notes_file=".release-notes-$(date +%s).tmp" + cat > "$notes_file" << EOF + ## $ext v$current_version + + ### Installation + Download the VSIX file and install via VS Code. + + $([ "$PRE_RELEASE" = "true" ] && echo "⚠️ **This is a pre-release version**") + $([ "$IS_NIGHTLY" = "true" ] && echo "🌙 **Nightly build from $(date -u +%Y%m%d)**") + EOF + + # Create release + gh release create "$release_tag" \ + --title "$release_title" \ + --notes-file "$notes_file" \ + --prerelease="$PRE_RELEASE" \ + --repo "$GITHUB_REPOSITORY" \ + "${filtered_files[@]}" + + rm -f "$notes_file" + echo "✅ Release created for $ext" + fi + fi + done + + echo "$([ "$DRY_RUN" = "true" ] && echo "✅ DRY RUN complete" || echo "✅ Releases created")" + + publish-to-cbweb-marketplace: + name: Publish to CBWeb Internal Marketplace + needs: [package, create-github-releases, calculate-artifact-name] + runs-on: ubuntu-latest + continue-on-error: true + if: needs.package.result == 'success' + steps: + - name: Download VSIX artifacts + uses: actions/download-artifact@v8 + with: + name: ${{ needs.calculate-artifact-name.outputs.artifact-name }} + path: ./vsix-artifacts + + - name: Find web-target VSIX for CBWeb + id: find-web-vsix + run: | + VSIX_FILE=$(find ./vsix-artifacts -type f -name "*-web-*.vsix" | head -1) + if [ -z "$VSIX_FILE" ]; then + echo "ℹ️ No web-target VSIX found in artifacts" + echo " This is expected for repos without web extensions" + echo " Skipping CBWeb marketplace publish" + echo "has_web_vsix=false" >> $GITHUB_OUTPUT + exit 0 + fi + + FILE_SIZE=$(stat -c%s "$VSIX_FILE" 2>/dev/null || stat -f%z "$VSIX_FILE" 2>/dev/null || echo "unknown") + echo "Found web VSIX: $VSIX_FILE (${FILE_SIZE} bytes)" + echo "vsix_file=$VSIX_FILE" >> $GITHUB_OUTPUT + echo "has_web_vsix=true" >> $GITHUB_OUTPUT + + - name: Publish web VSIX to CBWeb internal marketplace + if: steps.find-web-vsix.outputs.has_web_vsix == 'true' && inputs.dry-run != 'true' && github.event.inputs.dry-run != 'true' + run: | + echo "Publishing $VSIX_FILE to CBWeb marketplace..." + + HTTP_CODE=$(curl -s -o response.json -w '%{http_code}' \ + --retry 2 --retry-delay 5 \ + -X POST "${MARKETPLACE_URL}/api/internal/publish" \ + -H "Authorization: Bearer ${MARKETPLACE_DEPLOY_TOKEN}" \ + -F "vsix=@${VSIX_FILE}") + + echo "HTTP response code: $HTTP_CODE" + cat response.json + + if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then + echo "Successfully published to CBWeb marketplace" + else + echo "::warning::Failed to publish to CBWeb marketplace (HTTP $HTTP_CODE)" + exit 1 + fi + env: + VSIX_FILE: ${{ steps.find-web-vsix.outputs.vsix_file }} + MARKETPLACE_URL: ${{ vars.MARKETPLACE_URL }} + MARKETPLACE_DEPLOY_TOKEN: ${{ secrets.MARKETPLACE_DEPLOY_TOKEN }} + + - name: Dry-run summary + if: steps.find-web-vsix.outputs.has_web_vsix == 'true' && (inputs.dry-run == 'true' || github.event.inputs.dry-run == 'true') + run: | + echo "🔄 DRY RUN: Would publish ${{ steps.find-web-vsix.outputs.vsix_file }} to CBWeb marketplace" + + slack-notify: + name: Slack Notification + needs: + [bump-versions, package, publish, publish-skipped-notice] + runs-on: ubuntu-latest + if: always() && (needs.publish.result == 'success' || needs.publish.result == 'skipped') + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.branch || github.ref }} + + - name: Get Extension Details + id: extension-details + env: + EXTENSIONS_ROOT: ${{ inputs.extensions-root || 'packages' }} + run: | + # Get selected extensions and their details + SELECTED_EXTENSIONS="${{ inputs.extensions }}" + VERSION_BUMP="${{ inputs.version-bump }}" + PRE_RELEASE="true" + + # Initialize arrays for extension details + EXTENSION_NAMES="" + EXTENSION_VERSIONS="" + EXTENSION_DISPLAY_NAMES="" + + IFS=',' read -ra EXTENSIONS <<< "$SELECTED_EXTENSIONS" + for ext in "${EXTENSIONS[@]}"; do + if [ -n "$ext" ] && [ -f "$EXTENSIONS_ROOT/$ext/package.json" ]; then + # Get package details + PACKAGE_NAME=$(node -p "require('./$EXTENSIONS_ROOT/$ext/package.json').name") + PACKAGE_VERSION=$(node -p "require('./$EXTENSIONS_ROOT/$ext/package.json').version") + DISPLAY_NAME=$(node -p "require('./$EXTENSIONS_ROOT/$ext/package.json').displayName || require('./$EXTENSIONS_ROOT/$ext/package.json').name") + + # Add to arrays + if [ -z "$EXTENSION_NAMES" ]; then + EXTENSION_NAMES="$PACKAGE_NAME" + EXTENSION_VERSIONS="$PACKAGE_VERSION" + EXTENSION_DISPLAY_NAMES="$DISPLAY_NAME" + else + EXTENSION_NAMES="$EXTENSION_NAMES, $PACKAGE_NAME" + EXTENSION_VERSIONS="$EXTENSION_VERSIONS, $PACKAGE_VERSION" + EXTENSION_DISPLAY_NAMES="$EXTENSION_DISPLAY_NAMES, $DISPLAY_NAME" + fi + fi + done + + echo "extension_names=$EXTENSION_NAMES" >> $GITHUB_OUTPUT + echo "extension_versions=$EXTENSION_VERSIONS" >> $GITHUB_OUTPUT + echo "extension_display_names=$EXTENSION_DISPLAY_NAMES" >> $GITHUB_OUTPUT + echo "version_bump=$VERSION_BUMP" >> $GITHUB_OUTPUT + echo "pre_release=$PRE_RELEASE" >> $GITHUB_OUTPUT + + - name: Publish skipped summary + if: needs.publish.result == 'skipped' + run: | + echo "ℹ️ Slack notification skipped - marketplace publishing was skipped" + echo " Reason: Nightly builds only create GitHub releases" + echo " Extensions: ${{ steps.extension-details.outputs.extension_display_names }}" + echo " Versions: ${{ steps.extension-details.outputs.extension_versions }}" + + - name: Dry-run Slack summary + if: (inputs.dry-run == 'true' || github.event.inputs.dry-run == 'true') && needs.publish.result != 'skipped' + run: | + echo "🔄 DRY RUN: Would send Slack notification" + echo " Extensions: ${{ steps.extension-details.outputs.extension_display_names }}" + echo " Versions: ${{ steps.extension-details.outputs.extension_versions }}" + echo " Release Type: ${{ steps.extension-details.outputs.pre_release == 'true' && 'Pre-release' || 'Stable' }}" + + - name: Notify Slack + if: inputs.dry-run != 'true' && github.event.inputs.dry-run != 'true' && needs.publish.result == 'success' + uses: slackapi/slack-github-action@v3.0.3 + with: + payload: | + { + "text": "🎉 Apex Language Support Extensions Released Successfully!", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "🎉 Apex Language Support Extensions Released Successfully!" + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Repository:*\n${{ github.repository }}" + }, + { + "type": "mrkdwn", + "text": "*Branch:*\n${{ inputs.branch || github.ref_name }}" + }, + { + "type": "mrkdwn", + "text": "*Extensions:*\n${{ steps.extension-details.outputs.extension_display_names }}" + }, + { + "type": "mrkdwn", + "text": "*Versions:*\n${{ steps.extension-details.outputs.extension_versions }}" + }, + { + "type": "mrkdwn", + "text": "*Release Type:*\n${{ steps.extension-details.outputs.pre_release == 'true' && 'Pre-release' || 'Stable' }}" + }, + { + "type": "mrkdwn", + "text": "*Version Bump:*\n${{ steps.extension-details.outputs.version_bump }}" + } + ] + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Workflow Run:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Details>" + } + } + ] + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.IDEE_MAIN_SLACK_WEBHOOK }} + + slack-notify-failure: + name: Slack Failure Notification + needs: + [bump-versions, package, publish, publish-skipped-notice] + runs-on: ubuntu-latest + if: always() && (needs.publish.result == 'failure' || needs.bump-versions.result == 'failure' || needs.package.result == 'failure') + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.branch || github.ref }} + + - name: Get Extension Details + id: extension-details + env: + EXTENSIONS_ROOT: ${{ inputs.extensions-root || 'packages' }} + run: | + # Get selected extensions and their details + SELECTED_EXTENSIONS="${{ inputs.extensions }}" + VERSION_BUMP="${{ inputs.version-bump }}" + PRE_RELEASE="true" + + # Initialize arrays for extension details + EXTENSION_NAMES="" + EXTENSION_VERSIONS="" + EXTENSION_DISPLAY_NAMES="" + + IFS=',' read -ra EXTENSIONS <<< "$SELECTED_EXTENSIONS" + for ext in "${EXTENSIONS[@]}"; do + if [ -n "$ext" ] && [ -f "$EXTENSIONS_ROOT/$ext/package.json" ]; then + # Get package details + PACKAGE_NAME=$(node -p "require('./$EXTENSIONS_ROOT/$ext/package.json').name") + PACKAGE_VERSION=$(node -p "require('./$EXTENSIONS_ROOT/$ext/package.json').version") + DISPLAY_NAME=$(node -p "require('./$EXTENSIONS_ROOT/$ext/package.json').displayName || require('./$EXTENSIONS_ROOT/$ext/package.json').name") + + # Add to arrays + if [ -z "$EXTENSION_NAMES" ]; then + EXTENSION_NAMES="$PACKAGE_NAME" + EXTENSION_VERSIONS="$PACKAGE_VERSION" + EXTENSION_DISPLAY_NAMES="$DISPLAY_NAME" + else + EXTENSION_NAMES="$EXTENSION_NAMES, $PACKAGE_NAME" + EXTENSION_VERSIONS="$EXTENSION_VERSIONS, $PACKAGE_VERSION" + EXTENSION_DISPLAY_NAMES="$EXTENSION_DISPLAY_NAMES, $DISPLAY_NAME" + fi + fi + done + + echo "extension_names=$EXTENSION_NAMES" >> $GITHUB_OUTPUT + echo "extension_versions=$EXTENSION_VERSIONS" >> $GITHUB_OUTPUT + echo "extension_display_names=$EXTENSION_DISPLAY_NAMES" >> $GITHUB_OUTPUT + echo "version_bump=$VERSION_BUMP" >> $GITHUB_OUTPUT + echo "pre_release=$PRE_RELEASE" >> $GITHUB_OUTPUT + + - name: No failure to report + if: needs.publish.result == 'skipped' && needs.bump-versions.result != 'failure' && needs.package.result != 'failure' + run: | + echo "ℹ️ No failures to report" + echo " Publish was skipped (nightly builds don't publish to marketplace)" + echo " All other jobs succeeded" + + - name: Dry-run failure summary + if: (inputs.dry-run == 'true' || github.event.inputs.dry-run == 'true') && (needs.publish.result == 'failure' || needs.bump-versions.result == 'failure' || needs.package.result == 'failure') + run: | + echo "🔄 DRY RUN: Would send Slack failure notification" + echo " Extensions: ${{ steps.extension-details.outputs.extension_display_names }}" + echo " Failed jobs: publish=${{ needs.publish.result }}, bump-versions=${{ needs.bump-versions.result }}, package=${{ needs.package.result }}" + echo " Note: Failure occurred in dry-run mode" + + - name: Notify Slack + if: inputs.dry-run != 'true' && github.event.inputs.dry-run != 'true' && (needs.publish.result == 'failure' || needs.bump-versions.result == 'failure' || needs.package.result == 'failure') + uses: slackapi/slack-github-action@v3.0.3 + with: + payload: | + { + "text": "❌ VS Code Extension Release Failed!", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "❌ VS Code Extension Release Failed!" + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Repository:*\n${{ github.repository }}" + }, + { + "type": "mrkdwn", + "text": "*Branch:*\n${{ inputs.branch || github.ref_name }}" + }, + { + "type": "mrkdwn", + "text": "*Extensions:*\n${{ steps.extension-details.outputs.extension_display_names }}" + }, + { + "type": "mrkdwn", + "text": "*Versions:*\n${{ steps.extension-details.outputs.extension_versions }}" + }, + { + "type": "mrkdwn", + "text": "*Release Type:*\n${{ steps.extension-details.outputs.pre_release == 'true' && 'Pre-release' || 'Stable' }}" + }, + { + "type": "mrkdwn", + "text": "*Version Bump:*\n${{ steps.extension-details.outputs.version_bump }}" + } + ] + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Workflow Run:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Details>" + } + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "Please check the workflow logs for detailed error information." + } + ] + } + ] + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.IDEE_MAIN_SLACK_WEBHOOK }} diff --git a/.github/workflows/vscode-release-explicit.yml b/.github/workflows/vscode-release-explicit.yml new file mode 100644 index 0000000..ede1139 --- /dev/null +++ b/.github/workflows/vscode-release-explicit.yml @@ -0,0 +1,132 @@ +name: VS Code Extension Release (Explicit List) + +on: + workflow_call: + inputs: + extensions: + description: 'JSON array of extension paths (e.g., ["packages/ext1", "packages/ext2"])' + required: true + type: string + registries: + description: 'Where to publish: marketplace | openvsx | all' + required: false + type: string + default: 'all' + pre-release: + description: 'Mark as pre-release' + required: false + type: boolean + default: true + version-bump: + description: 'Version bump strategy: auto | major | minor | patch' + required: false + type: string + default: 'auto' + dry-run: + description: 'Skip actual publishing (for testing)' + required: false + type: boolean + default: false + package-command: + description: 'Command to build VSIX packages (e.g., "npm run vscode:package" or "vsce package")' + required: false + type: string + default: 'vsce package' + bundle-command: + description: 'Command to bundle extension code (e.g., "npm run vscode:bundle"). Set to empty string to skip bundling.' + required: false + type: string + default: 'npm run vscode:bundle' + secrets: + VSCE_PAT: + description: 'VS Code Marketplace Personal Access Token' + required: false + OVSX_PAT: + description: 'Open VSX Personal Access Token' + required: false + +jobs: + build-and-publish: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + extension: ${{ fromJson(inputs.extensions) }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.x' + + - name: Install dependencies + run: npm ci + + - name: Bundle extension + if: inputs.bundle-command != '' + run: | + cd ${{ matrix.extension }} + # Check if the bundle script exists in package.json + if npm run | grep -q "vscode:bundle"; then + ${{ inputs.bundle-command }} + else + echo "⏭️ Skipping bundle step - vscode:bundle script not found" + fi + + - name: Build extension + run: | + cd ${{ matrix.extension }} + ${{ inputs.package-command }} + + - name: Publish (dry-run) + if: inputs.dry-run + run: | + echo "🔍 DRY RUN: Would publish ${{ matrix.extension }}" + echo " Registry: ${{ inputs.registries }}" + echo " Pre-release: ${{ inputs.pre-release }}" + + - name: Publish to VS Code Marketplace + if: | + !inputs.dry-run && + (inputs.registries == 'marketplace' || inputs.registries == 'all') + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + run: | + cd ${{ matrix.extension }} + npx vsce publish ${{ inputs.pre-release && '--pre-release' || '' }} + + - name: Publish to Open VSX + if: | + !inputs.dry-run && + (inputs.registries == 'openvsx' || inputs.registries == 'all') + env: + OVSX_PAT: ${{ secrets.OVSX_PAT }} + run: | + cd ${{ matrix.extension }} + npx ovsx publish ${{ inputs.pre-release && '--pre-release' || '' }} -p $OVSX_PAT + + - name: Prepare artifact name + id: artifact + run: echo "name=$(echo '${{ matrix.extension }}' | tr '/' '-')-vsix" >> $GITHUB_OUTPUT + + - name: Upload VSIX artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.artifact.outputs.name }} + path: ${{ matrix.extension }}/*.vsix + + summary: + needs: build-and-publish + runs-on: ubuntu-latest + if: always() + steps: + - name: Summary + run: | + echo "## Release Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Extensions:** ${{ inputs.extensions }}" >> $GITHUB_STEP_SUMMARY + echo "**Registries:** ${{ inputs.registries }}" >> $GITHUB_STEP_SUMMARY + echo "**Pre-release:** ${{ inputs.pre-release }}" >> $GITHUB_STEP_SUMMARY + echo "**Dry-run:** ${{ inputs.dry-run }}" >> $GITHUB_STEP_SUMMARY diff --git a/README.md b/README.md index bdee275..ba5839a 100644 --- a/README.md +++ b/README.md @@ -387,3 +387,102 @@ jobs: PULL_REQUEST_URL: ${{ github.event.pull_request.html_url }} uses: salesforcecli/github-workflows/.github/actions/prNotification@main ``` + +## VS Code Extension Workflows + +This repository includes reusable workflows for VS Code extension CI/CD. + +### vscode-release-explicit + +Builds and publishes VS Code extensions from explicitly declared extension paths. + +**Usage:** + +```yaml +name: Nightly Release + +on: + schedule: + - cron: '0 4 * * *' + workflow_dispatch: + +jobs: + nightly: + uses: salesforcecli/github-workflows/.github/workflows/vscode-release-explicit.yml@main + with: + extensions: '["packages/ext1", "packages/ext2"]' # JSON array of paths + registries: all # all | marketplace | openvsx + pre-release: true + version-bump: auto # auto | major | minor | patch + package-command: 'npx vsce package --no-dependencies' + dry-run: false + secrets: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + OVSX_PAT: ${{ secrets.OVSX_PAT }} +``` + +**Inputs:** +- `extensions` (required) - JSON array of extension directory paths +- `registries` (optional) - Where to publish: `all`, `marketplace`, or `openvsx` (default: `all`) +- `pre-release` (optional) - Mark as pre-release version (default: `true`) +- `version-bump` (optional) - Version bump strategy: `auto`, `major`, `minor`, or `patch` (default: `auto`) +- `package-command` (optional) - Command to build VSIX packages (default: `vsce package`) +- `bundle-command` (optional) - Command to bundle extension code (default: `npm run vscode:bundle`) +- `dry-run` (optional) - Skip actual publishing for testing (default: `false`) + +**Required Secrets:** +- `VSCE_PAT` - VS Code Marketplace Personal Access Token +- `OVSX_PAT` - Open VSX Personal Access Token + +### vscode-package + +Packages VS Code extensions into VSIX files without publishing. + +**Usage:** + +```yaml +jobs: + package: + uses: salesforcecli/github-workflows/.github/workflows/vscode-package.yml@main + with: + branch: main + artifact-name: vsix-packages + pre-release: true + dry-run: false +``` + +### vscode-ci-template + +Reusable CI workflow template for VS Code extension repositories. Runs tests across multiple OS and Node.js versions with coverage reporting. + +**Usage:** + +```yaml +jobs: + ci: + uses: salesforcecli/github-workflows/.github/workflows/vscode-ci-template.yml@main + with: + lint-command: 'npm run lint' + compile-command: 'npm run compile' + test-command: 'npm run test' + test-coverage-command: 'npm run test:coverage' +``` + +### vscode-publish-extensions + +Full-featured publish workflow with version bumping, GitHub releases, and marketplace publishing. + +**Usage:** + +```yaml +jobs: + publish: + uses: salesforcecli/github-workflows/.github/workflows/vscode-publish-extensions.yml@main + with: + branch: main + extensions: 'ext1,ext2' + registries: all + pre-release: true + dry-run: false + secrets: inherit +```