Skip to content

Commit 41c3a91

Browse files
madhur310claude
andcommitted
feat: add VS Code extension CI/CD infrastructure
Add shared CI/CD tooling for Salesforce VS Code extensions: ## NPM Package: @salesforce/vscode-extension-ci - TypeScript CLI with 13 commands for release automation - Smart version bumping (even/odd minor for stable/pre-release) - Conventional commit analysis - Change detection and package selection - GitHub release creation - Configurable via environment variables: - PACKAGES_ROOT (default: packages) - TAG_PREFIX (default: marketplace) - AUDIT_LOG_DIR (default: .github/audit-logs) ## Composite Actions (.github/actions/vscode/) - calculate-artifact-name: Artifact naming with run isolation - check-ci-status: CI quality gate verification - detect-packages: Auto-detect extensions and npm packages - download-vsix-artifacts: VSIX artifact downloader - npm-install-with-retries: Retry wrapper for npm ci - publish-vsix: Marketplace publisher (vsce/ovsx) ## Reusable Workflows (.github/workflows/vscode/) - ci-template.yml: Parameterized CI workflow - package.yml: VSIX packaging with checksums - publish-extensions.yml: Complete release pipeline - promote-prerelease.yml: Nightly → pre-release promotion ## Usage Consuming repos reference workflows via: uses: salesforcecli/github-workflows/.github/workflows/vscode/<name>.yml@main Consuming repos install the CLI: npm install --save-dev @salesforce/vscode-extension-ci ## Testing - Tested with npm link in apex-language-support - CLI successfully detects VS Code extensions - All TypeScript builds cleanly Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 9721a35 commit 41c3a91

107 files changed

