diff --git a/.github/actions/ctcOpen/action.yml b/.github/actions/ctcOpen/action.yml index 478d82f..942c97d 100644 --- a/.github/actions/ctcOpen/action.yml +++ b/.github/actions/ctcOpen/action.yml @@ -15,7 +15,7 @@ inputs: description: Github token required: true githubTag: - description: 'The semver tag of the GitHub Release' + description: "The semver tag of the GitHub Release" required: false nodeVersion: description: version of node to use. It's better to specify latest, lts/* or lts/-1 than to hardcode numbers 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/scripts/README.md b/.github/scripts/README.md new file mode 100644 index 0000000..1d2b01f --- /dev/null +++ b/.github/scripts/README.md @@ -0,0 +1,79 @@ +# GitHub Workflows Scripts + +This directory contains TypeScript scripts used by the reusable workflows in this repository. + +## Scripts + +### ext-change-detector.ts + +Detects which VS Code extensions have changed since their last release and determines the appropriate version bump type. + +**Features:** +- Discovers all VS Code extensions in the `packages/` directory (packages with a `publisher` field) +- Supports special values for extension selection: + - `'all'` - selects all available extensions + - `'changed'` - detects extensions with changes via git diff against per-extension tags + - `'none'` - skips all extensions + - Specific names - validates and uses the specified extensions +- Git-based change detection: + - Finds last release tag per extension (pattern: `{extension-name}-v{version}`) + - Runs `git diff {lastTag}..HEAD -- {extensionPath}` + - Returns extensions with changes since their last tag +- Conventional commit analysis for `version-bump: 'auto'`: + - Detects `BREAKING CHANGE` → major bump + - Detects `feat:` → minor bump + - Default → patch bump + +**Usage in workflows:** +```yaml +- name: Detect changes and version bumps + id: changes + env: + IS_NIGHTLY: 'true' + VERSION_BUMP: 'auto' + PRE_RELEASE: 'true' + IS_PROMOTION: 'false' + SELECTED_EXTENSIONS: 'changed' # or 'all', 'none', or comma-separated names + run: | + npx tsx .github/scripts/index.ts ext-change-detector +``` + +**Outputs:** +- `selected-extensions` - Comma-separated list of extensions to release +- `version-bumps` - Determined version bump type (major, minor, patch) + +## Dependencies + +The scripts require the following npm packages: +- `simple-git` - Git operations +- `chalk` - Terminal output formatting +- `semver` - Semantic version parsing and comparison +- `zod` - Schema validation +- `tsx` - TypeScript execution + +## Local Development + +Install dependencies: +```bash +npm install +``` + +Run a script: +```bash +npm run ext-change-detector +``` + +Or directly with tsx: +```bash +npx tsx .github/scripts/index.ts ext-change-detector +``` + +## Integration with Consuming Repositories + +These scripts are automatically downloaded and executed by the reusable workflows in this repository. Consuming repositories do not need to copy these scripts - they are fetched at runtime from the main branch of this repository. + +The `vscode-publish-extensions.yml` workflow automatically: +1. Downloads the scripts from GitHub +2. Installs required dependencies +3. Executes the change detection +4. Uses the outputs to determine which extensions to publish diff --git a/.github/scripts/ext-change-detector.ts b/.github/scripts/ext-change-detector.ts new file mode 100644 index 0000000..6d464b5 --- /dev/null +++ b/.github/scripts/ext-change-detector.ts @@ -0,0 +1,434 @@ +/* + * 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, +} from './types'; +import { + log, + setOutput, + getExtensionInfo, + compareSemver, + extractVersionFromTag, +} from './utils'; + +/** + * Get all available VS Code extensions (packages with publisher field) + */ +function getAvailableExtensions(): ExtensionInfo[] { + const extensions: ExtensionInfo[] = []; + const extensionsRoot = process.env.EXTENSIONS_ROOT || 'packages'; + const packagesDir = join(process.cwd(), extensionsRoot); + + 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 extensionTags = tags.all + .filter((tag: string) => tag.startsWith(`${extensionName}-v`)) + .map((tag: string) => { + const version = extractVersionFromTag(tag); + return { tag, version }; + }) + .filter((item): item is { tag: string; version: SemanticVersion } => + item.version !== null + ) // Type guard: ensures version is non-null after this filter + .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 []; + } + + // Check for malformed comma-separated lists + if (/^,|,$|,,/.test(selectedExtensionsInput)) { + log.warning('Malformed extension list detected (extra commas) - cleaning up'); + log.warning(`Raw input: "${selectedExtensionsInput}"`); + } + + 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; + // Check for breaking changes (supports both singular and plural forms) + // Patterns: BREAKING CHANGE:, BREAKING CHANGES:, feat!:, feat(scope)!: + if ( + /BREAKING CHANGES?:/i.test(body) || + /^[a-z]+(\([^)]*\))?!:/i.test(firstLine) + ) { + return 'major'; + } + // Check for new features (minor version bump) + 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/.github/scripts/index.ts b/.github/scripts/index.ts new file mode 100644 index 0000000..20f9e35 --- /dev/null +++ b/.github/scripts/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.txt file in the + * repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +/** + * Entry point for running workflow scripts via CLI + * Usage: tsx .github/scripts/index.ts + */ + +import { main as extChangeDetector } from './ext-change-detector.js'; + +const command = process.argv[2]; + +(async () => { + switch (command) { + case 'ext-change-detector': + await extChangeDetector(); + break; + default: + console.error(`Unknown command: ${command}`); + console.error('Available commands: ext-change-detector'); + process.exit(1); + } +})(); diff --git a/.github/scripts/types.ts b/.github/scripts/types.ts new file mode 100644 index 0000000..03cd4d6 --- /dev/null +++ b/.github/scripts/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/.github/scripts/utils.ts b/.github/scripts/utils.ts new file mode 100644 index 0000000..aa53e61 --- /dev/null +++ b/.github/scripts/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 } from 'fs'; +import { join } from 'path'; +import { z } from 'zod'; +import chalk from 'chalk'; +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) { + const fs = require('fs'); + fs.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/.github/workflows/vscode-ci-template.yml b/.github/workflows/vscode-ci-template.yml new file mode 100644 index 0000000..9905942 --- /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@main + with: + branch: ${{ github.head_ref || github.ref_name }} + artifact-name: vsix-packages + dry-run: false + + ci-complete: + name: CI Complete + runs-on: ubuntu-latest + needs: [test, package] + if: always() + steps: + - name: Check all jobs result + env: + TEST_RESULT: ${{ needs.test.result }} + PACKAGE_RESULT: ${{ needs.package.result }} + run: | + if [[ "$TEST_RESULT" != "success" ]]; then + echo "Test job(s) failed" + exit 1 + fi + if [[ "$PACKAGE_RESULT" != "success" ]]; then + echo "Package job failed" + exit 1 + fi + echo "All jobs succeeded" + + slack-notify: + name: CI Failed Notification + needs: [test, package] + runs-on: ubuntu-latest + if: always() && github.event_name == 'push' && (needs.test.result == 'failure' || needs.package.result == 'failure') + steps: + - name: Notify Slack + uses: slackapi/slack-github-action@v3.0.3 + with: + payload: | + { + "text": "❌ CI Pipeline Failed", + "event": "CI workflow failed, run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", + "repo": "${{ github.repository }}", + "test_result": "${{ needs.test.result }}", + "package_result": "${{ needs.package.result }}", + "branch": "${{ github.ref_name }}", + "commit": "${{ github.sha }}" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.IDEE_MAIN_SLACK_WEBHOOK }} diff --git a/.github/workflows/vscode-manual-publish.yml b/.github/workflows/vscode-manual-publish.yml new file mode 100644 index 0000000..78ed14e --- /dev/null +++ b/.github/workflows/vscode-manual-publish.yml @@ -0,0 +1,913 @@ +name: Manual Publish VS Code Extension + +# Reusable workflow for manually publishing a VS Code extension to the marketplace. +# Supports two source paths: +# - A nightly GitHub Release (version-tag path — normal workflow) +# - A branch CI build artifact (source-run-id path — out-of-band, bypass mandatory) +# +# Usage from consuming repository: +# jobs: +# manual-publish: +# uses: salesforcecli/github-workflows/.github/workflows/vscode-manual-publish.yml@main +# with: +# extension-name: 'apex-lsp-vscode-extension' +# vsix-name-pattern: 'apex-language-server-extension-*.vsix' +# version-tag: 'apex-language-server-extension-v0.5.3-nightly.20260301' # OR source-run-id +# slot: 'pre-release' # or 'stable' +# registries: 'all' # or 'vsce' or 'ovsx' +# exclude-web-vsix: 'true' +# extensions-root: 'packages' +# dry-run: 'false' +# secrets: inherit +# +# Requirements - calling repository must have: +# - Secrets: IDEE_GH_TOKEN, VSCE_PERSONAL_ACCESS_TOKEN, IDEE_OVSX_PAT +# - Action: ./.github/actions/npm-install-with-retries (custom npm install with retry logic) +# - Action: ./.github/actions/check-ci-status (validates CI checks passed before publish) +# - Action: ./.github/actions/repackage-vsix-stable (repackages pre-release VSIX as stable) +# - Action: ./.github/actions/publish-vsix (publishes to VS Code Marketplace and/or Open VSX) +# - Environment: manual-publish-gate (with required reviewers for approval) +# +# Note: This workflow uses local actions from the calling repository. For a fully self-contained +# workflow that doesn't require local actions, use vscode-promote-prerelease.yml instead. + +on: + workflow_call: + inputs: + extension-name: + description: 'Extension name (e.g., "apex-lsp-vscode-extension")' + required: true + type: string + vsix-name-pattern: + description: 'Pattern to match VSIX files (e.g., "apex-language-server-extension-*.vsix")' + required: true + type: string + version-tag: + description: "[Tag path] Nightly git tag to publish from (e.g., apex-language-server-extension-v0.5.3-nightly.20260301). Mutually exclusive with source-run-id." + required: false + default: "" + type: string + source-run-id: + description: "[Run path] GitHub Actions run ID whose VSIX artifact to publish. Mutually exclusive with version-tag. REQUIRES skip-quality-checks=true and confirm-bypass=BYPASS." + required: false + default: "" + type: string + slot: + description: "Marketplace slot to publish to (pre-release or stable)" + required: true + type: string + registries: + description: "Registries to publish to (all, vsce, ovsx)" + required: false + default: "all" + type: string + target-stable-version: + description: "Optional stable version override (e.g., 0.6.1). Must be valid semver with EVEN minor. Only applies when slot is stable. Required when using source-run-id with slot stable." + required: false + default: "" + type: string + skip-quality-checks: + description: "Skip CI quality gate. REQUIRED when using source-run-id." + required: false + default: "false" + type: string + confirm-bypass: + description: "Type BYPASS to confirm skipping CI quality gate (required when skip-quality-checks is true)" + required: false + default: "" + type: string + exclude-web-vsix: + description: "Exclude *-web-* VSIX files" + required: false + default: "false" + type: string + extensions-root: + description: "Root directory for extensions (default: packages)" + required: false + default: "packages" + type: string + vsix-artifact-name: + description: "Name of the VSIX artifact in run artifacts (default: vsix-packages)" + required: false + default: "vsix-packages" + type: string + required-ci-checks: + description: 'Comma-separated list of required CI check names (default: "CI Complete, Package / Package, Test Quality (ubuntu-latest, lts/*)")' + required: false + default: "CI Complete, Package / Package, Test Quality (ubuntu-latest, lts/*)" + type: string + dry-run: + description: "Run in dry-run mode (no actual publishing or tagging)" + required: false + default: "false" + type: string + node-version: + description: "Node.js version to use" + required: false + default: "22.x" + type: string + workflow_dispatch: + inputs: + extension-name: + description: 'Extension name (e.g., "apex-lsp-vscode-extension")' + required: true + type: string + vsix-name-pattern: + description: 'Pattern to match VSIX files (e.g., "apex-language-server-extension-*.vsix")' + required: true + type: string + version-tag: + description: "[Tag path] Nightly git tag to publish from" + required: false + default: "" + type: string + source-run-id: + description: "[Run path] GitHub Actions run ID" + required: false + default: "" + type: string + slot: + description: "Marketplace slot (pre-release or stable)" + required: true + default: "pre-release" + type: choice + options: + - pre-release + - stable + registries: + description: "Registries to publish to" + required: false + default: "all" + type: choice + options: + - all + - vsce + - ovsx + target-stable-version: + description: "Optional stable version override" + required: false + default: "" + type: string + skip-quality-checks: + description: "Skip CI quality gate" + required: false + default: "false" + type: choice + options: + - "false" + - "true" + confirm-bypass: + description: "Type BYPASS to confirm bypass" + required: false + default: "" + type: string + exclude-web-vsix: + description: "Exclude *-web-* VSIX files" + required: false + default: "false" + type: choice + options: + - "false" + - "true" + extensions-root: + description: "Root directory for extensions" + required: false + default: "packages" + type: string + dry-run: + description: "Dry-run mode" + required: false + default: "false" + type: choice + options: + - "false" + - "true" + node-version: + description: "Node.js version to use" + required: false + default: "22.x" + type: string + +concurrency: + group: manual-publish + cancel-in-progress: false + +permissions: + contents: write + packages: write + actions: read + +jobs: + # ── prepare ──────────────────────────────────────────────────────────────── + # Validates all inputs, resolves source VSIX and version information, + # uploads VSIX as artifact for downstream jobs. + prepare: + runs-on: ubuntu-latest + outputs: + source-type: ${{ steps.resolve.outputs.source-type }} + prerelease-version: ${{ steps.resolve.outputs.prerelease-version }} + stable-version: ${{ steps.resolve.outputs.stable-version }} + prerelease-flag: ${{ steps.resolve.outputs.prerelease-flag }} + commit-sha: ${{ steps.resolve.outputs.commit-sha }} + ci-commit-sha: ${{ steps.ci-commit.outputs.ci-commit-sha }} + steps: + - name: Validate inputs — mutual exclusivity and bypass rules + run: | + VERSION_TAG="${{ inputs.version-tag }}" + SOURCE_RUN_ID="${{ inputs.source-run-id }}" + SKIP_QC="${{ inputs.skip-quality-checks }}" + BYPASS="${{ inputs.confirm-bypass }}" + + # Exactly one source must be provided + if [ -z "$VERSION_TAG" ] && [ -z "$SOURCE_RUN_ID" ]; then + echo "ERROR: Provide either version-tag or source-run-id." + exit 1 + fi + if [ -n "$VERSION_TAG" ] && [ -n "$SOURCE_RUN_ID" ]; then + echo "ERROR: version-tag and source-run-id are mutually exclusive." + exit 1 + fi + + # Run path requires bypass (branch CI profiles differ from main) + if [ -n "$SOURCE_RUN_ID" ]; then + if [ "$SKIP_QC" != "true" ]; then + echo "ERROR: source-run-id path requires skip-quality-checks=true (branch CI profiles differ from main)." + exit 1 + fi + fi + + # Bypass requires confirmation (case-insensitive) + if [ "$SKIP_QC" = "true" ]; then + BYPASS_LOWER=$(echo "$BYPASS" | tr '[:upper:]' '[:lower:]') + if [ "$BYPASS_LOWER" != "bypass" ]; then + echo "ERROR: skip-quality-checks=true requires confirm-bypass to be set to 'BYPASS' (any case)." + echo "You provided: '$BYPASS'" + exit 1 + fi + echo "WARNING: CI quality gate bypassed by ${{ github.actor }} for run ${{ github.run_id }}" + fi + + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.IDEE_GH_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.node-version || '22.x' }} + + - name: Install dependencies + uses: ./.github/actions/npm-install-with-retries + + - name: Resolve source and compute versions + id: resolve + env: + GH_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + VERSION_TAG: ${{ inputs.version-tag }} + SOURCE_RUN_ID: ${{ inputs.source-run-id }} + SLOT: ${{ inputs.slot }} + TARGET_STABLE: ${{ inputs.target-stable-version }} + VSIX_NAME_PATTERN: ${{ inputs.vsix-name-pattern }} + EXCLUDE_WEB_VSIX: ${{ inputs.exclude-web-vsix }} + EXTENSION_NAME: ${{ inputs.extension-name }} + VSIX_ARTIFACT_NAME: ${{ inputs.vsix-artifact-name }} + run: | + mkdir -p ./vsix-artifacts + + # ── Resolve VSIX and pre-release version ───────────────────────── + if [ -n "$VERSION_TAG" ]; then + echo "Source: nightly GH release tag ($VERSION_TAG)" + echo "source-type=tag" >> $GITHUB_OUTPUT + + # Get commit SHA from tag + git fetch --tags origin + COMMIT_SHA=$(git rev-list -n 1 "$VERSION_TAG" 2>/dev/null || echo "") + if [ -z "$COMMIT_SHA" ]; then + echo "ERROR: Could not resolve commit SHA for tag: $VERSION_TAG" + exit 1 + fi + echo "commit-sha=$COMMIT_SHA" >> $GITHUB_OUTPUT + + # Extract pre-release version from tag name + PRERELEASE_VERSION=$(echo "$VERSION_TAG" | grep -oP '\d+\.\d+\.\d+' | head -1) + if [ -z "$PRERELEASE_VERSION" ]; then + echo "ERROR: Could not extract semver from tag: $VERSION_TAG" + exit 1 + fi + + # Download VSIX from GH release + gh release download "$VERSION_TAG" \ + --pattern "$VSIX_NAME_PATTERN" \ + --dir ./vsix-artifacts \ + --repo "${{ github.repository }}" + + else + echo "Source: branch CI run ($SOURCE_RUN_ID)" + echo "source-type=run" >> $GITHUB_OUTPUT + + # Get commit SHA from the run + COMMIT_SHA=$(gh api "repos/${{ github.repository }}/actions/runs/$SOURCE_RUN_ID" \ + --jq '.head_sha') + if [ -z "$COMMIT_SHA" ]; then + echo "ERROR: Could not resolve head_sha for run $SOURCE_RUN_ID" + exit 1 + fi + echo "commit-sha=$COMMIT_SHA" >> $GITHUB_OUTPUT + + # Download VSIX from run artifact + gh run download "$SOURCE_RUN_ID" \ + --name "$VSIX_ARTIFACT_NAME" \ + --dir ./vsix-artifacts \ + --repo "${{ github.repository }}" 2>/dev/null || \ + gh run download "$SOURCE_RUN_ID" \ + --dir ./vsix-artifacts \ + --repo "${{ github.repository }}" + + # Find VSIX, excluding web variants if requested + if [ "$EXCLUDE_WEB_VSIX" = "true" ]; then + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" ! -name '*-web-*' | head -1) + else + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" | head -1) + fi + + if [ -z "$VSIX_FILE" ]; then + echo "ERROR: No VSIX found in run $SOURCE_RUN_ID artifacts" + exit 1 + fi + + # Extract pre-release version from the VSIX's package.json + PRERELEASE_VERSION=$(unzip -p "$VSIX_FILE" extension/package.json | \ + node -e "let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>console.log(JSON.parse(d).version));") + if [ -z "$PRERELEASE_VERSION" ]; then + echo "ERROR: Could not extract version from VSIX package.json" + exit 1 + fi + echo "Extracted version from VSIX: $PRERELEASE_VERSION" + fi + + echo "prerelease-version=$PRERELEASE_VERSION" >> $GITHUB_OUTPUT + + # Find VSIX, excluding web variants if requested + if [ "$EXCLUDE_WEB_VSIX" = "true" ]; then + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" ! -name '*-web-*' | head -1) + else + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" | head -1) + fi + + if [ -z "$VSIX_FILE" ]; then + echo "ERROR: No VSIX found under ./vsix-artifacts" + exit 1 + fi + + # Check if VSIX is packaged as pre-release (extension.vsixmanifest) + VSIX_PRERELEASE_PACKAGED=$(unzip -p "$VSIX_FILE" extension.vsixmanifest | \ + { grep -q 'Microsoft.VisualStudio.Code.PreRelease" Value="true"' && echo true || echo false; }) + echo "VSIX pre-release (extension.vsixmanifest): $VSIX_PRERELEASE_PACKAGED ($VSIX_FILE)" + + # ── Stable slot: determine and validate stable version ──────────── + if [ "$SLOT" != "stable" ]; then + echo "stable-version=" >> $GITHUB_OUTPUT + if [ "$VSIX_PRERELEASE_PACKAGED" != "true" ]; then + echo "ERROR: Pre-release marketplace slot requires a VSIX built with vsce package --pre-release." + echo "This artifact is missing Microsoft.VisualStudio.Code.PreRelease Value=\"true\" in extension.vsixmanifest." + exit 1 + fi + echo "prerelease-flag=true" >> $GITHUB_OUTPUT + exit 0 + fi + + git fetch --tags origin + + if [ -n "$TARGET_STABLE" ]; then + # User-supplied override — safeguards + + # 1. Valid semver + VALID=$(node -e "const semver = require('semver'); console.log(semver.valid('$TARGET_STABLE') ? 'yes' : 'no');") + if [ "$VALID" != "yes" ]; then + echo "ERROR: target-stable-version '$TARGET_STABLE' is not valid semver" + exit 1 + fi + + # 2. Minor must be even + MINOR=$(node -e "const semver = require('semver'); console.log(semver.minor('$TARGET_STABLE'));") + if [ $(( MINOR % 2 )) -ne 0 ]; then + echo "ERROR: target-stable-version '$TARGET_STABLE' has odd minor ($MINOR). Stable versions require even minor." + exit 1 + fi + + # 3. Must be >= auto-computed floor (no downgrade) + AUTO_STABLE=$(node -e "const semver = require('semver'); console.log(semver.inc('$PRERELEASE_VERSION', 'minor'));") + IS_GTE=$(node -e "const semver = require('semver'); console.log(semver.gte('$TARGET_STABLE', '$AUTO_STABLE') ? 'yes' : 'no');") + if [ "$IS_GTE" != "yes" ]; then + echo "ERROR: target-stable-version '$TARGET_STABLE' is below the floor '$AUTO_STABLE' for pre-release series $PRERELEASE_VERSION." + exit 1 + fi + + # 4. Not already published + EXISTING="marketplace-stable-${EXTENSION_NAME}-v${TARGET_STABLE}" + if git tag --list "$EXISTING" | grep -q .; then + echo "ERROR: Stable version '$TARGET_STABLE' already published ($EXISTING exists)." + exit 1 + fi + + STABLE_VERSION="$TARGET_STABLE" + echo "Stable version (user override): $STABLE_VERSION" + else + # Auto-compute via semver minor bump + if [ -n "$(echo "$SOURCE_RUN_ID")" ] && [ -z "$TARGET_STABLE" ] && [ "$SLOT" = "stable" ]; then + echo "ERROR: target-stable-version is required when using source-run-id with slot=stable." + exit 1 + fi + + STABLE_VERSION=$(node -e "const semver = require('semver'); console.log(semver.inc('$PRERELEASE_VERSION', 'minor'));") + if [ -z "$STABLE_VERSION" ] || [ "$STABLE_VERSION" = "null" ]; then + echo "ERROR: Could not compute stable version from $PRERELEASE_VERSION" + exit 1 + fi + + # Not already published + EXISTING="marketplace-stable-${EXTENSION_NAME}-v${STABLE_VERSION}" + if git tag --list "$EXISTING" | grep -q .; then + echo "ERROR: Stable version '$STABLE_VERSION' (auto-computed from $PRERELEASE_VERSION) already published." + exit 1 + fi + + echo "Stable version (auto-computed): $PRERELEASE_VERSION → $STABLE_VERSION" + fi + + echo "stable-version=$STABLE_VERSION" >> $GITHUB_OUTPUT + echo "prerelease-flag=false" >> $GITHUB_OUTPUT + + # Walk first-parent chain to find nearest ancestor with successful CI + - name: Resolve nearest CI-tested ancestor + id: ci-commit + if: inputs.skip-quality-checks != 'true' + env: + GH_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + REPO: ${{ github.repository }} + START_SHA: ${{ steps.resolve.outputs.commit-sha }} + run: | + MAX_DEPTH=20 + SHA="$START_SHA" + CI_SHA="" + + for i in $(seq 1 "$MAX_DEPTH"); do + CONCLUSION=$(gh api "repos/$REPO/commits/$SHA/check-runs" --paginate \ + --jq '[.check_runs[] | select(.name == "CI Complete")] | .[0].conclusion // empty' 2>/dev/null || echo "") + if [ "$CONCLUSION" = "success" ]; then + CI_SHA="$SHA" + echo "Found CI-tested ancestor at depth $((i - 1)): $CI_SHA" + break + fi + echo " $SHA: no successful 'CI Complete' (conclusion='${CONCLUSION:-none}') — walking to parent" + PARENT=$(git rev-parse "${SHA}^" 2>/dev/null || echo "") + if [ -z "$PARENT" ]; then + echo "Reached root of history without a CI-tested commit." + break + fi + SHA="$PARENT" + done + + if [ -z "$CI_SHA" ]; then + echo "ERROR: No ancestor with a successful 'CI Complete' check found within $MAX_DEPTH commits of $START_SHA." + echo "Cannot verify CI status — failing to prevent untested promotion. Use skip-quality-checks=true to bypass." + exit 1 + fi + echo "ci-commit-sha=$CI_SHA" >> "$GITHUB_OUTPUT" + + - name: Upload VSIX for downstream jobs + uses: actions/upload-artifact@v7 + with: + name: source-vsix + path: ./vsix-artifacts/*.vsix + + # ── preview ──────────────────────────────────────────────────────────────── + # Summarises every planned action — shown to the reviewer in the gate step. + preview: + needs: prepare + runs-on: ubuntu-latest + steps: + - name: Summarise planned actions + env: + SOURCE_TYPE: ${{ needs.prepare.outputs.source-type }} + PRERELEASE_VERSION: ${{ needs.prepare.outputs.prerelease-version }} + STABLE_VERSION: ${{ needs.prepare.outputs.stable-version }} + COMMIT_SHA: ${{ needs.prepare.outputs.commit-sha }} + PRERELEASE_FLAG: ${{ needs.prepare.outputs.prerelease-flag }} + EXTENSION_NAME: ${{ inputs.extension-name }} + VSIX_NAME_PATTERN: ${{ inputs.vsix-name-pattern }} + run: | + SLOT="${{ inputs.slot }}" + DRY_RUN="${{ inputs.dry-run }}" + TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + if [ "$SLOT" = "stable" ]; then + TRACKING_TAG="marketplace-stable-${EXTENSION_NAME}-v${STABLE_VERSION}" + BASE_NAME=$(echo "$VSIX_NAME_PATTERN" | sed 's/\*\.vsix$//') + GH_RELEASE_TAG="${BASE_NAME}v${STABLE_VERSION}" + VSIX_NAME="${BASE_NAME}${STABLE_VERSION}.vsix" + SLOT_LABEL="stable (pre-release=false)" + else + TRACKING_TAG="marketplace-prerelease-${EXTENSION_NAME}-v${PRERELEASE_VERSION}" + GH_RELEASE_TAG="(none — pre-release slot)" + VSIX_NAME="original nightly VSIX (no repackaging)" + SLOT_LABEL="pre-release (pre-release=true)" + fi + + if [ "$SOURCE_TYPE" = "run" ]; then + SOURCE_LABEL="Branch CI run ${{ inputs.source-run-id }}" + else + SOURCE_LABEL="Nightly GH release tag ${{ inputs.version-tag }}" + fi + + if [ "${{ inputs.registries }}" = "all" ]; then + REGISTRY_LABEL="VS Code Marketplace (vsce) + Open VSX (ovsx)" + else + REGISTRY_LABEL="${{ inputs.registries }}" + fi + + echo "╔══════════════════════════════════════════════════════════════════╗" + echo "║ MANUAL PUBLISH — PLANNED ACTIONS ║" + echo "╚══════════════════════════════════════════════════════════════════╝" + echo "" + echo " Timestamp : $TIMESTAMP" + echo " Initiated by : ${{ github.actor }}" + echo " Dry-run : $DRY_RUN" + echo "" + echo "── Source ──────────────────────────────────────────────────────────" + echo " Type : $SOURCE_LABEL" + echo " Commit SHA : $COMMIT_SHA" + echo " Pre-release ver : $PRERELEASE_VERSION" + echo "" + echo "── Publish target ──────────────────────────────────────────────────" + echo " Extension : ${{ inputs.extension-name }}" + echo " Slot : $SLOT_LABEL" + if [ "$SLOT" = "stable" ]; then + echo " Stable version : $STABLE_VERSION" + echo " VSIX (repacked) : $VSIX_NAME" + else + echo " VSIX : $VSIX_NAME" + fi + echo " Registries : $REGISTRY_LABEL" + echo "" + echo "── Side effects ─────────────────────────────────────────────────────" + echo " Tracking tag : $TRACKING_TAG → $COMMIT_SHA (pushed to origin)" + if [ "$SLOT" = "stable" ]; then + echo " GitHub release : $GH_RELEASE_TAG (created with repacked VSIX as asset)" + fi + echo "" + echo "── Quality gate ─────────────────────────────────────────────────────" + if [ "${{ inputs.skip-quality-checks }}" = "true" ]; then + echo " ⚠️ BYPASSED by ${{ github.actor }} (confirm-bypass=BYPASS)" + else + echo " ✅ Will run against commit $COMMIT_SHA" + fi + echo "" + if [ "$DRY_RUN" = "true" ]; then + echo " ℹ️ DRY-RUN MODE — no publishing, tagging, or releases will occur." + fi + echo "════════════════════════════════════════════════════════════════════" + + # ── quality-gate ─────────────────────────────────────────────────────────── + quality-gate: + needs: [prepare, preview] + if: inputs.skip-quality-checks != 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Check CI status + uses: ./.github/actions/check-ci-status + with: + commit-sha: ${{ needs.prepare.outputs.ci-commit-sha }} + token: ${{ secrets.IDEE_GH_TOKEN }} + required-checks: ${{ inputs.required-ci-checks }} + + # ── gate ─────────────────────────────────────────────────────────────────── + gate: + needs: [prepare, preview, quality-gate] + if: always() && needs.prepare.result == 'success' && needs.preview.result == 'success' && (needs.quality-gate.result == 'success' || needs.quality-gate.result == 'skipped') + runs-on: ubuntu-latest + environment: manual-publish-gate + steps: + - name: Approved — proceeding with publish + run: echo "Manual publish approved by ${{ github.actor }} at $(date -u +"%Y-%m-%dT%H:%M:%SZ")" + + # ── repackage ────────────────────────────────────────────────────────────── + repackage: + needs: [prepare, gate] + if: always() && needs.prepare.result == 'success' && needs.gate.result == 'success' + runs-on: ubuntu-latest + outputs: + vsix-path: ${{ steps.resolve-vsix.outputs.vsix-path }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.node-version || '22.x' }} + + - name: Download source VSIX + uses: actions/download-artifact@v8 + with: + name: source-vsix + path: ./vsix-artifacts + + - name: Locate source VSIX + id: source-vsix + env: + VSIX_NAME_PATTERN: ${{ inputs.vsix-name-pattern }} + EXCLUDE_WEB_VSIX: ${{ inputs.exclude-web-vsix }} + run: | + if [ "$EXCLUDE_WEB_VSIX" = "true" ]; then + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" ! -name '*-web-*' | head -1) + else + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" | head -1) + fi + + if [ -z "$VSIX_FILE" ]; then + echo "ERROR: No VSIX found in artifact" + exit 1 + fi + echo "vsix-file=$VSIX_FILE" >> $GITHUB_OUTPUT + + - name: Repackage for stable + id: repack + if: inputs.slot == 'stable' + uses: ./.github/actions/repackage-vsix-stable + with: + source-vsix-path: ${{ steps.source-vsix.outputs.vsix-file }} + prerelease-version: ${{ needs.prepare.outputs.prerelease-version }} + stable-version: ${{ needs.prepare.outputs.stable-version }} + + - name: Resolve publish-ready VSIX path + id: resolve-vsix + run: | + if [ "${{ inputs.slot }}" = "stable" ]; then + echo "vsix-path=${{ steps.repack.outputs.vsix-path }}" >> $GITHUB_OUTPUT + else + echo "vsix-path=${{ steps.source-vsix.outputs.vsix-file }}" >> $GITHUB_OUTPUT + fi + + - name: Upload publish-ready VSIX + uses: actions/upload-artifact@v7 + with: + name: publish-vsix + path: ${{ steps.resolve-vsix.outputs.vsix-path }} + + # ── publish ──────────────────────────────────────────────────────────────── + publish: + needs: [prepare, repackage] + if: always() && needs.prepare.result == 'success' && needs.repackage.result == 'success' + runs-on: ubuntu-latest + strategy: + matrix: + include: ${{ fromJson(inputs.registries == 'vsce' && '[{"registry":"vsce","publish-tool":"vsce"}]' || inputs.registries == 'ovsx' && '[{"registry":"ovsx","publish-tool":"ovsx"}]' || '[{"registry":"vsce","publish-tool":"vsce"},{"registry":"ovsx","publish-tool":"ovsx"}]') }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.node-version || '22.x' }} + + - name: Install dependencies + uses: ./.github/actions/npm-install-with-retries + + - name: Download VSIX + uses: actions/download-artifact@v8 + with: + name: publish-vsix + path: ./vsix-artifacts + + - name: Locate VSIX + env: + VSIX_NAME_PATTERN: ${{ inputs.vsix-name-pattern }} + EXCLUDE_WEB_VSIX: ${{ inputs.exclude-web-vsix }} + run: | + if [ "$EXCLUDE_WEB_VSIX" = "true" ]; then + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" ! -name '*-web-*' | head -1) + else + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" | head -1) + fi + echo "VSIX_PATH=$VSIX_FILE" >> $GITHUB_ENV + + - name: Publish to ${{ matrix.registry }} + uses: ./.github/actions/publish-vsix + with: + vsix-path: ${{ env.VSIX_PATH }} + publish-tool: ${{ matrix.publish-tool }} + pre-release: ${{ needs.prepare.outputs.prerelease-flag }} + dry-run: ${{ inputs.dry-run || 'false' }} + env: + VSCE_PERSONAL_ACCESS_TOKEN: ${{ secrets.VSCE_PERSONAL_ACCESS_TOKEN }} + OVSX_PAT: ${{ secrets.IDEE_OVSX_PAT }} + + # ── create-github-release ────────────────────────────────────────────────── + create-github-release: + needs: [prepare, repackage, publish] + if: always() && inputs.slot == 'stable' && needs.publish.result == 'success' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.IDEE_GH_TOKEN }} + + - name: Download VSIX + uses: actions/download-artifact@v8 + with: + name: publish-vsix + path: ./vsix-artifacts + + - name: Create GitHub release for stable version + env: + GH_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + STABLE_VERSION: ${{ needs.prepare.outputs.stable-version }} + PRERELEASE_VERSION: ${{ needs.prepare.outputs.prerelease-version }} + SOURCE_TYPE: ${{ needs.prepare.outputs.source-type }} + DRY_RUN: ${{ inputs.dry-run || 'false' }} + VSIX_NAME_PATTERN: ${{ inputs.vsix-name-pattern }} + EXCLUDE_WEB_VSIX: ${{ inputs.exclude-web-vsix }} + run: | + if [ "$EXCLUDE_WEB_VSIX" = "true" ]; then + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" ! -name '*-web-*' | head -1) + else + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" | head -1) + fi + + BASE_NAME=$(basename "$VSIX_FILE" .vsix | sed -E 's/-[0-9]+\.[0-9]+\.[0-9]+$//') + RELEASE_TAG="${BASE_NAME}-v${STABLE_VERSION}" + RELEASE_TITLE="${BASE_NAME} v${STABLE_VERSION} (stable)" + + if [ "$SOURCE_TYPE" = "run" ]; then + SOURCE_DESC="branch CI run ${{ inputs.source-run-id }}" + else + SOURCE_DESC="nightly tag ${{ inputs.version-tag }}" + fi + + RELEASE_NOTES="## Stable Release v${STABLE_VERSION} + + Manually promoted from pre-release v${PRERELEASE_VERSION} (source: ${SOURCE_DESC}) by ${{ github.actor }} (run ${{ github.run_id }}). + + This is a stable release. Install from the VS Code Marketplace or Open VSX Registry." + + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: Would create GitHub release $RELEASE_TAG with $VSIX_FILE" + else + if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" &>/dev/null; then + EXISTING_ASSETS=$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json assets --jq '.assets | length') + if [ "$EXISTING_ASSETS" -gt 0 ]; then + echo "⏭️ GitHub release $RELEASE_TAG already exists with assets — skipping (idempotent rerun)" + else + echo "GitHub release $RELEASE_TAG exists but has no assets — uploading VSIX" + gh release upload "$RELEASE_TAG" "$VSIX_FILE" --repo "${{ github.repository }}" + echo "VSIX uploaded to existing release: $RELEASE_TAG" + fi + else + gh release create "$RELEASE_TAG" \ + "$VSIX_FILE" \ + --title "$RELEASE_TITLE" \ + --notes "$RELEASE_NOTES" \ + --repo "${{ github.repository }}" + echo "GitHub release created: $RELEASE_TAG" + fi + fi + + # ── tag-published ────────────────────────────────────────────────────────── + tag-published: + needs: [prepare, publish, create-github-release] + if: always() && needs.publish.result == 'success' && (inputs.slot == 'pre-release' || needs.create-github-release.result == 'success') + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.IDEE_GH_TOKEN }} + + - name: Create tracking tag + env: + SLOT: ${{ inputs.slot }} + PRERELEASE_VERSION: ${{ needs.prepare.outputs.prerelease-version }} + STABLE_VERSION: ${{ needs.prepare.outputs.stable-version }} + COMMIT_SHA: ${{ needs.prepare.outputs.commit-sha }} + DRY_RUN: ${{ inputs.dry-run || 'false' }} + GH_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + EXTENSION_NAME: ${{ inputs.extension-name }} + run: | + if [ "$SLOT" = "stable" ]; then + TRACKING_TAG="marketplace-stable-${EXTENSION_NAME}-v${STABLE_VERSION}" + else + TRACKING_TAG="marketplace-prerelease-${EXTENSION_NAME}-v${PRERELEASE_VERSION}" + fi + + echo "Creating tracking tag: $TRACKING_TAG → $COMMIT_SHA" + + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: Would create and push tag $TRACKING_TAG pointing to $COMMIT_SHA" + else + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git remote set-url origin https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git + git fetch --tags origin + if git tag --list "$TRACKING_TAG" | grep -q .; then + echo "⏭️ Tracking tag $TRACKING_TAG already exists — skipping (idempotent rerun)" + else + git tag "$TRACKING_TAG" "$COMMIT_SHA" + git push origin "$TRACKING_TAG" + echo "Tracking tag pushed: $TRACKING_TAG" + fi + fi + + # ── commit-stable-version ──────────────────────────────────────────────── + commit-stable-version: + needs: [prepare, publish, create-github-release, tag-published] + if: needs.publish.result == 'success' && inputs.slot == 'stable' + runs-on: ubuntu-latest + steps: + - name: Checkout main + uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + token: ${{ secrets.IDEE_GH_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.node-version || '22.x' }} + + - name: Commit stable version bump to main + env: + STABLE_VERSION: ${{ needs.prepare.outputs.stable-version }} + DRY_RUN: ${{ inputs.dry-run || 'false' }} + GH_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + EXTENSION_NAME: ${{ inputs.extension-name }} + EXTENSIONS_ROOT: ${{ inputs.extensions-root }} + run: | + set -euo pipefail + PKG_DIR="${EXTENSIONS_ROOT}/${EXTENSION_NAME}" + CURRENT_VERSION=$(node -p "require('./$PKG_DIR/package.json').version") + echo "main version: $CURRENT_VERSION -> stable version: $STABLE_VERSION" + + if [ -z "$STABLE_VERSION" ]; then + echo "Error: stable-version is empty but slot is stable" + exit 1 + fi + + # Monotonic guard: never move main backwards or sideways + IS_GT=$(STABLE_VERSION="$STABLE_VERSION" CURRENT_VERSION="$CURRENT_VERSION" node -e ' + const parse = v => String(v).split("-")[0].split(".").map(Number); + const a = parse(process.env.STABLE_VERSION); + const b = parse(process.env.CURRENT_VERSION); + let gt = false; + for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) { gt = a[i] > b[i]; break; } + } + console.log(gt ? "yes" : "no"); + ') + if [ "$IS_GT" != "yes" ]; then + echo "Skipping commit-back: main ($CURRENT_VERSION) is already >= stable ($STABLE_VERSION)" + exit 0 + fi + + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: Would set $PKG_DIR to $STABLE_VERSION and commit to main" + exit 0 + fi + + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" + + ( cd "$PKG_DIR" && npm version "$STABLE_VERSION" --no-git-tag-version ) + + npm install --package-lock-only --ignore-scripts + + git add "$PKG_DIR/package.json" package-lock.json + if git diff --staged --quiet; then + echo "No version change to commit - skipping (idempotent rerun)" + exit 0 + fi + + git commit -m "chore: set stable version $STABLE_VERSION [skip ci]" + + echo "Pushing stable version bump to main..." + if ! git push origin HEAD:main; then + echo "Push failed, attempting fetch+rebase and retry..." + git fetch origin main + git rebase origin/main + if ! git push origin HEAD:main; then + echo "Error: Push failed after rebase. Check branch protection rules." + exit 1 + fi + fi + echo "main package.json set to $STABLE_VERSION" diff --git a/.github/workflows/vscode-package.yml b/.github/workflows/vscode-package.yml new file mode 100644 index 0000000..9842155 --- /dev/null +++ b/.github/workflows/vscode-package.yml @@ -0,0 +1,249 @@ +name: Package + +# Reusable workflow for packaging VS Code extensions into VSIX files +# +# Usage from consuming repository: +# jobs: +# package: +# uses: salesforcecli/github-workflows/.github/workflows/vscode/package.yml@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 + extensions-root: + description: 'Root directory for extensions (default: packages)' + required: false + default: 'packages' + type: string + outputs: + artifact-name: + description: 'The calculated artifact name' + value: ${{ jobs.package.outputs.artifact-name }} + workflow_dispatch: + inputs: + node-version: + description: 'Node.js version to use' + required: false + default: '22.x' + type: string + branch: + description: 'Branch to package from' + required: false + default: 'main' + type: string + dry-run: + description: 'Run in dry-run mode' + required: false + default: 'false' + type: string + pre-release: + description: 'Indicates if this is a pre-release version' + required: false + default: 'false' + type: string + +# Add explicit permissions for security +permissions: + contents: read + actions: read + +jobs: + package: + name: Package + runs-on: ubuntu-latest + outputs: + artifact-name: ${{ steps.calc-artifact-name.outputs.artifact-name }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ inputs.branch || github.head_ref || github.ref }} + + - name: Setup Node.js ${{ inputs.node-version || '22.x' }} + uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.node-version || '22.x' }} + + - name: Install dependencies + uses: salesforcecli/github-workflows/.github/actions/npmInstallWithRetries@main + + # Universal + web-target VSIXs are defined in packages/apex-lsp-vscode-extension (Wireit: package + package-web). + - name: Package packages + env: + EXTENSIONS_ROOT: ${{ inputs.extensions-root || 'packages' }} + run: | + if [ "${{ inputs.pre-release }}" = "true" ]; then + npm run package:packages:prerelease + else + npm run package:packages + fi + + - name: Generate MD5 checksums + id: md5-checksums + env: + EXTENSIONS_ROOT: ${{ inputs.extensions-root || 'packages' }} + run: | + echo "Generating MD5 checksums for VSIX files..." + + # Universal + web-target VSIX under packages/ + VSIX_FILES=$(find "$EXTENSIONS_ROOT" -name "*.vsix" -type f) + + if [ -z "$VSIX_FILES" ]; then + echo "No VSIX files found to generate checksums for" + echo "checksums_generated=false" >> $GITHUB_OUTPUT + exit 0 + fi + + # Create checksums directory structure + CHECKSUMS_FILE="checksums.md5" + CHECKSUMS_JSON_FILE="checksums.json" + > "$CHECKSUMS_FILE" # Create/clear checksums file + > "$CHECKSUMS_JSON_FILE" # Create/clear JSON file + echo "[" > "$CHECKSUMS_JSON_FILE" + + FIRST=true + # Generate MD5 checksums for each VSIX file + while IFS= read -r vsix_file; do + if [ -f "$vsix_file" ]; then + # Generate MD5 checksum + MD5_HASH=$(md5sum "$vsix_file" | cut -d' ' -f1) + + # Get relative path for display + RELATIVE_PATH=$(echo "$vsix_file" | sed "s|^$EXTENSIONS_ROOT/||") + + # Get file size + FILE_SIZE=$(stat -c%s "$vsix_file" 2>/dev/null || stat -f%z "$vsix_file" 2>/dev/null || echo "0") + + # Create individual .md5 file alongside VSIX file + MD5_FILE="${vsix_file}.md5" + echo "$MD5_HASH $(basename "$vsix_file")" > "$MD5_FILE" + + # Add to combined checksums file + echo "$MD5_HASH $RELATIVE_PATH" >> "$CHECKSUMS_FILE" + + # Add to JSON file for workflow summary + if [ "$FIRST" = true ]; then + FIRST=false + else + echo "," >> "$CHECKSUMS_JSON_FILE" + fi + echo " {\"file\":\"$RELATIVE_PATH\",\"md5\":\"$MD5_HASH\",\"size\":\"$FILE_SIZE\"}" >> "$CHECKSUMS_JSON_FILE" + + echo "Generated MD5 for: $RELATIVE_PATH" + echo " MD5: $MD5_HASH" + echo " Size: $FILE_SIZE bytes" + fi + done <<< "$VSIX_FILES" + + echo "]" >> "$CHECKSUMS_JSON_FILE" + + # Move combined checksums files to packages root for artifact upload + mv "$CHECKSUMS_FILE" "$EXTENSIONS_ROOT/checksums.md5" + mv "$CHECKSUMS_JSON_FILE" "$EXTENSIONS_ROOT/checksums.json" + + echo "checksums_generated=true" >> $GITHUB_OUTPUT + echo "checksums_file=$EXTENSIONS_ROOT/checksums.json" >> $GITHUB_OUTPUT + + - name: Calculate artifact name + id: calc-artifact-name + run: | + BASE_NAME="${{ inputs.artifact-name }}" + RUN_NUMBER="${{ github.run_number }}" + IS_DRY_RUN="${{ inputs.dry-run }}" + + # Check if already suffixed + if [[ "$BASE_NAME" =~ -dry-run$ ]] || [[ "$BASE_NAME" =~ -release$ ]]; then + ARTIFACT_NAME="$BASE_NAME" + else + if [ "$IS_DRY_RUN" = "true" ]; then + ARTIFACT_NAME="${BASE_NAME}-${RUN_NUMBER}-dry-run" + else + ARTIFACT_NAME="${BASE_NAME}-${RUN_NUMBER}-release" + fi + fi + + echo "artifact-name=$ARTIFACT_NAME" >> $GITHUB_OUTPUT + echo "Artifact name: $ARTIFACT_NAME" + + - name: Upload VSIX artifacts + id: upload + uses: actions/upload-artifact@v7 + env: + EXTENSIONS_ROOT: ${{ inputs.extensions-root || 'packages' }} + with: + name: ${{ steps.calc-artifact-name.outputs.artifact-name }} + path: | + ${{ inputs.extensions-root || 'packages' }}/**/*.vsix + ${{ inputs.extensions-root || 'packages' }}/**/*.vsix.md5 + ${{ inputs.extensions-root || 'packages' }}/checksums.md5 + ${{ inputs.extensions-root || 'packages' }}/checksums.json + retention-days: 5 + + - name: List VSIX files + env: + EXTENSIONS_ROOT: ${{ inputs.extensions-root || 'packages' }} + run: | + echo "VSIX files created:" + find "$EXTENSIONS_ROOT" -name "*.vsix" -exec ls -la {} \; + + - name: Add MD5 checksums to workflow summary + if: steps.md5-checksums.outputs.checksums_generated == 'true' + run: | + echo "## MD5 Checksums" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "MD5 checksums for all VSIX extension files:" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Extension | MD5 Checksum | Size |" >> $GITHUB_STEP_SUMMARY + echo "|-----------|-------------|------|" >> $GITHUB_STEP_SUMMARY + + # Read checksums from JSON file and format table + CHECKSUMS_FILE="${{ steps.md5-checksums.outputs.checksums_file }}" + + if [ -f "$CHECKSUMS_FILE" ]; then + # Use node to parse JSON and format table + node -e " + const fs = require('fs'); + const checksums = JSON.parse(fs.readFileSync('$CHECKSUMS_FILE', 'utf8')); + checksums.forEach(item => { + const file = item.file || 'unknown'; + const md5 = item.md5 || 'unknown'; + const size = item.size || '0'; + const sizeFormatted = size !== '0' && size !== 'unknown' ? (parseInt(size) / 1024).toFixed(2) + ' KB' : 'unknown'; + console.log(\`|\${file} |\` + md5 + \` |\${sizeFormatted}|\`); + }); + " >> $GITHUB_STEP_SUMMARY + else + echo "| No checksums available | - | - |" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Note:** Individual \`.md5\` files are available alongside each VSIX file in the artifacts." >> $GITHUB_STEP_SUMMARY + echo "A combined \`checksums.md5\` file and \`checksums.json\` file are also included in the artifacts." >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/vscode-promote-prerelease.yml b/.github/workflows/vscode-promote-prerelease.yml new file mode 100644 index 0000000..c101c5f --- /dev/null +++ b/.github/workflows/vscode-promote-prerelease.yml @@ -0,0 +1,298 @@ +name: Promote Nightly to Pre-release + +# Promotes a vetted nightly build to pre-release on VS Code Marketplace and Open VSX. +# +# Usage: +# jobs: +# promote: +# uses: salesforcecli/github-workflows/.github/workflows/vscode-promote-prerelease.yml@main +# with: +# extension-name: 'apex-lsp-vscode-extension' +# min-tag-age-days: '7' +# vsix-name-pattern: 'apex-language-server-extension-*.vsix' # optional, default: *.vsix +# exclude-web-vsix: 'true' # optional, default: false +# dry-run: 'false' +# secrets: inherit +# +# Requirements - calling repository must have: +# - Nightly tags matching *-nightly.* (e.g., v1.2.3-nightly.20260629) +# - GitHub releases for each nightly tag with VSIX file(s) attached +# - Passing CI checks on nightly commits +# - Secrets: IDEE_GH_TOKEN, VSCE_PERSONAL_ACCESS_TOKEN, IDEE_OVSX_PAT +# +# Workflow: finds oldest unpromoted nightly ≥ min-tag-age-days → verifies CI → +# downloads VSIX → publishes to both marketplaces → creates tracking tag + +on: + workflow_call: + inputs: + min-tag-age-days: + description: "Minimum nightly age in days before eligible for promotion" + required: false + default: "7" + type: string + extension-name: + description: "Extension name for tracking tag (e.g., 'apex-lsp-vscode-extension')" + required: true + type: string + vsix-name-pattern: + description: "Pattern to match VSIX files in release (e.g., 'apex-language-server-extension-*.vsix')" + required: false + default: "*.vsix" + type: string + exclude-web-vsix: + description: "Exclude *-web-* VSIX files from promotion" + required: false + default: "false" + type: string + dry-run: + description: "Run in dry-run mode (no actual publishing or tagging)" + required: false + default: "false" + type: string + node-version: + description: 'Node.js version to use' + required: false + default: '22.x' + type: string + workflow_dispatch: + inputs: + min-tag-age-days: + description: "Minimum nightly age in days before eligible for promotion (default: 7)" + required: false + default: "7" + type: string + extension-name: + description: "Extension name for tracking tag (e.g., 'apex-lsp-vscode-extension')" + required: true + type: string + vsix-name-pattern: + description: "Pattern to match VSIX files in release (e.g., 'apex-language-server-extension-*.vsix')" + required: false + default: "*.vsix" + type: string + exclude-web-vsix: + description: "Exclude *-web-* VSIX files from promotion" + required: false + default: "false" + type: choice + options: + - "false" + - "true" + dry-run: + description: "Run in dry-run mode (no actual publishing or tagging)" + required: false + default: "false" + type: choice + options: + - "false" + - "true" + node-version: + description: 'Node.js version to use' + required: false + default: '22.x' + type: string + +concurrency: + group: promote-prerelease + cancel-in-progress: false + +permissions: + contents: write + packages: write + actions: read + +jobs: + find-nightly-candidate: + runs-on: ubuntu-latest + outputs: + commit-sha: ${{ steps.find.outputs.commit-sha }} + nightly-tag: ${{ steps.find.outputs.nightly-tag }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.IDEE_GH_TOKEN }} + + - name: Find eligible nightly + id: find + env: + MIN_TAG_AGE_DAYS: ${{ inputs.min-tag-age-days || '7' }} + run: | + echo "Finding nightly tags older than $MIN_TAG_AGE_DAYS days..." + + # Get all nightly tags sorted by date (newest first) + NIGHTLY_TAGS=$(git tag -l '*-nightly.*' --sort=-creatordate) + + if [ -z "$NIGHTLY_TAGS" ]; then + echo "No nightly tags found" + echo "nightly-tag=" >> $GITHUB_OUTPUT + echo "commit-sha=" >> $GITHUB_OUTPUT + exit 0 + fi + + # Calculate cutoff timestamp (MIN_TAG_AGE_DAYS ago) + CUTOFF_TIMESTAMP=$(date -u -d "$MIN_TAG_AGE_DAYS days ago" +%s 2>/dev/null || date -u -v-${MIN_TAG_AGE_DAYS}d +%s) + + # Find first tag older than cutoff that hasn't been promoted + SELECTED_TAG="" + SELECTED_SHA="" + + while IFS= read -r tag; do + [ -z "$tag" ] && continue + + # Get tag creation timestamp + TAG_TIMESTAMP=$(git log -1 --format=%ct "$tag" 2>/dev/null) + [ -z "$TAG_TIMESTAMP" ] && continue + + # Check if tag is old enough + if [ "$TAG_TIMESTAMP" -lt "$CUTOFF_TIMESTAMP" ]; then + # Check if already promoted (has corresponding marketplace-prerelease tag) + VERSION=$(echo "$tag" | grep -oP '\d+\.\d+\.\d+' | head -1) + PROMO_TAG="marketplace-prerelease-*-v${VERSION}" + + if ! git tag -l "$PROMO_TAG" | grep -q .; then + SELECTED_TAG="$tag" + SELECTED_SHA=$(git rev-list -n 1 "$tag") + break + fi + fi + done <<< "$NIGHTLY_TAGS" + + if [ -n "$SELECTED_TAG" ]; then + echo "Found eligible nightly: $SELECTED_TAG (SHA: $SELECTED_SHA)" + echo "nightly-tag=$SELECTED_TAG" >> $GITHUB_OUTPUT + echo "commit-sha=$SELECTED_SHA" >> $GITHUB_OUTPUT + else + echo "No eligible nightly found" + echo "nightly-tag=" >> $GITHUB_OUTPUT + echo "commit-sha=" >> $GITHUB_OUTPUT + fi + + - name: Fail if no candidate found + if: steps.find.outputs.nightly-tag == '' + run: | + echo "No eligible nightly candidate found. Nothing to promote this week." + exit 1 + + quality-gate: + needs: find-nightly-candidate + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Check CI status for candidate commit + uses: salesforcecli/github-workflows/.github/actions/vscode/check-ci-status@main + 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: ${{ inputs.node-version || '22.x' }} + + - name: Install dependencies + uses: salesforcecli/github-workflows/.github/actions/npmInstallWithRetries@main + + - name: Download VSIX from nightly GitHub release + env: + GH_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + NIGHTLY_TAG: ${{ needs.find-nightly-candidate.outputs.nightly-tag }} + VSIX_PATTERN: ${{ inputs.vsix-name-pattern || '*.vsix' }} + EXCLUDE_WEB: ${{ inputs.exclude-web-vsix || 'false' }} + run: | + mkdir -p ./vsix-artifacts + echo "Downloading VSIX from release: $NIGHTLY_TAG" + gh release download "$NIGHTLY_TAG" \ + --pattern "*.vsix" \ + --dir ./vsix-artifacts \ + --repo "${{ github.repository }}" + + # Find VSIX matching the pattern + if [ "$EXCLUDE_WEB" = "true" ]; then + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_PATTERN" ! -name '*-web-*' | head -1) + else + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_PATTERN" | head -1) + fi + + if [ -z "$VSIX_FILE" ]; then + echo "No VSIX found matching pattern '$VSIX_PATTERN' in release $NIGHTLY_TAG" + exit 1 + fi + echo "Found VSIX: $VSIX_FILE" + echo "VSIX_PATH=$VSIX_FILE" >> $GITHUB_ENV + + - name: Publish to ${{ matrix.registry }} + uses: salesforcecli/github-workflows/.github/actions/vscode/publish-vsix@main + with: + vsix-path: ${{ env.VSIX_PATH }} + publish-tool: ${{ matrix.publish-tool }} + pre-release: "true" + dry-run: ${{ inputs.dry-run || 'false' }} + env: + VSCE_PERSONAL_ACCESS_TOKEN: ${{ secrets.VSCE_PERSONAL_ACCESS_TOKEN }} + OVSX_PAT: ${{ secrets.IDEE_OVSX_PAT }} + + tag-promoted: + needs: [find-nightly-candidate, publish] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.IDEE_GH_TOKEN }} + + - name: Create marketplace-prerelease tracking tag + env: + NIGHTLY_TAG: ${{ needs.find-nightly-candidate.outputs.nightly-tag }} + EXTENSION_NAME: ${{ inputs.extension-name }} + DRY_RUN: ${{ inputs.dry-run || 'false' }} + GH_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + run: | + # Extract version from the nightly tag (format: ...-v-nightly.*) + VERSION=$(echo "$NIGHTLY_TAG" | grep -oP '\d+\.\d+\.\d+' | head -1) + if [ -z "$VERSION" ]; then + echo "Could not extract version from tag: $NIGHTLY_TAG" + exit 1 + fi + + TRACKING_TAG="marketplace-prerelease-${EXTENSION_NAME}-v${VERSION}" + COMMIT_SHA="${{ needs.find-nightly-candidate.outputs.commit-sha }}" + + echo "Creating tracking tag: $TRACKING_TAG → $COMMIT_SHA" + + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: Would create and push tag $TRACKING_TAG" + else + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git remote set-url origin https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git + git fetch --tags origin + if git tag --list "$TRACKING_TAG" | grep -q .; then + echo "⏭️ Tracking tag $TRACKING_TAG already exists — skipping (idempotent rerun)" + else + git tag "$TRACKING_TAG" "$COMMIT_SHA" + git push origin "$TRACKING_TAG" + echo "Tracking tag pushed: $TRACKING_TAG" + fi + fi diff --git a/.github/workflows/vscode-promote-stable.yml b/.github/workflows/vscode-promote-stable.yml new file mode 100644 index 0000000..50c2997 --- /dev/null +++ b/.github/workflows/vscode-promote-stable.yml @@ -0,0 +1,523 @@ +name: Promote Pre-release to Stable + +# Reusable workflow for promoting a pre-release VS Code extension to stable. +# +# Usage from consuming repository: +# jobs: +# promote: +# uses: salesforcecli/github-workflows/.github/workflows/vscode-promote-stable.yml@main +# with: +# extension-name: 'apex-lsp-vscode-extension' +# vsix-name-pattern: 'apex-language-server-extension-*.vsix' +# exclude-web-vsix: 'true' +# extensions-root: 'packages' +# dry-run: 'false' +# secrets: inherit +# +# Requirements - calling repository must have: +# - marketplace-prerelease-* tracking tags +# - Nightly tags matching v{version}-nightly.* pattern +# - GitHub releases for each nightly tag with VSIX file(s) attached +# - Passing CI checks (gate, tag-published) +# - Secrets: IDEE_GH_TOKEN, VSCE_PERSONAL_ACCESS_TOKEN, IDEE_OVSX_PAT +# - Action: ./.github/actions/repackage-vsix-stable (repackages pre-release VSIX as stable) +# - Action: ./.github/actions/publish-vsix (publishes to VS Code Marketplace and/or Open VSX) +# - Action: ./.github/actions/npm-install-with-retries (custom npm install with retry logic) +# +# Note: This workflow uses local actions from the calling repository. For a fully self-contained +# workflow that doesn't require local actions, use vscode-promote-prerelease.yml instead. +# +# Workflow: finds latest pre-release → verifies quality gate → repackages VSIX +# → publishes to both marketplaces → creates GitHub release → tags stable +# → commits stable version back to main + +on: + workflow_call: + inputs: + extension-name: + description: 'Extension name for tracking tags (e.g., "apex-lsp-vscode-extension")' + required: true + type: string + vsix-name-pattern: + description: 'Pattern to match VSIX files in release (e.g., "apex-language-server-extension-*.vsix")' + required: false + default: '*.vsix' + type: string + exclude-web-vsix: + description: 'Exclude *-web-* VSIX files from promotion' + required: false + default: 'false' + type: string + extensions-root: + description: 'Root directory for extensions (default: packages)' + required: false + default: 'packages' + type: string + dry-run: + description: 'Run in dry-run mode (no actual publishing or tagging)' + required: false + default: 'false' + type: string + node-version: + description: 'Node.js version to use' + required: false + default: '22.x' + type: string + workflow_dispatch: + inputs: + extension-name: + description: 'Extension name for tracking tags (e.g., "apex-lsp-vscode-extension")' + required: true + type: string + vsix-name-pattern: + description: 'Pattern to match VSIX files in release (e.g., "apex-language-server-extension-*.vsix")' + required: false + default: '*.vsix' + type: string + exclude-web-vsix: + description: 'Exclude *-web-* VSIX files from promotion' + required: false + default: 'false' + type: choice + options: + - 'false' + - 'true' + extensions-root: + description: 'Root directory for extensions (default: packages)' + required: false + default: 'packages' + type: string + dry-run: + description: 'Run in dry-run mode (no actual publishing or tagging)' + required: false + default: 'false' + type: choice + options: + - 'false' + - 'true' + node-version: + description: 'Node.js version to use' + required: false + default: '22.x' + type: string + +concurrency: + group: promote-stable + cancel-in-progress: false + +permissions: + contents: write + packages: write + actions: read + +jobs: + find-prerelease-candidate: + runs-on: ubuntu-latest + outputs: + prerelease-version: ${{ steps.find.outputs.prerelease-version }} + stable-version: ${{ steps.find.outputs.stable-version }} + nightly-tag: ${{ steps.find.outputs.nightly-tag }} + commit-sha: ${{ steps.find.outputs.commit-sha }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.IDEE_GH_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.node-version || '22.x' }} + + - name: Install dependencies + uses: ./.github/actions/npm-install-with-retries + + - name: Find latest pre-release candidate and compute stable version + id: find + env: + GH_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + EXTENSION_NAME: ${{ inputs.extension-name }} + run: | + # Find the latest marketplace-prerelease-* tracking tag + git fetch --tags origin + + PRERELEASE_TAG=$(git tag --list "marketplace-prerelease-${EXTENSION_NAME}-v*" \ + --sort=-version:refname | head -1) + + if [ -z "$PRERELEASE_TAG" ]; then + echo "No marketplace-prerelease-${EXTENSION_NAME}-v* tracking tags found. Nothing to promote." + exit 1 + fi + + echo "Found pre-release tracking tag: $PRERELEASE_TAG" + + # Extract version from tag (format: marketplace-prerelease--v) + PRERELEASE_VERSION=$(echo "$PRERELEASE_TAG" | grep -oP '\d+\.\d+\.\d+' | head -1) + if [ -z "$PRERELEASE_VERSION" ]; then + echo "Could not extract version from tag: $PRERELEASE_TAG" + exit 1 + fi + + echo "Pre-release version: $PRERELEASE_VERSION" + + # Compute stable version via semver minor bump using Node.js/semver + STABLE_VERSION=$(node -e "const semver = require('semver'); console.log(semver.inc('$PRERELEASE_VERSION', 'minor'));") + if [ -z "$STABLE_VERSION" ] || [ "$STABLE_VERSION" = "null" ]; then + echo "Could not compute stable version from $PRERELEASE_VERSION" + exit 1 + fi + + echo "Computed stable version: $STABLE_VERSION" + + # Check if stable was already published (floor check) + STABLE_TRACKING_TAG="marketplace-stable-${EXTENSION_NAME}-v${STABLE_VERSION}" + if git tag --list "$STABLE_TRACKING_TAG" | grep -q .; then + echo "Stable version $STABLE_VERSION already published ($STABLE_TRACKING_TAG exists). Nothing to do." + exit 1 + fi + + # Find the nightly tag that corresponds to this pre-release version + # It was the nightly that had version $PRERELEASE_VERSION + NIGHTLY_TAG=$(git tag --list "v${PRERELEASE_VERSION}-nightly*" \ + --sort=-version:refname | head -1) + + if [ -z "$NIGHTLY_TAG" ]; then + echo "Could not find nightly tag for version $PRERELEASE_VERSION" + exit 1 + fi + + echo "Corresponding nightly tag: $NIGHTLY_TAG" + + # Get commit SHA from the nightly tag + COMMIT_SHA=$(git rev-list -n 1 "$NIGHTLY_TAG") + echo "Commit SHA: $COMMIT_SHA" + + echo "prerelease-version=$PRERELEASE_VERSION" >> $GITHUB_OUTPUT + echo "stable-version=$STABLE_VERSION" >> $GITHUB_OUTPUT + echo "nightly-tag=$NIGHTLY_TAG" >> $GITHUB_OUTPUT + echo "commit-sha=$COMMIT_SHA" >> $GITHUB_OUTPUT + + quality-gate: + needs: find-prerelease-candidate + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Check CI status for candidate commit + uses: ./.github/actions/check-ci-status + with: + commit-sha: ${{ needs.find-prerelease-candidate.outputs.commit-sha }} + token: ${{ secrets.IDEE_GH_TOKEN }} + # Validate only the publish-pipeline checks that gate the candidate's quality. + # Without this, the action validates every check-run on the commit — including + # this workflow's own `quality-gate` job, whose superseded historical runs can + # carry a stale `failure` conclusion and block all future promotions. + # `gate` is the Manual Publish aggregate gate; `tag-published` confirms the + # nightly/pre-release artifact was successfully published for this commit. + required-checks: 'gate,tag-published' + + repackage: + needs: [find-prerelease-candidate, quality-gate] + runs-on: ubuntu-latest + outputs: + stable-vsix-path: ${{ steps.repack.outputs.vsix-path }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.node-version || '22.x' }} + + - name: Download VSIX from nightly GitHub release + env: + GH_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + NIGHTLY_TAG: ${{ needs.find-prerelease-candidate.outputs.nightly-tag }} + VSIX_NAME_PATTERN: ${{ inputs.vsix-name-pattern }} + EXCLUDE_WEB_VSIX: ${{ inputs.exclude-web-vsix }} + run: | + mkdir -p ./vsix-artifacts + echo "Downloading VSIX from release: $NIGHTLY_TAG" + gh release download "$NIGHTLY_TAG" \ + --pattern "$VSIX_NAME_PATTERN" \ + --dir ./vsix-artifacts \ + --repo "${{ github.repository }}" + + # Find VSIX, excluding web variants if requested + if [ "$EXCLUDE_WEB_VSIX" = "true" ]; then + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" ! -name '*-web-*' | head -1) + else + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" | head -1) + fi + + if [ -z "$VSIX_FILE" ]; then + echo "No VSIX found in release $NIGHTLY_TAG" + exit 1 + fi + echo "VSIX_FILE=$VSIX_FILE" >> $GITHUB_ENV + + - name: Repackage VSIX with stable version + id: repack + uses: ./.github/actions/repackage-vsix-stable + with: + source-vsix-path: ${{ env.VSIX_FILE }} + prerelease-version: ${{ needs.find-prerelease-candidate.outputs.prerelease-version }} + stable-version: ${{ needs.find-prerelease-candidate.outputs.stable-version }} + + - name: Upload stable VSIX as artifact + uses: actions/upload-artifact@v7 + with: + name: stable-vsix + path: ${{ steps.repack.outputs.vsix-path }} + + publish: + needs: [find-prerelease-candidate, repackage] + runs-on: ubuntu-latest + strategy: + matrix: + include: + - registry: vsce + publish-tool: vsce + - registry: ovsx + publish-tool: ovsx + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.node-version || '22.x' }} + + - name: Install dependencies + uses: ./.github/actions/npm-install-with-retries + + - name: Download stable VSIX artifact + uses: actions/download-artifact@v8 + with: + name: stable-vsix + path: ./vsix-artifacts + + - name: Locate stable VSIX + env: + VSIX_NAME_PATTERN: ${{ inputs.vsix-name-pattern }} + EXCLUDE_WEB_VSIX: ${{ inputs.exclude-web-vsix }} + run: | + # Find VSIX, excluding web variants if requested + if [ "$EXCLUDE_WEB_VSIX" = "true" ]; then + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" ! -name '*-web-*' | head -1) + else + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" | head -1) + fi + + if [ -z "$VSIX_FILE" ]; then + echo "No stable VSIX found in artifact" + exit 1 + fi + echo "VSIX_PATH=$VSIX_FILE" >> $GITHUB_ENV + + - name: Publish to ${{ matrix.registry }} + uses: ./.github/actions/publish-vsix + with: + vsix-path: ${{ env.VSIX_PATH }} + publish-tool: ${{ matrix.publish-tool }} + pre-release: 'false' + dry-run: ${{ inputs.dry-run || 'false' }} + env: + VSCE_PERSONAL_ACCESS_TOKEN: ${{ secrets.VSCE_PERSONAL_ACCESS_TOKEN }} + OVSX_PAT: ${{ secrets.IDEE_OVSX_PAT }} + + create-github-release: + needs: [find-prerelease-candidate, repackage, publish] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.IDEE_GH_TOKEN }} + + - name: Download stable VSIX artifact + uses: actions/download-artifact@v8 + with: + name: stable-vsix + path: ./vsix-artifacts + + - name: Create GitHub release for stable version + env: + GH_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + STABLE_VERSION: ${{ needs.find-prerelease-candidate.outputs.stable-version }} + PRERELEASE_VERSION: ${{ needs.find-prerelease-candidate.outputs.prerelease-version }} + NIGHTLY_TAG: ${{ needs.find-prerelease-candidate.outputs.nightly-tag }} + DRY_RUN: ${{ inputs.dry-run || 'false' }} + VSIX_NAME_PATTERN: ${{ inputs.vsix-name-pattern }} + EXCLUDE_WEB_VSIX: ${{ inputs.exclude-web-vsix }} + run: | + # Find VSIX, excluding web variants if requested + if [ "$EXCLUDE_WEB_VSIX" = "true" ]; then + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" ! -name '*-web-*' | head -1) + else + VSIX_FILE=$(find ./vsix-artifacts -type f -name "$VSIX_NAME_PATTERN" | head -1) + fi + + # Extract base name for release tag (remove version and .vsix) + # e.g., apex-language-server-extension-1.2.3.vsix -> apex-language-server-extension + BASE_NAME=$(basename "$VSIX_FILE" .vsix | sed -E 's/-[0-9]+\.[0-9]+\.[0-9]+$//') + RELEASE_TAG="${BASE_NAME}-v${STABLE_VERSION}" + RELEASE_TITLE="${BASE_NAME} v${STABLE_VERSION} (stable)" + RELEASE_NOTES="## Stable Release v${STABLE_VERSION} + + Promoted from pre-release v${PRERELEASE_VERSION} (nightly: ${NIGHTLY_TAG}). + + This is a stable release. Install from the VS Code Marketplace or Open VSX Registry." + + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: Would create GitHub release $RELEASE_TAG with $VSIX_FILE" + else + # Idempotent: check if release already exists + if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" &>/dev/null; then + EXISTING_ASSETS=$(gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" --json assets --jq '.assets | length') + if [ "$EXISTING_ASSETS" -gt 0 ]; then + echo "⏭️ GitHub release $RELEASE_TAG already exists with assets — skipping (idempotent rerun)" + else + echo "GitHub release $RELEASE_TAG exists but has no assets — uploading VSIX" + gh release upload "$RELEASE_TAG" "$VSIX_FILE" --repo "${{ github.repository }}" + echo "VSIX uploaded to existing release: $RELEASE_TAG" + fi + else + gh release create "$RELEASE_TAG" \ + "$VSIX_FILE" \ + --title "$RELEASE_TITLE" \ + --notes "$RELEASE_NOTES" \ + --repo "${{ github.repository }}" + echo "GitHub release created: $RELEASE_TAG" + fi + fi + + tag-stable: + needs: [find-prerelease-candidate, create-github-release] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.IDEE_GH_TOKEN }} + + - name: Create marketplace-stable tracking tag + env: + STABLE_VERSION: ${{ needs.find-prerelease-candidate.outputs.stable-version }} + COMMIT_SHA: ${{ needs.find-prerelease-candidate.outputs.commit-sha }} + DRY_RUN: ${{ inputs.dry-run || 'false' }} + GH_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + EXTENSION_NAME: ${{ inputs.extension-name }} + run: | + TRACKING_TAG="marketplace-stable-${EXTENSION_NAME}-v${STABLE_VERSION}" + + echo "Creating tracking tag: $TRACKING_TAG → $COMMIT_SHA" + + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: Would create and push tag $TRACKING_TAG" + else + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git remote set-url origin https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git + git fetch --tags origin + if git tag --list "$TRACKING_TAG" | grep -q .; then + echo "⏭️ Tracking tag $TRACKING_TAG already exists — skipping (idempotent rerun)" + else + git tag "$TRACKING_TAG" "$COMMIT_SHA" + git push origin "$TRACKING_TAG" + echo "Tracking tag pushed: $TRACKING_TAG" + fi + fi + + # ── commit-stable-version ──────────────────────────────────────────────── + # Advance main's package.json to the published stable version so the next + # nightly derives its version from the stable line. With the odd/even minor + # scheme, an even-minor stable (e.g. 0.6.0) makes the next nightly bump to the + # next odd minor (0.7.0). Without this, main stays on the old nightly line and + # the marketplace version diverges from the repo. Idempotent and monotonic: + # only bumps when stable-version is strictly greater than main's current + # version, so reruns and out-of-order promotions never move main backwards. + commit-stable-version: + needs: [find-prerelease-candidate, create-github-release, tag-stable] + runs-on: ubuntu-latest + steps: + - name: Checkout main + uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 0 + token: ${{ secrets.IDEE_GH_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.node-version || '22.x' }} + + - name: Commit stable version bump to main + env: + STABLE_VERSION: ${{ needs.find-prerelease-candidate.outputs.stable-version }} + DRY_RUN: ${{ inputs.dry-run || 'false' }} + GH_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + EXTENSION_NAME: ${{ inputs.extension-name }} + EXTENSIONS_ROOT: ${{ inputs.extensions-root }} + run: | + set -euo pipefail + PKG_DIR="${EXTENSIONS_ROOT}/${EXTENSION_NAME}" + CURRENT_VERSION=$(node -p "require('./$PKG_DIR/package.json').version") + echo "main version: $CURRENT_VERSION -> stable version: $STABLE_VERSION" + + # Monotonic guard: never move main backwards or sideways. + # Use semver CLI for reliable version comparison + if npx --yes semver@7 "$STABLE_VERSION" -r ">$CURRENT_VERSION" >/dev/null 2>&1; then + IS_GT="yes" + else + IS_GT="no" + fi + if [ "$IS_GT" != "yes" ]; then + echo "Skipping commit-back: main ($CURRENT_VERSION) is already >= stable ($STABLE_VERSION)" + exit 0 + fi + + if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN: Would set $PKG_DIR to $STABLE_VERSION and commit to main" + exit 0 + fi + + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" + + ( cd "$PKG_DIR" && npm version "$STABLE_VERSION" --no-git-tag-version ) + + # Keep the root lockfile's workspace entry in sync with package.json. + # --package-lock-only rewrites package-lock.json from the manifests + # without installing node_modules, so the lockfile never drifts behind + # the committed version. This commit carries [skip ci], so nothing + # downstream re-derives the lockfile — it must be correct here. + npm install --package-lock-only --ignore-scripts + + git add "$PKG_DIR/package.json" package-lock.json + if git diff --staged --quiet; then + echo "No version change to commit - skipping (idempotent rerun)" + exit 0 + fi + + git commit -m "chore: set stable version $STABLE_VERSION [skip ci]" + + echo "Pushing stable version bump to main..." + if ! git push origin HEAD:main; then + echo "Push failed, attempting fetch+rebase and retry..." + git fetch origin main + git rebase origin/main + if ! git push origin HEAD:main; then + echo "Error: Push failed after rebase. Check branch protection rules." + exit 1 + fi + fi + echo "main package.json set to $STABLE_VERSION" diff --git a/.github/workflows/vscode-publish-extensions.yml b/.github/workflows/vscode-publish-extensions.yml new file mode 100644 index 0000000..be81e79 --- /dev/null +++ b/.github/workflows/vscode-publish-extensions.yml @@ -0,0 +1,1229 @@ +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: +# - Requires jq for JSON manipulation (pre-installed on GitHub runners) +# - Requires secrets: IDEE_GH_TOKEN, VSCE_PERSONAL_ACCESS_TOKEN, IDEE_OVSX_PAT +# +# Features: +# - Auto-detects changed extensions +# - Smart version bumping (even/odd minor for stable/pre-release) +# - Conventional commit analysis +# - GitHub release creation with VSIX artifacts +# - Marketplace publishing (can be skipped for nightly by setting registries appropriately) +# +# Versioning Strategy: +# This workflow uses an odd/even minor version convention to distinguish release types: +# - Nightly builds: odd minor versions (e.g., 0.5.x-nightly.20260709) +# - Pre-release: odd minor versions (e.g., 0.5.3) +# - Stable releases: even minor versions (e.g., 0.6.0) +# +# Benefits: +# - Clear visual distinction between release channels +# - Prevents accidental stable overwrites of pre-release versions +# - Predictable version progression: nightly 0.5.x → pre-release 0.5.y → stable 0.6.0 +# +# Version bump logic: +# - major: Breaking change, bumps to next major with first odd minor (e.g., 0.5.3 → 1.1.0) +# - minor: New feature, bumps to next odd minor (e.g., 0.5.3 → 0.7.0) +# - patch: Bug fix, increments patch while maintaining odd minor (e.g., 0.5.3 → 0.5.4) +# - auto: Analyzes conventional commits to determine bump type +# +# Note: This convention is specific to VS Code extension release workflows. If your +# project uses a different versioning strategy, you may need to customize the version +# bumping logic in the bump-versions job. + +on: + workflow_call: + inputs: + branch: + description: 'Branch to release from' + required: false + default: 'main' + type: string + extensions: + description: 'Extensions to release (all, changed, or comma-separated extension names)' + required: false + default: 'changed' + type: string + registries: + description: 'Registries to publish to (all, vsce, ovsx)' + required: false + default: 'all' + type: string + available-extensions: + description: 'Available VS Code extensions' + required: false + type: string + dry-run: + description: 'Run in dry-run mode (no actual publishing)' + required: false + default: 'false' + type: string + pre-release: + description: 'Publish as pre-release version' + required: false + default: 'true' + type: string + + version-bump: + description: 'Version bump type (auto, patch, minor, major)' + required: false + default: 'auto' + type: string + extensions-root: + description: 'Root directory for extensions (default: packages)' + required: false + default: 'packages' + type: string + exclude-web-vsix: + description: 'Exclude *-web-* VSIX files from publishing and releases' + required: false + default: 'false' + type: string + slack-notification-title: + description: 'Title for Slack success notifications' + required: false + default: '🎉 Extensions Released Successfully!' + type: string + node-version: + description: 'Node.js version to use' + required: false + default: '22.x' + 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: + 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: ${{ inputs.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: ${{ needs.determine-changes.outputs.version-bumps }} + REGISTRIES: ${{ inputs.registries }} + PRE_RELEASE: 'true' + SELECTED_EXTENSIONS: ${{ needs.determine-changes.outputs.selected-extensions }} + EXTENSIONS_ROOT: ${{ inputs.extensions-root || 'packages' }} + run: | + echo "=== EXTENSION RELEASE PLAN ===" + echo "Branch: $BRANCH" + echo "Build type: $BUILD_TYPE" + echo "Is nightly: $IS_NIGHTLY" + echo "Version bump type: $VERSION_BUMP" + echo "Registries: $REGISTRIES" + echo "Pre-release: $PRE_RELEASE" + echo "Dry run mode: ENABLED" + echo "" + echo "Extensions to release: $SELECTED_EXTENSIONS" + echo "" + + IFS=',' read -ra extensions <<< "$SELECTED_EXTENSIONS" + for ext in "${extensions[@]}"; do + [ -z "$ext" ] && continue + + package_json="$EXTENSIONS_ROOT/$ext/package.json" + if [ ! -f "$package_json" ]; then + echo "Extension: $ext (package.json not found)" + continue + fi + + current_version=$(jq -r '.version' "$package_json") + publisher=$(jq -r '.publisher // "N/A"' "$package_json") + + echo "Extension: $ext" + echo " Current version: $current_version" + echo " Publisher: $publisher" + + if [ "$IS_NIGHTLY" = "true" ]; then + echo " Version strategy: Nightly build (odd minor + nightly timestamp)" + elif [ "$PRE_RELEASE" = "true" ]; then + echo " Version strategy: Pre-release (odd minor version)" + else + echo " Version strategy: Stable release (even minor version)" + fi + + echo " Would create GitHub release" + echo " Would package VSIX" + + case "$REGISTRIES" in + marketplace) echo " Would publish to: VS Code Marketplace only" ;; + openvsx) echo " Would publish to: Open VSX only" ;; + all) echo " Would publish to: VS Code Marketplace and Open VSX" ;; + esac + echo "" + done + + echo "=== END OF RELEASE PLAN ===" + + 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 # Need full history for git diff against tags + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.node-version || '22.x' }} + + - name: Detect changes and version bumps + id: changes + env: + IS_NIGHTLY: 'true' + VERSION_BUMP: ${{ inputs.version-bump }} + PRE_RELEASE: ${{ inputs.pre-release || 'true' }} + IS_PROMOTION: 'false' + SELECTED_EXTENSIONS: ${{ inputs.extensions }} + EXTENSIONS_ROOT: ${{ inputs.extensions-root || 'packages' }} + run: | + # Download the change detection scripts from salesforcecli/github-workflows + SCRIPTS_URL="https://raw.githubusercontent.com/salesforcecli/github-workflows/main/.github/scripts" + mkdir -p .github/scripts + + echo "Downloading change detection scripts..." + curl -sS "$SCRIPTS_URL/ext-change-detector.ts" -o .github/scripts/ext-change-detector.ts + curl -sS "$SCRIPTS_URL/types.ts" -o .github/scripts/types.ts + curl -sS "$SCRIPTS_URL/utils.ts" -o .github/scripts/utils.ts + curl -sS "$SCRIPTS_URL/index.ts" -o .github/scripts/index.ts + + echo "Installing dependencies..." + npm install --no-save simple-git@3.27.0 chalk@5.3.0 semver@7.6.3 zod@3.24.1 tsx@4.19.2 + + echo "Running change detection..." + npx tsx .github/scripts/index.ts ext-change-detector + + 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: ${{ inputs.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: ${{ 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' + BRANCH: ${{ inputs.branch || github.ref_name }} + EXTENSIONS_ROOT: ${{ inputs.extensions-root || 'packages' }} + run: | + set -e + + # Function to parse semantic version + parse_version() { + local version=$1 + echo "$version" | awk -F. '{print $1, $2, $3}' + } + + # Function to calculate new version based on strategy + calculate_new_version() { + local current=$1 + local bump_type=$2 + local is_nightly=$3 + local is_promotion=$4 + local is_prerelease=$5 + + read -r major minor patch <<< $(parse_version "$current") + + if [ "$is_nightly" = "true" ]; then + # Nightly: enforce odd minor versions + case "$bump_type" in + major) + # Breaking change: new major, start at first odd minor + echo "$((major + 1)).1.0" + ;; + minor) + # New feature: skip to next odd minor + if [ $((minor % 2)) -eq 0 ]; then + echo "$major.$((minor + 1)).0" + else + echo "$major.$((minor + 2)).0" + fi + ;; + *) + # Patch/auto: ensure odd minor then increment patch + if [ $((minor % 2)) -eq 0 ]; then + echo "$major.$((minor + 1)).0" + else + echo "$major.$minor.$((patch + 1))" + fi + ;; + esac + elif [ "$is_promotion" = "true" ]; then + # Promotion: bump from odd (nightly) to even (stable) + if [ $((minor % 2)) -eq 1 ]; then + echo "$major.$((minor + 1)).0" + else + echo >&2 "Warning: Current version has even minor, expected odd for promotion" + echo "$major.$((minor + 2)).0" + fi + else + # Regular build + case "$bump_type" in + patch) + echo "$major.$minor.$((patch + 1))" + ;; + minor) + if [ "$is_prerelease" = "true" ]; then + # Pre-release: ensure odd minor + if [ $((minor % 2)) -eq 0 ]; then + echo "$major.$((minor + 1)).0" + else + echo "$major.$((minor + 2)).0" + fi + else + # Stable: ensure even minor + if [ $((minor % 2)) -eq 1 ]; then + echo "$major.$((minor + 1)).0" + else + echo "$major.$((minor + 2)).0" + fi + fi + ;; + major) + if [ "$is_prerelease" = "true" ]; then + echo "$((major + 1)).1.0" + else + echo "$((major + 1)).0.0" + fi + ;; + auto|*) + echo "$major.$minor.$((patch + 1))" + ;; + esac + fi + } + + # Function to create git tag + create_git_tag() { + local package_name=$1 + local version=$2 + local is_prerelease=$3 + local is_nightly=$4 + + local tag_name + if [ "$is_nightly" = "true" ]; then + # Nightly format: v{version}-nightly[.branch].{date} + local nightly_date=$(date -u +%Y%m%d) + local branch_suffix="" + if [ "$BRANCH" != "main" ] && [ "$BRANCH" != "master" ]; then + branch_suffix=".$(echo "$BRANCH" | tr '/' '-')" + fi + tag_name="v${version}-nightly${branch_suffix}.${nightly_date}" + else + # Regular format: {package}-v{version}[-pre-release] + if [ "$is_prerelease" = "true" ]; then + tag_name="${package_name}-v${version}-pre-release" + else + tag_name="${package_name}-v${version}" + fi + fi + + # Check if tag already exists (idempotency) + if git rev-parse "$tag_name" >/dev/null 2>&1 || git ls-remote --tags origin "$tag_name" 2>/dev/null | grep -q "$tag_name"; then + echo "⏭️ Tag $tag_name already exists — skipping (idempotent rerun)" + return 0 + fi + + echo "Creating tag $tag_name..." + git tag "$tag_name" + echo "✅ Tag created: $tag_name" + } + + # Main version bump logic + echo "=== Version Bumping ===" + echo "Strategy: $VERSION_BUMP" + echo "Pre-release: $PRE_RELEASE" + echo "Is nightly: $IS_NIGHTLY" + echo "Is promotion: $IS_PROMOTION" + echo "" + + IFS=',' read -ra extensions <<< "$SELECTED_EXTENSIONS" + for ext in "${extensions[@]}"; do + ext=$(echo "$ext" | xargs) # Trim whitespace + [ -z "$ext" ] && continue + + package_json="$EXTENSIONS_ROOT/$ext/package.json" + if [ ! -f "$package_json" ]; then + echo "⚠️ Skipping $ext: package.json not found" + continue + fi + + # Read current version and package name + current_version=$(jq -r '.version' "$package_json") + package_name=$(jq -r '.name' "$package_json") + + # Calculate new version + new_version=$(calculate_new_version "$current_version" "$VERSION_BUMP" "$IS_NIGHTLY" "$IS_PROMOTION" "$PRE_RELEASE") + + echo "📦 $ext" + echo " Current: $current_version" + echo " New: $new_version" + + # Update package.json + jq --arg ver "$new_version" '.version = $ver' "$package_json" > "${package_json}.tmp" + mv "${package_json}.tmp" "$package_json" + + # Create git tag + create_git_tag "$package_name" "$new_version" "$PRE_RELEASE" "$IS_NIGHTLY" + + echo "" + done + + echo "✅ Version bumping complete" + + - name: Validate GitHub authentication + env: + GITHUB_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + run: | + # Validate that required tokens are present + if [ -z "$GITHUB_TOKEN" ]; then + echo "❌ Error: GITHUB_TOKEN is not set" + exit 1 + fi + + # Test GitHub CLI authentication + if ! gh auth status >/dev/null 2>&1; then + echo "❌ Error: GitHub CLI authentication failed" + exit 1 + fi + + echo "✅ GitHub authentication validated" + + # If the branch push succeeds but tag push fails, do NOT re-run this job. + # Manually push the missing tags: git push origin --tags + - name: Commit version bumps with tags + env: + # Ensure GitHub CLI has proper authentication + GITHUB_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + DRY_RUN: ${{ inputs.dry-run || github.event.inputs.dry-run || 'false' }} + run: | + if [ "$DRY_RUN" = "true" ]; then + echo "🔄 DRY RUN: Would commit and push version bumps..." + echo "📋 DRY RUN: Changes that would be committed:" + git status --porcelain + echo "📋 DRY RUN: Tags that would be pushed:" + git tag --list | tail -10 || echo "No tags found" + echo "✅ DRY RUN: Would commit version bumps and push tags" + else + echo "🔄 Committing version bumps..." + + # Configure git for the action + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + + # Configure git to use the PAT for authentication + git remote set-url origin https://x-access-token:${{ secrets.IDEE_GH_TOKEN }}@github.com/${{ github.repository }}.git + + # Add all changes + # Note: git add . respects .gitignore, so ignored files won't be added + # This is intentional - Wireit output files in test fixtures should remain ignored + git add . + + if git diff --staged --quiet; then + # Nothing to stage — check whether the bump was already committed to remote + # (idempotent rerun: version bumper ran, committed, pushed, then a later step failed) + git fetch origin ${{ inputs.branch || github.ref_name }} + REMOTE_MSG=$(git log -1 --format='%s' origin/${{ inputs.branch || github.ref_name }}) + if echo "$REMOTE_MSG" | grep -q "chore: bump versions for release"; then + echo "⏭️ Version bump already committed to remote — skipping commit (idempotent rerun)" + else + echo "❌ Error: No staged changes and no prior bump commit found on remote. Version bumper may have failed silently." + exit 1 + fi + else + # Create commit with version bump message + git commit -m "chore: bump versions for release [skip ci]" + + # Push version bumps — retry once with rebase on non-fast-forward + echo "Pushing version bumps to ${{ inputs.branch || github.ref_name }}..." + if ! git push origin HEAD:${{ inputs.branch || github.ref_name }}; then + echo "⚠️ Push failed, attempting fetch+rebase and retry..." + git fetch origin + git rebase origin/${{ inputs.branch || github.ref_name }} + if ! git push origin HEAD:${{ inputs.branch || github.ref_name }}; then + echo "❌ Error: Push failed after rebase. Check branch protection rules." + exit 1 + fi + fi + fi + + # Push all tags — tolerate already-existing tags (do not fail the job) + echo "Pushing tags..." + if ! git push origin --tags; then + echo "⚠️ Warning: Some tags may already exist on remote. Continuing." + fi + + echo "✅ Version bumps and tags pushed successfully" + fi + + calculate-artifact-name: + runs-on: ubuntu-latest + outputs: + artifact-name: ${{ steps.calc.outputs.artifact-name }} + steps: + - name: Calculate artifact name + id: calc + run: | + BASE_NAME="vsix-packages" + RUN_NUMBER="${{ github.run_number }}" + IS_DRY_RUN="${{ inputs.dry-run || github.event.inputs.dry-run || 'false' }}" + + # Check if already suffixed + if [[ "$BASE_NAME" =~ -dry-run$ ]] || [[ "$BASE_NAME" =~ -release$ ]]; then + ARTIFACT_NAME="$BASE_NAME" + else + if [ "$IS_DRY_RUN" = "true" ]; then + ARTIFACT_NAME="${BASE_NAME}-${RUN_NUMBER}-dry-run" + else + ARTIFACT_NAME="${BASE_NAME}-${RUN_NUMBER}-release" + fi + fi + + echo "artifact-name=$ARTIFACT_NAME" >> $GITHUB_OUTPUT + echo "Artifact name: $ARTIFACT_NAME" + + package: + needs: [bump-versions, calculate-artifact-name] + uses: salesforcecli/github-workflows/.github/workflows/vscode-package.yml@main + with: + branch: ${{ inputs.branch || github.ref_name }} + artifact-name: ${{ needs.calculate-artifact-name.outputs.artifact-name }} + dry-run: ${{ inputs.dry-run || github.event.inputs.dry-run || 'false' }} + pre-release: ${{ inputs.pre-release || github.event.inputs.pre-release || 'false' }} + extensions-root: ${{ inputs.extensions-root || 'packages' }} + + determine-publish-matrix: + needs: [calculate-artifact-name] + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.matrix.outputs.matrix }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.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' + EXTENSIONS_ROOT: ${{ inputs.extensions-root || 'packages' }} + run: | + # Skip marketplace publishing for nightly builds + if [ "$IS_NIGHTLY" = "true" ]; then + echo "Nightly build detected - skipping marketplace publishing" + echo 'matrix=[]' >> $GITHUB_OUTPUT + else + # Parse extensions + IFS=',' read -ra extensions <<< "$SELECTED_EXTENSIONS" + + # Determine registries + if [ "$REGISTRIES" = "all" ]; then + registries=("vsce" "ovsx") + else + IFS=',' read -ra registries <<< "$REGISTRIES" + fi + + # Build matrix JSON + matrix_entries=() + for ext in "${extensions[@]}"; do + [ -z "$ext" ] && continue + + # Read package name from package.json to build VSIX pattern + package_json="$EXTENSIONS_ROOT/$ext/package.json" + if [ -f "$package_json" ]; then + package_name=$(jq -r '.name' "$package_json") + vsix_pattern="*${package_name}*-[0-9]*.vsix" + else + # Fallback to extension directory name + vsix_pattern="*${ext}*.vsix" + fi + + for registry in "${registries[@]}"; do + [ -z "$registry" ] && continue + + case "$registry" in + vsce) marketplace="VS Code Marketplace" ;; + ovsx) marketplace="Open VSX Registry" ;; + *) marketplace="$registry" ;; + esac + + entry=$(jq -n \ + --arg reg "$registry" \ + --arg pattern "$vsix_pattern" \ + --arg market "$marketplace" \ + '{registry: $reg, vsix_pattern: $pattern, marketplace: $market}') + + matrix_entries+=("$entry") + done + done + + # Combine into matrix JSON + if [ ${#matrix_entries[@]} -eq 0 ]; then + matrix_json='{"include":[]}' + else + matrix_json=$(printf '%s\n' "${matrix_entries[@]}" | jq -s '{include: .}') + fi + + echo "matrix=$matrix_json" >> $GITHUB_OUTPUT + fi + + publish-skipped-notice: + name: Publish Skipped Notice + needs: + [ + bump-versions, + package, + calculate-artifact-name, + determine-publish-matrix, + ] + runs-on: ubuntu-latest + if: needs.determine-publish-matrix.outputs.matrix == '[]' || needs.determine-publish-matrix.outputs.matrix == '' + steps: + - name: Log skipped publish + run: | + echo "ℹ️ Marketplace publishing skipped for this build type" + echo " Reason: Nightly builds only create GitHub releases, not marketplace publishes" + echo " Extensions would be published: ${{ inputs.extensions }}" + echo " Registries configured: ${{ inputs.registries }}" + + publish: + needs: + [ + bump-versions, + package, + calculate-artifact-name, + determine-publish-matrix, + ] + runs-on: ubuntu-latest + if: needs.determine-publish-matrix.outputs.matrix != '[]' && needs.determine-publish-matrix.outputs.matrix != '' + strategy: + matrix: + include: ${{ fromJson(needs.determine-publish-matrix.outputs.matrix) }} + steps: + - name: Audit release attempt + shell: bash + run: | + # Create audit log entry for release attempt + AUDIT_LOG="/tmp/release_audit.log" + TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + ACTOR="${{ github.actor }}" + REPO="${{ github.repository }}" + RUN_ID="${{ github.run_id }}" + WORKFLOW="${{ github.workflow }}" + BRANCH="${{ inputs.branch || github.ref_name }}" + + # Log audit information + echo "[$TIMESTAMP] RELEASE_ATTEMPT: actor=$ACTOR, repo=$REPO, run_id=$RUN_ID, workflow=$WORKFLOW, branch=$BRANCH, registry=${{ matrix.registry }}, marketplace=${{ matrix.marketplace }}, dry_run=${{ inputs.dry-run || github.event.inputs.dry-run || 'false' }}" >> "$AUDIT_LOG" + + # Also log to GitHub Actions output for visibility + echo "🔍 AUDIT: Release attempt logged - $TIMESTAMP" + echo " Actor: $ACTOR" + echo " Repository: $REPO" + echo " Run ID: $RUN_ID" + echo " Workflow: $WORKFLOW" + echo " Branch: $BRANCH" + echo " Registry: ${{ matrix.registry }}" + echo " Marketplace: ${{ matrix.marketplace }}" + echo " Dry-run: ${{ inputs.dry-run || github.event.inputs.dry-run || 'false' }}" + + - name: Checkout + uses: actions/checkout@v6 + with: + token: ${{ secrets.IDEE_GH_TOKEN }} + ref: ${{ inputs.branch || github.ref }} + + - name: Download VSIX artifacts + uses: actions/download-artifact@v8 + with: + name: ${{ needs.calculate-artifact-name.outputs.artifact-name }} + path: ./vsix-artifacts + + - name: List downloaded artifacts + run: | + echo "=== DEBUG: Downloaded Artifacts ===" + echo "Artifact name: ${{ needs.calculate-artifact-name.outputs.artifact-name }}" + echo "Download path: ./vsix-artifacts" + echo "" + + if [ -d "./vsix-artifacts" ]; then + echo "Directory exists. Contents:" + ls -la ./vsix-artifacts/ + echo "" + + echo "VSIX files found:" + find ./vsix-artifacts -name "*.vsix" -exec ls -la {} \; + echo "" + + echo "Total VSIX files: $(find ./vsix-artifacts -name "*.vsix" | wc -l)" + else + echo "❌ Directory ./vsix-artifacts does not exist!" + fi + echo "=== END DEBUG ===" + + - name: Find VSIX file for publishing + id: find_vsix + env: + EXCLUDE_WEB: ${{ inputs.exclude-web-vsix || 'false' }} + run: | + ARTIFACTS_DIR="./vsix-artifacts" + VSIX_PATTERN="${{ matrix.vsix_pattern }}" + + # Find VSIX, optionally excluding *-web-* files + if [ "$EXCLUDE_WEB" = "true" ]; then + VSIX_FILE=$(find "$ARTIFACTS_DIR" -type f -name "$VSIX_PATTERN" ! -name '*-web-*' | head -1) + else + VSIX_FILE=$(find "$ARTIFACTS_DIR" -name "$VSIX_PATTERN" | head -1) + fi + + if [ -z "$VSIX_FILE" ]; then + echo "❌ No VSIX file found matching pattern: $VSIX_PATTERN" + echo "Searching in: $ARTIFACTS_DIR" + echo "Available files:" + find "$ARTIFACTS_DIR" -name "*.vsix" -exec ls -la {} \; + exit 1 + fi + + echo "vsix_file=$VSIX_FILE" >> $GITHUB_OUTPUT + echo "Found VSIX file: $VSIX_FILE" + + - name: Publish to ${{ matrix.marketplace }} + uses: salesforcecli/github-workflows/.github/actions/vscode/publish-vsix@main + 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: ${{ inputs.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 + EXTENSIONS_ROOT: ${{ inputs.extensions-root || 'packages' }} + EXCLUDE_WEB: ${{ inputs.exclude-web-vsix || 'false' }} + run: | + echo "Mode: $([ "$DRY_RUN" = "true" ] && echo "DRY RUN" || echo "LIVE")" + echo "Creating GitHub releases..." + + IFS=',' read -ra extensions <<< "$SELECTED_EXTENSIONS" + + for ext in "${extensions[@]}"; do + [ -z "$ext" ] && continue + + package_json="$EXTENSIONS_ROOT/$ext/package.json" + if [ ! -f "$package_json" ]; then + echo "⚠️ Skipping $ext: package.json not found" + continue + fi + + current_version=$(jq -r '.version' "$package_json") + package_name=$(jq -r '.name' "$package_json") + echo "Processing extension: $ext (package: $package_name, version: $current_version)" + + # Find VSIX files using the package name from package.json + vsix_pattern="*${package_name}*.vsix" + + vsix_files=("$VSIX_ARTIFACTS_PATH"/$ext/$vsix_pattern) + if [ ! -f "${vsix_files[0]}" ]; then + echo "⚠️ No VSIX files found for $ext" + continue + fi + + # Optionally filter out *-web-* builds + if [ "${EXCLUDE_WEB:-false}" = "true" ]; then + filtered_files=() + for file in "${vsix_files[@]}"; do + if [[ ! "$(basename "$file")" =~ -web- ]]; then + filtered_files+=("$file") + fi + done + else + filtered_files=("${vsix_files[@]}") + fi + + # Create release tag + release_tag="v$current_version" + release_title="$ext v$current_version" + + if [ "$IS_NIGHTLY" = "true" ]; then + nightly_date=$(date -u +%Y%m%d) + branch_suffix="" + [ "$BRANCH" != "main" ] && branch_suffix=".${BRANCH//\//-}" + release_tag="v${current_version}-nightly${branch_suffix}.${nightly_date}" + release_title="$ext v$current_version (Nightly $BRANCH $nightly_date)" + fi + + if [ "$DRY_RUN" = "true" ]; then + echo "✅ DRY RUN: Would create release $release_tag with ${#filtered_files[@]} VSIX files" + else + # Check if release exists + if gh release view "$release_tag" --repo "$GITHUB_REPOSITORY" > /dev/null 2>&1; then + echo "⏭️ Release $release_tag already exists - skipping" + else + # Generate release notes + notes_file=".release-notes-$(date +%s).tmp" + cat > "$notes_file" << EOF + ## $ext v$current_version + + ### Installation + Download the VSIX file and install via VS Code. + + $([ "$PRE_RELEASE" = "true" ] && echo "⚠️ **This is a pre-release version**") + $([ "$IS_NIGHTLY" = "true" ] && echo "🌙 **Nightly build from $(date -u +%Y%m%d)**") + EOF + + # Create release + gh release create "$release_tag" \ + --title "$release_title" \ + --notes-file "$notes_file" \ + --prerelease="$PRE_RELEASE" \ + --repo "$GITHUB_REPOSITORY" \ + "${filtered_files[@]}" + + rm -f "$notes_file" + echo "✅ Release created for $ext" + fi + fi + done + + echo "$([ "$DRY_RUN" = "true" ] && echo "✅ DRY RUN complete" || echo "✅ Releases created")" + + publish-to-cbweb-marketplace: + name: Publish to CBWeb Internal Marketplace + needs: [package, create-github-releases, calculate-artifact-name] + runs-on: ubuntu-latest + continue-on-error: true + if: needs.package.result == 'success' + steps: + - name: Download VSIX artifacts + uses: actions/download-artifact@v8 + with: + name: ${{ needs.calculate-artifact-name.outputs.artifact-name }} + path: ./vsix-artifacts + + - name: Find web-target VSIX for CBWeb + id: find-web-vsix + run: | + VSIX_FILE=$(find ./vsix-artifacts -type f -name "*-web-*.vsix" | head -1) + if [ -z "$VSIX_FILE" ]; then + echo "ℹ️ No web-target VSIX found in artifacts" + echo " This is expected for repos without web extensions" + echo " Skipping CBWeb marketplace publish" + echo "has_web_vsix=false" >> $GITHUB_OUTPUT + exit 0 + fi + + FILE_SIZE=$(stat -c%s "$VSIX_FILE" 2>/dev/null || stat -f%z "$VSIX_FILE" 2>/dev/null || echo "unknown") + echo "Found web VSIX: $VSIX_FILE (${FILE_SIZE} bytes)" + echo "vsix_file=$VSIX_FILE" >> $GITHUB_OUTPUT + echo "has_web_vsix=true" >> $GITHUB_OUTPUT + + - name: Publish web VSIX to CBWeb internal marketplace + if: steps.find-web-vsix.outputs.has_web_vsix == 'true' && inputs.dry-run != 'true' && github.event.inputs.dry-run != 'true' + run: | + echo "Publishing $VSIX_FILE to CBWeb marketplace..." + + HTTP_CODE=$(curl -s -o response.json -w '%{http_code}' \ + --retry 2 --retry-delay 5 \ + -X POST "${MARKETPLACE_URL}/api/internal/publish" \ + -H "Authorization: Bearer ${MARKETPLACE_DEPLOY_TOKEN}" \ + -F "vsix=@${VSIX_FILE}") + + echo "HTTP response code: $HTTP_CODE" + cat response.json + + if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then + echo "Successfully published to CBWeb marketplace" + else + echo "::warning::Failed to publish to CBWeb marketplace (HTTP $HTTP_CODE)" + exit 1 + fi + env: + VSIX_FILE: ${{ steps.find-web-vsix.outputs.vsix_file }} + MARKETPLACE_URL: ${{ vars.MARKETPLACE_URL }} + MARKETPLACE_DEPLOY_TOKEN: ${{ secrets.MARKETPLACE_DEPLOY_TOKEN }} + + - name: Dry-run summary + if: steps.find-web-vsix.outputs.has_web_vsix == 'true' && (inputs.dry-run == 'true' || github.event.inputs.dry-run == 'true') + run: | + echo "🔄 DRY RUN: Would publish ${{ steps.find-web-vsix.outputs.vsix_file }} to CBWeb marketplace" + + slack-notify: + name: Slack Notification + needs: + [bump-versions, package, publish, publish-skipped-notice] + runs-on: ubuntu-latest + if: always() && (needs.publish.result == 'success' || needs.publish.result == 'skipped') + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.branch || github.ref }} + + - name: Get Extension Details + id: extension-details + env: + EXTENSIONS_ROOT: ${{ inputs.extensions-root || 'packages' }} + run: | + # Get selected extensions and their details + SELECTED_EXTENSIONS="${{ inputs.extensions }}" + VERSION_BUMP="${{ inputs.version-bump }}" + PRE_RELEASE="true" + + # Initialize arrays for extension details + EXTENSION_NAMES="" + EXTENSION_VERSIONS="" + EXTENSION_DISPLAY_NAMES="" + + IFS=',' read -ra EXTENSIONS <<< "$SELECTED_EXTENSIONS" + for ext in "${EXTENSIONS[@]}"; do + if [ -n "$ext" ] && [ -f "$EXTENSIONS_ROOT/$ext/package.json" ]; then + # Get package details + PACKAGE_NAME=$(node -p "require('./$EXTENSIONS_ROOT/$ext/package.json').name") + PACKAGE_VERSION=$(node -p "require('./$EXTENSIONS_ROOT/$ext/package.json').version") + DISPLAY_NAME=$(node -p "require('./$EXTENSIONS_ROOT/$ext/package.json').displayName || require('./$EXTENSIONS_ROOT/$ext/package.json').name") + + # Add to arrays + if [ -z "$EXTENSION_NAMES" ]; then + EXTENSION_NAMES="$PACKAGE_NAME" + EXTENSION_VERSIONS="$PACKAGE_VERSION" + EXTENSION_DISPLAY_NAMES="$DISPLAY_NAME" + else + EXTENSION_NAMES="$EXTENSION_NAMES, $PACKAGE_NAME" + EXTENSION_VERSIONS="$EXTENSION_VERSIONS, $PACKAGE_VERSION" + EXTENSION_DISPLAY_NAMES="$EXTENSION_DISPLAY_NAMES, $DISPLAY_NAME" + fi + fi + done + + echo "extension_names=$EXTENSION_NAMES" >> $GITHUB_OUTPUT + echo "extension_versions=$EXTENSION_VERSIONS" >> $GITHUB_OUTPUT + echo "extension_display_names=$EXTENSION_DISPLAY_NAMES" >> $GITHUB_OUTPUT + echo "version_bump=$VERSION_BUMP" >> $GITHUB_OUTPUT + echo "pre_release=$PRE_RELEASE" >> $GITHUB_OUTPUT + + - name: Publish skipped summary + if: needs.publish.result == 'skipped' + run: | + echo "ℹ️ Slack notification skipped - marketplace publishing was skipped" + echo " Reason: Nightly builds only create GitHub releases" + echo " Extensions: ${{ steps.extension-details.outputs.extension_display_names }}" + echo " Versions: ${{ steps.extension-details.outputs.extension_versions }}" + + - name: Dry-run Slack summary + if: (inputs.dry-run == 'true' || github.event.inputs.dry-run == 'true') && needs.publish.result != 'skipped' + run: | + echo "🔄 DRY RUN: Would send Slack notification" + echo " Extensions: ${{ steps.extension-details.outputs.extension_display_names }}" + echo " Versions: ${{ steps.extension-details.outputs.extension_versions }}" + echo " Release Type: ${{ steps.extension-details.outputs.pre_release == 'true' && 'Pre-release' || 'Stable' }}" + + - name: Notify Slack + if: inputs.dry-run != 'true' && github.event.inputs.dry-run != 'true' && needs.publish.result == 'success' + uses: slackapi/slack-github-action@v3.0.3 + with: + payload: | + { + "text": "${{ inputs.slack-notification-title }}", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "${{ inputs.slack-notification-title }}" + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Repository:*\n${{ github.repository }}" + }, + { + "type": "mrkdwn", + "text": "*Branch:*\n${{ inputs.branch || github.ref_name }}" + }, + { + "type": "mrkdwn", + "text": "*Extensions:*\n${{ steps.extension-details.outputs.extension_display_names }}" + }, + { + "type": "mrkdwn", + "text": "*Versions:*\n${{ steps.extension-details.outputs.extension_versions }}" + }, + { + "type": "mrkdwn", + "text": "*Release Type:*\n${{ steps.extension-details.outputs.pre_release == 'true' && 'Pre-release' || 'Stable' }}" + }, + { + "type": "mrkdwn", + "text": "*Version Bump:*\n${{ steps.extension-details.outputs.version_bump }}" + } + ] + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Workflow Run:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Details>" + } + } + ] + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.IDEE_MAIN_SLACK_WEBHOOK }} + + slack-notify-failure: + name: Slack Failure Notification + needs: + [bump-versions, package, publish, publish-skipped-notice] + runs-on: ubuntu-latest + if: always() && (needs.publish.result == 'failure' || needs.bump-versions.result == 'failure' || needs.package.result == 'failure') + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.branch || github.ref }} + + - name: Get Extension Details + id: extension-details + env: + EXTENSIONS_ROOT: ${{ inputs.extensions-root || 'packages' }} + run: | + # Get selected extensions and their details + SELECTED_EXTENSIONS="${{ inputs.extensions }}" + VERSION_BUMP="${{ inputs.version-bump }}" + PRE_RELEASE="true" + + # Initialize arrays for extension details + EXTENSION_NAMES="" + EXTENSION_VERSIONS="" + EXTENSION_DISPLAY_NAMES="" + + IFS=',' read -ra EXTENSIONS <<< "$SELECTED_EXTENSIONS" + for ext in "${EXTENSIONS[@]}"; do + if [ -n "$ext" ] && [ -f "$EXTENSIONS_ROOT/$ext/package.json" ]; then + # Get package details + PACKAGE_NAME=$(node -p "require('./$EXTENSIONS_ROOT/$ext/package.json').name") + PACKAGE_VERSION=$(node -p "require('./$EXTENSIONS_ROOT/$ext/package.json').version") + DISPLAY_NAME=$(node -p "require('./$EXTENSIONS_ROOT/$ext/package.json').displayName || require('./$EXTENSIONS_ROOT/$ext/package.json').name") + + # Add to arrays + if [ -z "$EXTENSION_NAMES" ]; then + EXTENSION_NAMES="$PACKAGE_NAME" + EXTENSION_VERSIONS="$PACKAGE_VERSION" + EXTENSION_DISPLAY_NAMES="$DISPLAY_NAME" + else + EXTENSION_NAMES="$EXTENSION_NAMES, $PACKAGE_NAME" + EXTENSION_VERSIONS="$EXTENSION_VERSIONS, $PACKAGE_VERSION" + EXTENSION_DISPLAY_NAMES="$EXTENSION_DISPLAY_NAMES, $DISPLAY_NAME" + fi + fi + done + + echo "extension_names=$EXTENSION_NAMES" >> $GITHUB_OUTPUT + echo "extension_versions=$EXTENSION_VERSIONS" >> $GITHUB_OUTPUT + echo "extension_display_names=$EXTENSION_DISPLAY_NAMES" >> $GITHUB_OUTPUT + echo "version_bump=$VERSION_BUMP" >> $GITHUB_OUTPUT + echo "pre_release=$PRE_RELEASE" >> $GITHUB_OUTPUT + + - name: No failure to report + if: needs.publish.result == 'skipped' && needs.bump-versions.result != 'failure' && needs.package.result != 'failure' + run: | + echo "ℹ️ No failures to report" + echo " Publish was skipped (nightly builds don't publish to marketplace)" + echo " All other jobs succeeded" + + - name: Dry-run failure summary + if: (inputs.dry-run == 'true' || github.event.inputs.dry-run == 'true') && (needs.publish.result == 'failure' || needs.bump-versions.result == 'failure' || needs.package.result == 'failure') + run: | + echo "🔄 DRY RUN: Would send Slack failure notification" + echo " Extensions: ${{ steps.extension-details.outputs.extension_display_names }}" + echo " Failed jobs: publish=${{ needs.publish.result }}, bump-versions=${{ needs.bump-versions.result }}, package=${{ needs.package.result }}" + echo " Note: Failure occurred in dry-run mode" + + - name: Notify Slack + if: inputs.dry-run != 'true' && github.event.inputs.dry-run != 'true' && (needs.publish.result == 'failure' || needs.bump-versions.result == 'failure' || needs.package.result == 'failure') + uses: slackapi/slack-github-action@v3.0.3 + with: + payload: | + { + "text": "❌ VS Code Extension Release Failed!", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "❌ VS Code Extension Release Failed!" + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Repository:*\n${{ github.repository }}" + }, + { + "type": "mrkdwn", + "text": "*Branch:*\n${{ inputs.branch || github.ref_name }}" + }, + { + "type": "mrkdwn", + "text": "*Extensions:*\n${{ steps.extension-details.outputs.extension_display_names }}" + }, + { + "type": "mrkdwn", + "text": "*Versions:*\n${{ steps.extension-details.outputs.extension_versions }}" + }, + { + "type": "mrkdwn", + "text": "*Release Type:*\n${{ steps.extension-details.outputs.pre_release == 'true' && 'Pre-release' || 'Stable' }}" + }, + { + "type": "mrkdwn", + "text": "*Version Bump:*\n${{ steps.extension-details.outputs.version_bump }}" + } + ] + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Workflow Run:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Details>" + } + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "Please check the workflow logs for detailed error information." + } + ] + } + ] + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.IDEE_MAIN_SLACK_WEBHOOK }} diff --git a/.github/workflows/vscode-release-explicit.yml b/.github/workflows/vscode-release-explicit.yml new file mode 100644 index 0000000..adb1aef --- /dev/null +++ b/.github/workflows/vscode-release-explicit.yml @@ -0,0 +1,141 @@ +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' + node-version: + description: 'Node.js version to use' + required: false + default: '22.x' + type: string + secrets: + VSCE_PAT: + description: 'VS Code Marketplace Personal Access Token' + required: false + OVSX_PAT: + description: 'Open VSX Personal Access Token' + required: false + +permissions: + contents: write + packages: write + +jobs: + build-and-publish: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + extension: ${{ fromJson(inputs.extensions) }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.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@v7 + 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..8e06cbe 100644 --- a/README.md +++ b/README.md @@ -387,3 +387,268 @@ 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, supporting both monorepo and single-extension repositories. + +### Prerequisites + +Before using these workflows, ensure your repository has: + +1. **Required Secrets** (configured as GitHub Actions secrets): + - `IDEE_GH_TOKEN` - Personal Access Token with repo permissions for creating releases and pushing tags + - `VSCE_PERSONAL_ACCESS_TOKEN` - VS Code Marketplace Personal Access Token ([create one](https://code.visualstudio.com/api/working-with-extensions/publishing-extension#get-a-personal-access-token)) + - `IDEE_OVSX_PAT` - Open VSX Personal Access Token ([create one](https://open-vsx.org/user-settings/tokens)) + +2. **Repository Structure**: + - For monorepos: Extensions under a common directory (default: `packages/`) + - For single repos: Extension at repository root + - Each extension must have a valid `package.json` with VS Code extension metadata + +3. **Optional Local Actions** (only for `vscode-manual-publish.yml` and `vscode-promote-stable.yml`): + - `.github/actions/npm-install-with-retries` - Custom npm install with retry logic + - `.github/actions/check-ci-status` - Validates CI checks passed before publish + - `.github/actions/repackage-vsix-stable` - Repackages pre-release VSIX as stable + - `.github/actions/publish-vsix` - Publishes to VS Code Marketplace and/or Open VSX + + Note: Most workflows (like `vscode-publish-extensions.yml` and `vscode-promote-prerelease.yml`) are fully self-contained and don't require these local actions. + +### Architecture Overview + +The VS Code workflows follow a modular design: + +1. **CI/Testing** (`vscode-ci-template.yml`) - Lints, compiles, and tests across multiple OS/Node versions +2. **Packaging** (`vscode-package.yml`) - Creates VSIX files without publishing +3. **Publishing** (`vscode-publish-extensions.yml`) - Complete release pipeline with version bumping and marketplace publishing +4. **Promotion** (`vscode-promote-prerelease.yml`, `vscode-promote-stable.yml`) - Promotes vetted builds between release channels + +### Versioning Strategy + +These workflows use an **odd/even minor version convention** to distinguish release types: + +- **Nightly builds**: Odd minor versions (e.g., `0.5.x-nightly.20260709`) +- **Pre-release**: Odd minor versions (e.g., `0.5.3`) +- **Stable releases**: Even minor versions (e.g., `0.6.0`) + +**Benefits:** +- Clear visual distinction between release channels +- Prevents accidental overwrites +- Predictable version progression: nightly `0.5.x` → pre-release `0.5.y` → stable `0.6.0` + +**Version bump types:** +- `major`: Breaking change → next major with first odd minor (e.g., `0.5.3` → `1.1.0`) +- `minor`: New feature → next odd minor (e.g., `0.5.3` → `0.7.0`) +- `patch`: Bug fix → increment patch, maintain odd minor (e.g., `0.5.3` → `0.5.4`) +- `auto`: Analyzes conventional commits to determine bump type + +### Common Inputs + +Most workflows share these common inputs: + +- `node-version` (optional) - Node.js version to use (default: `22.x`) +- `extensions-root` (optional) - Root directory for extensions (default: `packages`) +- `dry-run` (optional) - Run without publishing/tagging (default: `false`) +- `pre-release` (optional) - Mark as pre-release version (default: `true`) +- `registries` (optional) - Where to publish: `all`, `vsce`, or `ovsx` (default: `all`) + +--- + +### 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 automatic version bumping, GitHub releases, and marketplace publishing. Supports auto-detecting changed extensions or publishing specific extensions. + +**Usage:** + +```yaml +jobs: + publish: + uses: salesforcecli/github-workflows/.github/workflows/vscode-publish-extensions.yml@main + with: + branch: main + extensions: changed # or 'all' or 'ext1,ext2' + registries: all # all | vsce | ovsx + pre-release: true + version-bump: auto # auto | major | minor | patch + extensions-root: packages # for monorepos + exclude-web-vsix: 'false' + slack-notification-title: '🎉 Extensions Released Successfully!' + node-version: '22.x' + dry-run: false + secrets: inherit +``` + +**Key Features:** +- Auto-detects changed extensions in monorepos +- Smart version bumping using odd/even convention +- Conventional commit analysis +- Creates GitHub releases with VSIX artifacts +- Publishes to VS Code Marketplace and/or Open VSX +- Slack notifications on success + +**Inputs:** +- `extensions` (optional) - Extensions to release: `changed`, `all`, or comma-separated names (default: `changed`) +- `version-bump` (optional) - Version bump type: `auto`, `patch`, `minor`, `major` (default: `auto`) +- `slack-notification-title` (optional) - Slack notification title (default: `🎉 Extensions Released Successfully!`) + +### vscode-promote-prerelease + +Promotes a vetted nightly build to pre-release on VS Code Marketplace and Open VSX. This workflow finds the oldest unpromoted nightly that meets the minimum age requirement, verifies CI checks passed, and publishes it as a pre-release. + +**Usage:** + +```yaml +jobs: + promote: + uses: salesforcecli/github-workflows/.github/workflows/vscode-promote-prerelease.yml@main + with: + extension-name: 'my-extension' # Used for tracking tags + min-tag-age-days: '7' # Nightly must be at least 7 days old + vsix-name-pattern: 'my-extension-*.vsix' # Pattern to match VSIX files + exclude-web-vsix: 'true' # Exclude *-web-* VSIX files + dry-run: 'false' + secrets: inherit +``` + +**Requirements:** +- Nightly tags matching `v{version}-nightly.*` pattern (e.g., `v1.2.3-nightly.20260709`) +- GitHub releases for each nightly tag with VSIX files attached +- Passing CI checks on nightly commits + +**How it works:** +1. Finds oldest nightly tag ≥ min-tag-age-days that hasn't been promoted +2. Verifies CI checks passed for that commit +3. Downloads VSIX from nightly GitHub release +4. Publishes to marketplace(s) as pre-release +5. Creates `marketplace-prerelease-{extension-name}-v{version}` tracking tag + +### vscode-promote-stable + +Promotes a vetted pre-release to stable. This workflow finds the latest pre-release, verifies quality gates, repackages the VSIX for stable, and publishes it. + +**Usage:** + +```yaml +jobs: + promote: + uses: salesforcecli/github-workflows/.github/workflows/vscode-promote-stable.yml@main + with: + extension-name: 'my-extension' + vsix-name-pattern: 'my-extension-*.vsix' + exclude-web-vsix: 'true' + extensions-root: 'packages' + dry-run: 'false' + secrets: inherit +``` + +**Requirements:** +- `marketplace-prerelease-*` tracking tags from previous promotions +- Local actions in calling repository (see Prerequisites section above) + +**Note:** This workflow requires local actions. For a fully self-contained alternative, use `vscode-promote-prerelease.yml`. + +### vscode-manual-publish + +Manually publish a specific nightly or CI build to the marketplace. Supports two source paths: +1. **Tag path**: Publish from a nightly GitHub Release +2. **Run path**: Publish from a CI build artifact (with quality check bypass) + +**Usage:** + +```yaml +jobs: + manual-publish: + uses: salesforcecli/github-workflows/.github/workflows/vscode-manual-publish.yml@main + with: + extension-name: 'my-extension' + vsix-name-pattern: 'my-extension-*.vsix' + version-tag: 'v0.5.3-nightly.20260301' # OR use source-run-id + slot: 'pre-release' # or 'stable' + registries: 'all' + exclude-web-vsix: 'true' + extensions-root: 'packages' + dry-run: 'false' + secrets: inherit +``` + +**Requirements:** +- Local actions in calling repository (see Prerequisites section above) +- Environment: `manual-publish-gate` (with required reviewers for approval) + +**Note:** This workflow requires local actions. Use for special cases where you need to manually control which build gets published. diff --git a/package.json b/package.json new file mode 100644 index 0000000..cb3bd36 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "@salesforcecli/github-workflows", + "version": "1.0.0", + "description": "Shared GitHub Actions workflows and scripts for Salesforce CLI projects", + "type": "module", + "scripts": { + "ext-change-detector": "tsx .github/scripts/ext-change-detector.ts" + }, + "keywords": [ + "github-actions", + "workflows", + "salesforce" + ], + "author": "Salesforce", + "license": "BSD-3-Clause", + "dependencies": { + "chalk": "^5.3.0", + "semver": "^7.6.3", + "simple-git": "^3.27.0", + "tsx": "^4.19.2", + "zod": "^3.24.1" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@types/semver": "^7.5.8" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b898829 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true, + "strict": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist", + "rootDir": "." + }, + "include": [".github/scripts/**/*.ts"], + "exclude": ["node_modules"] +}