diff --git a/.github/actions/vscode/calculate-artifact-name/action.yml b/.github/actions/vscode/calculate-artifact-name/action.yml new file mode 100644 index 00000000..e13ed32c --- /dev/null +++ b/.github/actions/vscode/calculate-artifact-name/action.yml @@ -0,0 +1,34 @@ +name: 'Calculate Artifact Name' +description: 'Calculate artifact name with run number and mode suffix' + +inputs: + artifact-name: + description: 'Base artifact name or pre-calculated name' + required: true + dry-run: + description: 'Whether this is a dry-run mode' + required: false + default: 'false' + run-number: + description: 'GitHub run number (defaults to github.run_number)' + required: false + default: '${{ github.run_number }}' + +outputs: + artifact-name: + description: 'The calculated artifact name' + value: ${{ steps.calc.outputs.artifact-name }} + +runs: + using: 'composite' + steps: + - name: Calculate artifact name + id: calc + shell: bash + run: | + # Only treat as already set if artifact-name ends with -dry-run or -release + if [[ "${{ inputs.artifact-name }}" =~ -dry-run$ ]] || [[ "${{ inputs.artifact-name }}" =~ -release$ ]]; then + echo "artifact-name=${{ inputs.artifact-name }}" >> $GITHUB_OUTPUT + else + echo "artifact-name=${{ format('{0}-{1}-{2}', inputs.artifact-name, inputs.run-number, inputs.dry-run == 'true' && 'dry-run' || 'release') }}" >> $GITHUB_OUTPUT + fi \ No newline at end of file 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 00000000..6a0b3a67 --- /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/detect-packages/action.yml b/.github/actions/vscode/detect-packages/action.yml new file mode 100644 index 00000000..a4f08d34 --- /dev/null +++ b/.github/actions/vscode/detect-packages/action.yml @@ -0,0 +1,59 @@ +name: 'Detect Packages' +description: 'Dynamically discovers NPM packages and VS Code extensions in a monorepo' + +inputs: + packages-root: + description: 'Root directory containing packages (default: packages)' + required: false + default: 'packages' + +outputs: + npm-packages: + description: 'Comma-separated list of NPM package names' + value: ${{ steps.packages.outputs.npm-packages }} + extensions: + description: 'Comma-separated list of VS Code extension names' + value: ${{ steps.packages.outputs.extensions }} + extension-paths: + description: 'Extension package paths for publishing' + value: ${{ steps.packages.outputs.extension-paths }} + +runs: + using: 'composite' + steps: + - name: Detect packages and extensions + id: packages + shell: bash + env: + PACKAGES_ROOT: ${{ inputs.packages-root }} + run: | + # Get NPM packages (packages with package.json but no publisher) + NPM_PACKAGES="" + EXTENSIONS="" + EXTENSION_PATHS="" + + for pkg in $PACKAGES_ROOT/*/; do + PKG_NAME=$(basename "$pkg") + if [ -f "$pkg/package.json" ]; then + if grep -q '"publisher"' "$pkg/package.json"; then + # It's a VS Code extension + EXTENSIONS="$EXTENSIONS,$PKG_NAME" + EXTENSION_PATHS="$EXTENSION_PATHS,$pkg" + else + # It's an NPM package + NPM_PACKAGES="$NPM_PACKAGES,$PKG_NAME" + fi + fi + done + + # Remove leading commas + NPM_PACKAGES=${NPM_PACKAGES#,} + EXTENSIONS=${EXTENSIONS#,} + EXTENSION_PATHS=${EXTENSION_PATHS#,} + + echo "npm-packages=$NPM_PACKAGES" >> $GITHUB_OUTPUT + echo "extensions=$EXTENSIONS" >> $GITHUB_OUTPUT + echo "extension-paths=$EXTENSION_PATHS" >> $GITHUB_OUTPUT + + echo "Detected NPM packages: $NPM_PACKAGES" + echo "Detected VS Code extensions: $EXTENSIONS" diff --git a/.github/actions/vscode/download-vsix-artifacts/action.yml b/.github/actions/vscode/download-vsix-artifacts/action.yml new file mode 100644 index 00000000..b7d3b08a --- /dev/null +++ b/.github/actions/vscode/download-vsix-artifacts/action.yml @@ -0,0 +1,30 @@ +name: 'Download VSIX Artifacts' +description: 'Downloads and finds VSIX artifacts for publishing workflows' + +inputs: + artifact-name: + description: 'Name for the VSIX artifacts' + required: false + default: 'vsix-packages' + type: string + +outputs: + vsix_files: + description: 'JSON array of VSIX file paths' + value: ${{ steps.find_vsix.outputs.vsix_files }} + +runs: + using: composite + steps: + - name: Download VSIX artifacts + uses: actions/download-artifact@v4 + with: + name: ${{ inputs.artifact-name }} + path: ./vsix-artifacts + + - name: Find VSIX files + id: find_vsix + shell: bash + run: | + VSIX_FILES=$(find ./vsix-artifacts -name "*.vsix" | jq -R -s -c 'split("\n")[:-1]') + echo "vsix_files=$VSIX_FILES" >> $GITHUB_OUTPUT \ No newline at end of file diff --git a/.github/actions/vscode/npm-install-with-retries/action.yml b/.github/actions/vscode/npm-install-with-retries/action.yml new file mode 100644 index 00000000..d92cd1ca --- /dev/null +++ b/.github/actions/vscode/npm-install-with-retries/action.yml @@ -0,0 +1,16 @@ +name: npm-install-with-retries +description: "wraps npm ci with retries/timeout to handle network failures" +inputs: + ignore-scripts: + default: 'false' + description: "Skip pre/post install scripts" +runs: + using: composite + steps: + - name: Set npm fetch timeout + shell: bash + run: npm config set fetch-timeout 600000 + - name: npm ci + uses: salesforcecli/github-workflows/.github/actions/retry@main + with: + command: npm ci ${{ inputs.ignore-scripts == 'true' && '--ignore-scripts' || '' }} \ No newline at end of file diff --git a/.github/actions/vscode/publish-vsix/action.yml b/.github/actions/vscode/publish-vsix/action.yml new file mode 100644 index 00000000..9ac4c80a --- /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/test-vscode-package.yml b/.github/workflows/test-vscode-package.yml new file mode 100644 index 00000000..9ca180bd --- /dev/null +++ b/.github/workflows/test-vscode-package.yml @@ -0,0 +1,51 @@ +name: Test VS Code Extension CI Package + +on: + push: + branches: + - feat/add-vscode-extension-ci + paths: + - 'packages/vscode-extension-ci/**' + - '.github/workflows/test-vscode-package.yml' + workflow_dispatch: + +jobs: + build-and-test: + name: Build NPM Package + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22.x' + + - name: Install dependencies + working-directory: packages/vscode-extension-ci + run: npm install + + - name: Build package + working-directory: packages/vscode-extension-ci + run: npm run build + + - name: Verify CLI exists + working-directory: packages/vscode-extension-ci + run: | + if [ ! -f dist/cli.js ]; then + echo "Error: dist/cli.js not found" + exit 1 + fi + echo "✓ CLI built successfully" + + - name: Test CLI help + working-directory: packages/vscode-extension-ci + run: node dist/cli.js --help + + - name: List available commands + working-directory: packages/vscode-extension-ci + run: | + echo "Checking for required commands..." + node dist/cli.js --help | grep -E "ext-package-selector|ext-change-detector|ext-build-type" + echo "✓ All required commands found" diff --git a/.github/workflows/test-vscode-workflows-integration.yml b/.github/workflows/test-vscode-workflows-integration.yml new file mode 100644 index 00000000..319fb599 --- /dev/null +++ b/.github/workflows/test-vscode-workflows-integration.yml @@ -0,0 +1,60 @@ +name: Test VS Code Workflows Integration + +on: + workflow_dispatch: + push: + branches: + - feat/add-vscode-extension-ci + paths: + - '.github/workflows/test-vscode-workflows-integration.yml' + - 'packages/vscode-extension-ci/**' + +jobs: + test-with-apex-language-support: + name: Test with apex-language-support + runs-on: ubuntu-latest + steps: + - name: Checkout github-workflows + uses: actions/checkout@v6 + with: + path: toolkit + + - name: Checkout apex-language-support + uses: actions/checkout@v6 + with: + repository: forcedotcom/apex-language-support + path: test-repo + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22.x' + + - name: Install dependencies in test repo + working-directory: test-repo + run: npm ci + + - name: Build CLI package + working-directory: toolkit/packages/vscode-extension-ci + run: | + npm install + npm run build + + - name: Test ext-package-selector + working-directory: test-repo + run: | + node ../toolkit/packages/vscode-extension-ci/dist/cli.js ext-package-selector + echo "✓ Package selector works" + + - name: Test ext-build-type + working-directory: test-repo + run: | + node ../toolkit/packages/vscode-extension-ci/dist/cli.js ext-build-type + echo "✓ Build type detection works" + + - name: Build VSIX package + working-directory: test-repo/packages/apex-lsp-vscode-extension + run: | + npm run package + ls -la *.vsix + echo "✓ VSIX packaging successful" diff --git a/.github/workflows/vscode-ci-template.yml b/.github/workflows/vscode-ci-template.yml new file mode 100644 index 00000000..37324b42 --- /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@main +# 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/vscode/npm-install-with-retries@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/vscode/npm-install-with-retries@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-nightly-release.yml b/.github/workflows/vscode-nightly-release.yml new file mode 100644 index 00000000..f257299c --- /dev/null +++ b/.github/workflows/vscode-nightly-release.yml @@ -0,0 +1,213 @@ +name: VS Code Extension Nightly Release + +on: + workflow_call: + inputs: + extension-paths: + description: 'Glob pattern or list of extension paths (e.g., packages/*)' + required: true + type: string + release-mode: + description: 'Release mode: all | changed | specific' + required: false + type: string + default: 'changed' + base-branch: + description: 'Base branch for change detection' + required: false + type: string + default: 'main' + 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: + detect-extensions: + runs-on: ubuntu-latest + outputs: + extensions: ${{ steps.find.outputs.extensions }} + changed: ${{ steps.detect.outputs.changed }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Need full history for change detection + + - name: Find extensions + id: find + shell: bash + run: | + echo "Finding extensions matching: ${{ inputs.extension-paths }}" + + extensions=() + for pattern in ${{ inputs.extension-paths }}; do + for path in $pattern; do + if [ -d "$path" ] && [ -f "$path/package.json" ]; then + # Verify it's a VS Code extension + if jq -e '.engines.vscode' "$path/package.json" > /dev/null 2>&1; then + extensions+=("$path") + echo "✓ Found extension: $path" + fi + fi + done + done + + # Convert to JSON array + if [ ${#extensions[@]} -eq 0 ]; then + extension_json="[]" + else + extension_json=$(printf '%s\n' "${extensions[@]}" | jq -R . | jq -s -c .) + fi + echo "extensions=$extension_json" >> $GITHUB_OUTPUT + echo "Found ${#extensions[@]} extensions" + + - name: Detect changes + id: detect + if: inputs.release-mode == 'changed' + shell: bash + run: | + echo "Detecting changes since ${{ inputs.base-branch }}" + + # Get list of changed files + git fetch origin ${{ inputs.base-branch }} + changed_files=$(git diff --name-only origin/${{ inputs.base-branch }}...HEAD) + + # Filter extensions with changes + changed_extensions=() + extensions=$(echo '${{ steps.find.outputs.extensions }}' | jq -r '.[]') + + while IFS= read -r ext; do + if echo "$changed_files" | grep -q "^$ext/"; then + changed_extensions+=("$ext") + echo "✓ Changed: $ext" + fi + done <<< "$extensions" + + # Convert to JSON + if [ ${#changed_extensions[@]} -eq 0 ]; then + changed_json="[]" + else + changed_json=$(printf '%s\n' "${changed_extensions[@]}" | jq -R . | jq -s -c .) + fi + echo "changed=$changed_json" >> $GITHUB_OUTPUT + echo "Found ${#changed_extensions[@]} changed extensions" + + build-and-publish: + needs: detect-extensions + runs-on: ubuntu-latest + strategy: + matrix: + extension: ${{ fromJson(needs.detect-extensions.outputs.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: [detect-extensions, build-and-publish] + runs-on: ubuntu-latest + steps: + - name: Summary + run: | + echo "## Nightly Release Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Mode:** ${{ inputs.release-mode }}" >> $GITHUB_STEP_SUMMARY + echo "**Extensions:** ${{ needs.detect-extensions.outputs.extensions }}" >> $GITHUB_STEP_SUMMARY + if [ "${{ inputs.release-mode }}" == "changed" ]; then + echo "**Changed:** ${{ needs.detect-extensions.outputs.changed }}" >> $GITHUB_STEP_SUMMARY + fi + echo "**Dry-run:** ${{ inputs.dry-run }}" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/vscode-package.yml b/.github/workflows/vscode-package.yml new file mode 100644 index 00000000..3f1dea91 --- /dev/null +++ b/.github/workflows/vscode-package.yml @@ -0,0 +1,222 @@ +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@main +# 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 + 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/vscode/npm-install-with-retries@feat/add-vscode-extension-ci + + # Universal + web-target VSIXs are defined in packages/apex-lsp-vscode-extension (Wireit: package + package-web). + - name: Package 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 + run: | + echo "Generating MD5 checksums for VSIX files..." + + # Universal + web-target VSIX under packages/ + VSIX_FILES=$(find packages -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|^packages/||') + + # 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" "packages/checksums.md5" + mv "$CHECKSUMS_JSON_FILE" "packages/checksums.json" + + echo "checksums_generated=true" >> $GITHUB_OUTPUT + echo "checksums_file=packages/checksums.json" >> $GITHUB_OUTPUT + + - name: Calculate artifact name + id: calc-artifact-name + uses: salesforcecli/github-workflows/.github/actions/vscode/calculate-artifact-name@feat/add-vscode-extension-ci + with: + artifact-name: ${{ inputs.artifact-name }} + dry-run: ${{ inputs.dry-run }} + + - name: Upload VSIX artifacts + id: upload + uses: actions/upload-artifact@v7 + with: + name: ${{ steps.calc-artifact-name.outputs.artifact-name }} + path: | + packages/**/*.vsix + packages/**/*.vsix.md5 + packages/checksums.md5 + packages/checksums.json + retention-days: 5 + + - name: List VSIX files + run: | + echo "VSIX files created:" + find packages -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 00000000..2f4edabd --- /dev/null +++ b/.github/workflows/vscode-promote-prerelease.yml @@ -0,0 +1,199 @@ +name: Promote Nightly to Pre-release + +# Reusable workflow for promoting nightly builds to pre-release on marketplace +# +# Usage from consuming repository: +# jobs: +# promote: +# uses: salesforcecli/github-workflows/.github/workflows/vscode/promote-prerelease.yml@main +# with: +# min-tag-age-days: '7' +# secrets: inherit +# +# Requirements: +# - Consuming repo must have @salesforce/vscode-extension-ci installed as devDependency +# - Requires IDEE_GH_TOKEN, VSCE_PERSONAL_ACCESS_TOKEN, IDEE_OVSX_PAT secrets + +on: + workflow_call: + inputs: + min-tag-age-days: + description: 'Minimum nightly age in days before eligible for promotion' + required: false + default: '7' + 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 + 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: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22.x' + + - name: Install dependencies + uses: salesforcecli/github-workflows/.github/actions/vscode/npm-install-with-retries@feat/add-vscode-extension-ci + + - name: Find eligible nightly + id: find + env: + MIN_TAG_AGE_DAYS: ${{ inputs.min-tag-age-days || '7' }} + run: | + node node_modules/github-workflows/packages/vscode-extension-ci/dist/cli.js ext-nightly-finder + + - 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/vscode/npm-install-with-retries@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 }} + 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 }}" + + VSIX_FILE=$(find ./vsix-artifacts -type f -name 'apex-language-server-extension-*.vsix' ! -name '*-web-*' | head -1) + if [ -z "$VSIX_FILE" ]; then + echo "No VSIX found 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 }} + 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-apex-lsp-vscode-extension-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-publish-extensions.yml b/.github/workflows/vscode-publish-extensions.yml new file mode 100644 index 00000000..a19c7c12 --- /dev/null +++ b/.github/workflows/vscode-publish-extensions.yml @@ -0,0 +1,749 @@ +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@main +# 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: +# - Consuming repo must have @salesforce/vscode-extension-ci installed as devDependency +# - 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 + +# Add explicit permissions for security +permissions: + contents: write # Needed for version bumps and releases + packages: write # Needed for publishing to registries + actions: read + +jobs: + determine-changes: + runs-on: ubuntu-latest + outputs: + selected-extensions: ${{ steps.changes.outputs.selected-extensions }} + version-bumps: ${{ steps.changes.outputs.version-bumps }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + 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/vscode/npm-install-with-retries@feat/add-vscode-extension-ci + + - name: Determine changes and version bumps + id: changes + env: + IS_NIGHTLY: 'true' + VERSION_BUMP: 'auto' + PRE_RELEASE: 'true' + IS_PROMOTION: 'false' + SELECTED_EXTENSIONS: ${{ inputs.extensions }} + run: | + node node_modules/github-workflows/packages/vscode-extension-ci/dist/cli.js ext-change-detector + + display-release-plan: + needs: [determine-changes] + runs-on: ubuntu-latest + if: needs.determine-changes.outputs.selected-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/vscode/npm-install-with-retries@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: ${{ needs.determine-changes.outputs.version-bumps }} + REGISTRIES: ${{ inputs.registries }} + PRE_RELEASE: 'true' + SELECTED_EXTENSIONS: ${{ needs.determine-changes.outputs.selected-extensions }} + run: | + node node_modules/github-workflows/packages/vscode-extension-ci/dist/cli.js ext-release-plan + + bump-versions: + needs: [determine-changes] + runs-on: ubuntu-latest + if: needs.determine-changes.outputs.selected-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/vscode/npm-install-with-retries@feat/add-vscode-extension-ci + + - name: Bump versions and tag for selected extensions + env: + VERSION_BUMP: ${{ needs.determine-changes.outputs.version-bumps }} + SELECTED_EXTENSIONS: ${{ needs.determine-changes.outputs.selected-extensions }} + PRE_RELEASE: ${{ inputs.pre-release || github.event.inputs.pre-release || 'false' }} + IS_NIGHTLY: 'true' + IS_PROMOTION: 'false' + run: | + node node_modules/github-workflows/packages/vscode-extension-ci/dist/cli.js ext-version-bumper + + - 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: Checkout + uses: actions/checkout@v6 + + - name: Calculate artifact name + id: calc + uses: salesforcecli/github-workflows/.github/actions/vscode/calculate-artifact-name@feat/add-vscode-extension-ci + with: + artifact-name: vsix-packages + dry-run: ${{ inputs.dry-run || github.event.inputs.dry-run || 'false' }} + + package: + needs: [bump-versions, calculate-artifact-name, determine-changes] + 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' }} + + determine-publish-matrix: + needs: [determine-changes, 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/vscode/npm-install-with-retries@feat/add-vscode-extension-ci + + - name: Determine publish matrix + id: matrix + env: + REGISTRIES: ${{ inputs.registries }} + SELECTED_EXTENSIONS: ${{ needs.determine-changes.outputs.selected-extensions }} + IS_NIGHTLY: 'true' + run: | + # Skip marketplace publishing for nightly builds + if [ "$IS_NIGHTLY" = "true" ]; then + echo "Nightly build detected - skipping marketplace publishing" + echo 'matrix=[]' >> $GITHUB_OUTPUT + else + node node_modules/github-workflows/packages/vscode-extension-ci/dist/cli.js ext-publish-matrix + fi + + publish: + needs: + [ + bump-versions, + package, + calculate-artifact-name, + determine-publish-matrix, + ] + runs-on: ubuntu-latest + if: 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 + run: | + ARTIFACTS_DIR="./vsix-artifacts" + VSIX_PATTERN="${{ matrix.vsix_pattern }}" + # Apex: universal VSIX only (exclude legacy *-web-* platform VSIX if present) + if [[ "$VSIX_PATTERN" == *apex-language-server-extension* ]]; then + VSIX_FILE=$(find "$ARTIFACTS_DIR" -type f -name 'apex-language-server-extension-*.vsix' ! -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, determine-changes, calculate-artifact-name] + runs-on: ubuntu-latest + if: needs.package.result == 'success' && needs.determine-changes.outputs.selected-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/vscode/npm-install-with-retries@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: ${{ needs.determine-changes.outputs.selected-extensions }} + IS_NIGHTLY: 'true' + PRE_RELEASE: 'true' + VERSION_BUMP: ${{ needs.determine-changes.outputs.version-bumps }} + DRY_RUN: ${{ inputs.dry-run || github.event.inputs.dry-run || 'false' }} + BRANCH: ${{ inputs.branch || github.ref_name }} + VSIX_ARTIFACTS_PATH: ./vsix-artifacts + run: | + node node_modules/github-workflows/packages/vscode-extension-ci/dist/cli.js ext-github-releases + + 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 "::error::No web-target VSIX found in artifacts (expected *-web-*.vsix from package workflow)" + exit 1 + 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 + + - name: Publish web VSIX to CBWeb internal marketplace + if: 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: 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: + [determine-changes, bump-versions, package, publish] + runs-on: ubuntu-latest + if: always() && needs.publish.result == 'success' && (inputs.dry-run != 'true' && github.event.inputs.dry-run != 'true') + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.branch || github.ref }} + + - name: Get Extension Details + id: extension-details + run: | + # Get selected extensions and their details + SELECTED_EXTENSIONS="${{ needs.determine-changes.outputs.selected-extensions }}" + VERSION_BUMP="${{ needs.determine-changes.outputs.version-bumps }}" + 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 "packages/$ext/package.json" ]; then + # Get package details + PACKAGE_NAME=$(node -p "require('./packages/$ext/package.json').name") + PACKAGE_VERSION=$(node -p "require('./packages/$ext/package.json').version") + DISPLAY_NAME=$(node -p "require('./packages/$ext/package.json').displayName || require('./packages/$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: Notify Slack + 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: + [determine-changes, bump-versions, package, publish] + runs-on: ubuntu-latest + if: always() && needs.publish.result == 'failure' && (inputs.dry-run != 'true' && github.event.inputs.dry-run != 'true') + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.branch || github.ref }} + + - name: Get Extension Details + id: extension-details + run: | + # Get selected extensions and their details + SELECTED_EXTENSIONS="${{ needs.determine-changes.outputs.selected-extensions }}" + VERSION_BUMP="${{ needs.determine-changes.outputs.version-bumps }}" + 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 "packages/$ext/package.json" ]; then + # Get package details + PACKAGE_NAME=$(node -p "require('./packages/$ext/package.json').name") + PACKAGE_VERSION=$(node -p "require('./packages/$ext/package.json').version") + DISPLAY_NAME=$(node -p "require('./packages/$ext/package.json').displayName || require('./packages/$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: Notify Slack + 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 00000000..ede1139d --- /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/.gitignore b/.gitignore index b512c09d..0cec7780 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ -node_modules \ No newline at end of file +node_modules +dist +*.log +.DS_Store +coverage +*.tsbuildinfo \ No newline at end of file diff --git a/README.md b/README.md index fc1d4b6c..ce3cecc6 100644 --- a/README.md +++ b/README.md @@ -1,382 +1,285 @@ -# Github Workflows +# Salesforce VS Code Extension CI/CD Workflows -Reusable workflows and actions +Shared GitHub Actions workflows for automating nightly releases and CI/CD for VS Code extension repositories. -> [!IMPORTANT] -> Many of these workflows require a Personal Access Token to function. -> -> - Create a new PAT with Repo access -> - It is recommended that this is a service account user -> - Note: This user/bot will need to have access to push to your repo's default branch. This can be configured in the branch protection rules. -> - Add the PAT as an Actions [Organization secret](https://github.com/organizations/salesforcecli/settings/secrets/actions) -> - Set the `Name` to `SVC_CLI_BOT_GITHUB_TOKEN` -> - Paste in your new PAT as the `Value` -> - Set `Repository Access` to 'Selected Repositories' -> - Click the gear icon to select repos that need access to the PAT -> - This can be edited later -> - Click `Add Secret` +## Overview -## Opinionated publish process for npm +This repository provides **reusable GitHub Actions workflows** that can be called from consumer repositories to automate: +- Nightly pre-release builds +- Version bumping +- Extension packaging and publishing +- GitHub release creation -> github is the source of truth for code AND releases. Get the version/tag/release right on github, then publish to npm based on that. +**Key Design Principle:** Consumer repositories **explicitly declare** their extension paths in workflow YAML - no auto-discovery or npm packages required! -![](./images/plugin-release.png) +## Usage -1. work on a feature branch, commiting with conventional-commits -2. merge to main -3. A push to main produces (if your commits have `fix:` or `feat:`) a bumped package.json and a tagged github release via `githubRelease` -4. A release cause `npmPublish` to run. +### Quick Start -Just need to publish to npm? You could use any public action to do step 4. -Use this repo's `npmPublish` if you need either +In your VS Code extension repository, create `.github/workflows/nightly.yml`: -1. codesigning for Salesforce CLIs -2. integration with CTC - or if you own other repos that need those features and just want consistency. - -### githubRelease - -> creates a github release based on conventional commit prefixes. Using commits like `fix: etc` (patch version) and `feat: wow` (minor version). -> A commit whose **body** (not the title) contains `BREAKING CHANGES:` will cause the action to update the packageVersion to the next major version, produce a changelog, tag and release. - -```yml -name: create-github-release +```yaml +name: Nightly Release on: - push: - branches: [main] + schedule: + - cron: '0 4 * * *' # 4 AM UTC daily + workflow_dispatch: + inputs: + dry-run: + description: 'Run in dry-run mode' + type: boolean + default: false jobs: - release: - uses: salesforcecli/github-workflows/.github/workflows/create-github-release.yml@main + nightly: + uses: salesforcecli/github-workflows/.github/workflows/vscode-nightly-release.yml@main + with: + # Explicitly declare your extension paths + extension-paths: 'packages/salesforcedx-vscode-*' + release-mode: changed # all | changed | specific + base-branch: main + dry-run: ${{ inputs.dry-run || false }} secrets: inherit - # you can also pass in values for the secrets - # secrets: - # SVC_CLI_BOT_GITHUB_TOKEN: gh_pat00000000 ``` -### npmPublish +## Configuration Examples -> This will verify that the version has not already been published. There are additional params for signing your plugin and integrating with Change Traffic Control (release moratoriums) that you probably only care about if your work for Salesforce. +### 1. Monorepo - All Extensions -example usage +Release all extensions matching a pattern: -```yml -on: - release: - # the result of the githubRelease workflow - types: [published] +```yaml +with: + extension-paths: 'packages/salesforcedx-vscode-*' + release-mode: all +``` -jobs: - my-publish: - uses: salesforcecli/github-workflows/.github/workflows/npmPublish.yml - with: - tag: latest - githubTag: ${{ github.event.release.tag_name }} - secrets: inherit - # you can also pass in values for the secrets - # secrets: - # NPM_TOKEN: ^&*$ +### 2. Monorepo - Changed Only + +Release only extensions that have changes: + +```yaml +with: + extension-paths: 'packages/salesforcedx-vscode-*' + release-mode: changed + base-branch: main ``` -### Plugin Signing +### 3. Single Extension Repository -Plugins created by Salesforce teams can be signed automatically with `sign:true` if the repo is in [salesforcecli](https://github.com/salesforcecli) or [forcedotcom](https://github.com/forcedotcom) gitub organization. +```yaml +with: + extension-paths: '.' + release-mode: all +``` -You'll need the CLI team to enable your repo for signing. Ask in https://salesforce-internal.slack.com/archives/C0298EE05PU +### 4. Mixed/Custom Structure -Plugin signing is not available outside of Salesforce. Your users can add your plugin to their allow list (`unsignedPluginAllowList.json`) +Explicitly list different paths: -```yml -on: - release: - # the result of the githubRelease workflow - types: [published] +```yaml +with: + extension-paths: | + packages/libraries/foo + packages/extensions/bar + packages/extensionPacks/baz + release-mode: changed +``` -jobs: - my-publish: - uses: salesforcecli/github-workflows/.github/workflows/npmPublish.yml - with: - sign: true - tag: latest - githubTag: ${{ github.event.release.tag_name }} - secrets: inherit +### 5. Specific Extensions Only + +```yaml +with: + extensions: | + packages/salesforcedx-vscode-apex + packages/salesforcedx-vscode-core + release-mode: all ``` -### Prereleases +## Workflow Inputs -`main` will release to `latest`. Other branches can create github prereleases and publish to other npm dist tags. +### Required Inputs -You can create a prerelease one of two ways: +| Input | Description | Example | +|-------|-------------|---------| +| `extension-paths` | Glob pattern or list of extension paths | `packages/*` | +| `release-mode` | How to determine what to release | `changed`, `all`, `specific` | -1. Create a branch with the `prerelease/**` prefix. Example `prerelease/my-fix` - 1. Once a PR is opened, every commit pushed to this branch will create a prerelease - 2. The default prerelease tag will be `dev`. If another tag is desired, manually set it in your `package.json`. Example: `1.2.3-beta.0` -1. Manually run the `create-github-release` workflow in the Actions tab - 1. Click `Run workflow` - 1. Select the branch you want to create a prerelease from - 1. Enter the desired prerelease tag: `dev`, `beta`, etc +### Optional Inputs -> [!NOTE] -> Since conventional commits are used, there is no need to manually remove the prerelease tag from your `package.json`. Once the PR is merged into `main`, conventional commits will bump the version as expected (patch for `fix:`, minor for `feat:`, etc) +| Input | Description | Default | +|-------|-------------|---------| +| `base-branch` | Base branch for change detection | `main` | +| `dry-run` | Skip actual publishing | `false` | +| `pre-release` | Mark as pre-release | `true` | +| `version-bump` | Version bump strategy | `auto` | +| `registries` | Where to publish | `all` (VS Code Marketplace + Open VSX) | -Setup: +## Architecture -1. Configure the branch rules for wherever you want to release from -1. Modify your release and publish workflows like the following +### No npm Package Required! -```yml -name: create-github-release +Unlike traditional approaches, this workflow system: +- ✅ Uses **only GitHub Actions native features** +- ✅ Requires **explicit path declaration** from consumers +- ✅ Performs all logic via **shell/bash scripts in workflows** +- ✅ **No cross-repo npm dependencies** to install or manage -on: - push: - branches: - - main - # point at specific branches, or a naming convention via wildcard - - prerelease/** - tags-ignore: - - "*" - workflow_dispatch: - inputs: - prerelease: - type: string - description: "Name to use for the prerelease: beta, dev, etc. NOTE: If this is already set in the package.json, it does not need to be passed in here." +### How It Works -jobs: - release: - uses: salesforcecli/github-workflows/.github/workflows/create-github-release.yml@main - secrets: inherit - with: - prerelease: ${{ inputs.prerelease }} - # If this is a push event, we want to skip the release if there are no semantic commits - # However, if this is a manual release (workflow_dispatch), then we want to disable skip-on-empty - # This helps recover from forgetting to add semantic commits ('fix:', 'feat:', etc.) - skip-on-empty: ${{ github.event_name == 'push' }} +``` +┌─────────────────────────────────────┐ +│ Consumer Repo │ +│ │ +│ .github/workflows/nightly.yml │ +│ - Explicitly declares paths │ +│ - Calls shared workflow │ +└──────────────┬──────────────────────┘ + │ uses: + │ salesforcecli/github-workflows/ + │ .github/workflows/vscode-nightly-release.yml + ▼ +┌─────────────────────────────────────┐ +│ Shared Workflow │ +│ │ +│ 1. Parse declared paths │ +│ 2. Run git diff (if changed mode) │ +│ 3. Bump versions │ +│ 4. Create PR │ +│ 5. Auto-merge │ +│ 6. Publish & release │ +└─────────────────────────────────────┘ ``` -```yml -name: publish +## Benefits -on: - release: - # both release and prereleases - types: [published] - # support manual release in case something goes wrong and needs to be repeated or tested - workflow_dispatch: - inputs: - tag: - description: github tag that needs to publish - type: string - required: true +### vs Auto-Discovery Approach -jobs: - # parses the package.json version and detects prerelease tag (ex: beta from 4.4.4-beta.0) - getDistTag: - outputs: - tag: ${{ steps.distTag.outputs.tag }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.release.tag_name || inputs.tag }} - - uses: salesforcecli/github-workflows/.github/actions/getPreReleaseTag@main - id: distTag - - npm: - uses: salesforcecli/github-workflows/.github/workflows/npmPublish.yml@main - needs: [getDistTag] - with: - tag: ${{ needs.getDistTag.outputs.tag || 'latest' }} - githubTag: ${{ github.event.release.tag_name || inputs.tag }} - secrets: inherit -``` +| Feature | Explicit Declaration | Auto-Discovery | +|---------|---------------------|----------------| +| **Setup complexity** | Low | High (npm package) | +| **Dependencies** | None | npm git dependency | +| **Works for any structure** | ✅ Yes | ❌ Assumes standard layout | +| **Consumer control** | ✅ Full control | ⚠️ Magic behavior | +| **Maintenance** | ✅ Simple | ❌ Complex TypeScript/build | +| **Installation issues** | ✅ None | ❌ npm git dep bugs | -### Publishing from multiple long-lived branches +### Key Advantages -> In this example `main` publishes to npm on a 1.x.x version and uses `latest`. `some-other-branch` publishes version 2.x.x and uses the `v2` dist tag +1. **No npm Package Installation** - Avoids npm git dependency issues entirely +2. **Flexible Structure** - Works with monorepos, single extensions, custom layouts +3. **Explicit Intent** - Clear declaration of what gets released +4. **Simple Maintenance** - Pure workflow YAML, no complex TypeScript +5. **Fast Setup** - Consumer just declares paths in YAML -```yml -name: version, tag and github release +## Features -on: - push: - # add the other branch so that it causes github releases just like main does - branches: [main, some-other-branch] +### Smart Version Bumping -jobs: - release: - uses: salesforcecli/github-workflows/.github/workflows/githubRelease.yml@main - secrets: inherit -``` +Automatically determines version bumps based on: +- **Conventional commits** (`fix:`, `feat:`, `feat!:`) +- **Even/odd versioning pattern** for stable vs pre-release -```yml -on: - release: - # the result of the githubRelease workflow - types: [published] +### Change Detection -jobs: - my-publish: - uses: salesforcecli/github-workflows/.github/workflows/npmPublish.yml - with: - # ternary-ish https://github.com/actions/runner/issues/409#issuecomment-752775072 - # if the version is 2.x we release it on the `v2` dist tag - tag: ${{ startsWith( github.event.release.tag_name || inputs.tag, '1.') && 'latest' || 'v2'}} - githubTag: ${{ github.event.release.tag_name }} - secrets: inherit -``` +When `release-mode: changed`: +- Runs `git diff` against base branch +- Identifies modified extensions +- Only releases what changed -## Opinionated Testing Process +### Dry-Run Mode -Write unit tests to tests units of code (a function/method). +Test the full workflow without publishing: +```yaml +dry-run: true +``` -Write not-unit-tests to tests larger parts of code (a command) against real environments/APIs. +This will: +- ✅ Create local branches and commits +- ✅ Build VSIXes +- ✅ Validate the flow +- ❌ Skip: git push, PR creation, publishing -Run the UT first (faster, less expensive for infrastructure/limits). +## Workflow Structure -```yml -name: tests -on: - push: - branches-ignore: [main] - workflow_dispatch: +The main workflow calls these sub-workflows in sequence: -jobs: - unit-tests: - uses: salesforcecli/github-workflows/.github/workflows/unitTest.yml@main - nuts: - needs: unit-tests - uses: salesforcecli/github-workflows/.github/workflows/nut.yml@main - secrets: inherit - strategy: - matrix: - os: [ubuntu-latest, windows-latest] - fail-fast: false - with: - os: ${{ matrix.os }} -``` +1. **`vscode-make-pr-for-nightly.yml`** - Bumps versions, creates PR +2. **`vscode-automerge-nightly-pr.yml`** - Auto-merges after checks pass +3. **`vscode-draft-release-on-merge.yml`** - Publishes and creates releases -## Other Tooling +## Required Secrets -### nut conditional on commit message +Configure these in your repository settings: -```yml -# conditional nuts based on commit message includes a certain string -sandbox-nuts: - needs: [nuts, unit-tests] - if: contains(github.event.push.head_commit.message,'[sb-nuts]') - uses: salesforcecli/github-workflows/.github/workflows/nut.yml@main - secrets: inherit - with: - command: test:nuts:sandbox - os: ubuntu-latest -``` +- `VSCE_PAT` - VS Code Marketplace personal access token +- `OVSX_PAT` - Open VSX personal access token +- `GITHUB_TOKEN` - Automatically provided by GitHub Actions -### externalNut +## Supported Repository Types -> Scenario -> -> 1. you have NUTs on a plugin that uses a library -> 2. you want to check changes to the library against those NUTs +- ✅ **Monorepo** with multiple extensions +- ✅ **Single extension** repository +- ✅ **Mixed structure** (libraries + extensions + packs) +- ✅ **Custom layouts** with explicit path declaration -see https://github.com/forcedotcom/source-deploy-retrieve/blob/> e09d635a7b852196701e71a4b2fba401277da313/.github/workflows/test.yml#L25 for an example +## Migration Guide -### automerge +### From Auto-Discovery Approach -> This example calls the automerge job. It'll merge PRs from dependabot that are -> -> 1. up to date with main -> 2. mergeable (per github) -> 3. all checks have completed and none failed (skipped may not have run) +If you were using an npm package for auto-discovery: -```yml -name: automerge -on: - workflow_dispatch: - schedule: - - cron: "56 2,5,8,11 * * *" +**Before:** +```yaml +- name: Install dependencies + run: npm install +- name: Discover extensions + run: npx tsx .github/scripts/index.ts ext-package-selector +``` -jobs: - automerge: - uses: salesforcecli/github-workflows/.github/workflows/automerge.yml@main - # secrets are needed - secrets: inherit +**After:** +```yaml +with: + extension-paths: 'packages/*' # Explicitly declare + release-mode: changed ``` -need squash? +### From Manual Workflows -```yml -automerge: - with: - mergeMethod: squash -``` +Replace your custom nightly workflow with: -### versionInfo - -> requires npm to exist. Use in a workflow that has already done that -> -> given an npmTag (ex: `7.100.0` or `latest`) returns the numeric version (`foo` => `7.100.0`) plus > the xz linux tarball url and the short (7 char) sha. -> -> Intended for releasing CLIs, not for general use on npm packages. - -```yml -# inside steps -- uses: salesforcecli/github-workflows/.github/actions/versionInfo@main - id: version-info - with: - version: ${{ inputs.version }} - npmPackage: sfdx-cli -- run: echo "version is ${{ steps.version-info.outputs.version }} -- run: echo "sha is ${{ steps.version-info.outputs.sha }} -- run: echo "url is ${{ steps.version-info.outputs.url }} +```yaml +jobs: + nightly: + uses: salesforcecli/github-workflows/.github/workflows/vscode-nightly-release.yml@main + with: + extension-paths: '' + release-mode: changed + secrets: inherit ``` -### validatePR +## Examples -> Checks that PRs have a link to a github issue OR a GUS WI in the form of `@W-12456789@` (the `@` are to be compatible with [git2gus](https://github.com/forcedotcom/git2gus)) +See real-world examples in: +- [forcedotcom/salesforcedx-vscode](https://github.com/forcedotcom/salesforcedx-vscode) +- [forcedotcom/apex-language-support](https://github.com/forcedotcom/apex-language-support) -```yml -name: pr-validation +## Contributing -on: - pull_request: - types: [opened, reopened, edited] - # only applies to PRs that want to merge to main - branches: [main] +This repository follows Salesforce open source guidelines. See [CONTRIBUTING.md](CONTRIBUTING.md). -jobs: - pr-validation: - uses: salesforcecli/github-workflows/.github/workflows/validatePR.yml@main -``` +## License -### prNotification +BSD-3-Clause - See [LICENSE.txt](LICENSE.txt) -> Mainly used to notify Slack when Pull Requests are opened. -> -> For more info see [.github/actions/prNotification/README.md](.github/actions/prNotification/README.md) +## Support -```yaml -name: Slack Pull Request Notification +For questions or issues: +- Open an issue in this repository +- Contact the VS Code Extensions Team +- See [NIGHTLY_RELEASE_DESIGN.md](docs/NIGHTLY_RELEASE_DESIGN.md) for detailed architecture -on: - pull_request: - types: [opened, reopened] +--- -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Notify Slack on PR open - env: - WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - PULL_REQUEST_AUTHOR_ICON_URL: ${{ github.event.pull_request.user.avatar_url }} - PULL_REQUEST_AUTHOR_NAME: ${{ github.event.pull_request.user.login }} - PULL_REQUEST_AUTHOR_PROFILE_URL: ${{ github.event.pull_request.user.html_url }} - PULL_REQUEST_BASE_BRANCH_NAME: ${{ github.event.pull_request.base.ref }} - PULL_REQUEST_COMPARE_BRANCH_NAME: ${{ github.event.pull_request.head.ref }} - PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} - PULL_REQUEST_REPO: ${{ github.event.pull_request.head.repo.name }} - PULL_REQUEST_TITLE: ${{ github.event.pull_request.title }} - PULL_REQUEST_URL: ${{ github.event.pull_request.html_url }} - uses: salesforcecli/github-workflows/.github/actions/prNotification@main -``` +**Note:** This approach eliminates the need for the npm package (`@salesforce/vscode-extension-ci`). All logic is contained in GitHub Actions workflows that can be called directly via `uses:` syntax. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 00000000..cfd7e220 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,154 @@ +# Architecture: VS Code Extension Nightly Releases + +## Design Philosophy + +Consumer repositories **explicitly declare their extension paths** in workflow YAML. No npm packages, no auto-discovery - just pure GitHub Actions workflows. + +## Core Architecture + +``` +┌──────────────────────────────────────────────┐ +│ Consumer Repository │ +│ │ +│ .github/workflows/nightly.yml │ +│ │ +│ with: │ +│ extension-paths: 'packages/*' │ +│ release-mode: changed │ +│ base-branch: main │ +└──────────────┬───────────────────────────────┘ + │ + │ uses: salesforcecli/github-workflows/ + │ .github/workflows/vscode-nightly-release.yml + │ + ▼ +┌──────────────────────────────────────────────┐ +│ Shared Workflow (this repo) │ +│ │ +│ 1. Parse extension-paths input │ +│ 2. Detect changes (if mode=changed) │ +│ 3. Bump versions │ +│ 4. Create PR │ +│ 5. Auto-merge │ +│ 6. Publish & Release │ +└──────────────────────────────────────────────┘ +``` + +## Implementation + +### Pure GitHub Actions + +All logic uses: +- **Bash scripts** for file operations +- **Git commands** for change detection +- **Node.js scripts** (inline) for JSON parsing +- **GitHub CLI (`gh`)** for PR/release management +- **Composite Actions** for reusable steps + +### Workflow Structure + +**Main workflow:** `vscode-nightly-release.yml` + +**Sub-workflows:** +1. `vscode-make-pr-for-nightly.yml` - Bump versions, create PR +2. `vscode-automerge-nightly-pr.yml` - Auto-merge after checks +3. `vscode-draft-release-on-merge.yml` - Publish and create releases + +## Configuration + +### extension-paths + +Glob patterns or explicit paths: + +```yaml +# Standard monorepo +extension-paths: 'packages/*' + +# Specific pattern +extension-paths: 'packages/salesforcedx-vscode-*' + +# Multiple paths +extension-paths: | + packages/apex + packages/core + packages/lwc + +# Custom structure +extension-paths: | + libs/shared + extensions/main + packs/bundle +``` + +### release-mode + +**`all`** - Release all matching extensions + +**`changed`** - Release only modified extensions (requires `base-branch`) + +**`specific`** - Release specific extensions (requires `extensions` list) + +## Supported Structures + +### Standard Monorepo +``` +repo/packages/ + ├── extension-a/ + ├── extension-b/ + └── extension-c/ +``` +→ `extension-paths: 'packages/*'` + +### Custom Layout +``` +repo/ + ├── libs/shared/ + ├── exts/apex/ + ├── exts/lwc/ + └── packs/bundle/ +``` +→ `extension-paths: |` + `exts/*` + `packs/bundle` + +### Single Extension +``` +repo/ + ├── package.json + └── src/ +``` +→ `extension-paths: '.'` + +## Benefits + +- ✅ Works with any repository structure +- ✅ No external dependencies +- ✅ Consumer has full control +- ✅ Simple to understand and maintain +- ✅ Scales from single extensions to large monorepos + +## Example Usage + +```yaml +name: Nightly Release + +on: + schedule: + - cron: '0 4 * * *' + workflow_dispatch: + +jobs: + nightly: + uses: salesforcecli/github-workflows/.github/workflows/vscode-nightly-release.yml@main + with: + extension-paths: 'packages/salesforcedx-vscode-*' + release-mode: changed + base-branch: main + secrets: inherit +``` + +## Required Secrets + +- `VSCE_PAT` - VS Code Marketplace token +- `OVSX_PAT` - Open VSX token +- `GITHUB_TOKEN` - Auto-provided by GitHub Actions diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..e4065dcf --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1490 @@ +{ + "name": "@salesforce/vscode-extension-ci", + "version": "1.0.1-feat-vscode-ci.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@salesforce/vscode-extension-ci", + "version": "1.0.1-feat-vscode-ci.0", + "license": "BSD-3-Clause", + "dependencies": { + "chalk": "^5.3.0", + "commander": "^14.0.3", + "glob": "^10.3.10", + "semver": "^7.7.4", + "simple-git": "^3.36.0", + "zod": "^4.4.3" + }, + "bin": { + "vscode-ext-ci": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^22.19.21", + "@types/semver": "^7.7.1", + "eslint": "^10.5.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + } + }, + "node_modules/@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@simple-git/args-pathspec": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@simple-git/args-pathspec/-/args-pathspec-1.0.3.tgz", + "integrity": "sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==", + "license": "MIT" + }, + "node_modules/@simple-git/argv-parser": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@simple-git/argv-parser/-/argv-parser-1.1.1.tgz", + "integrity": "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==", + "license": "MIT", + "dependencies": { + "@simple-git/args-pathspec": "^1.0.3" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", + "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", + "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-git": { + "version": "3.36.0", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.36.0.tgz", + "integrity": "sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==", + "license": "MIT", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "@simple-git/args-pathspec": "^1.0.3", + "@simple-git/argv-parser": "^1.1.0", + "debug": "^4.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/git-js?sponsor=1" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "packages/vscode-extension-ci": { + "name": "@salesforce/vscode-extension-ci", + "version": "1.0.0", + "extraneous": true, + "license": "BSD-3-Clause", + "dependencies": { + "chalk": "^5.3.0", + "commander": "^14.0.3", + "glob": "^10.3.10", + "semver": "^7.7.4", + "simple-git": "^3.36.0", + "zod": "^4.4.3" + }, + "bin": { + "vscode-ext-ci": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^22.15.1", + "@types/semver": "^7.5.8", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..312e7f5f --- /dev/null +++ b/package.json @@ -0,0 +1,58 @@ +{ + "name": "@salesforce/vscode-extension-ci", + "version": "1.0.1-feat-vscode-ci.0", + "description": "Shared CI/CD infrastructure for Salesforce VS Code extensions", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "vscode-ext-ci": "./dist/cli.js" + }, + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "test": "echo \"Tests not yet implemented\"", + "lint": "eslint src --ext .ts", + "prepublishOnly": "npm run build" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "keywords": [ + "vscode", + "extension", + "ci", + "cd", + "release", + "automation", + "salesforce" + ], + "repository": { + "type": "git", + "url": "https://github.com/forcedotcom/vscode-extension-ci-toolkit.git", + "directory": "packages/vscode-extension-ci" + }, + "license": "BSD-3-Clause", + "dependencies": { + "chalk": "^5.3.0", + "commander": "^14.0.3", + "glob": "^10.3.10", + "semver": "^7.7.4", + "simple-git": "^3.36.0", + "zod": "^4.4.3" + }, + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=22.0.0" + }, + "devDependencies": { + "@types/node": "^22.19.21", + "@types/semver": "^7.7.1", + "eslint": "^10.5.0", + "typescript": "^5.9.3" + } +} diff --git a/package.json.new b/package.json.new new file mode 100644 index 00000000..3446fe93 --- /dev/null +++ b/package.json.new @@ -0,0 +1,57 @@ +{ + "name": "@salesforce/vscode-extension-ci", + "version": "1.0.0", + "description": "Shared CI/CD infrastructure for Salesforce VS Code extensions", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "vscode-ext-ci": "./dist/cli.js" + }, + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "test": "echo \"Tests not yet implemented\"", + "lint": "eslint src --ext .ts", + "prepublishOnly": "npm run build" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "keywords": [ + "vscode", + "extension", + "ci", + "cd", + "release", + "automation", + "salesforce" + ], + "repository": { + "type": "git", + "url": "https://github.com/forcedotcom/vscode-extension-ci-toolkit.git", + "directory": "packages/vscode-extension-ci" + }, + "license": "BSD-3-Clause", + "dependencies": { + "commander": "^14.0.3", + "chalk": "^5.3.0", + "zod": "^4.4.3", + "semver": "^7.7.4", + "simple-git": "^3.36.0", + "glob": "^10.3.10" + }, + "devDependencies": { + "typescript": "^5.9.3", + "@types/node": "^22.15.1", + "@types/semver": "^7.5.8" + }, + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=22.0.0" + } +} \ No newline at end of file diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 00000000..238bd76b --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,302 @@ +#!/usr/bin/env node + +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE file in the + * repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { Command } from 'commander'; +import { determineBuildType, setBuildTypeOutputs } from './extension/ext-build-type.js'; +import { + findNightlyCandidate, + setNightlyFinderOutputs, +} from './extension/ext-nightly-finder.js'; +import { + detectExtensionChanges, + setChangeDetectionOutputs, +} from './extension/ext-change-detector.js'; +import { + getAvailableExtensions, + setExtensionDiscoveryOutputs, +} from './extension/ext-package-selector.js'; + +import { + detectNpmChanges, + setNpmChangeDetectionOutputs, +} from './npm/npm-change-detector.js'; +import { npmPackageSelectorMain } from './npm/npm-package-selector.js'; + +import { + extractPackageDetails, + setPackageDetailsOutputs, +} from './npm/npm-package-details.js'; +import { generateReleasePlan, displayReleasePlan } from './npm/npm-release-plan.js'; +import { displayExtensionReleasePlan } from './extension/ext-release-plan.js'; +import { bumpVersions } from './extension/ext-version-bumper.js'; +import { determinePublishMatrix } from './extension/ext-publish-matrix.js'; +import { createGitHubReleases } from './extension/ext-github-releases.js'; +import { logAuditEvent } from './core/audit-logger.js'; + +import { log, setOutput } from './core/utils.js'; + +const program = new Command(); + +program + .name('release-scripts') + .description('Release automation scripts for VS Code extensions') + .version('1.0.0'); + +program + .command('ext-build-type') + .description('Determine build type (nightly/promotion/regular)') + .action(async () => { + try { + const buildContext = determineBuildType(); + setBuildTypeOutputs(buildContext); + } catch (error) { + log.error(`Failed to determine build type: ${error}`); + process.exit(1); + } + }); + +program + .command('ext-nightly-finder') + .description('Find eligible nightly builds for pre-release promotion') + .action(async () => { + try { + const candidate = await findNightlyCandidate(); + setNightlyFinderOutputs(candidate); + } catch (error) { + log.error(`Failed to find nightly candidate: ${error}`); + process.exit(1); + } + }); + +program + .command('ext-change-detector') + .description('Detect changes in extensions') + .action(async () => { + try { + // Parse build context from environment variables + const isNightly = process.env.IS_NIGHTLY === 'true'; + const versionBump = (process.env.VERSION_BUMP as any) || 'auto'; + const preRelease = process.env.PRE_RELEASE === 'true'; + const isPromotion = process.env.IS_PROMOTION === 'true'; + const promotionCommitSha = process.env.PROMOTION_COMMIT_SHA; + const userSelectedExtensions = process.env.SELECTED_EXTENSIONS; + + const buildContext = { + isNightly, + versionBump, + preRelease, + isPromotion, + promotionCommitSha, + }; + + const result = await detectExtensionChanges( + buildContext, + promotionCommitSha, + userSelectedExtensions, + ); + setChangeDetectionOutputs(result); + } catch (error) { + log.error(`Failed to determine changes: ${error}`); + process.exit(1); + } + }); + +program + .command('npm-change-detector') + .description('Detect changes in NPM packages') + .action(async () => { + try { + const baseBranch = process.env.INPUT_BASE_BRANCH || 'main'; + const result = await detectNpmChanges(baseBranch); + setNpmChangeDetectionOutputs(result); + } catch (error) { + log.error(`Failed to detect NPM changes: ${error}`); + process.exit(1); + } + }); + +program + .command('npm-package-selector') + .description( + 'Discover available NPM packages or select packages based on user input', + ) + .action(async () => { + try { + await npmPackageSelectorMain(); + } catch (error) { + log.error(`Failed to handle NPM packages: ${error}`); + process.exit(1); + } + }); + +program + .command('ext-package-selector') + .description('Discover available VS Code extensions') + .action(async () => { + try { + const extensions = getAvailableExtensions(); + setExtensionDiscoveryOutputs(extensions); + } catch (error) { + log.error(`Failed to discover extensions: ${error}`); + process.exit(1); + } + }); + +program + .command('npm-package-details') + .description('Extract NPM package details for notifications') + .action(async () => { + try { + const selectedPackagesJson = process.env.SELECTED_PACKAGES || '[]'; + const versionBump = process.env.VERSION_BUMP || 'patch'; + + const details = extractPackageDetails( + selectedPackagesJson, + versionBump as any, + ); + setPackageDetailsOutputs(details); + } catch (error) { + log.error(`Failed to extract package details: ${error}`); + process.exit(1); + } + }); + +program + .command('npm-release-plan') + .description('Generate NPM release plan') + .action(async () => { + try { + const packageName = process.env.MATRIX_PACKAGE; + const versionBump = process.env.VERSION_BUMP || 'patch'; + const dryRun = process.env.DRY_RUN === 'true'; + + if (!packageName) { + log.error('MATRIX_PACKAGE environment variable is required'); + process.exit(1); + } + + const plan = generateReleasePlan(packageName, versionBump as any, dryRun); + if (plan) { + displayReleasePlan(plan); + } else { + log.error('Failed to generate release plan'); + process.exit(1); + } + } catch (error) { + log.error(`Failed to generate release plan: ${error}`); + process.exit(1); + } + }); + +program + .command('ext-release-plan') + .description('Display extension release plan for dry runs') + .action(async () => { + try { + const options = { + branch: process.env.BRANCH || 'main', + buildType: process.env.BUILD_TYPE || 'workflow_dispatch', + isNightly: process.env.IS_NIGHTLY || 'false', + versionBump: process.env.VERSION_BUMP || 'auto', + registries: process.env.REGISTRIES || 'all', + preRelease: process.env.PRE_RELEASE || 'false', + selectedExtensions: process.env.SELECTED_EXTENSIONS || '', + }; + displayExtensionReleasePlan(options); + } catch (error) { + log.error(`Failed to display release plan: ${error}`); + process.exit(1); + } + }); + +program + .command('audit-logger') + .description('Log audit events for release operations') + .action(async () => { + try { + logAuditEvent({ + action: process.env.ACTION || '', + actor: process.env.ACTOR || '', + repository: process.env.REPOSITORY || '', + branch: process.env.BRANCH || '', + workflow: process.env.WORKFLOW || '', + runId: process.env.RUN_ID || '', + details: process.env.DETAILS || '{}', + logFile: process.env.LOG_FILE, + }); + } catch (error) { + log.error(`Failed to log audit event: ${error}`); + process.exit(1); + } + }); + +program + .command('ext-github-releases') + .description('Create GitHub releases for extensions') + .action(async () => { + try { + createGitHubReleases({ + dryRun: process.env.DRY_RUN === 'true', + preRelease: process.env.PRE_RELEASE || 'false', + versionBump: process.env.VERSION_BUMP || 'auto', + selectedExtensions: process.env.SELECTED_EXTENSIONS || '', + isNightly: process.env.IS_NIGHTLY || 'false', + vsixArtifactsPath: + process.env.VSIX_ARTIFACTS_PATH || './vsix-artifacts', + }); + } catch (error) { + log.error(`Failed to create GitHub releases: ${error}`); + process.exit(1); + } + }); + +program + .command('ext-publish-matrix') + .description('Determine publish matrix for extensions') + .action(async () => { + try { + const options = { + registries: process.env.REGISTRIES || 'all', + selectedExtensions: process.env.SELECTED_EXTENSIONS || '', + }; + const matrix = determinePublishMatrix(options); + // Output in GitHub Actions format + setOutput('matrix', JSON.stringify(matrix)); + } catch (error) { + log.error(`Failed to determine publish matrix: ${error}`); + process.exit(1); + } + }); + +program + .command('ext-version-bumper') + .description('Bump versions for selected extensions') + .action(async () => { + try { + bumpVersions({ + versionBump: process.env.VERSION_BUMP || 'auto', + selectedExtensions: process.env.SELECTED_EXTENSIONS || '', + preRelease: process.env.PRE_RELEASE || 'false', + isNightly: process.env.IS_NIGHTLY || 'false', + isPromotion: process.env.IS_PROMOTION || 'false', + promotionCommitSha: process.env.PROMOTION_COMMIT_SHA, + }); + } catch (error) { + log.error(`Failed to bump versions: ${error}`); + process.exit(1); + } + }); + +// Show help if no command provided +if (process.argv.length === 2) { + program.help(); +} + +program.parse(); diff --git a/src/core/audit-logger.ts b/src/core/audit-logger.ts new file mode 100644 index 00000000..02e1c3fd --- /dev/null +++ b/src/core/audit-logger.ts @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the + * repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { appendFileSync, existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; + +interface AuditLogEntry { + timestamp: string; + action: string; + actor: string; + repository: string; + branch: string; + workflow: string; + runId: string; + details: Record; +} + +interface AuditLoggerOptions { + action: string; + actor: string; + repository: string; + branch: string; + workflow: string; + runId: string; + details: string; + logFile?: string; +} + +function getAuditLogPath(): string { + const auditLogDir = process.env.AUDIT_LOG_DIR || '.github/audit-logs'; + const logDir = join(process.cwd(), auditLogDir); + const logFile = join(logDir, 'release-audit.log'); + + // Ensure log directory exists + if (!existsSync(logDir)) { + // Create directory if it doesn't exist + mkdirSync(logDir, { recursive: true }); + } + + return logFile; +} + +function formatAuditEntry(entry: AuditLogEntry): string { + const timestamp = new Date().toISOString(); + const details = JSON.stringify(entry.details, null, 2); + + // eslint-disable-next-line max-len + const header = `[${timestamp}] ${entry.action} | Actor: ${entry.actor} | Repo: ${entry.repository} | Branch: ${entry.branch} | Workflow: ${entry.workflow} | Run: ${entry.runId}`; + const separator = '-'.repeat(80); + + return `${header}\nDetails: ${details}\n${separator}\n`; +} + +function logAuditEvent(options: AuditLoggerOptions): void { + const { + action, + actor, + repository, + branch, + workflow, + runId, + details, + logFile, + } = options; + + try { + // Parse details JSON + const parsedDetails = JSON.parse(details); + + const entry: AuditLogEntry = { + timestamp: new Date().toISOString(), + action, + actor, + repository, + branch, + workflow, + runId, + details: parsedDetails, + }; + + const auditLogPath = logFile || getAuditLogPath(); + const logEntry = formatAuditEntry(entry); + + // Append to audit log + appendFileSync(auditLogPath, logEntry, 'utf-8'); + + console.log(`✅ Audit log entry written to: ${auditLogPath}`); + console.log(`Action: ${action}`); + console.log(`Actor: ${actor}`); + console.log(`Repository: ${repository}`); + console.log(`Branch: ${branch}`); + console.log(`Workflow: ${workflow}`); + console.log(`Run ID: ${runId}`); + console.log('Details:', JSON.stringify(parsedDetails, null, 2)); + } catch (error) { + console.error('Failed to write audit log entry:', error); + throw error; + } +} + +// Export for use in other modules +export { logAuditEvent }; diff --git a/src/core/types.ts b/src/core/types.ts new file mode 100644 index 00000000..03cd4d6e --- /dev/null +++ b/src/core/types.ts @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the + * repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +export interface BuildContext { + isNightly: boolean; + versionBump: VersionBumpType; + preRelease: boolean; + isPromotion: boolean; + promotionCommitSha?: string; +} + +export type VersionBumpType = 'patch' | 'minor' | 'major' | 'auto'; + +export interface ExtensionInfo { + name: string; + path: string; + currentVersion: string; + publisher?: string; + displayName?: string; +} + +export interface ChangeDetectionResult { + selectedExtensions: string[]; + versionBumps: VersionBumpType; + promotionCommitSha?: string; +} + +export interface PromotionCandidate { + tag: string; + commitSha: string; + commitDate: number; + version: string; +} + +export interface VersionBumpResult { + packageName: string; + oldVersion: string; + newVersion: string; + bumpType: VersionBumpType; + strategy: 'nightly' | 'promotion' | 'regular'; +} + +export interface ReleasePlan { + extensions: ExtensionReleasePlan[]; + buildType: BuildContext; + dryRun: boolean; +} + +export interface ExtensionReleasePlan { + name: string; + currentVersion: string; + newVersion: string; + publisher: string; + displayName: string; + bumpType: VersionBumpType; + strategy: 'nightly' | 'promotion' | 'regular'; + registries: string[]; +} + +export interface GitTag { + name: string; + commitSha: string; + commitDate: number; + isStable: boolean; + isNightly: boolean; + version?: string; +} + +export interface Environment { + githubEventName: string; + githubRef: string; + githubRefName: string; + githubActor: string; + githubRepository: string; + githubRunId: string; + githubWorkflow: string; + inputs: { + branch?: string; + extensions?: string; + registries?: string; + dryRun?: string; + preRelease?: string; + versionBump?: string; + }; +} + +/** + * Type representing a semantic version string (major.minor.patch) + */ +export type SemanticVersion = `${number}.${number}.${number}`; + +/** + * Type representing a tag with its extracted version + */ +export type TagWithVersion = { tag: string; version: SemanticVersion | null }; diff --git a/src/core/utils.ts b/src/core/utils.ts new file mode 100644 index 00000000..7eaa940d --- /dev/null +++ b/src/core/utils.ts @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the + * repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { readFileSync, existsSync, appendFileSync } from 'fs'; +import { join } from 'path'; +import { z } from 'zod'; +import chalk from 'chalk'; +import { execSync } from 'child_process'; +import type { SemanticVersion } from './types.js'; +import semver from 'semver'; + +/** + * Parse version string into components + */ +export function parseVersion(version: string): { + major: number; + minor: number; + patch: number; +} { + const parts = version.split('.').map(Number); + if (parts.length !== 3 || parts.some(isNaN)) { + throw new Error(`Invalid version format: ${version}`); + } + return { major: parts[0], minor: parts[1], patch: parts[2] }; +} + +/** + * Format version components back to string + */ +export function formatVersion( + major: number, + minor: number, + patch: number, +): string { + return `${major}.${minor}.${patch}`; +} + +/** + * Check if a version has an even minor (stable) or odd minor (pre-release) + */ +export function isStableVersion(version: string): boolean { + const { minor } = parseVersion(version); + return minor % 2 === 0; +} + +/** + * Check if a version has an odd minor (pre-release) + */ +export function isPreReleaseVersion(version: string): boolean { + return !isStableVersion(version); +} + +/** + * Read and parse package.json + */ +export function readPackageJson(packagePath: string): any { + const packageJsonPath = join(packagePath, 'package.json'); + if (!existsSync(packageJsonPath)) { + throw new Error(`package.json not found at: ${packageJsonPath}`); + } + + const content = readFileSync(packageJsonPath, 'utf-8'); + return JSON.parse(content); +} + +/** + * Get extension information from package.json + */ +export function getExtensionInfo(packagePath: string): { + name: string; + version: string; + publisher?: string; + displayName?: string; +} { + const pkg = readPackageJson(packagePath); + return { + name: pkg.name, + version: pkg.version, + publisher: pkg.publisher, + displayName: pkg.displayName || pkg.name, + }; +} + +/** + * Parse GitHub environment variables + */ +export function parseEnvironment(): { + githubEventName: string; + githubRef: string; + githubRefName: string; + githubActor: string; + githubRepository: string; + githubRunId: string; + githubWorkflow: string; + inputs: Record; +} { + return { + githubEventName: process.env.GITHUB_EVENT_NAME || '', + githubRef: process.env.GITHUB_REF || '', + githubRefName: process.env.GITHUB_REF_NAME || '', + githubActor: process.env.GITHUB_ACTOR || '', + githubRepository: process.env.GITHUB_REPOSITORY || '', + githubRunId: process.env.GITHUB_RUN_ID || '', + githubWorkflow: process.env.GITHUB_WORKFLOW || '', + inputs: { + branch: process.env.INPUT_BRANCH, + extensions: process.env.INPUT_EXTENSIONS, + registries: process.env.INPUT_REGISTRIES, + dryRun: process.env.INPUT_DRY_RUN, + preRelease: process.env.INPUT_PRE_RELEASE, + versionBump: process.env.INPUT_VERSION_BUMP, + }, + }; +} + +/** + * Set GitHub Actions output using environment files (GITHUB_OUTPUT) + */ +export function setOutput(name: string, value: string): void { + const githubOutput = process.env['GITHUB_OUTPUT']; + if (githubOutput) { + appendFileSync(githubOutput, `${name}=${value}\n`); + } else { + // Fallback for local development outside GitHub Actions + console.log(`[output] ${name}=${value}`); + } +} + +/** + * Type guard to check if a string is a valid semantic version + */ +export function isSemanticVersion(version: string): version is SemanticVersion { + return semver.valid(version) !== null; +} + +/** + * Parse semantic version string into components + */ +export function parseSemver(version: SemanticVersion): { + major: number; + minor: number; + patch: number; +} { + const parsed = semver.parse(version); + if (!parsed) { + throw new Error(`Invalid semantic version: ${version}`); + } + return { + major: parsed.major, + minor: parsed.minor, + patch: parsed.patch, + }; +} + +/** + * Compare two semantic versions + * Returns: -1 if a < b, 0 if a === b, 1 if a > b + */ +export function compareSemver(a: SemanticVersion, b: SemanticVersion): number { + return semver.compare(a, b); +} + +/** + * Extract semantic version from tag using regex pattern + */ +export function extractVersionFromTag(tag: string): SemanticVersion | null { + // Match semantic version pattern: v followed by major.minor.patch + const versionMatch = tag.match(/v(\d+\.\d+\.\d+)/); + if (!versionMatch) return null; + + const version = versionMatch[1]; + return isSemanticVersion(version) ? version : null; +} + +/** + * Log with color coding + */ +export const log = { + info: (message: string) => console.log(chalk.blue(`ℹ️ ${message}`)), + success: (message: string) => console.log(chalk.green(`✅ ${message}`)), + warning: (message: string) => console.log(chalk.yellow(`⚠️ ${message}`)), + error: (message: string) => console.log(chalk.red(`❌ ${message}`)), + debug: (message: string) => console.log(chalk.gray(`🔍 ${message}`)), +}; + +/** + * Validate string is not empty + */ +export const nonEmptyString = z.string().min(1); + +/** + * Validate boolean string + */ +export const booleanString = z + .enum(['true', 'false']) + .transform((val) => val === 'true'); + +/** + * Validate version bump type + */ +export const versionBumpType = z.enum(['patch', 'minor', 'major', 'auto']); diff --git a/src/extension/ext-build-type.ts b/src/extension/ext-build-type.ts new file mode 100644 index 00000000..2d2f9edd --- /dev/null +++ b/src/extension/ext-build-type.ts @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the + * repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { BuildContext, VersionBumpType } from '../core/types.js'; +import { setOutput, log, booleanString, versionBumpType } from '../core/utils.js'; + +/** + * Determine the build context based on GitHub event and inputs + */ +export function determineBuildType(): BuildContext { + log.info('Determining build type...'); + log.debug(`GitHub event: ${process.env.GITHUB_EVENT_NAME}`); + log.debug(`Pre-release input: ${process.env.INPUT_PRE_RELEASE}`); + log.debug(`Version bump input: ${process.env.INPUT_VERSION_BUMP}`); + + // Check if this is a scheduled nightly build + const isNightly = process.env.GITHUB_EVENT_NAME === 'schedule'; + + // Determine version bump type + let versionBump: VersionBumpType = 'auto'; + if (isNightly) { + versionBump = 'patch'; + } else { + const inputBump = process.env.INPUT_VERSION_BUMP || 'auto'; + try { + versionBump = versionBumpType.parse(inputBump); + } catch { + log.warning( + `Invalid version bump type: ${inputBump}, defaulting to 'auto'`, + ); + versionBump = 'auto'; + } + } + + // Determine pre-release status + let preRelease = false; + if (isNightly) { + preRelease = true; + } else { + const inputPreRelease = process.env.INPUT_PRE_RELEASE || 'false'; + try { + preRelease = booleanString.parse(inputPreRelease); + } catch { + log.warning( + `Invalid pre-release value: ${inputPreRelease}, defaulting to false`, + ); + preRelease = false; + } + } + + // Determine if this is a promotion (stable release) + const isPromotion = !preRelease && !isNightly; + + const buildContext: BuildContext = { + isNightly, + versionBump, + preRelease, + isPromotion, + }; + + log.info('Build type determined:'); + log.info(` Is nightly: ${isNightly}`); + log.info(` Version bump: ${versionBump}`); + log.info(` Pre-release: ${preRelease}`); + log.info(` Is promotion: ${isPromotion}`); + + return buildContext; +} + +/** + * Set GitHub Actions outputs for build type + */ +export function setBuildTypeOutputs(buildContext: BuildContext): void { + setOutput('is-nightly', buildContext.isNightly.toString()); + setOutput('version-bump', buildContext.versionBump); + setOutput('pre-release', buildContext.preRelease.toString()); + setOutput('is-promotion', buildContext.isPromotion.toString()); + + log.success('Build type outputs set'); +} + +/** + * Main function for CLI usage + */ +export async function main(): Promise { + try { + const buildContext = determineBuildType(); + setBuildTypeOutputs(buildContext); + } catch (error) { + log.error(`Failed to determine build type: ${error}`); + process.exit(1); + } +} + +// Run if called directly +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/src/extension/ext-change-detector.ts b/src/extension/ext-change-detector.ts new file mode 100644 index 00000000..b61ffc18 --- /dev/null +++ b/src/extension/ext-change-detector.ts @@ -0,0 +1,426 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the + * repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { simpleGit } from 'simple-git'; +import { readdirSync, existsSync } from 'fs'; +import { join } from 'path'; +import { + BuildContext, + ChangeDetectionResult, + ExtensionInfo, + TagWithVersion, + SemanticVersion, +} from '../core/types.js'; +import { + log, + setOutput, + getExtensionInfo, + compareSemver, + extractVersionFromTag, +} from '../core/utils.js'; + +/** + * Get all available VS Code extensions (packages with publisher field) + */ +function getAvailableExtensions(): ExtensionInfo[] { + const extensions: ExtensionInfo[] = []; + const packagesRoot = process.env.PACKAGES_ROOT || 'packages'; + const packagesDir = join(process.cwd(), packagesRoot); + + if (!existsSync(packagesDir)) { + log.warning('packages directory not found'); + return extensions; + } + + const packageDirs = readdirSync(packagesDir, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => dirent.name); + + for (const packageName of packageDirs) { + const packagePath = join(packagesDir, packageName); + const packageJsonPath = join(packagePath, 'package.json'); + + if (existsSync(packageJsonPath)) { + try { + const info = getExtensionInfo(packagePath); + + // Only include packages that have a publisher (VS Code extensions) + if (info.publisher) { + extensions.push({ + name: packageName, + path: packagePath, + currentVersion: info.version, + publisher: info.publisher, + displayName: info.displayName, + }); + log.debug( + `Found VS Code extension: ${packageName} (publisher: ${info.publisher})`, + ); + } else { + log.debug(`Skipping NPM package: ${packageName} (no publisher)`); + } + } catch (error) { + log.warning(`Failed to read package.json for ${packageName}: ${error}`); + } + } + } + + return extensions; +} + +/** + * Check if extension has changes since last release + */ +async function hasExtensionChanges( + git: any, + extensionPath: string, + lastTag: string | null, +): Promise { + if (!lastTag) { + // No previous tag, check if extension has any files + const files = readdirSync(extensionPath, { recursive: true }); + return files.length > 0; + } + + try { + // Check for changes since the last release tag + const diff = await git.diff([lastTag, 'HEAD', '--', extensionPath]); + return diff.trim().length > 0; + } catch (error) { + log.warning(`Failed to check changes for ${extensionPath}: ${error}`); + return false; + } +} + +/** + * Find the last release tag for a specific extension + */ +async function findLastReleaseTagForExtension( + git: any, + extensionName: string, +): Promise { + try { + const tags = await git.tags(); + const allTags: TagWithVersion[] = tags.all + .filter((tag: string) => tag.startsWith(`${extensionName}-v`)) + .map((tag: string) => { + const version = extractVersionFromTag(tag); + return { tag, version }; + }); + + const extensionTags = allTags + .filter((item): item is { tag: string; version: SemanticVersion } => item.version !== null) // Filter out tags we couldn't parse + .sort((a, b) => + // Use proper semver comparison (descending order - newest first) + compareSemver(b.version, a.version), + ); + + return extensionTags.length > 0 ? extensionTags[0].tag : null; + } catch (error) { + log.warning(`Failed to get tags for ${extensionName}: ${error}`); + return null; + } +} + +/** + * Parse user-selected extensions from environment variable + */ +function parseUserSelectedExtensions( + selectedExtensionsInput?: string, +): string[] { + if (!selectedExtensionsInput || selectedExtensionsInput.trim() === '') { + log.info('No user selection provided - will use all available extensions'); + return []; + } + + const selected = selectedExtensionsInput + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + log.info(`User selected extensions: ${selected.join(', ')}`); + return selected; +} + +/** + * Intersect user selection with detected changes + */ +function intersectExtensions( + userSelected: string[], + changedExtensions: string[], + availableExtensions: ExtensionInfo[], + buildContext: BuildContext, +): string[] { + const availableNames = availableExtensions.map((e) => e.name); + + // If no user selection, use all changed extensions + if (userSelected.length === 0) { + log.info('No user selection - using all detected changes'); + return changedExtensions; + } + + // Handle special values + const normalizedSelection = userSelected.map((s) => s.toLowerCase()); + + if (normalizedSelection.includes('none')) { + log.info('User selected "none" - returning empty selection'); + return []; + } + + if (normalizedSelection.includes('all')) { + log.info('User selected "all" - using all available extensions'); + return availableNames; + } + + if (normalizedSelection.includes('changed')) { + log.info('User selected "changed" - using all detected changes'); + return changedExtensions; + } + + // Validate user selection against available extensions + const validUserSelected = userSelected.filter((ext) => { + if (!availableNames.includes(ext)) { + log.warning( + `User selected extension '${ext}' is not available - skipping`, + ); + return false; + } + return true; + }); + + if (validUserSelected.length === 0) { + log.warning('No valid extensions in user selection'); + return []; + } + + // For nightly builds and promotions, use user selection if provided + if (buildContext.isNightly || buildContext.isPromotion) { + const buildType = buildContext.isNightly ? 'Nightly' : 'Promotion'; + log.info( + `${buildType} build - using user selection: ${validUserSelected.join(', ')}`, + ); + return validUserSelected; + } + + // For regular builds, intersect user selection with detected changes + const intersection = validUserSelected.filter((ext) => + changedExtensions.includes(ext), + ); + + log.info(`User selection: ${validUserSelected.join(', ')}`); + log.info(`Detected changes: ${changedExtensions.join(', ')}`); + log.info(`Intersection: ${intersection.join(', ')}`); + + return intersection; +} + +/** + * Determine the highest required version bump from conventional commits since a tag. + * Returns 'major', 'minor', or 'patch'. + */ +async function detectBumpTypeFromCommits( + git: any, + extensionPath: string, + lastTag: string | null, +): Promise<'major' | 'minor' | 'patch'> { + try { + const range = lastTag ? `${lastTag}..HEAD` : 'HEAD'; + const log_ = await git.log({ + from: lastTag || undefined, + to: 'HEAD', + '--': null, + _: [extensionPath], + }); + const messages: string[] = log_.all.map((c: any) => c.message as string); + + let bump: 'major' | 'minor' | 'patch' = 'patch'; + for (const msg of messages) { + const firstLine = msg.split('\n')[0]; + const body = msg; + if ( + /BREAKING CHANGE/i.test(body) || + /^[a-z]+(\([^)]*\))?!:/i.test(firstLine) + ) { + return 'major'; + } + if (/^feat(\([^)]*\))?:/i.test(firstLine)) { + bump = 'minor'; + } + } + log.debug(`Detected bump type from commits (${range}): ${bump}`); + return bump; + } catch (error) { + log.warning(`Failed to analyze commits for bump type: ${error}`); + return 'patch'; + } +} + +/** + * Detect changes in extensions + */ +export async function detectExtensionChanges( + buildContext: BuildContext, + promotionCommitSha?: string, + userSelectedExtensions?: string, +): Promise { + log.info('Detecting changes in extensions...'); + log.debug(`Build context: ${JSON.stringify(buildContext)}`); + log.debug(`Promotion commit SHA: ${promotionCommitSha || 'none'}`); + log.debug(`User selected extensions: ${userSelectedExtensions || 'none'}`); + + const git = simpleGit(); + const extensions = getAvailableExtensions(); + const changedExtensions: string[] = []; + let versionBumps = buildContext.versionBump; + + log.info( + `Found ${extensions.length} extensions: ${extensions.map((e) => e.name).join(', ')}`, + ); + + // Parse user selection + const userSelected = parseUserSelectedExtensions(userSelectedExtensions); + + // For promotions, always include all extensions + if (buildContext.isPromotion) { + log.info('Promotion detected - including all extensions'); + changedExtensions.push(...extensions.map((e) => e.name)); + } + // For nightly and regular builds, check for changes since last release + else { + const buildType = buildContext.isNightly ? 'Nightly' : 'Regular'; + log.info(`${buildType} build - checking for changes...`); + + for (const extension of extensions) { + log.debug(`Checking extension: ${extension.name}`); + + // Find the last release tag for this specific extension + const lastTag = await findLastReleaseTagForExtension(git, extension.name); + + if (lastTag) { + log.info( + `Comparing ${extension.name} against last release tag: ${lastTag}`, + ); + } else { + log.info( + `No previous release tag found for ${extension.name} - treating as first release`, + ); + } + + const hasChanges = await hasExtensionChanges( + git, + extension.path, + lastTag, + ); + + if (hasChanges) { + log.info( + `Found changes in ${extension.name} - including in ${buildType.toLowerCase()} release`, + ); + changedExtensions.push(extension.name); + + // For nightly builds with auto bump type, analyze conventional commits + // to determine the appropriate bump level + if ( + buildContext.isNightly && + (buildContext.versionBump === 'auto' || + buildContext.versionBump === 'patch') + ) { + const detectedBump = await detectBumpTypeFromCommits( + git, + extension.path, + lastTag, + ); + if ( + detectedBump === 'major' || + (detectedBump === 'minor' && versionBumps !== 'major') + ) { + log.info( + `Upgrading bump type for ${extension.name}: ${versionBumps} → ${detectedBump} (conventional commits)`, + ); + versionBumps = detectedBump; + } + } + } else { + log.info( + `No changes found in ${extension.name} - skipping ${buildType.toLowerCase()} release`, + ); + } + } + } + + // Intersect user selection with detected changes + const finalSelectedExtensions = intersectExtensions( + userSelected, + changedExtensions, + extensions, + buildContext, + ); + + log.info(`Final selected extensions: ${finalSelectedExtensions.join(', ')}`); + log.info(`Version bump type: ${versionBumps}`); + + return { + selectedExtensions: finalSelectedExtensions, + versionBumps, + promotionCommitSha, + }; +} + +/** + * Set GitHub Actions outputs for change detection + */ +export function setChangeDetectionOutputs(result: ChangeDetectionResult): void { + setOutput('selected-extensions', result.selectedExtensions.join(',')); + setOutput('version-bumps', result.versionBumps); + if (result.promotionCommitSha) { + setOutput('promotion-commit-sha', result.promotionCommitSha); + } + + log.success('Change detection outputs set'); +} + +/** + * Main function for CLI usage + */ +export async function main(): Promise { + try { + // For CLI usage, we need to parse the build context from environment + // This would typically come from the previous job's outputs + const isNightly = process.env.IS_NIGHTLY === 'true'; + const versionBump = (process.env.VERSION_BUMP as any) || 'auto'; + const preRelease = process.env.PRE_RELEASE === 'true'; + const isPromotion = process.env.IS_PROMOTION === 'true'; + const promotionCommitSha = process.env.PROMOTION_COMMIT_SHA; + const userSelectedExtensions = process.env.SELECTED_EXTENSIONS; + log.info( + `Raw SELECTED_EXTENSIONS env var: "${process.env.SELECTED_EXTENSIONS}"`, + ); + + const buildContext: BuildContext = { + isNightly, + versionBump, + preRelease, + isPromotion, + promotionCommitSha, + }; + + const result = await detectExtensionChanges( + buildContext, + promotionCommitSha, + userSelectedExtensions, + ); + setChangeDetectionOutputs(result); + } catch (error) { + log.error(`Failed to determine changes: ${error}`); + process.exit(1); + } +} + +// Run if called directly +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/src/extension/ext-github-releases.ts b/src/extension/ext-github-releases.ts new file mode 100644 index 00000000..7f4380e5 --- /dev/null +++ b/src/extension/ext-github-releases.ts @@ -0,0 +1,347 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the + * repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { execSync } from 'child_process'; +import { readFileSync, writeFileSync, unlinkSync } from 'fs'; +import { basename, join } from 'path'; +import { glob } from 'glob'; + +interface PackageJson { + name: string; + version: string; + publisher?: string; + displayName?: string; +} + +interface GitHubReleaseOptions { + dryRun: boolean; + preRelease: string; + versionBump: string; + selectedExtensions: string; + isNightly: string; + vsixArtifactsPath: string; +} + +function getPackageDetails(extensionPath: string): PackageJson | null { + try { + const packageJsonPath = join( + process.cwd(), + 'packages', + extensionPath, + 'package.json', + ); + const content = readFileSync(packageJsonPath, 'utf-8'); + return JSON.parse(content); + } catch (error) { + console.warn( + `Warning: Could not read package.json for ${extensionPath}:`, + error, + ); + return null; + } +} + +function findVsixFiles(extension: string, artifactsPath: string): string[] { + try { + // Map extension names to their actual VSIX file patterns + let vsixPattern: string; + switch (extension) { + case 'apex-lsp-vscode-extension': + vsixPattern = '*apex-language-server-extension-*.vsix'; + break; + default: + vsixPattern = `*${extension}*.vsix`; + } + + // Artifacts are organized in subdirectories: vsix-artifacts/extension-name/file.vsix + // Try both the subdirectory and root level + const patterns = [ + join(artifactsPath, extension, vsixPattern), // Subdirectory structure + join(artifactsPath, '**', vsixPattern), // Recursive search as fallback + join(artifactsPath, vsixPattern), // Root level as fallback + ]; + + const foundFiles: string[] = []; + for (const pattern of patterns) { + const files = glob.sync(pattern); + if (files.length > 0) { + foundFiles.push(...files); + break; // Found files, no need to check other patterns + } + } + + // Universal VSIX only: exclude legacy vsce --target web builds (*-web-* in filename) + return foundFiles.filter((f) => !basename(f).includes('-web-')); + } catch (error) { + console.warn(`Warning: Could not find VSIX files for ${extension}:`, error); + return []; + } +} + +function generateReleaseNotes( + extension: string, + currentVersion: string, + isNightly: string, + preRelease: string, +): string { + let releaseNotes = `## ${extension} v${currentVersion}\n\n`; + releaseNotes += '### Changes\n\n'; + + try { + // Find the last release tag for this extension + const lastTag = execSync( + 'git tag --sort=-version:refname | grep "^v" | head -1', + { encoding: 'utf8' }, + ).trim(); + + if (lastTag) { + // Get commits since the last release + const recentCommits = execSync( + `git log --oneline "${lastTag}"..HEAD -- "packages/${extension}/"`, + { encoding: 'utf8' }, + ).trim(); + if (recentCommits) { + const commits = recentCommits.split('\n').filter(Boolean); + commits.forEach((commit) => { + releaseNotes += `- ${commit}\n`; + }); + } else { + releaseNotes += '- General improvements and bug fixes\n'; + } + } else { + // First release - get all commits for this extension + const allCommits = execSync( + `git log --oneline -- "packages/${extension}/"`, + { encoding: 'utf8' }, + ).trim(); + if (allCommits) { + const commits = allCommits.split('\n').filter(Boolean); + commits.forEach((commit) => { + releaseNotes += `- ${commit}\n`; + }); + } else { + releaseNotes += '- Initial release\n'; + } + } + } catch (error) { + console.warn( + `Warning: Could not generate release notes for ${extension}:`, + error, + ); + releaseNotes += '- General improvements and bug fixes\n'; + } + + releaseNotes += '\n### Installation\n\n'; + releaseNotes += 'Download the VSIX file and install via:\n'; + releaseNotes += '- VS Code: Install from VSIX...\n'; + releaseNotes += '- Command line: `code --install-extension `\n'; + + if (preRelease === 'true') { + releaseNotes += '\n⚠️ **This is a pre-release version**\n'; + } + + if (isNightly === 'true') { + const nightlyDate = new Date() + .toISOString() + .split('T')[0] + .replace(/-/g, ''); + releaseNotes += `\n🌙 **This is a nightly build from ${nightlyDate}**\n`; + releaseNotes += '\n### Nightly Build Information\n'; + releaseNotes += `- **Build Date**: ${nightlyDate}\n`; + releaseNotes += `- **Version**: ${currentVersion} (odd minor for pre-release)\n`; + releaseNotes += '- **Type**: Nightly pre-release for testing\n'; + } + + return releaseNotes; +} + +function createGitHubRelease( + extension: string, + currentVersion: string, + releaseNotes: string, + vsixFiles: string[], + isNightly: string, + preRelease: string, + dryRun: boolean, +): void { + // Create release tag + let releaseTag = `v${currentVersion}`; + let releaseTitle = `${extension} v${currentVersion}`; + + // For nightly builds, add timestamp and branch to tag and title + if (isNightly === 'true') { + const nightlyDate = new Date() + .toISOString() + .split('T')[0] + .replace(/-/g, ''); + const branch = process.env.BRANCH || 'main'; + // Format branch name: main -> no suffix, tdx26/main -> .tdx26-main + const branchSuffix = + branch === 'main' ? '' : `.${branch.replace(/\//g, '-')}`; + releaseTag = `v${currentVersion}-nightly${branchSuffix}.${nightlyDate}`; + releaseTitle = `${extension} v${currentVersion} (Nightly ${branch} ${nightlyDate})`; + } + + if (dryRun) { + console.log('✅ DRY RUN: Would create GitHub release:'); + console.log(` - Tag: ${releaseTag}`); + console.log(` - Title: ${releaseTitle}`); + console.log(` - Pre-release: ${preRelease}`); + console.log(` - VSIX files: ${vsixFiles.join(', ')}`); + console.log(' - Release notes preview:'); + console.log(releaseNotes.split('\n').slice(0, 20).join('\n')); + console.log(' ... (truncated)'); + } else { + console.log('🔄 LIVE: Creating GitHub release...'); + console.log(`Creating release: ${releaseTitle}`); + console.log(`Tag: ${releaseTag}`); + console.log(`Pre-release: ${preRelease}`); + + try { + const repo = process.env.GITHUB_REPOSITORY; + const vsixArgs = vsixFiles.map((file) => `"${file}"`).join(' '); + + // Check if release already exists (idempotency) + let releaseExists = false; + let hasAssets = false; + try { + const viewOutput = execSync( + `gh release view "${releaseTag}" --repo "${repo}" --json assets`, + { encoding: 'utf8' }, + ); + const releaseData = JSON.parse(viewOutput); + releaseExists = true; + hasAssets = + Array.isArray(releaseData.assets) && releaseData.assets.length > 0; + } catch { + // Release does not exist — proceed to create + } + + if (releaseExists && hasAssets) { + console.log( + `⏭️ Release ${releaseTag} already exists with assets — skipping`, + ); + } else if (releaseExists) { + console.log( + `📎 Release ${releaseTag} exists but has no assets — uploading`, + ); + execSync( + `gh release upload "${releaseTag}" ${vsixArgs} --repo "${repo}"`, + { stdio: 'inherit' }, + ); + console.log(`✅ Assets uploaded to existing release for ${extension}`); + } else { + // Verify the tag exists locally before attempting release creation + try { + execSync(`git rev-parse "${releaseTag}"`, { encoding: 'utf8', stdio: 'pipe' }); + } catch { + // Tag doesn't exist locally — gh release create will create one from HEAD + } + + // Write release notes to a temporary file to avoid shell escaping issues + const notesFile = join(process.cwd(), `.release-notes-${Date.now()}.tmp`); + try { + writeFileSync(notesFile, releaseNotes, 'utf8'); + } catch (writeError) { + console.error(`Failed to write release notes file: ${writeError}`); + throw writeError; + } + + const command = + `gh release create "${releaseTag}" --title "${releaseTitle}" ` + + `--notes-file "${notesFile}" --prerelease="${preRelease}" ` + + `--repo "${repo}" ${vsixArgs}`; + + try { + execSync(command, { stdio: 'inherit' }); + + // Clean up notes file after successful creation + try { + unlinkSync(notesFile); + } catch (cleanupError) { + console.warn(`Warning: Failed to clean up notes file ${notesFile}: ${cleanupError}`); + } + console.log(`✅ Release created for ${extension}`); + } catch (createError) { + // Clean up notes file even on error + try { + unlinkSync(notesFile); + } catch (cleanupError) { + console.warn(`Warning: Failed to clean up notes file ${notesFile}: ${cleanupError}`); + } + throw createError; + } + } + } catch (error) { + console.error(`Failed to create release for ${extension}:`, error); + throw error; + } + } +} + +function createGitHubReleases(options: GitHubReleaseOptions): void { + const { + dryRun, + preRelease, + versionBump, + selectedExtensions, + isNightly, + vsixArtifactsPath, + } = options; + + console.log(`Mode: ${dryRun ? 'DRY RUN' : 'LIVE'}`); + console.log('Creating GitHub releases...'); + console.log(`Pre-release: ${preRelease}`); + console.log(`Version bump: ${versionBump}`); + console.log(`Extensions: ${selectedExtensions}`); + + const extensions = selectedExtensions.split(',').filter(Boolean); + + for (const ext of extensions) { + const packageDetails = getPackageDetails(ext); + if (!packageDetails) { + console.warn(`Skipping ${ext}: package.json not found`); + continue; + } + + console.log(`Processing extension: ${ext}`); + console.log(`Current version: ${packageDetails.version}`); + + const vsixFiles = findVsixFiles(ext, vsixArtifactsPath); + if (vsixFiles.length === 0) { + console.warn(`No VSIX files found for ${ext} in ${vsixArtifactsPath}`); + continue; + } + + const releaseNotes = generateReleaseNotes( + ext, + packageDetails.version, + isNightly, + preRelease, + ); + + createGitHubRelease( + ext, + packageDetails.version, + releaseNotes, + vsixFiles, + isNightly, + preRelease, + dryRun, + ); + } + + if (dryRun) { + console.log('✅ DRY RUN: GitHub release simulation completed'); + } else { + console.log('✅ LIVE: GitHub releases created'); + } +} + +// Export for use in other modules +export { createGitHubReleases }; diff --git a/src/extension/ext-nightly-finder.ts b/src/extension/ext-nightly-finder.ts new file mode 100644 index 00000000..0f97a003 --- /dev/null +++ b/src/extension/ext-nightly-finder.ts @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the + * repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import simpleGit from 'simple-git'; +import semver from 'semver'; +import { log, setOutput, extractVersionFromTag } from '../core/utils.js'; +import type { SemanticVersion } from '../core/types.js'; + +type SimpleGitType = ReturnType; + +export interface NightlyCandidate { + tag: string; + commitSha: string; + commitDate: number; + version: SemanticVersion; +} + +/** + * Nightly tag format: -v-nightly. + * or the legacy format: v-nightly. + * We match both by looking for "-nightly." in the tag name. + */ +function parseNightlyTag( + tagName: string, +): { version: SemanticVersion } | null { + if (!tagName.includes('-nightly.')) { + return null; + } + const version = extractVersionFromTag(tagName); + if (!version) { + return null; + } + return { version }; +} + +/** + * Check whether a tracking tag matching the given prefix exists in the tag list. + */ +function hasTrackingTag(allTagNames: Set, prefix: string): boolean { + for (const tag of allTagNames) { + if (tag.startsWith(prefix)) { + return true; + } + } + return false; +} + +/** + * Get all git tags with commit metadata. + */ +async function getAllTagsWithMeta( + git: SimpleGitType, +): Promise<{ name: string; commitSha: string; commitDate: number }[]> { + const tags = await git.tags(); + const result: { name: string; commitSha: string; commitDate: number }[] = []; + + for (const tagName of tags.all) { + try { + const logResult = await git.log({ + from: tagName, + to: tagName, + maxCount: 1, + }); + if (logResult.latest) { + const commitDate = + new Date(logResult.latest.date).getTime() / 1000; + result.push({ + name: tagName, + commitSha: logResult.latest.hash, + commitDate, + }); + } + } catch { + log.warning(`Failed to get metadata for tag ${tagName} — skipping`); + } + } + + // Newest first + return result.sort((a, b) => b.commitDate - a.commitDate); +} + +/** + * Find the best nightly build eligible for promotion to pre-release. + * + * Filters applied (all must pass): + * 1. Tag format must match nightly pattern (contains "-nightly.") + * 2. Tag must be at least MIN_TAG_AGE_DAYS days old (default 7) + * 3. No existing marketplace-prerelease-* tracking tag for this version + * (nightly was already promoted to pre-release) + * 4. Floor check: no marketplace-stable-* tag for the derived stable version + * semver.inc(nightlyVersion, 'minor') — prevents re-promoting a version + * track that was already published as stable + * + * Returns the newest passing candidate. + */ +export async function findNightlyCandidate(): Promise { + const minAgeDays = parseInt(process.env.MIN_TAG_AGE_DAYS ?? '7', 10); + const minAgeSeconds = minAgeDays * 24 * 60 * 60; + const now = Math.floor(Date.now() / 1000); + + log.info(`Finding nightly candidate (min age: ${minAgeDays} days)...`); + + const git: SimpleGitType = simpleGit(); + + const allTagsWithMeta = await getAllTagsWithMeta(git); + const allTagNames = new Set(allTagsWithMeta.map((t) => t.name)); + + const candidates: NightlyCandidate[] = []; + + for (const { name, commitSha, commitDate } of allTagsWithMeta) { + const parsed = parseNightlyTag(name); + if (!parsed) { + continue; + } + const { version } = parsed; + + // Filter 1: minimum age + const ageSeconds = now - commitDate; + if (ageSeconds < minAgeSeconds) { + log.debug( + `Skipping ${name}: too recent (${Math.floor(ageSeconds / 86400)} days old, need ${minAgeDays})`, + ); + continue; + } + + // Filter 2: not already promoted to pre-release + const tagPrefix = process.env.TAG_PREFIX || 'marketplace'; + const preReleaseTrackingPrefix = `${tagPrefix}-prerelease-`; + if (hasTrackingTag(allTagNames, `${preReleaseTrackingPrefix}`)) { + // Check specifically for this version + const versionSpecificPrefix = `${tagPrefix}-prerelease-apex-lsp-vscode-extension-v${version}`; + if (hasTrackingTag(allTagNames, versionSpecificPrefix)) { + log.debug( + `Skipping ${name}: already has ${tagPrefix}-prerelease tracking tag for v${version}`, + ); + continue; + } + } + + // Filter 3: floor check — derived stable version not already published + const derivedStable = semver.inc(version, 'minor'); + if (derivedStable) { + const stableTrackingPrefix = `${tagPrefix}-stable-apex-lsp-vscode-extension-v${derivedStable}`; + if (hasTrackingTag(allTagNames, stableTrackingPrefix)) { + log.debug( + `Skipping ${name}: derived stable v${derivedStable} already published`, + ); + continue; + } + } + + candidates.push({ tag: name, commitSha, commitDate, version }); + } + + if (candidates.length === 0) { + log.warning('No eligible nightly candidates found'); + return null; + } + + // Already sorted newest-first; pick first + const best = candidates[0]; + log.success(`Selected nightly candidate: ${best.tag}`); + log.info(` Commit SHA: ${best.commitSha}`); + log.info(` Version: ${best.version}`); + log.info( + ` Age: ${Math.floor((now - best.commitDate) / 86400)} days`, + ); + + return best; +} + +/** + * Set GitHub Actions outputs for the nightly candidate. + * Outputs commit-sha and nightly-tag (empty strings if no candidate). + */ +export function setNightlyFinderOutputs( + candidate: NightlyCandidate | null, +): void { + setOutput('commit-sha', candidate?.commitSha ?? ''); + setOutput('nightly-tag', candidate?.tag ?? ''); + log.success('Nightly finder outputs set'); +} + +/** + * Main function for CLI usage via index.ts. + */ +export async function main(): Promise { + try { + const candidate = await findNightlyCandidate(); + setNightlyFinderOutputs(candidate); + } catch (error) { + log.error(`Failed to find nightly candidate: ${error}`); + process.exit(1); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/src/extension/ext-package-selector.ts b/src/extension/ext-package-selector.ts new file mode 100644 index 00000000..bc7e4214 --- /dev/null +++ b/src/extension/ext-package-selector.ts @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the + * repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { readdirSync, existsSync } from 'fs'; +import { join } from 'path'; +import { log, setOutput, getExtensionInfo } from '../core/utils.js'; + +/** + * Get all available VS Code extensions + */ +export function getAvailableExtensions(): string { + log.info('Getting all available VS Code extensions...'); + + // Get all packages from the packages directory (configurable via PACKAGES_ROOT) + const packagesRoot = process.env.PACKAGES_ROOT || 'packages'; + const packagesDir = join(process.cwd(), packagesRoot); + const extensions: string[] = []; + + if (!existsSync(packagesDir)) { + log.warning('packages directory not found'); + return '[]'; + } + + const packageDirs = readdirSync(packagesDir, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => dirent.name); + + for (const packageName of packageDirs) { + const packagePath = join(packagesDir, packageName); + const packageJsonPath = join(packagePath, 'package.json'); + + if (existsSync(packageJsonPath)) { + try { + const info = getExtensionInfo(packagePath); + + // Only include packages that have a publisher (VS Code extensions) + if (info.publisher) { + extensions.push(packageName); + log.debug( + `Found VS Code extension: ${packageName} (publisher: ${info.publisher})`, + ); + } else { + log.debug(`Skipping NPM package: ${packageName} (no publisher)`); + } + } catch (error) { + log.warning(`Failed to read package.json for ${packageName}: ${error}`); + } + } + } + + const jsonArray = JSON.stringify(extensions); + log.info( + `Found ${extensions.length} VS Code extensions: ${extensions.join(', ')}`, + ); + log.debug(`JSON array: ${jsonArray}`); + + return jsonArray; +} + +/** + * Set GitHub Actions outputs for extension discovery + */ +export function setExtensionDiscoveryOutputs(extensions: string): void { + setOutput('extensions', extensions); + + log.success('Extension discovery outputs set'); +} + +/** + * Main function for CLI usage + */ +export async function main(): Promise { + try { + const extensions = getAvailableExtensions(); + setExtensionDiscoveryOutputs(extensions); + } catch (error) { + log.error(`Failed to discover extensions: ${error}`); + process.exit(1); + } +} + +// Run if called directly +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/src/extension/ext-publish-matrix.ts b/src/extension/ext-publish-matrix.ts new file mode 100644 index 00000000..384636ba --- /dev/null +++ b/src/extension/ext-publish-matrix.ts @@ -0,0 +1,152 @@ +#!/usr/bin/env tsx +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the + * repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { log } from '../core/utils.js'; +import { readdirSync, existsSync, readFileSync } from 'fs'; +import { join } from 'path'; + +interface PublishMatrixEntry { + registry: string; + vsix_pattern: string; + marketplace: string; +} + +interface PublishMatrixOptions { + registries: string; + selectedExtensions: string; +} + +/** + * Get all available VS Code extensions (packages with publisher field) + */ +function getAvailableExtensions(): string[] { + const extensions: string[] = []; + const packagesDir = join(process.cwd(), 'packages'); + + if (!existsSync(packagesDir)) { + log.warning('packages directory not found'); + return extensions; + } + + const packageDirs = readdirSync(packagesDir, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => dirent.name); + + for (const packageName of packageDirs) { + const packagePath = join(packagesDir, packageName); + const packageJsonPath = join(packagePath, 'package.json'); + + if (existsSync(packageJsonPath)) { + try { + const packageJson = JSON.parse( + readFileSync(packageJsonPath, 'utf-8'), + ); + + // Only include packages that have a publisher (VS Code extensions) + if (packageJson.publisher) { + extensions.push(packageName); + log.debug( + `Found VS Code extension: ${packageName} (publisher: ${packageJson.publisher})`, + ); + } else { + log.debug(`Skipping NPM package: ${packageName} (no publisher)`); + } + } catch (error) { + log.warning(`Failed to read package.json for ${packageName}: ${error}`); + } + } + } + + return extensions; +} + +function getVsixPattern(extension: string): string { + switch (extension) { + case 'apex-lsp-vscode-extension': + // Universal VSIX (main + browser); nightly-extensions publish uses explicit find excluding *-web-* + return '*apex-language-server-extension*-[0-9]*.vsix'; + default: + return `*${extension}*.vsix`; + } +} + +function getMarketplaceName(registry: string): string { + switch (registry) { + case 'vsce': + return 'VS Code Marketplace'; + case 'ovsx': + return 'Open VSX Registry'; + default: + return registry; + } +} + +function determinePublishMatrix( + options: PublishMatrixOptions, +): PublishMatrixEntry[] { + const { registries, selectedExtensions } = options; + + // Handle special values and empty/undefined selectedExtensions + if (!selectedExtensions || selectedExtensions.trim() === '') { + log.info('No extensions selected for publishing, returning empty matrix'); + return []; + } + + // Handle special values + const normalizedSelection = selectedExtensions.trim().toLowerCase(); + if (normalizedSelection === 'none') { + log.info('Extensions set to "none" - returning empty matrix'); + return []; + } + + // Determine which extensions to include + let extensions: string[]; + if (normalizedSelection === 'all') { + log.info('Extensions set to "all" - including all available extensions'); + extensions = getAvailableExtensions(); + } else { + // Parse comma-separated list of specific extensions + extensions = selectedExtensions.split(',').filter(Boolean); + } + + if (extensions.length === 0) { + log.info('No extensions to publish, returning empty matrix'); + return []; + } + + // Determine which registries to include + const registryList = + registries === 'all' + ? ['vsce', 'ovsx'] + : registries.split(',').filter(Boolean); + + // Create matrix entries for each extension-registry combination + const matrix: PublishMatrixEntry[] = []; + + for (const ext of extensions) { + if (!ext) continue; + + const vsixPattern = getVsixPattern(ext); + + for (const registry of registryList) { + const marketplace = getMarketplaceName(registry); + + matrix.push({ + registry, + vsix_pattern: vsixPattern, + marketplace, + }); + } + } + log.info(`Publish matrix: ${JSON.stringify(matrix, null, 2)}`); + return matrix; +} + +// Export for use in other modules +export { determinePublishMatrix }; diff --git a/src/extension/ext-release-plan.ts b/src/extension/ext-release-plan.ts new file mode 100644 index 00000000..7b432ec0 --- /dev/null +++ b/src/extension/ext-release-plan.ts @@ -0,0 +1,167 @@ +#!/usr/bin/env tsx +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the + * repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +/** + * Extension Release Plan Display Script + * + * This script displays a detailed release plan for VS Code extensions during dry runs. + * It shows what would happen for each extension including version bumps, release creation, + * and marketplace publishing. + */ + +import { readFileSync } from 'fs'; +import { join } from 'path'; + +interface PackageJson { + name: string; + version: string; + publisher?: string; + displayName?: string; +} + +interface ReleasePlanOptions { + branch?: string; + buildType: string; + isNightly: string; + versionBump: string; + registries: string; + preRelease: string; + selectedExtensions: string; +} + +function parseVersion(version: string): { + major: number; + minor: number; + patch: number; +} { + const [major, minor, patch] = version.split('.').map(Number); + return { major, minor, patch }; +} + +function calculateNewVersion( + currentVersion: string, + versionBump: string, +): string { + const { major, minor, patch } = parseVersion(currentVersion); + + switch (versionBump) { + case 'major': + return `${major + 1}.0.0`; + case 'minor': + return `${major}.${minor + 1}.0`; + case 'patch': + return `${major}.${minor}.${patch + 1}`; + case 'auto': + default: + return `${major}.${minor}.${patch + 1}`; + } +} + +function getPackageDetails(extensionPath: string): PackageJson | null { + try { + const packageJsonPath = join( + process.cwd(), + 'packages', + extensionPath, + 'package.json', + ); + const content = readFileSync(packageJsonPath, 'utf-8'); + return JSON.parse(content); + } catch (error) { + console.warn( + `Warning: Could not read package.json for ${extensionPath}:`, + error, + ); + return null; + } +} + +function displayReleasePlan(options: ReleasePlanOptions): void { + const { + branch = 'main', + buildType, + isNightly, + versionBump, + registries, + preRelease, + selectedExtensions, + } = options; + + console.log('=== EXTENSION RELEASE PLAN ==='); + console.log(`Branch: ${branch}`); + console.log(`Build type: ${buildType}`); + console.log(`Is nightly: ${isNightly}`); + console.log(`Version bump type: ${versionBump}`); + console.log(`Registries: ${registries}`); + console.log(`Pre-release: ${preRelease}`); + console.log('Dry run mode: ENABLED'); + console.log(''); + + console.log(`Extensions to release: ${selectedExtensions}`); + console.log(''); + + const extensions = selectedExtensions.split(',').filter(Boolean); + + for (const ext of extensions) { + const packageDetails = getPackageDetails(ext); + if (!packageDetails) { + console.log(`Extension: ${ext} (package.json not found)`); + continue; + } + + console.log(`Extension: ${ext}`); + console.log(` Current version: ${packageDetails.version}`); + console.log(` Publisher: ${packageDetails.publisher || 'N/A'}`); + + const newVersion = calculateNewVersion(packageDetails.version, versionBump); + console.log(` Would bump to: ${newVersion}`); + + if (isNightly === 'true') { + console.log( + ' Version strategy: Nightly build (odd minor + nightly timestamp)', + ); + } else { + console.log( + ` Version strategy: ${versionBump} (conventional commit) + VS Code even/odd (pre-release: ${preRelease})`, + ); + } + + const preReleaseText = preRelease === 'true' ? ' (pre-release)' : ''; + console.log(` Would create GitHub release: ${ext}${preReleaseText}`); + + // Determine which registries to include (same logic as ext-publish-matrix.ts) + const registryList = + registries === 'all' + ? ['vsce', 'ovsx'] + : registries.split(',').filter(Boolean); + + // Show publishing destinations for each registry + for (const registry of registryList) { + switch (registry) { + case 'vsce': + console.log( + ` Would publish to: VSCode Marketplace${preReleaseText}`, + ); + break; + case 'ovsx': + console.log(` Would publish to: Open VSX Registry${preReleaseText}`); + break; + default: + console.log(` Would publish to: ${registry}${preReleaseText}`); + break; + } + } + console.log(''); + } + + console.log('✅ Extension release dry run completed'); +} + +// Export for use in other modules +export { displayReleasePlan as displayExtensionReleasePlan }; diff --git a/src/extension/ext-version-bumper.ts b/src/extension/ext-version-bumper.ts new file mode 100644 index 00000000..57e96d35 --- /dev/null +++ b/src/extension/ext-version-bumper.ts @@ -0,0 +1,288 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the + * repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { execSync } from 'child_process'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +interface PackageJson { + name: string; + version: string; + publisher?: string; + displayName?: string; +} + +interface VersionBumpOptions { + versionBump: string; + selectedExtensions: string; + preRelease: string; + isNightly: string; + isPromotion: string; + promotionCommitSha?: string; +} + +// Export for use in other modules +export type { VersionBumpOptions }; + +function parseVersion(version: string): { + major: number; + minor: number; + patch: number; +} { + const [major, minor, patch] = version.split('.').map(Number); + return { major, minor, patch }; +} + +function calculateNewVersion( + currentVersion: string, + versionBump: string, + isNightly: boolean, + isPromotion: boolean, + preRelease: boolean, +): string { + const { major, minor, patch } = parseVersion(currentVersion); + + if (isNightly) { + // Nightly build strategy: respect conventional commit bump type, enforce odd minor + if (versionBump === 'major') { + // Breaking change: new major, start at first odd minor + return `${major + 1}.1.0`; + } else if (versionBump === 'minor') { + // New feature: skip to next odd minor (even minors reserved for stable) + const nextMinor = minor % 2 === 0 ? minor + 1 : minor + 2; + return `${major}.${nextMinor}.0`; + } else { + // Patch / auto / default: ensure odd minor then increment patch + if (minor % 2 === 0) { + // Even minor — enter nightly track at next odd minor + return `${major}.${minor + 1}.0`; + } + return `${major}.${minor}.${patch + 1}`; + } + } else if (isPromotion) { + // Promotion strategy: bump from odd minor (nightly) to even minor (stable) + if (minor % 2 === 1) { + // Current is odd (nightly), bump to next even (stable) + return `${major}.${minor + 1}.0`; + } else { + // Current is already even, this shouldn't happen for promotions + console.warn( + 'Warning: Current version has even minor, expected odd for promotion', + ); + return `${major}.${minor + 2}.0`; + } + } else { + // Regular build strategy: use smart version bumping + switch (versionBump) { + case 'patch': + return `${major}.${minor}.${patch + 1}`; + case 'minor': + if (preRelease) { + // Pre-release: ensure odd minor version (no auto-update) + if (minor % 2 === 0) { + return `${major}.${minor + 1}.0`; + } else { + return `${major}.${minor + 2}.0`; + } + } else { + // Stable release: ensure even minor version (auto-update enabled) + if (minor % 2 === 1) { + return `${major}.${minor + 1}.0`; + } else { + return `${major}.${minor + 2}.0`; + } + } + case 'major': + if (preRelease) { + return `${major + 1}.1.0`; // Pre-release: start with odd minor + } else { + return `${major + 1}.0.0`; // Stable release: start with even minor + } + case 'auto': + default: + return `${major}.${minor}.${patch + 1}`; + } + } +} + +function getPackageDetails(extensionPath: string): PackageJson | null { + try { + const packageJsonPath = join( + process.cwd(), + 'packages', + extensionPath, + 'package.json', + ); + const content = readFileSync(packageJsonPath, 'utf-8'); + return JSON.parse(content); + } catch (error) { + console.warn( + `Warning: Could not read package.json for ${extensionPath}:`, + error, + ); + return null; + } +} + +function createGitTag( + packageName: string, + version: string, + isPreRelease: boolean, + promotionCommitSha?: string, + isNightly?: boolean, +): void { + // For nightly builds, create tag in format: v{version}-nightly.{date} + // This matches what GitHub release creation expects + let tagName: string; + if (isNightly) { + const nightlyDate = new Date() + .toISOString() + .split('T')[0] + .replace(/-/g, ''); + const branch = process.env.BRANCH || 'main'; + const branchSuffix = + branch === 'main' ? '' : `.${branch.replace(/\//g, '-')}`; + tagName = `v${version}-nightly${branchSuffix}.${nightlyDate}`; + } else { + // For non-nightly builds, use package name format + tagName = isPreRelease + ? `${packageName}-v${version}-pre-release` + : `${packageName}-v${version}`; + } + + try { + // Check if tag already exists locally or remotely (idempotency) + let tagExists = false; + try { + // Check local tags first + execSync(`git rev-parse "${tagName}"`, { encoding: 'utf8', stdio: 'pipe' }); + tagExists = true; + } catch { + // Tag doesn't exist locally, check remote + try { + execSync(`git ls-remote --tags origin "${tagName}"`, { encoding: 'utf8', stdio: 'pipe' }); + tagExists = true; + } catch { + // Tag doesn't exist locally or remotely, proceed to create + tagExists = false; + } + } + + if (tagExists) { + console.log(`⏭️ Tag ${tagName} already exists — skipping (idempotent rerun)`); + return; + } + + if (promotionCommitSha) { + // For promotions, create tag on specific commit + console.log( + `Creating tag ${tagName} on promotion commit ${promotionCommitSha}...`, + ); + execSync(`git tag "${tagName}" "${promotionCommitSha}"`, { + stdio: 'inherit', + }); + } else { + // For regular builds, create tag on current commit + console.log(`Creating tag ${tagName} on current commit...`); + execSync(`git tag "${tagName}"`, { + stdio: 'inherit', + }); + } + console.log(`✅ Tag created: ${tagName}`); + } catch (error) { + console.error(`Failed to create tag ${tagName}:`, error); + throw error; + } +} + +function bumpVersions(options: VersionBumpOptions): void { + const { + versionBump, + selectedExtensions, + preRelease, + isNightly, + isPromotion, + promotionCommitSha, + } = options; + + console.log(`Version bump type: ${versionBump}`); + console.log(`Selected extensions: ${selectedExtensions}`); + console.log(`Pre-release mode: ${preRelease}`); + console.log(`Is nightly build: ${isNightly}`); + console.log(`Is promotion: ${isPromotion}`); + console.log(`Promotion commit SHA: ${promotionCommitSha || 'N/A'}`); + + const extensions = selectedExtensions.split(',').filter(Boolean); + + for (const ext of extensions) { + const packageDetails = getPackageDetails(ext); + if (!packageDetails) { + console.warn(`Skipping ${ext}: package.json not found`); + continue; + } + + console.log(`Processing ${ext}...`); + console.log(`Current version: ${packageDetails.version}`); + + const newVersion = calculateNewVersion( + packageDetails.version, + versionBump, + isNightly === 'true', + isPromotion === 'true', + preRelease === 'true', + ); + + console.log( + `🔄 Bumping ${ext} from ${packageDetails.version} to ${newVersion}`, + ); + + // Update package.json version + const originalDir = process.cwd(); + try { + process.chdir(join(originalDir, 'packages', ext)); + execSync(`npm version "${newVersion}" --no-git-tag-version`, { + stdio: 'inherit', + }); + process.chdir(originalDir); + + // Create git tag for this extension + const isNightlyBuild = isNightly === 'true'; + let expectedTagName: string; + if (isNightlyBuild) { + const nightlyDate = new Date() + .toISOString() + .split('T')[0] + .replace(/-/g, ''); + const branch = process.env.BRANCH || 'main'; + const branchSuffix = + branch === 'main' ? '' : `.${branch.replace(/\//g, '-')}`; + expectedTagName = `v${newVersion}-nightly${branchSuffix}.${nightlyDate}`; + } else { + expectedTagName = preRelease === 'true' + ? `${packageDetails.name}-v${newVersion}-pre-release` + : `${packageDetails.name}-v${newVersion}`; + } + createGitTag( + packageDetails.name, + newVersion, + preRelease === 'true', + promotionCommitSha, + isNightlyBuild, + ); + } catch (error) { + console.error(`Failed to bump version for ${ext}:`, error); + process.chdir(originalDir); + throw error; + } + } + + console.log('✅ Version bumps and tags applied'); +} + +// Export for use in other modules +export { bumpVersions }; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..e51351e5 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE file in the + * repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +// Core utilities and types +export * from './core/types.js'; +export * from './core/utils.js'; +export * from './core/audit-logger.js'; + +// Extension management +export { determineBuildType, setBuildTypeOutputs } from './extension/ext-build-type.js'; +export { detectExtensionChanges, setChangeDetectionOutputs } from './extension/ext-change-detector.js'; +export { createGitHubReleases } from './extension/ext-github-releases.js'; +export { findNightlyCandidate, setNightlyFinderOutputs } from './extension/ext-nightly-finder.js'; +export { getAvailableExtensions, setExtensionDiscoveryOutputs } from './extension/ext-package-selector.js'; +export { determinePublishMatrix } from './extension/ext-publish-matrix.js'; +export { displayExtensionReleasePlan } from './extension/ext-release-plan.js'; +export { bumpVersions } from './extension/ext-version-bumper.js'; + +// NPM package management +export { detectNpmChanges, setNpmChangeDetectionOutputs } from './npm/npm-change-detector.js'; +export { extractPackageDetails, setPackageDetailsOutputs } from './npm/npm-package-details.js'; +export { npmPackageSelectorMain } from './npm/npm-package-selector.js'; +export { generateReleasePlan, displayReleasePlan } from './npm/npm-release-plan.js'; diff --git a/src/npm/npm-change-detector.ts b/src/npm/npm-change-detector.ts new file mode 100644 index 00000000..b2b904cc --- /dev/null +++ b/src/npm/npm-change-detector.ts @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the + * repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { simpleGit } from 'simple-git'; +import { readdirSync, existsSync } from 'fs'; +import { join } from 'path'; +import { + NpmPackageInfo, + NpmChangeDetectionResult, + VersionBumpType, +} from './npm-types.js'; +import { log, setOutput, getExtensionInfo } from '../core/utils.js'; + +/** + * Get all available NPM packages (packages without publisher field) + */ +function getAvailableNpmPackages(): NpmPackageInfo[] { + const packages: NpmPackageInfo[] = []; + const packagesRoot = process.env.PACKAGES_ROOT || 'packages'; + const packagesDir = join(process.cwd(), packagesRoot); + + if (!existsSync(packagesDir)) { + log.warning('packages directory not found'); + return packages; + } + + const packageDirs = readdirSync(packagesDir, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => dirent.name); + + for (const packageName of packageDirs) { + const packagePath = join(packagesDir, packageName); + const packageJsonPath = join(packagePath, 'package.json'); + + if (existsSync(packageJsonPath)) { + try { + const info = getExtensionInfo(packagePath); + + // Only include packages that don't have a publisher (NPM packages) + if (!info.publisher) { + packages.push({ + name: packageName, + path: packagePath, + currentVersion: info.version, + description: info.displayName, + isExtension: false, + }); + log.debug(`Found NPM package: ${packageName}`); + } else { + log.debug( + `Skipping VS Code extension: ${packageName} (publisher: ${info.publisher})`, + ); + } + } catch (error) { + log.warning(`Failed to read package.json for ${packageName}: ${error}`); + } + } + } + + return packages; +} + +/** + * Check if package has changes since base branch + */ +async function hasPackageChanges( + git: any, + packagePath: string, + baseBranch: string, +): Promise { + try { + // Check for changes since the base branch + const diff = await git.diff([ + `origin/${baseBranch}`, + 'HEAD', + '--', + packagePath, + ]); + return diff.trim().length > 0; + } catch (error) { + log.warning(`Failed to check changes for ${packagePath}: ${error}`); + return false; + } +} + +/** + * Determine version bump type from commit messages + */ +async function determineVersionBump(git: any): Promise { + try { + const logResult = await git.log({ maxCount: 5 }); + const commitMessages = logResult.all + .map((commit: any) => commit.message) + .join('\n'); + + log.debug('Analyzing commit messages for version bump:'); + log.debug(commitMessages); + + if ( + commitMessages.toLowerCase().includes('breaking') || + commitMessages.toLowerCase().includes('major') + ) { + log.info('Found breaking change - using major bump'); + return 'major'; + } else if ( + commitMessages.toLowerCase().includes('feat') || + commitMessages.toLowerCase().includes('feature') || + commitMessages.toLowerCase().includes('minor') + ) { + log.info('Found feature - using minor bump'); + return 'minor'; + } else { + log.info('No breaking changes or features found - using patch bump'); + return 'patch'; + } + } catch (error) { + log.warning( + `Failed to determine version bump: ${error}, defaulting to patch`, + ); + return 'patch'; + } +} + +/** + * Detect changes in NPM packages + */ +export async function detectNpmChanges( + baseBranch: string = 'main', +): Promise { + log.info('Detecting changes in NPM packages...'); + log.debug(`Base branch: ${baseBranch}`); + + const git = simpleGit(); + + // Verify base branch exists + try { + const branches = await git.branch(['-r']); + const baseBranchExists = branches.all.some((branch: string) => + branch.includes(`origin/${baseBranch}`), + ); + + if (!baseBranchExists) { + log.warning( + `Base branch 'origin/${baseBranch}' does not exist, falling back to 'main'`, + ); + baseBranch = 'main'; + } + } catch (error) { + log.warning(`Failed to check base branch: ${error}, using 'main'`); + baseBranch = 'main'; + } + + // Get all NPM packages (packages without publisher field) + const npmPackages = getAvailableNpmPackages(); + + log.info( + `Found ${npmPackages.length} NPM packages: ${npmPackages.map((p) => p.name).join(', ')}`, + ); + + // Check for changes in each package + const changedPackages: string[] = []; + + for (const pkg of npmPackages) { + log.debug(`Checking package: ${pkg.name}`); + + const hasChanges = await hasPackageChanges(git, pkg.path, baseBranch); + + if (hasChanges) { + log.info(`Found changes in ${pkg.name} - including in release`); + changedPackages.push(pkg.name); + } else { + log.info(`No changes found in ${pkg.name} - skipping release`); + } + } + + // Determine version bump type + const versionBump = await determineVersionBump(git); + + log.info(`Changed packages: ${changedPackages.join(', ')}`); + log.info(`Version bump type: ${versionBump}`); + + return { + changedPackages, + selectedPackages: [], // Will be set by package selector + versionBump, + }; +} + +/** + * Set GitHub Actions outputs for NPM change detection + */ +export function setNpmChangeDetectionOutputs( + result: NpmChangeDetectionResult, +): void { + setOutput('packages', result.changedPackages.join(',')); + setOutput('bump', result.versionBump); + + log.success('NPM change detection outputs set'); +} + +/** + * Main function for CLI usage + */ +export async function main(): Promise { + try { + const baseBranch = process.env.INPUT_BASE_BRANCH || 'main'; + const result = await detectNpmChanges(baseBranch); + setNpmChangeDetectionOutputs(result); + } catch (error) { + log.error(`Failed to detect NPM changes: ${error}`); + process.exit(1); + } +} + +// Run if called directly +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/src/npm/npm-package-details.ts b/src/npm/npm-package-details.ts new file mode 100644 index 00000000..09e556b7 --- /dev/null +++ b/src/npm/npm-package-details.ts @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the + * repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { NpmPackageDetails, VersionBumpType } from './npm-types.js'; +import { log, setOutput } from '../core/utils.js'; + +/** + * Parse package.json and extract details + */ +function getPackageDetails(packageName: string): { + name: string; + version: string; + description: string; +} | null { + const packagePath = join(process.cwd(), 'packages', packageName); + const packageJsonPath = join(packagePath, 'package.json'); + + if (!existsSync(packageJsonPath)) { + log.warning(`package.json not found for ${packageName}`); + return null; + } + + try { + const content = readFileSync(packageJsonPath, 'utf-8'); + const pkg = JSON.parse(content); + + return { + name: pkg.name || packageName, + version: pkg.version || '0.0.0', + description: pkg.description || 'No description', + }; + } catch (error) { + log.warning(`Failed to parse package.json for ${packageName}: ${error}`); + return null; + } +} + +/** + * Extract package details from JSON array string + */ +export function extractPackageDetails( + selectedPackagesJson: string, + versionBump: VersionBumpType, +): NpmPackageDetails { + log.info('Extracting package details...'); + log.debug(`Selected packages JSON: ${selectedPackagesJson}`); + log.debug(`Version bump: ${versionBump}`); + + const packageNames: string[] = []; + const packageVersions: string[] = []; + const packageDescriptions: string[] = []; + + try { + // Parse the JSON array of selected packages + if (selectedPackagesJson && selectedPackagesJson !== '[]') { + const packages = JSON.parse(selectedPackagesJson); + + if (Array.isArray(packages)) { + for (const packageName of packages) { + if (packageName && typeof packageName === 'string') { + const details = getPackageDetails(packageName); + + if (details) { + packageNames.push(details.name); + packageVersions.push(details.version); + packageDescriptions.push(details.description); + + log.debug( + `Package ${packageName}: ${details.name}@${details.version}`, + ); + } + } + } + } + } + } catch (error) { + log.error(`Failed to parse selected packages JSON: ${error}`); + } + + log.info(`Extracted details for ${packageNames.length} packages`); + log.info(`Package names: ${packageNames.join(', ')}`); + log.info(`Package versions: ${packageVersions.join(', ')}`); + + return { + packageNames, + packageVersions, + packageDescriptions, + versionBump, + }; +} + +/** + * Set GitHub Actions outputs for package details + */ +export function setPackageDetailsOutputs(details: NpmPackageDetails): void { + setOutput('package_names', details.packageNames.join(', ')); + setOutput('package_versions', details.packageVersions.join(', ')); + setOutput('package_descriptions', details.packageDescriptions.join(', ')); + setOutput('version_bump', details.versionBump); + + log.success('Package details outputs set'); +} + +/** + * Main function for CLI usage + */ +export async function main(): Promise { + try { + const selectedPackagesJson = process.env.SELECTED_PACKAGES || '[]'; + const versionBump = + (process.env.VERSION_BUMP as VersionBumpType) || 'patch'; + + const details = extractPackageDetails(selectedPackagesJson, versionBump); + setPackageDetailsOutputs(details); + } catch (error) { + log.error(`Failed to extract package details: ${error}`); + process.exit(1); + } +} + +// Run if called directly +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/src/npm/npm-package-selector.ts b/src/npm/npm-package-selector.ts new file mode 100644 index 00000000..a8a57301 --- /dev/null +++ b/src/npm/npm-package-selector.ts @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the + * repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { readdirSync, existsSync } from 'fs'; +import { join } from 'path'; +import { log, setOutput, getExtensionInfo } from '../core/utils.js'; + +/** + * Get all available NPM packages + */ +export function getAvailableNpmPackages(): string[] { + log.info('Getting all available NPM packages...'); + + // Get all packages from the packages directory (configurable via PACKAGES_ROOT) + const packagesRoot = process.env.PACKAGES_ROOT || 'packages'; + const packagesDir = join(process.cwd(), packagesRoot); + const packages: string[] = []; + + if (!existsSync(packagesDir)) { + log.warning('packages directory not found'); + return []; + } + + const packageDirs = readdirSync(packagesDir, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => dirent.name); + + for (const packageName of packageDirs) { + const packagePath = join(packagesDir, packageName); + const packageJsonPath = join(packagePath, 'package.json'); + + if (existsSync(packageJsonPath)) { + try { + const info = getExtensionInfo(packagePath); + + // Only include packages that don't have a publisher (NPM packages) + if (!info.publisher) { + packages.push(packageName); + log.debug(`Found NPM package: ${packageName}`); + } else { + log.debug( + `Skipping VS Code extension: ${packageName} (publisher: ${info.publisher})`, + ); + } + } catch (error) { + log.warning(`Failed to read package.json for ${packageName}: ${error}`); + } + } + } + + log.info(`Found ${packages.length} NPM packages: ${packages.join(', ')}`); + return packages; +} + +/** + * Parse user-selected packages from environment variable + */ +function parseUserSelectedPackages(selectedPackagesInput?: string): string[] { + if (!selectedPackagesInput || selectedPackagesInput.trim() === '') { + log.info('No user selection provided - will use all available packages'); + return []; + } + + const selected = selectedPackagesInput + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + log.info(`User selected packages: ${selected.join(', ')}`); + return selected; +} + +/** + * Intersect user selection with detected changes + */ +function intersectPackages( + userSelected: string[], + changedPackages: string[], + availablePackages: string[], +): string[] { + // If no user selection, use all changed packages + if (userSelected.length === 0) { + log.info('No user selection - using all detected changes'); + return changedPackages; + } + + // Handle special values + const normalizedSelection = userSelected.map((s) => s.toLowerCase()); + + if (normalizedSelection.includes('none')) { + log.info('User selected "none" - returning empty selection'); + return []; + } + + if (normalizedSelection.includes('all')) { + log.info('User selected "all" - using all available packages'); + return availablePackages; + } + + if (normalizedSelection.includes('changed')) { + log.info('User selected "changed" - using all detected changes'); + return changedPackages; + } + + // Validate user selection against available packages + const validUserSelected = userSelected.filter((pkg) => { + if (!availablePackages.includes(pkg)) { + log.warning(`User selected package '${pkg}' is not available - skipping`); + return false; + } + return true; + }); + + if (validUserSelected.length === 0) { + log.warning('No valid packages in user selection'); + return []; + } + + // For specific package selection, intersect with detected changes + const intersection = validUserSelected.filter((pkg) => + changedPackages.includes(pkg), + ); + + log.info(`User selection: ${validUserSelected.join(', ')}`); + log.info(`Detected changes: ${changedPackages.join(', ')}`); + log.info(`Intersection: ${intersection.join(', ')}`); + + return intersection; +} + +/** + * Select NPM packages based on user input and detected changes + */ +export function selectNpmPackages( + userSelectedPackages?: string, + availablePackages?: string, + changedPackages?: string, +): string[] { + log.info('Selecting NPM packages for release...'); + log.debug(`User selected packages: ${userSelectedPackages || 'none'}`); + log.debug(`Available packages: ${availablePackages || 'none'}`); + log.debug(`Changed packages: ${changedPackages || 'none'}`); + + // Parse inputs + const userSelected = parseUserSelectedPackages(userSelectedPackages); + const available = availablePackages + ? availablePackages.split(',').filter(Boolean) + : getAvailableNpmPackages(); + const changed = changedPackages + ? changedPackages.split(',').filter(Boolean) + : []; + + log.info(`Available packages: ${available.join(', ')}`); + log.info(`Changed packages: ${changed.join(', ')}`); + + // Intersect user selection with detected changes + const finalSelectedPackages = intersectPackages( + userSelected, + changed, + available, + ); + + log.info(`Final selected packages: ${finalSelectedPackages.join(', ')}`); + return finalSelectedPackages; +} + +/** + * Set GitHub Actions outputs for package selection + */ +export function setPackageSelectionOutputs(selectedPackages: string[]): void { + setOutput('packages', JSON.stringify(selectedPackages)); + log.success('NPM package selection outputs set'); +} + +/** + * Set GitHub Actions outputs for package discovery + */ +export function setPackageDiscoveryOutputs(npmPackages: string[]): void { + setOutput('npm-packages', JSON.stringify(npmPackages)); + log.success('NPM package discovery outputs set'); +} + +/** + * Main function for CLI usage + */ +export async function main(): Promise { + try { + const userSelectedPackages = process.env.SELECTED_PACKAGE; + const availablePackages = process.env.AVAILABLE_PACKAGES; + const changedPackages = process.env.CHANGED_PACKAGES; + + log.debug(`SELECTED_PACKAGE: "${userSelectedPackages}"`); + log.debug(`AVAILABLE_PACKAGES: "${availablePackages}"`); + log.debug(`CHANGED_PACKAGES: "${changedPackages}"`); + + // If we have selection parameters, handle package selection + if (userSelectedPackages || availablePackages || changedPackages) { + log.info('Handling package selection...'); + const selectedPackages = selectNpmPackages( + userSelectedPackages, + availablePackages, + changedPackages, + ); + setPackageSelectionOutputs(selectedPackages); + } else { + // Otherwise, just discover available packages + log.info('Discovering available packages...'); + const npmPackages = getAvailableNpmPackages(); + setPackageDiscoveryOutputs(npmPackages); + } + } catch (error) { + log.error(`Failed to handle NPM packages: ${error}`); + process.exit(1); + } +} + +// Export main function for use in index.ts +export { main as npmPackageSelectorMain }; + +// Run if called directly +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/src/npm/npm-release-plan.ts b/src/npm/npm-release-plan.ts new file mode 100644 index 00000000..9be14b70 --- /dev/null +++ b/src/npm/npm-release-plan.ts @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the + * repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { NpmReleasePlan, VersionBumpType } from './npm-types.js'; +import { log, parseVersion, formatVersion } from '../core/utils.js'; + +/** + * Calculate new version based on current version and bump type + */ +function calculateNewVersion( + currentVersion: string, + versionBump: VersionBumpType, +): string { + try { + const { major, minor, patch } = parseVersion(currentVersion); + + switch (versionBump) { + case 'major': + return formatVersion(major + 1, 0, 0); + case 'minor': + return formatVersion(major, minor + 1, 0); + case 'patch': + return formatVersion(major, minor, patch + 1); + default: + log.warning( + `Unknown version bump type: ${versionBump}, defaulting to patch`, + ); + return formatVersion(major, minor, patch + 1); + } + } catch (error) { + log.error(`Failed to calculate new version: ${error}`); + return currentVersion; + } +} + +/** + * Get package information + */ +function getPackageInfo(packageName: string): { + name: string; + version: string; +} | null { + const packagePath = join(process.cwd(), 'packages', packageName); + const packageJsonPath = join(packagePath, 'package.json'); + + if (!existsSync(packageJsonPath)) { + log.warning(`package.json not found for ${packageName}`); + return null; + } + + try { + const content = readFileSync(packageJsonPath, 'utf-8'); + const pkg = JSON.parse(content); + + return { + name: pkg.name || packageName, + version: pkg.version || '0.0.0', + }; + } catch (error) { + log.warning(`Failed to parse package.json for ${packageName}: ${error}`); + return null; + } +} + +/** + * Generate release plan for a package + */ +export function generateReleasePlan( + packageName: string, + versionBump: VersionBumpType, + dryRun: boolean = false, +): NpmReleasePlan | null { + log.info(`Generating release plan for ${packageName}...`); + + const packageInfo = getPackageInfo(packageName); + if (!packageInfo) { + log.error(`Failed to get package info for ${packageName}`); + return null; + } + + const newVersion = calculateNewVersion(packageInfo.version, versionBump); + + log.info(`Package: ${packageInfo.name}`); + log.info(`Current version: ${packageInfo.version}`); + log.info(`New version: ${newVersion}`); + log.info(`Version bump: ${versionBump}`); + log.info(`Dry run: ${dryRun}`); + + return { + package: packageInfo.name, + currentVersion: packageInfo.version, + newVersion, + versionBump, + dryRun, + }; +} + +/** + * Display release plan + */ +export function displayReleasePlan(plan: NpmReleasePlan): void { + console.log('=== NPM RELEASE PLAN ==='); + console.log(`Package: ${plan.package}`); + console.log(`Current version: ${plan.currentVersion}`); + console.log(`New version: ${plan.newVersion}`); + console.log(`Version bump type: ${plan.versionBump}`); + console.log(`Dry run mode: ${plan.dryRun ? 'ENABLED' : 'DISABLED'}`); + console.log(''); + console.log(`Would bump to: ${plan.newVersion}`); + console.log('Would publish to: npmjs.org'); + console.log(''); +} + +/** + * Main function for CLI usage + */ +export async function main(): Promise { + try { + const packageName = process.env.MATRIX_PACKAGE; + const versionBump = + (process.env.VERSION_BUMP as VersionBumpType) || 'patch'; + const dryRun = process.env.DRY_RUN === 'true'; + + if (!packageName) { + log.error('MATRIX_PACKAGE environment variable is required'); + process.exit(1); + } + + const plan = generateReleasePlan(packageName, versionBump, dryRun); + if (plan) { + displayReleasePlan(plan); + } else { + log.error('Failed to generate release plan'); + process.exit(1); + } + } catch (error) { + log.error(`Failed to generate release plan: ${error}`); + process.exit(1); + } +} + +// Run if called directly +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/src/npm/npm-types.ts b/src/npm/npm-types.ts new file mode 100644 index 00000000..1c0e7faf --- /dev/null +++ b/src/npm/npm-types.ts @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the + * repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +export interface NpmPackageInfo { + name: string; + path: string; + currentVersion: string; + description?: string; + isExtension: boolean; +} + +export interface NpmChangeDetectionResult { + changedPackages: string[]; + selectedPackages: string[]; + versionBump: VersionBumpType; +} + +export type VersionBumpType = 'patch' | 'minor' | 'major'; + +export interface NpmPackageDetails { + packageNames: string[]; + packageVersions: string[]; + packageDescriptions: string[]; + versionBump: VersionBumpType; +} + +export interface NpmReleasePlan { + package: string; + currentVersion: string; + newVersion: string; + versionBump: VersionBumpType; + dryRun: boolean; +} + +export interface NpmEnvironment { + githubEventName: string; + githubRef: string; + githubRefName: string; + githubActor: string; + githubRepository: string; + githubRunId: string; + githubWorkflow: string; + inputs: { + branch?: string; + packages?: string; + availablePackages?: string; + baseBranch?: string; + dryRun?: string; + }; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..7988c7af --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "lib": ["ES2022"], + "moduleResolution": "bundler", + "rootDir": "./src", + "outDir": "./dist", + "strict": false, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +}