diff --git a/.github/actions/vscode/check-ci-status/action.yml b/.github/actions/vscode/check-ci-status/action.yml new file mode 100644 index 0000000..6a0b3a6 --- /dev/null +++ b/.github/actions/vscode/check-ci-status/action.yml @@ -0,0 +1,100 @@ +name: Check CI Status +description: > + Verifies that CI checks passed for a given commit SHA before promotion. + Fails if any required check did not succeed. + +inputs: + commit-sha: + description: 'Commit SHA to check CI status for' + required: true + token: + description: 'GitHub token with repo read access' + required: true + required-checks: + description: > + Comma-separated list of check names that must have succeeded. + If empty, all non-skipped check-runs must have conclusion "success". + required: false + default: '' + +runs: + using: composite + steps: + - name: Verify CI checks passed + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + COMMIT_SHA: ${{ inputs.commit-sha }} + REQUIRED_CHECKS: ${{ inputs.required-checks }} + REPO: ${{ github.repository }} + run: | + echo "Checking CI status for commit $COMMIT_SHA in $REPO..." + + # Fetch all check-runs for the commit (paginate up to 100) + CHECK_RUNS=$(gh api \ + "repos/$REPO/commits/$COMMIT_SHA/check-runs" \ + --paginate \ + --jq '.check_runs[] | {name: .name, status: .status, conclusion: .conclusion}' \ + 2>&1) + + if [ -z "$CHECK_RUNS" ]; then + echo "No check-runs found for commit $COMMIT_SHA" + echo "Cannot verify CI status — failing to prevent untested promotion" + exit 1 + fi + + echo "Check-runs found:" + echo "$CHECK_RUNS" | jq -r '" \(.name): status=\(.status) conclusion=\(.conclusion)"' + + FAILED=0 + + if [ -n "$REQUIRED_CHECKS" ]; then + # Only validate the specified checks + IFS=',' read -ra CHECKS <<< "$REQUIRED_CHECKS" + for CHECK in "${CHECKS[@]}"; do + CHECK=$(echo "$CHECK" | xargs) # trim whitespace + CONCLUSION=$(echo "$CHECK_RUNS" | jq -r --arg name "$CHECK" \ + 'select(.name == $name) | .conclusion' | head -1) + if [ "$CONCLUSION" != "success" ]; then + echo "FAIL: required check '$CHECK' has conclusion '$CONCLUSION' (expected 'success')" + FAILED=1 + else + echo "PASS: required check '$CHECK' succeeded" + fi + done + else + # Validate all non-skipped check-runs + while IFS= read -r RUN; do + NAME=$(echo "$RUN" | jq -r '.name') + STATUS=$(echo "$RUN" | jq -r '.status') + CONCLUSION=$(echo "$RUN" | jq -r '.conclusion') + + # Skip queued/in-progress (treat as not-yet-run, which is a failure) + if [ "$STATUS" != "completed" ]; then + echo "FAIL: check '$NAME' is not completed (status=$STATUS)" + FAILED=1 + continue + fi + + # Allow skipped checks (neutral conclusion) + if [ "$CONCLUSION" = "skipped" ] || [ "$CONCLUSION" = "neutral" ]; then + echo "SKIP: check '$NAME' was skipped — ignoring" + continue + fi + + if [ "$CONCLUSION" != "success" ]; then + echo "FAIL: check '$NAME' has conclusion '$CONCLUSION'" + FAILED=1 + fi + done < <(echo "$CHECK_RUNS" | jq -c '.') + fi + + if [ "$FAILED" -eq 1 ]; then + echo "" + echo "CI quality gate FAILED for commit $COMMIT_SHA" + echo "Promotion blocked. Fix failing checks before retrying." + exit 1 + fi + + echo "" + echo "CI quality gate PASSED for commit $COMMIT_SHA" diff --git a/.github/actions/vscode/publish-vsix/action.yml b/.github/actions/vscode/publish-vsix/action.yml new file mode 100644 index 0000000..9ac4c80 --- /dev/null +++ b/.github/actions/vscode/publish-vsix/action.yml @@ -0,0 +1,147 @@ +name: "Publish VSIX" +description: "Publishes VSIX files to a marketplace with dry-run support" + +inputs: + vsix-path: + description: "Path to the VSIX file to publish" + required: true + publish-tool: + description: "Publishing tool to use" + required: true + pre-release: + description: "Publish as pre-release version" + required: false + default: "false" + dry-run: + description: "Run in dry-run mode" + required: false + default: "false" + +runs: + using: composite + steps: + - name: Validate inputs + shell: bash + run: | + # Validate VSIX path exists + if [ ! -f "${{ inputs.vsix-path }}" ]; then + echo "❌ Error: VSIX file not found at ${{ inputs.vsix-path }}" + exit 1 + fi + + # Validate VSIX file extension + if [[ ! "${{ inputs.vsix-path }}" =~ \.vsix$ ]]; then + echo "❌ Error: File must have .vsix extension" + exit 1 + fi + + # Validate publish tool + if [[ ! "${{ inputs.publish-tool }}" =~ ^(ovsx|vsce)$ ]]; then + echo "❌ Error: Invalid publish tool: ${{ inputs.publish-tool }}" + exit 1 + fi + + echo "✅ Input validation passed" + + - name: Audit publish attempt + shell: bash + run: | + # Create audit log entry + AUDIT_LOG="/tmp/publish_audit.log" + TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + ACTOR="${{ github.actor }}" + REPO="${{ github.repository }}" + RUN_ID="${{ github.run_id }}" + WORKFLOW="${{ github.workflow }}" + + # Get file info for audit + FILE_SIZE=$(stat -c%s "${{ inputs.vsix-path }}" 2>/dev/null || stat -f%z "${{ inputs.vsix-path }}" 2>/dev/null || echo "unknown") + FILE_HASH=$(sha256sum "${{ inputs.vsix-path }}" 2>/dev/null | cut -d' ' -f1 || echo "unknown") + + # Log audit information + echo "[$TIMESTAMP] PUBLISH_ATTEMPT: actor=$ACTOR, repo=$REPO, run_id=$RUN_ID, workflow=$WORKFLOW, tool=${{ inputs.publish-tool }}, file=${{ inputs.vsix-path }}, size=$FILE_SIZE, hash=$FILE_HASH, pre_release=${{ inputs.pre-release }}, dry_run=${{ inputs.dry-run }}" >> "$AUDIT_LOG" + + # Also log to GitHub Actions output for visibility + echo "🔍 AUDIT: Publish attempt logged - $TIMESTAMP" + echo " Actor: $ACTOR" + echo " Repository: $REPO" + echo " Run ID: $RUN_ID" + echo " Workflow: $WORKFLOW" + echo " Tool: ${{ inputs.publish-tool }}" + echo " File: ${{ inputs.vsix-path }}" + echo " Size: $FILE_SIZE bytes" + echo " Hash: $FILE_HASH" + echo " Pre-release: ${{ inputs.pre-release }}" + echo " Dry-run: ${{ inputs.dry-run }}" + + - name: Publish VSIX + shell: bash + run: | + echo "Publishing ${{ inputs.vsix-path }}" + + # Calculate marketplace name based on publish tool + if [ "${{ inputs.publish-tool }}" = "ovsx" ]; then + MARKETPLACE_NAME="Open VSX Registry" + TOKEN_ENV="OVSX_PAT" + else + MARKETPLACE_NAME="Visual Studio Marketplace" + TOKEN_ENV="VSCE_PERSONAL_ACCESS_TOKEN" + fi + + PRE_RELEASE_FLAG="" + if [ "${{ inputs.pre-release }}" = "true" ]; then + PRE_RELEASE_FLAG="--pre-release" + echo "Would publish as pre-release version" + fi + + # Mask token in logs for security + TOKEN_MASK="***" + + if [ "${{ inputs.dry-run }}" = "true" ]; then + echo "🔍 DRY RUN MODE - Would publish to $MARKETPLACE_NAME:" + echo " VSIX: ${{ inputs.vsix-path }}" + echo " Pre-release: ${{ inputs.pre-release }}" + + if [ "${{ inputs.publish-tool }}" = "ovsx" ]; then + echo " Command: npx ovsx publish \"${{ inputs.vsix-path }}\" -p $TOKEN_MASK $PRE_RELEASE_FLAG" + else + echo " Command: npx @vscode/vsce publish --packagePath \"${{ inputs.vsix-path }}\" --skip-duplicate $PRE_RELEASE_FLAG" + fi + echo "✅ Dry run completed - no actual publish performed" + else + echo "Publishing VSIX: ${{ inputs.vsix-path }}" + + # Verify token is available + if [ -z "${!TOKEN_ENV}" ]; then + echo "❌ Error: $TOKEN_ENV environment variable is not set" + exit 1 + fi + + if [ "${{ inputs.publish-tool }}" = "vsce" ]; then + export VSCE_PAT="${!TOKEN_ENV}" # ensure the expected env var is set + npx @vscode/vsce publish --packagePath "${{ inputs.vsix-path }}" --skip-duplicate $PRE_RELEASE_FLAG + else + npx ovsx publish "${{ inputs.vsix-path }}" -p "${!TOKEN_ENV}" --skip-duplicate $PRE_RELEASE_FLAG + fi + + echo "✅ Successfully published to $MARKETPLACE_NAME" + fi + + - name: Audit publish result + shell: bash + if: inputs.dry-run != 'true' + run: | + # Log the result of the publish attempt + AUDIT_LOG="/tmp/publish_audit.log" + TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + ACTOR="${{ github.actor }}" + REPO="${{ github.repository }}" + RUN_ID="${{ github.run_id }}" + + if [ $? -eq 0 ]; then + echo "[$TIMESTAMP] PUBLISH_SUCCESS: actor=$ACTOR, repo=$REPO, run_id=$RUN_ID, tool=${{ inputs.publish-tool }}, file=${{ inputs.vsix-path }}" >> "$AUDIT_LOG" + echo "✅ AUDIT: Publish successful - $TIMESTAMP" + else + echo "[$TIMESTAMP] PUBLISH_FAILURE: actor=$ACTOR, repo=$REPO, run_id=$RUN_ID, tool=${{ inputs.publish-tool }}, file=${{ inputs.vsix-path }}" >> "$AUDIT_LOG" + echo "❌ AUDIT: Publish failed - $TIMESTAMP" + fi diff --git a/.github/workflows/vscode-ci-template.yml b/.github/workflows/vscode-ci-template.yml new file mode 100644 index 0000000..9661788 --- /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/npmInstallWithRetries@main + + - name: Run linting + run: ${{ inputs.lint-command }} + + - name: Compile project + run: ${{ inputs.compile-command }} + + - name: Run tests with coverage (lts/current) + if: ${{ matrix.node-version != 'lts/-1' }} + run: ${{ inputs.test-coverage-command }} + + - name: Run tests (lts/-1, no coverage) + if: ${{ matrix.node-version == 'lts/-1' }} + env: + # Old-LTS defaults to ~4 GB old-space, which is too low for heavy stdlib suites. + # Keep this scoped to lts/-1 and non-coverage runs only. + NODE_OPTIONS: --max-old-space-size=6144 + run: ${{ inputs.test-command }} + + - name: Merge coverage reports + if: ${{ matrix.node-version != 'lts/-1' }} + run: ${{ inputs.coverage-report-command }} + + - name: Determine Node Label + id: node-label + shell: bash + env: + NODE_VERSION: ${{ matrix.node-version }} + run: | + if [ "$NODE_VERSION" = "lts/*" ]; then + echo "value=lts" >> $GITHUB_OUTPUT + elif [ "$NODE_VERSION" = "lts/-1" ]; then + echo "value=lts-1" >> $GITHUB_OUTPUT + elif [ "$NODE_VERSION" = "current" ]; then + echo "value=current" >> $GITHUB_OUTPUT + else + echo "value=$NODE_VERSION" >> $GITHUB_OUTPUT + fi + + - name: Upload coverage report + if: ${{ matrix.node-version != 'lts/-1' }} + uses: actions/upload-artifact@v7 + with: + name: coverage-report-${{ matrix.os }}-${{ steps.node-label.outputs.value }} + path: ./coverage + + test-quality: + name: Test Quality + needs: test + strategy: + matrix: + os: [ubuntu-latest] + node-version: ['lts/*'] + fail-fast: false + + runs-on: ${{ matrix.os }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: false + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install dependencies + uses: salesforcecli/github-workflows/.github/actions/npmInstallWithRetries@main + + - 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-package.yml b/.github/workflows/vscode-package.yml new file mode 100644 index 0000000..36eb786 --- /dev/null +++ b/.github/workflows/vscode-package.yml @@ -0,0 +1,236 @@ +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/npmInstallWithRetries@main + + # 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 + run: | + BASE_NAME="${{ inputs.artifact-name }}" + RUN_NUMBER="${{ github.run_number }}" + IS_DRY_RUN="${{ inputs.dry-run }}" + + # Check if already suffixed + if [[ "$BASE_NAME" =~ -dry-run$ ]] || [[ "$BASE_NAME" =~ -release$ ]]; then + ARTIFACT_NAME="$BASE_NAME" + else + if [ "$IS_DRY_RUN" = "true" ]; then + ARTIFACT_NAME="${BASE_NAME}-${RUN_NUMBER}-dry-run" + else + ARTIFACT_NAME="${BASE_NAME}-${RUN_NUMBER}-release" + fi + fi + + echo "artifact-name=$ARTIFACT_NAME" >> $GITHUB_OUTPUT + echo "Artifact name: $ARTIFACT_NAME" + + - name: Upload VSIX artifacts + id: upload + uses: actions/upload-artifact@v7 + 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 0000000..2f4edab --- /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 0000000..f48cec7 --- /dev/null +++ b/.github/workflows/vscode-publish-extensions.yml @@ -0,0 +1,908 @@ +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: + display-release-plan: + runs-on: ubuntu-latest + if: inputs.extensions != '' + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.branch || github.ref }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22.x' + + - name: Install dependencies + uses: salesforcecli/github-workflows/.github/actions/npmInstallWithRetries@main + + - name: Display Extension Release Plan + env: + BRANCH: ${{ inputs.branch || github.ref_name }} + BUILD_TYPE: ${{ github.event_name }} + IS_NIGHTLY: 'true' + VERSION_BUMP: ${{ inputs.version-bump }} + REGISTRIES: ${{ inputs.registries }} + PRE_RELEASE: 'true' + SELECTED_EXTENSIONS: ${{ inputs.extensions }} + run: | + echo "=== EXTENSION RELEASE PLAN ===" + echo "Branch: $BRANCH" + echo "Build type: $BUILD_TYPE" + echo "Is nightly: $IS_NIGHTLY" + echo "Version bump type: $VERSION_BUMP" + echo "Registries: $REGISTRIES" + echo "Pre-release: $PRE_RELEASE" + echo "Dry run mode: ENABLED" + echo "" + echo "Extensions to release: $SELECTED_EXTENSIONS" + echo "" + + IFS=',' read -ra extensions <<< "$SELECTED_EXTENSIONS" + for ext in "${extensions[@]}"; do + [ -z "$ext" ] && continue + + package_json="packages/$ext/package.json" + if [ ! -f "$package_json" ]; then + echo "Extension: $ext (package.json not found)" + continue + fi + + current_version=$(jq -r '.version' "$package_json") + publisher=$(jq -r '.publisher // "N/A"' "$package_json") + + echo "Extension: $ext" + echo " Current version: $current_version" + echo " Publisher: $publisher" + + if [ "$IS_NIGHTLY" = "true" ]; then + echo " Version strategy: Nightly build (odd minor + nightly timestamp)" + elif [ "$PRE_RELEASE" = "true" ]; then + echo " Version strategy: Pre-release (odd minor version)" + else + echo " Version strategy: Stable release (even minor version)" + fi + + echo " Would create GitHub release" + echo " Would package VSIX" + + case "$REGISTRIES" in + marketplace) echo " Would publish to: VS Code Marketplace only" ;; + openvsx) echo " Would publish to: Open VSX only" ;; + all) echo " Would publish to: VS Code Marketplace and Open VSX" ;; + esac + echo "" + done + + echo "=== END OF RELEASE PLAN ===" + + bump-versions: + needs: [] + runs-on: ubuntu-latest + if: inputs.extensions != '' + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + token: ${{ secrets.IDEE_GH_TOKEN }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22.x' + + - name: Install dependencies + uses: salesforcecli/github-workflows/.github/actions/npmInstallWithRetries@main + + - name: Bump versions and tag for selected extensions + env: + VERSION_BUMP: ${{ inputs.version-bump }} + SELECTED_EXTENSIONS: ${{ inputs.extensions }} + PRE_RELEASE: ${{ inputs.pre-release || github.event.inputs.pre-release || 'false' }} + IS_NIGHTLY: 'true' + IS_PROMOTION: 'false' + 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: Calculate artifact name + id: calc + run: | + BASE_NAME="vsix-packages" + RUN_NUMBER="${{ github.run_number }}" + IS_DRY_RUN="${{ inputs.dry-run || github.event.inputs.dry-run || 'false' }}" + + # Check if already suffixed + if [[ "$BASE_NAME" =~ -dry-run$ ]] || [[ "$BASE_NAME" =~ -release$ ]]; then + ARTIFACT_NAME="$BASE_NAME" + else + if [ "$IS_DRY_RUN" = "true" ]; then + ARTIFACT_NAME="${BASE_NAME}-${RUN_NUMBER}-dry-run" + else + ARTIFACT_NAME="${BASE_NAME}-${RUN_NUMBER}-release" + fi + fi + + echo "artifact-name=$ARTIFACT_NAME" >> $GITHUB_OUTPUT + echo "Artifact name: $ARTIFACT_NAME" + + package: + needs: [bump-versions, calculate-artifact-name] + uses: salesforcecli/github-workflows/.github/workflows/vscode-package.yml@feat/add-vscode-extension-ci + with: + branch: ${{ inputs.branch || github.ref_name }} + artifact-name: ${{ needs.calculate-artifact-name.outputs.artifact-name }} + dry-run: ${{ inputs.dry-run || github.event.inputs.dry-run || 'false' }} + pre-release: ${{ inputs.pre-release || github.event.inputs.pre-release || 'false' }} + + determine-publish-matrix: + needs: [calculate-artifact-name] + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.matrix.outputs.matrix }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22.x' + + - name: Install dependencies + uses: salesforcecli/github-workflows/.github/actions/npmInstallWithRetries@main + + - name: Determine publish matrix + id: matrix + env: + REGISTRIES: ${{ inputs.registries }} + SELECTED_EXTENSIONS: ${{ inputs.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 + # Parse extensions + IFS=',' read -ra extensions <<< "$SELECTED_EXTENSIONS" + + # Determine registries + if [ "$REGISTRIES" = "all" ]; then + registries=("vsce" "ovsx") + else + IFS=',' read -ra registries <<< "$REGISTRIES" + fi + + # Build matrix JSON + matrix_entries=() + for ext in "${extensions[@]}"; do + [ -z "$ext" ] && continue + + # Map extension names to VSIX patterns + if [ "$ext" = "apex-lsp-vscode-extension" ]; then + vsix_pattern="*apex-language-server-extension*-[0-9]*.vsix" + else + vsix_pattern="*${ext}*.vsix" + fi + + for registry in "${registries[@]}"; do + [ -z "$registry" ] && continue + + case "$registry" in + vsce) marketplace="VS Code Marketplace" ;; + ovsx) marketplace="Open VSX Registry" ;; + *) marketplace="$registry" ;; + esac + + entry=$(jq -n \ + --arg reg "$registry" \ + --arg pattern "$vsix_pattern" \ + --arg market "$marketplace" \ + '{registry: $reg, vsix_pattern: $pattern, marketplace: $market}') + + matrix_entries+=("$entry") + done + done + + # Combine into matrix JSON + if [ ${#matrix_entries[@]} -eq 0 ]; then + matrix_json='{"include":[]}' + else + matrix_json=$(printf '%s\n' "${matrix_entries[@]}" | jq -s '{include: .}') + fi + + echo "matrix=$matrix_json" >> $GITHUB_OUTPUT + fi + + publish: + 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, calculate-artifact-name] + runs-on: ubuntu-latest + if: needs.package.result == 'success' && inputs.extensions != '' && github.event_name != 'pull_request' + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.branch || github.ref }} + token: ${{ secrets.IDEE_GH_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: '22.x' + + - name: Install dependencies + uses: salesforcecli/github-workflows/.github/actions/npmInstallWithRetries@main + + - name: Download VSIX artifacts + uses: actions/download-artifact@v8 + with: + name: ${{ needs.calculate-artifact-name.outputs.artifact-name }} + path: ./vsix-artifacts + + - name: Create GitHub releases + env: + GITHUB_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + SELECTED_EXTENSIONS: ${{ inputs.extensions }} + IS_NIGHTLY: 'true' + PRE_RELEASE: 'true' + VERSION_BUMP: ${{ inputs.version-bump }} + DRY_RUN: ${{ inputs.dry-run || github.event.inputs.dry-run || 'false' }} + BRANCH: ${{ inputs.branch || github.ref_name }} + VSIX_ARTIFACTS_PATH: ./vsix-artifacts + run: | + echo "Mode: $([ "$DRY_RUN" = "true" ] && echo "DRY RUN" || echo "LIVE")" + echo "Creating GitHub releases..." + + IFS=',' read -ra extensions <<< "$SELECTED_EXTENSIONS" + + for ext in "${extensions[@]}"; do + [ -z "$ext" ] && continue + + package_json="packages/$ext/package.json" + if [ ! -f "$package_json" ]; then + echo "⚠️ Skipping $ext: package.json not found" + continue + fi + + current_version=$(jq -r '.version' "$package_json") + echo "Processing extension: $ext (version: $current_version)" + + # Find VSIX files + if [ "$ext" = "apex-lsp-vscode-extension" ]; then + vsix_pattern="*apex-language-server-extension-*.vsix" + else + vsix_pattern="*${ext}*.vsix" + fi + + vsix_files=("$VSIX_ARTIFACTS_PATH"/$ext/$vsix_pattern) + if [ ! -f "${vsix_files[0]}" ]; then + echo "⚠️ No VSIX files found for $ext" + continue + fi + + # Filter out *-web-* builds + filtered_files=() + for file in "${vsix_files[@]}"; do + if [[ ! "$(basename "$file")" =~ -web- ]]; then + filtered_files+=("$file") + fi + done + + # Create release tag + release_tag="v$current_version" + release_title="$ext v$current_version" + + if [ "$IS_NIGHTLY" = "true" ]; then + nightly_date=$(date -u +%Y%m%d) + branch_suffix="" + [ "$BRANCH" != "main" ] && branch_suffix=".${BRANCH//\//-}" + release_tag="v${current_version}-nightly${branch_suffix}.${nightly_date}" + release_title="$ext v$current_version (Nightly $BRANCH $nightly_date)" + fi + + if [ "$DRY_RUN" = "true" ]; then + echo "✅ DRY RUN: Would create release $release_tag with ${#filtered_files[@]} VSIX files" + else + # Check if release exists + if gh release view "$release_tag" --repo "$GITHUB_REPOSITORY" > /dev/null 2>&1; then + echo "⏭️ Release $release_tag already exists - skipping" + else + # Generate release notes + notes_file=".release-notes-$(date +%s).tmp" + cat > "$notes_file" << EOF + ## $ext v$current_version + + ### Installation + Download the VSIX file and install via VS Code. + + $([ "$PRE_RELEASE" = "true" ] && echo "⚠️ **This is a pre-release version**") + $([ "$IS_NIGHTLY" = "true" ] && echo "🌙 **Nightly build from $(date -u +%Y%m%d)**") + EOF + + # Create release + gh release create "$release_tag" \ + --title "$release_title" \ + --notes-file "$notes_file" \ + --prerelease="$PRE_RELEASE" \ + --repo "$GITHUB_REPOSITORY" \ + "${filtered_files[@]}" + + rm -f "$notes_file" + echo "✅ Release created for $ext" + fi + fi + done + + echo "$([ "$DRY_RUN" = "true" ] && echo "✅ DRY RUN complete" || echo "✅ Releases created")" + + publish-to-cbweb-marketplace: + name: Publish to CBWeb Internal Marketplace + needs: [package, create-github-releases, calculate-artifact-name] + runs-on: ubuntu-latest + continue-on-error: true + if: needs.package.result == 'success' + steps: + - name: Download VSIX artifacts + uses: actions/download-artifact@v8 + with: + name: ${{ needs.calculate-artifact-name.outputs.artifact-name }} + path: ./vsix-artifacts + + - name: Find web-target VSIX for CBWeb + id: find-web-vsix + run: | + VSIX_FILE=$(find ./vsix-artifacts -type f -name "*-web-*.vsix" | head -1) + if [ -z "$VSIX_FILE" ]; then + echo "::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: + [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="${{ inputs.extensions }}" + VERSION_BUMP="${{ inputs.version-bump }}" + PRE_RELEASE="true" + + # Initialize arrays for extension details + EXTENSION_NAMES="" + EXTENSION_VERSIONS="" + EXTENSION_DISPLAY_NAMES="" + + IFS=',' read -ra EXTENSIONS <<< "$SELECTED_EXTENSIONS" + for ext in "${EXTENSIONS[@]}"; do + if [ -n "$ext" ] && [ -f "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: + [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="${{ inputs.extensions }}" + VERSION_BUMP="${{ inputs.version-bump }}" + PRE_RELEASE="true" + + # Initialize arrays for extension details + EXTENSION_NAMES="" + EXTENSION_VERSIONS="" + EXTENSION_DISPLAY_NAMES="" + + IFS=',' read -ra EXTENSIONS <<< "$SELECTED_EXTENSIONS" + for ext in "${EXTENSIONS[@]}"; do + if [ -n "$ext" ] && [ -f "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 0000000..ede1139 --- /dev/null +++ b/.github/workflows/vscode-release-explicit.yml @@ -0,0 +1,132 @@ +name: VS Code Extension Release (Explicit List) + +on: + workflow_call: + inputs: + extensions: + description: 'JSON array of extension paths (e.g., ["packages/ext1", "packages/ext2"])' + required: true + type: string + registries: + description: 'Where to publish: marketplace | openvsx | all' + required: false + type: string + default: 'all' + pre-release: + description: 'Mark as pre-release' + required: false + type: boolean + default: true + version-bump: + description: 'Version bump strategy: auto | major | minor | patch' + required: false + type: string + default: 'auto' + dry-run: + description: 'Skip actual publishing (for testing)' + required: false + type: boolean + default: false + package-command: + description: 'Command to build VSIX packages (e.g., "npm run vscode:package" or "vsce package")' + required: false + type: string + default: 'vsce package' + bundle-command: + description: 'Command to bundle extension code (e.g., "npm run vscode:bundle"). Set to empty string to skip bundling.' + required: false + type: string + default: 'npm run vscode:bundle' + secrets: + VSCE_PAT: + description: 'VS Code Marketplace Personal Access Token' + required: false + OVSX_PAT: + description: 'Open VSX Personal Access Token' + required: false + +jobs: + build-and-publish: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + extension: ${{ fromJson(inputs.extensions) }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.x' + + - name: Install dependencies + run: npm ci + + - name: Bundle extension + if: inputs.bundle-command != '' + run: | + cd ${{ matrix.extension }} + # Check if the bundle script exists in package.json + if npm run | grep -q "vscode:bundle"; then + ${{ inputs.bundle-command }} + else + echo "⏭️ Skipping bundle step - vscode:bundle script not found" + fi + + - name: Build extension + run: | + cd ${{ matrix.extension }} + ${{ inputs.package-command }} + + - name: Publish (dry-run) + if: inputs.dry-run + run: | + echo "🔍 DRY RUN: Would publish ${{ matrix.extension }}" + echo " Registry: ${{ inputs.registries }}" + echo " Pre-release: ${{ inputs.pre-release }}" + + - name: Publish to VS Code Marketplace + if: | + !inputs.dry-run && + (inputs.registries == 'marketplace' || inputs.registries == 'all') + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + run: | + cd ${{ matrix.extension }} + npx vsce publish ${{ inputs.pre-release && '--pre-release' || '' }} + + - name: Publish to Open VSX + if: | + !inputs.dry-run && + (inputs.registries == 'openvsx' || inputs.registries == 'all') + env: + OVSX_PAT: ${{ secrets.OVSX_PAT }} + run: | + cd ${{ matrix.extension }} + npx ovsx publish ${{ inputs.pre-release && '--pre-release' || '' }} -p $OVSX_PAT + + - name: Prepare artifact name + id: artifact + run: echo "name=$(echo '${{ matrix.extension }}' | tr '/' '-')-vsix" >> $GITHUB_OUTPUT + + - name: Upload VSIX artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.artifact.outputs.name }} + path: ${{ matrix.extension }}/*.vsix + + summary: + needs: build-and-publish + runs-on: ubuntu-latest + if: always() + steps: + - name: Summary + run: | + echo "## Release Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Extensions:** ${{ inputs.extensions }}" >> $GITHUB_STEP_SUMMARY + echo "**Registries:** ${{ inputs.registries }}" >> $GITHUB_STEP_SUMMARY + echo "**Pre-release:** ${{ inputs.pre-release }}" >> $GITHUB_STEP_SUMMARY + echo "**Dry-run:** ${{ inputs.dry-run }}" >> $GITHUB_STEP_SUMMARY diff --git a/README.md b/README.md index bdee275..ba5839a 100644 --- a/README.md +++ b/README.md @@ -387,3 +387,102 @@ jobs: PULL_REQUEST_URL: ${{ github.event.pull_request.html_url }} uses: salesforcecli/github-workflows/.github/actions/prNotification@main ``` + +## VS Code Extension Workflows + +This repository includes reusable workflows for VS Code extension CI/CD. + +### vscode-release-explicit + +Builds and publishes VS Code extensions from explicitly declared extension paths. + +**Usage:** + +```yaml +name: Nightly Release + +on: + schedule: + - cron: '0 4 * * *' + workflow_dispatch: + +jobs: + nightly: + uses: salesforcecli/github-workflows/.github/workflows/vscode-release-explicit.yml@main + with: + extensions: '["packages/ext1", "packages/ext2"]' # JSON array of paths + registries: all # all | marketplace | openvsx + pre-release: true + version-bump: auto # auto | major | minor | patch + package-command: 'npx vsce package --no-dependencies' + dry-run: false + secrets: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + OVSX_PAT: ${{ secrets.OVSX_PAT }} +``` + +**Inputs:** +- `extensions` (required) - JSON array of extension directory paths +- `registries` (optional) - Where to publish: `all`, `marketplace`, or `openvsx` (default: `all`) +- `pre-release` (optional) - Mark as pre-release version (default: `true`) +- `version-bump` (optional) - Version bump strategy: `auto`, `major`, `minor`, or `patch` (default: `auto`) +- `package-command` (optional) - Command to build VSIX packages (default: `vsce package`) +- `bundle-command` (optional) - Command to bundle extension code (default: `npm run vscode:bundle`) +- `dry-run` (optional) - Skip actual publishing for testing (default: `false`) + +**Required Secrets:** +- `VSCE_PAT` - VS Code Marketplace Personal Access Token +- `OVSX_PAT` - Open VSX Personal Access Token + +### vscode-package + +Packages VS Code extensions into VSIX files without publishing. + +**Usage:** + +```yaml +jobs: + package: + uses: salesforcecli/github-workflows/.github/workflows/vscode-package.yml@main + with: + branch: main + artifact-name: vsix-packages + pre-release: true + dry-run: false +``` + +### vscode-ci-template + +Reusable CI workflow template for VS Code extension repositories. Runs tests across multiple OS and Node.js versions with coverage reporting. + +**Usage:** + +```yaml +jobs: + ci: + uses: salesforcecli/github-workflows/.github/workflows/vscode-ci-template.yml@main + with: + lint-command: 'npm run lint' + compile-command: 'npm run compile' + test-command: 'npm run test' + test-coverage-command: 'npm run test:coverage' +``` + +### vscode-publish-extensions + +Full-featured publish workflow with version bumping, GitHub releases, and marketplace publishing. + +**Usage:** + +```yaml +jobs: + publish: + uses: salesforcecli/github-workflows/.github/workflows/vscode-publish-extensions.yml@main + with: + branch: main + extensions: 'ext1,ext2' + registries: all + pre-release: true + dry-run: false + secrets: inherit +```