Lines changed: 8902 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: 'Calculate Artifact Name'
2+
description: 'Calculate artifact name with run number and mode suffix'
3+
4+
inputs:
5+
artifact-name:
6+
description: 'Base artifact name or pre-calculated name'
7+
required: true
8+
dry-run:
9+
description: 'Whether this is a dry-run mode'
10+
required: false
11+
default: 'false'
12+
run-number:
13+
description: 'GitHub run number (defaults to github.run_number)'
14+
required: false
15+
default: '${{ github.run_number }}'
16+
17+
outputs:
18+
artifact-name:
19+
description: 'The calculated artifact name'
20+
value: ${{ steps.calc.outputs.artifact-name }}
21+
22+
runs:
23+
using: 'composite'
24+
steps:
25+
- name: Calculate artifact name
26+
id: calc
27+
shell: bash
28+
run: |
29+
# Only treat as already set if artifact-name ends with -dry-run or -release
30+
if [[ "${{ inputs.artifact-name }}" =~ -dry-run$ ]] || [[ "${{ inputs.artifact-name }}" =~ -release$ ]]; then
31+
echo "artifact-name=${{ inputs.artifact-name }}" >> $GITHUB_OUTPUT
32+
else
33+
echo "artifact-name=${{ format('{0}-{1}-{2}', inputs.artifact-name, inputs.run-number, inputs.dry-run == 'true' && 'dry-run' || 'release') }}" >> $GITHUB_OUTPUT
34+
fi
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
name: Check CI Status
2+
description: >
3+
Verifies that CI checks passed for a given commit SHA before promotion.
4+
Fails if any required check did not succeed.
5+
6+
inputs:
7+
commit-sha:
8+
description: 'Commit SHA to check CI status for'
9+
required: true
10+
token:
11+
description: 'GitHub token with repo read access'
12+
required: true
13+
required-checks:
14+
description: >
15+
Comma-separated list of check names that must have succeeded.
16+
If empty, all non-skipped check-runs must have conclusion "success".
17+
required: false
18+
default: ''
19+
20+
runs:
21+
using: composite
22+
steps:
23+
- name: Verify CI checks passed
24+
shell: bash
25+
env:
26+
GH_TOKEN: ${{ inputs.token }}
27+
COMMIT_SHA: ${{ inputs.commit-sha }}
28+
REQUIRED_CHECKS: ${{ inputs.required-checks }}
29+
REPO: ${{ github.repository }}
30+
run: |
31+
echo "Checking CI status for commit $COMMIT_SHA in $REPO..."
32+
33+
# Fetch all check-runs for the commit (paginate up to 100)
34+
CHECK_RUNS=$(gh api \
35+
"repos/$REPO/commits/$COMMIT_SHA/check-runs" \
36+
--paginate \
37+
--jq '.check_runs[] | {name: .name, status: .status, conclusion: .conclusion}' \
38+
2>&1)
39+
40+
if [ -z "$CHECK_RUNS" ]; then
41+
echo "No check-runs found for commit $COMMIT_SHA"
42+
echo "Cannot verify CI status — failing to prevent untested promotion"
43+
exit 1
44+
fi
45+
46+
echo "Check-runs found:"
47+
echo "$CHECK_RUNS" | jq -r '" \(.name): status=\(.status) conclusion=\(.conclusion)"'
48+
49+
FAILED=0
50+
51+
if [ -n "$REQUIRED_CHECKS" ]; then
52+
# Only validate the specified checks
53+
IFS=',' read -ra CHECKS <<< "$REQUIRED_CHECKS"
54+
for CHECK in "${CHECKS[@]}"; do
55+
CHECK=$(echo "$CHECK" | xargs) # trim whitespace
56+
CONCLUSION=$(echo "$CHECK_RUNS" | jq -r --arg name "$CHECK" \
57+
'select(.name == $name) | .conclusion' | head -1)
58+
if [ "$CONCLUSION" != "success" ]; then
59+
echo "FAIL: required check '$CHECK' has conclusion '$CONCLUSION' (expected 'success')"
60+
FAILED=1
61+
else
62+
echo "PASS: required check '$CHECK' succeeded"
63+
fi
64+
done
65+
else
66+
# Validate all non-skipped check-runs
67+
while IFS= read -r RUN; do
68+
NAME=$(echo "$RUN" | jq -r '.name')
69+
STATUS=$(echo "$RUN" | jq -r '.status')
70+
CONCLUSION=$(echo "$RUN" | jq -r '.conclusion')
71+
72+
# Skip queued/in-progress (treat as not-yet-run, which is a failure)
73+
if [ "$STATUS" != "completed" ]; then
74+
echo "FAIL: check '$NAME' is not completed (status=$STATUS)"
75+
FAILED=1
76+
continue
77+
fi
78+
79+
# Allow skipped checks (neutral conclusion)
80+
if [ "$CONCLUSION" = "skipped" ] || [ "$CONCLUSION" = "neutral" ]; then
81+
echo "SKIP: check '$NAME' was skipped — ignoring"
82+
continue
83+
fi
84+
85+
if [ "$CONCLUSION" != "success" ]; then
86+
echo "FAIL: check '$NAME' has conclusion '$CONCLUSION'"
87+
FAILED=1
88+
fi
89+
done < <(echo "$CHECK_RUNS" | jq -c '.')
90+
fi
91+
92+
if [ "$FAILED" -eq 1 ]; then
93+
echo ""
94+
echo "CI quality gate FAILED for commit $COMMIT_SHA"
95+
echo "Promotion blocked. Fix failing checks before retrying."
96+
exit 1
97+
fi
98+
99+
echo ""
100+
echo "CI quality gate PASSED for commit $COMMIT_SHA"
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: 'Detect Packages'
2+
description: 'Dynamically discovers NPM packages and VS Code extensions in a monorepo'
3+
4+
inputs:
5+
packages-root:
6+
description: 'Root directory containing packages (default: packages)'
7+
required: false
8+
default: 'packages'
9+
10+
outputs:
11+
npm-packages:
12+
description: 'Comma-separated list of NPM package names'
13+
value: ${{ steps.packages.outputs.npm-packages }}
14+
extensions:
15+
description: 'Comma-separated list of VS Code extension names'
16+
value: ${{ steps.packages.outputs.extensions }}
17+
extension-paths:
18+
description: 'Extension package paths for publishing'
19+
value: ${{ steps.packages.outputs.extension-paths }}
20+
21+
runs:
22+
using: 'composite'
23+
steps:
24+
- name: Detect packages and extensions
25+
id: packages
26+
shell: bash
27+
env:
28+
PACKAGES_ROOT: ${{ inputs.packages-root }}
29+
run: |
30+
# Get NPM packages (packages with package.json but no publisher)
31+
NPM_PACKAGES=""
32+
EXTENSIONS=""
33+
EXTENSION_PATHS=""
34+
35+
for pkg in $PACKAGES_ROOT/*/; do
36+
PKG_NAME=$(basename "$pkg")
37+
if [ -f "$pkg/package.json" ]; then
38+
if grep -q '"publisher"' "$pkg/package.json"; then
39+
# It's a VS Code extension
40+
EXTENSIONS="$EXTENSIONS,$PKG_NAME"
41+
EXTENSION_PATHS="$EXTENSION_PATHS,$pkg"
42+
else
43+
# It's an NPM package
44+
NPM_PACKAGES="$NPM_PACKAGES,$PKG_NAME"
45+
fi
46+
fi
47+
done
48+
49+
# Remove leading commas
50+
NPM_PACKAGES=${NPM_PACKAGES#,}
51+
EXTENSIONS=${EXTENSIONS#,}
52+
EXTENSION_PATHS=${EXTENSION_PATHS#,}
53+
54+
echo "npm-packages=$NPM_PACKAGES" >> $GITHUB_OUTPUT
55+
echo "extensions=$EXTENSIONS" >> $GITHUB_OUTPUT
56+
echo "extension-paths=$EXTENSION_PATHS" >> $GITHUB_OUTPUT
57+
58+
echo "Detected NPM packages: $NPM_PACKAGES"
59+
echo "Detected VS Code extensions: $EXTENSIONS"
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: 'Download VSIX Artifacts'
2+
description: 'Downloads and finds VSIX artifacts for publishing workflows'
3+
4+
inputs:
5+
artifact-name:
6+
description: 'Name for the VSIX artifacts'
7+
required: false
8+
default: 'vsix-packages'
9+
type: string
10+
11+
outputs:
12+
vsix_files:
13+
description: 'JSON array of VSIX file paths'
14+
value: ${{ steps.find_vsix.outputs.vsix_files }}
15+
16+
runs:
17+
using: composite
18+
steps:
19+
- name: Download VSIX artifacts
20+
uses: actions/download-artifact@v4
21+
with:
22+
name: ${{ inputs.artifact-name }}
23+
path: ./vsix-artifacts
24+
25+
- name: Find VSIX files
26+
id: find_vsix
27+
shell: bash
28+
run: |
29+
VSIX_FILES=$(find ./vsix-artifacts -name "*.vsix" | jq -R -s -c 'split("\n")[:-1]')
30+
echo "vsix_files=$VSIX_FILES" >> $GITHUB_OUTPUT
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
name: npm-install-with-retries
2+
description: "wraps npm ci with retries/timeout to handle network failures"
3+
inputs:
4+
ignore-scripts:
5+
default: 'false'
6+
description: "Skip pre/post install scripts"
7+
runs:
8+
using: composite
9+
steps:
10+
- name: Set npm fetch timeout
11+
shell: bash
12+
run: npm config set fetch-timeout 600000
13+
- name: npm ci
14+
uses: salesforcecli/github-workflows/.github/actions/retry@main
15+
with:
16+
command: npm ci ${{ inputs.ignore-scripts == 'true' && '--ignore-scripts' || '' }}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
name: "Publish VSIX"
2+
description: "Publishes VSIX files to a marketplace with dry-run support"
3+
4+
inputs:
5+
vsix-path:
6+
description: "Path to the VSIX file to publish"
7+
required: true
8+
publish-tool:
9+
description: "Publishing tool to use"
10+
required: true
11+
pre-release:
12+
description: "Publish as pre-release version"
13+
required: false
14+
default: "false"
15+
dry-run:
16+
description: "Run in dry-run mode"
17+
required: false
18+
default: "false"
19+
20+
runs:
21+
using: composite
22+
steps:
23+
- name: Validate inputs
24+
shell: bash
25+
run: |
26+
# Validate VSIX path exists
27+
if [ ! -f "${{ inputs.vsix-path }}" ]; then
28+
echo "❌ Error: VSIX file not found at ${{ inputs.vsix-path }}"
29+
exit 1
30+
fi
31+
32+
# Validate VSIX file extension
33+
if [[ ! "${{ inputs.vsix-path }}" =~ \.vsix$ ]]; then
34+
echo "❌ Error: File must have .vsix extension"
35+
exit 1
36+
fi
37+
38+
# Validate publish tool
39+
if [[ ! "${{ inputs.publish-tool }}" =~ ^(ovsx|vsce)$ ]]; then
40+
echo "❌ Error: Invalid publish tool: ${{ inputs.publish-tool }}"
41+
exit 1
42+
fi
43+
44+
echo "✅ Input validation passed"
45+
46+
- name: Audit publish attempt
47+
shell: bash
48+
run: |
49+
# Create audit log entry
50+
AUDIT_LOG="/tmp/publish_audit.log"
51+
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
52+
ACTOR="${{ github.actor }}"
53+
REPO="${{ github.repository }}"
54+
RUN_ID="${{ github.run_id }}"
55+
WORKFLOW="${{ github.workflow }}"
56+
57+
# Get file info for audit
58+
FILE_SIZE=$(stat -c%s "${{ inputs.vsix-path }}" 2>/dev/null || stat -f%z "${{ inputs.vsix-path }}" 2>/dev/null || echo "unknown")
59+
FILE_HASH=$(sha256sum "${{ inputs.vsix-path }}" 2>/dev/null | cut -d' ' -f1 || echo "unknown")
60+
61+
# Log audit information
62+
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"
63+
64+
# Also log to GitHub Actions output for visibility
65+
echo "🔍 AUDIT: Publish attempt logged - $TIMESTAMP"
66+
echo " Actor: $ACTOR"
67+
echo " Repository: $REPO"
68+
echo " Run ID: $RUN_ID"
69+
echo " Workflow: $WORKFLOW"
70+
echo " Tool: ${{ inputs.publish-tool }}"
71+
echo " File: ${{ inputs.vsix-path }}"
72+
echo " Size: $FILE_SIZE bytes"
73+
echo " Hash: $FILE_HASH"
74+
echo " Pre-release: ${{ inputs.pre-release }}"
75+
echo " Dry-run: ${{ inputs.dry-run }}"
76+
77+
- name: Publish VSIX
78+
shell: bash
79+
run: |
80+
echo "Publishing ${{ inputs.vsix-path }}"
81+
82+
# Calculate marketplace name based on publish tool
83+
if [ "${{ inputs.publish-tool }}" = "ovsx" ]; then
84+
MARKETPLACE_NAME="Open VSX Registry"
85+
TOKEN_ENV="OVSX_PAT"
86+
else
87+
MARKETPLACE_NAME="Visual Studio Marketplace"
88+
TOKEN_ENV="VSCE_PERSONAL_ACCESS_TOKEN"
89+
fi
90+
91+
PRE_RELEASE_FLAG=""
92+
if [ "${{ inputs.pre-release }}" = "true" ]; then
93+
PRE_RELEASE_FLAG="--pre-release"
94+
echo "Would publish as pre-release version"
95+
fi
96+
97+
# Mask token in logs for security
98+
TOKEN_MASK="***"
99+
100+
if [ "${{ inputs.dry-run }}" = "true" ]; then
101+
echo "🔍 DRY RUN MODE - Would publish to $MARKETPLACE_NAME:"
102+
echo " VSIX: ${{ inputs.vsix-path }}"
103+
echo " Pre-release: ${{ inputs.pre-release }}"
104+
105+
if [ "${{ inputs.publish-tool }}" = "ovsx" ]; then
106+
echo " Command: npx ovsx publish \"${{ inputs.vsix-path }}\" -p $TOKEN_MASK $PRE_RELEASE_FLAG"
107+
else
108+
echo " Command: npx @vscode/vsce publish --packagePath \"${{ inputs.vsix-path }}\" --skip-duplicate $PRE_RELEASE_FLAG"
109+
fi
110+
echo "✅ Dry run completed - no actual publish performed"
111+
else
112+
echo "Publishing VSIX: ${{ inputs.vsix-path }}"
113+
114+
# Verify token is available
115+
if [ -z "${!TOKEN_ENV}" ]; then
116+
echo "❌ Error: $TOKEN_ENV environment variable is not set"
117+
exit 1
118+
fi
119+
120+
if [ "${{ inputs.publish-tool }}" = "vsce" ]; then
121+
export VSCE_PAT="${!TOKEN_ENV}" # ensure the expected env var is set
122+
npx @vscode/vsce publish --packagePath "${{ inputs.vsix-path }}" --skip-duplicate $PRE_RELEASE_FLAG
123+
else
124+
npx ovsx publish "${{ inputs.vsix-path }}" -p "${!TOKEN_ENV}" --skip-duplicate $PRE_RELEASE_FLAG
125+
fi
126+
127+
echo "✅ Successfully published to $MARKETPLACE_NAME"
128+
fi
129+
130+
- name: Audit publish result
131+
shell: bash
132+
if: inputs.dry-run != 'true'
133+
run: |
134+
# Log the result of the publish attempt
135+
AUDIT_LOG="/tmp/publish_audit.log"
136+
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
137+
ACTOR="${{ github.actor }}"
138+
REPO="${{ github.repository }}"
139+
RUN_ID="${{ github.run_id }}"
140+
141+
if [ $? -eq 0 ]; then
142+
echo "[$TIMESTAMP] PUBLISH_SUCCESS: actor=$ACTOR, repo=$REPO, run_id=$RUN_ID, tool=${{ inputs.publish-tool }}, file=${{ inputs.vsix-path }}" >> "$AUDIT_LOG"
143+
echo "✅ AUDIT: Publish successful - $TIMESTAMP"
144+
else
145+
echo "[$TIMESTAMP] PUBLISH_FAILURE: actor=$ACTOR, repo=$REPO, run_id=$RUN_ID, tool=${{ inputs.publish-tool }}, file=${{ inputs.vsix-path }}" >> "$AUDIT_LOG"
146+
echo "❌ AUDIT: Publish failed - $TIMESTAMP"
147+
fi

0 commit comments

Comments
 (0)