diff --git a/.github/actions/calculate-artifact-name/action.yml b/.github/actions/calculate-artifact-name/action.yml new file mode 100644 index 0000000..906885c --- /dev/null +++ b/.github/actions/calculate-artifact-name/action.yml @@ -0,0 +1,36 @@ +name: 'Calculate Artifact Name' +description: > + Generates a unique artifact name by appending run number and mode suffix (dry-run/release). + Used to ensure artifact names are unique per workflow run and distinguishable by mode. + +inputs: + artifact-name: + description: 'Base artifact name or pre-calculated name' + required: true + dry-run: + description: 'Whether this is a dry-run mode' + required: false + default: 'false' + run-number: + description: 'GitHub run number (defaults to github.run_number)' + required: false + default: '${{ github.run_number }}' + +outputs: + artifact-name: + description: 'The calculated artifact name' + value: ${{ steps.calc.outputs.artifact-name }} + +runs: + using: 'composite' + steps: + - name: Calculate artifact name + id: calc + shell: bash + run: | + # Only treat as already set if artifact-name ends with -dry-run or -release + if [[ "${{ inputs.artifact-name }}" =~ -dry-run$ ]] || [[ "${{ inputs.artifact-name }}" =~ -release$ ]]; then + echo "artifact-name=${{ inputs.artifact-name }}" >> $GITHUB_OUTPUT + else + echo "artifact-name=${{ format('{0}-{1}-{2}', inputs.artifact-name, inputs.run-number, inputs.dry-run == 'true' && 'dry-run' || 'release') }}" >> $GITHUB_OUTPUT + fi diff --git a/.github/actions/check-ci-status/action.yml b/.github/actions/check-ci-status/action.yml new file mode 100644 index 0000000..709ba6e --- /dev/null +++ b/.github/actions/check-ci-status/action.yml @@ -0,0 +1,115 @@ +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 + + # Grab the latest run for this name (check-runs are newest-first per name). + MATCH=$(echo "$CHECK_RUNS" | jq -c --arg name "$CHECK" \ + 'select(.name == $name)' | head -1) + + if [ -z "$MATCH" ]; then + echo "FAIL: required check '$CHECK' is absent on this commit (never ran)" + FAILED=1 + continue + fi + + STATUS=$(echo "$MATCH" | jq -r '.status') + CONCLUSION=$(echo "$MATCH" | jq -r '.conclusion') + + if [ "$STATUS" != "completed" ]; then + echo "FAIL: required check '$CHECK' is not completed (status=$STATUS)" + FAILED=1 + elif [ "$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/publish-vsix/action.yml b/.github/actions/publish-vsix/action.yml new file mode 100644 index 0000000..be6c5d1 --- /dev/null +++ b/.github/actions/publish-vsix/action.yml @@ -0,0 +1,159 @@ +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 + id: publish + 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" + echo "result=success" >> $GITHUB_OUTPUT + 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" + echo "result=failure" >> $GITHUB_OUTPUT + 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 + + PUBLISH_EXIT_CODE=$? + if [ $PUBLISH_EXIT_CODE -eq 0 ]; then + echo "✅ Successfully published to $MARKETPLACE_NAME" + echo "result=success" >> $GITHUB_OUTPUT + else + echo "❌ Failed to publish to $MARKETPLACE_NAME" + echo "result=failure" >> $GITHUB_OUTPUT + exit $PUBLISH_EXIT_CODE + fi + fi + + - name: Audit publish result + shell: bash + if: always() && 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 }}" + PUBLISH_RESULT="${{ steps.publish.outputs.result }}" + + if [ "$PUBLISH_RESULT" = "success" ]; 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/actions/vscode/publish-vsix/action.yml b/.github/actions/vscode/publish-vsix/action.yml index 9ac4c80..b969601 100644 --- a/.github/actions/vscode/publish-vsix/action.yml +++ b/.github/actions/vscode/publish-vsix/action.yml @@ -103,7 +103,7 @@ runs: 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" + echo " Command: npx ovsx publish \"${{ inputs.vsix-path }}\" -p $TOKEN_MASK --skip-duplicate $PRE_RELEASE_FLAG" else echo " Command: npx @vscode/vsce publish --packagePath \"${{ inputs.vsix-path }}\" --skip-duplicate $PRE_RELEASE_FLAG" fi diff --git a/.github/workflows/vscode-publish-extensions.yml b/.github/workflows/vscode-publish-extensions.yml index be81e79..668731e 100644 --- a/.github/workflows/vscode-publish-extensions.yml +++ b/.github/workflows/vscode-publish-extensions.yml @@ -427,12 +427,13 @@ jobs: echo "✅ Version bumping complete" - name: Validate GitHub authentication + if: inputs.dry-run != 'true' && github.event.inputs.dry-run != 'true' env: - GITHUB_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} + GH_TOKEN: ${{ secrets.IDEE_GH_TOKEN }} run: | # Validate that required tokens are present - if [ -z "$GITHUB_TOKEN" ]; then - echo "❌ Error: GITHUB_TOKEN is not set" + if [ -z "$GH_TOKEN" ]; then + echo "❌ Error: GH_TOKEN is not set" exit 1 fi