chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:52:40 +08:00
commit 9c06d70b3c
3233 changed files with 672160 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
/.github/ @saoudrizwan @arafatkatze @maxpaulus43 @dominiccooney
/README.md @saoudrizwan @juanpflores
+88
View File
@@ -0,0 +1,88 @@
name: 🐛 Bug Report
description: File a bug report
labels: ['bug']
body:
- type: markdown
attributes:
value: |
**Important:** All bug reports must be reproducible using Claude Sonnet 4.5. Cline uses complex prompts so less capable models may not work as expected.
- type: dropdown
id: cline-surface
attributes:
label: Cline Surface
description: Which Cline surface are you reporting a bug for?
options:
- VSCode Extension
- JetBrains Plugin
- CLI
default: 0
validations:
required: true
- type: input
id: cline-version
attributes:
label: Cline Version
description: What version of Cline are you using? (You can find this at the bottom of the Settings view)
placeholder: 'e.g., 1.2.3'
validations:
required: true
- type: checkboxes
id: beta
attributes:
label: Beta version
options:
- label: I am using a beta version of Cline
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Also tell us, what did you expect to happen?
placeholder: Tell us what you see!
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: How do you trigger this bug? Please walk us through it step by step.
value: |
1.
2.
3.
validations:
required: false
- type: input
id: provider-model
attributes:
label: Provider/Model
description: What provider and model were you using when the issue occurred?
placeholder: 'e.g., cline:anthropic/claude-sonnet-4.5, gemini:gemini-2.5-pro-exp-03-25'
validations:
required: false
- type: textarea
id: ide-diagnostics
attributes:
label: IDE / CLI Diagnostics
description: |
Paste the "About" diagnostics for your Cline surface. This captures the IDE build, runtime, and host details we need.
- VSCode Extension: open `Help → About` (Windows/Linux) or `Code → About Visual Studio Code` (macOS), then copy the info.
- JetBrains Plugin: open `Help → About` (Windows/Linux) or `<IDE name> → About` (macOS), then click `Copy` to grab build, runtime, OS, memory, and cores.
- CLI: there is no About dialog. Run `cline --version` and paste the output.
placeholder: Paste the copied About info or `cline --version` output here.
validations:
required: false
- type: textarea
id: system-info
attributes:
label: System Information
description: What operating system and hardware are you using?
placeholder: |
Operating System: Windows 11, macOS Sonoma, Ubuntu 22.04, etc.
Hardware: CPU, GPU, RAM specifications if relevant
e.g.,
OS: Windows 11
CPU: Intel Core i7-11700K
GPU: NVIDIA GeForce RTX 3070
RAM: 32GB DDR4
validations:
required: false
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: ✨ Feature Request
url: https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop
about: Share and vote on feature requests for Cline
- name: 👋 Cline Discord
url: https://discord.gg/cline
about: Join our Discord community for discussions and support
+57
View File
@@ -0,0 +1,57 @@
# Copilot Instructions for Cline
This is a VS Code extension. Read `.clinerules/general.md` for tribal knowledge and nuanced patterns.
## Architecture
- **Core** (`src/`): `extension.ts``WebviewProvider``Controller` (single source of truth) → `Task` (agent loop).
- **Webview** (`webview-ui/`): React/Vite app. State via `ExtensionStateContext.tsx`, synced through message passing.
- **Communication**: Protobuf-defined gRPC-like protocol over VS Code message passing. Schemas in `proto/`.
- **MCP**: `src/services/mcp/McpHub.ts`.
## Build & Test (Critical — non-obvious commands)
- **Build**: `bun run compile` — NOT `bun run build`.
- **Watch**: `bun run watch` (extension + webview).
- **Protos**: `bun run protos` — run **immediately** after any `.proto` change. Generates into `src/shared/proto/`, `src/generated/`.
- **Tests**: `bun run test:unit`. After prompt/tool changes: `UPDATE_SNAPSHOTS=true bun run test:unit`.
## Protobuf RPC Workflow (4 steps)
1. **Define** in `proto/cline/*.proto`. Naming: `PascalCaseService`, `camelCase` RPCs, `PascalCase` Messages. Use `common.proto` shared types for simple data.
2. **Generate**: `bun run protos`.
3. **Backend handler**: `src/core/controller/<domain>/`.
4. **Frontend call**: `UiServiceClient.myMethod(Request.create({...}))`.
- Adding enums (e.g. `ClineSay`) → also update `src/shared/proto-conversions/cline-message.ts`.
## Adding API Providers (silent failure risk)
Three proto conversion updates are **required** or the provider silently resets to Anthropic:
1. `proto/cline/models.proto` — add to `ApiProvider` enum.
2. `convertApiProviderToProto()` in `src/shared/proto-conversions/models/api-configuration-conversion.ts`.
3. `convertProtoToApiProvider()` in the same file.
Also update: `src/shared/api.ts`, `src/shared/providers/providers.json`, `src/core/api/index.ts`, `webview-ui/.../providerUtils.ts`, `webview-ui/.../validate.ts`, `webview-ui/.../ApiOptions.tsx`.
For Responses API providers: add to `isNextGenModelProvider()` in `src/utils/model-utils.ts` and set `apiFormat: ApiFormat.OPENAI_RESPONSES` on models.
## Adding Tools to System Prompt (5+ file chain)
1. Add enum to `ClineDefaultTool` in `src/shared/tools.ts`.
2. Create definition in `src/core/prompts/system-prompt/tools/` (export `[GENERIC]` minimum).
3. Register in `src/core/prompts/system-prompt/tools/init.ts`.
4. Whitelist in `src/core/prompts/system-prompt/variants/*/config.ts` for each model family.
5. Handler in `src/core/task/tools/handlers/`, wire in `ToolExecutor.ts`.
6. If tool has UI: add `ClineSay` enum in proto → `ExtensionMessage.ts``cline-message.ts``ChatRow.tsx`.
7. Regenerate snapshots: `UPDATE_SNAPSHOTS=true bun run test:unit`.
## Modifying System Prompt
Modular: `components/` (shared) + `variants/` (model-specific) + `templates/` (`{{PLACEHOLDER}}`). Variants override components via `componentOverrides` in `config.ts` or custom `template.ts`. XS variant is heavily condensed inline. Always regenerate snapshots after changes.
## Global State Keys (silent failure risk)
Adding a key requires updating the typed storage definitions in `src/shared/storage/state-keys.ts`; runtime reads and writes should go through `StateManager`, not VS Code `ExtensionContext` storage. Persistent state is file-backed so it works across VS Code, CLI, and JetBrains hosts.
## Slash Commands (3 places)
- `src/core/slash-commands/index.ts` — definitions.
- `src/core/prompts/commands.ts` — system prompt integration.
- `webview-ui/src/utils/slash-commands.ts` — webview autocomplete.
## Conventions
- **Paths**: Always use `src/utils/path` helpers (`toPosixString`) for cross-platform compatibility.
- **Logging**: `src/shared/services/Logger.ts`.
- **Feature flags**: See PR #7566 as reference pattern.
+36
View File
@@ -0,0 +1,36 @@
version: 2
updates:
# Main extension dependencies
- package-ecosystem: "npm"
directory: "/apps/vscode"
schedule:
interval: "weekly"
# Group all updates into a single PR
groups:
all-dependencies:
patterns:
- "*"
ignore:
# Ignore all non-security updates (security vulnerabilities bypass these ignore rules)
- dependency-name: "*"
update-types:
- "version-update:semver-major"
- "version-update:semver-minor"
- "version-update:semver-patch"
# Webview UI dependencies
- package-ecosystem: "npm"
directory: "/apps/vscode/webview-ui"
schedule:
interval: "weekly"
groups:
all-dependencies:
patterns:
- "*"
ignore:
- dependency-name: "@testing-library/*"
- dependency-name: "*"
update-types:
- "version-update:semver-major"
- "version-update:semver-minor"
- "version-update:semver-patch"
+79
View File
@@ -0,0 +1,79 @@
<!--
Thank you for contributing to Cline!
⚠️ Important: Before submitting this PR, please ensure you have:
- For feature requests: Created a discussion in our Feature Requests discussions board https://github.com/cline/cline/discussions/categories/feature-requests and received approval from core maintainers before implementation
- For all changes: Link the associated issue/discussion in the "Related Issue" section below
Limited exceptions:
Small bug fixes, typo corrections, minor wording improvements, or simple type fixes that don't change functionality may be submitted directly without prior discussion.
Why this requirement?
We deeply appreciate all community contributions - they are essential to Cline's success! To ensure the best use of everyone's time and maintain project direction, we use our Feature Requests discussions board to gauge community interest and validate feature ideas before implementation begins. This helps us focus development efforts on features that will benefit the most users.
-->
### Related Issue
<!-- Replace XXXX with the issue number that this PR addresses -->
**Issue:** #XXXX
### Description
<!--
Help reviewers understand your changes by making this PR readable and well-organized:
- What problem does this PR solve?
- Why were these changes introduced and what purpose do they serve?
- For larger changes, provide context about your approach and reasoning
Small PRs may need minimal description, but larger changes benefit from explaining where you're coming from. Much of this context can be in the linked issue above, so feel free to reference it rather than repeating everything here.
-->
### Test Procedure
<!--
Please walk us through your testing approach and thought process. This helps reviewers understand that you've thoroughly considered the impact of your changes:
- How did you test this change?
- What could potentially break and how did you verify it doesn't?
- What existing functionality might be affected and how did you check it still works?
- Why are you confident this is ready for merge?
We're not looking for exhaustive documentation - just evidence that you've thought through the implications of your changes and tested accordingly.
-->
### Type of Change
<!-- Put an 'x' in all boxes that apply -->
- [ ] 🐛 Bug fix (non-breaking change which fixes an issue)
- [ ] ✨ New feature (non-breaking change which adds functionality)
- [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] ♻️ Refactor Changes
- [ ] 💅 Cosmetic Changes
- [ ] 📚 Documentation update
- [ ] 🏃 Workflow Changes
### Pre-flight Checklist
<!-- Put an 'x' in all boxes that apply -->
- [ ] Changes are limited to a single feature, bugfix or chore (split larger changes into separate PRs)
- [ ] Tests are passing (`bun test`) and code is formatted and linted (`bun run format && bun run lint`)
- [ ] I have reviewed [contributor guidelines](https://github.com/cline/cline/blob/main/CONTRIBUTING.md)
### Screenshots
<!--
Help reviewers quickly understand your changes:
- **UI Changes**: Please include screenshots showing before/after states
- **Complex Workflows**: Consider uploading a screen recording (video) if your changes involve multiple steps or state transitions
- **Backend Changes**: Not required, but feel free to include terminal output or other evidence that demonstrates functionality
This helps reviewers see what you've built without having to pull down and test your branch first.
-->
### Additional Notes
<!-- Add any additional notes for reviewers -->
@@ -0,0 +1,19 @@
"""
Coverage utility package for GitHub Actions workflows.
This package handles extracting coverage percentages, comparing them, and generating PR comments.
"""
# Import external dependencies
import requests
# Import main function for CLI usage
from .__main__ import main
# Import functions from extraction module
from .extraction import extract_coverage, compare_coverage, run_coverage, set_verbose
# Import functions from github_api module
from .github_api import generate_comment, post_comment, set_github_output
# Import functions from workflow module
from .workflow import process_coverage_workflow
+154
View File
@@ -0,0 +1,154 @@
"""
Main module.
This module provides the CLI interface for the coverage utility script.
"""
import sys
import argparse
from .extraction import extract_coverage, compare_coverage, run_coverage, set_verbose
from .github_api import generate_comment, post_comment, set_github_output
from .workflow import process_coverage_workflow
from .util import log
def setup_verbose_mode(args):
"""
Set up verbose mode based on command line arguments.
Args:
args: Parsed command line arguments
"""
if getattr(args, 'verbose', False):
set_verbose(True)
log("Verbose mode enabled")
def main():
# Create parent parser with common arguments
parent_parser = argparse.ArgumentParser(add_help=False)
parent_parser.add_argument('-v', '--verbose', action='store_true', help='Enable verbose output')
# Create main parser that inherits common arguments
parser = argparse.ArgumentParser(description='Coverage utility script for GitHub Actions workflows', parents=[parent_parser])
subparsers = parser.add_subparsers(dest='command', help='Command to run')
# extract-coverage command - used directly in workflow
extract_parser = subparsers.add_parser('extract-coverage', help='Extract coverage percentage from a file', parents=[parent_parser])
extract_parser.add_argument('file_path', help='Path to the coverage report file')
extract_parser.add_argument('--type', choices=['extension', 'webview'], default='extension',
help='Type of coverage report')
extract_parser.add_argument('--github-output', action='store_true', help='Output in GitHub Actions format')
# compare-coverage command - used by process-workflow
compare_parser = subparsers.add_parser('compare-coverage', help='Compare coverage percentages', parents=[parent_parser])
compare_parser.add_argument('base_cov', help='Base branch coverage percentage')
compare_parser.add_argument('pr_cov', help='PR branch coverage percentage')
compare_parser.add_argument('--output-prefix', default='', help='Prefix for GitHub Actions output variables')
compare_parser.add_argument('--github-output', action='store_true', help='Output in GitHub Actions format')
# generate-comment command - used by process-workflow
comment_parser = subparsers.add_parser('generate-comment', help='Generate PR comment with coverage comparison', parents=[parent_parser])
comment_parser.add_argument('base_ext_cov', help='Base branch extension coverage')
comment_parser.add_argument('pr_ext_cov', help='PR branch extension coverage')
comment_parser.add_argument('ext_decreased', help='Whether extension coverage decreased (true/false)')
comment_parser.add_argument('ext_diff', help='Extension coverage difference')
comment_parser.add_argument('base_web_cov', help='Base branch webview coverage')
comment_parser.add_argument('pr_web_cov', help='PR branch webview coverage')
comment_parser.add_argument('web_decreased', help='Whether webview coverage decreased (true/false)')
comment_parser.add_argument('web_diff', help='Webview coverage difference')
# post-comment command - used by process-workflow
post_parser = subparsers.add_parser('post-comment', help='Post a comment to a GitHub PR', parents=[parent_parser])
post_parser.add_argument('comment_path', help='Path to the file containing the comment text')
post_parser.add_argument('pr_number', help='PR number')
post_parser.add_argument('repo', help='Repository in the format "owner/repo"')
post_parser.add_argument('--token', help='GitHub token')
# run-coverage command - used by process-workflow
run_parser = subparsers.add_parser('run-coverage', help='Run a coverage command and extract the coverage percentage', parents=[parent_parser])
run_parser.add_argument('coverage_cmd', help='Command to run')
run_parser.add_argument('output_file', help='File to save the output to')
run_parser.add_argument('--type', choices=['extension', 'webview'], default='extension',
help='Type of coverage report')
run_parser.add_argument('--github-output', action='store_true', help='Output in GitHub Actions format')
# process-workflow command - used directly in workflow
workflow_parser = subparsers.add_parser('process-workflow', help='Process the entire coverage workflow', parents=[parent_parser])
workflow_parser.add_argument('--base-branch', required=True, help='Base branch name')
workflow_parser.add_argument('--pr-number', help='PR number')
workflow_parser.add_argument('--repo', help='Repository in the format "owner/repo"')
workflow_parser.add_argument('--token', help='GitHub token')
# set-github-output command - used by process-workflow
output_parser = subparsers.add_parser('set-github-output', help='Set GitHub Actions output variable', parents=[parent_parser])
output_parser.add_argument('name', help='Output variable name')
output_parser.add_argument('value', help='Output variable value')
args = parser.parse_args()
# Set up verbose mode
setup_verbose_mode(args)
if args.command == 'extract-coverage':
log(f"Extracting coverage from file: {args.file_path} (type: {args.type})")
coverage_pct = extract_coverage(args.file_path, args.type)
if args.github_output:
set_github_output(f"{args.type}_coverage", coverage_pct)
else:
log(f"Coverage: {coverage_pct}%")
elif args.command == 'compare-coverage':
log(f"Comparing coverage: base={args.base_cov}%, PR={args.pr_cov}%")
decreased, diff = compare_coverage(args.base_cov, args.pr_cov)
if args.github_output:
prefix = args.output_prefix
set_github_output(f"{prefix}decreased", str(decreased).lower())
set_github_output(f"{prefix}diff", diff)
log(f"Coverage difference: {diff}%")
log(f"Coverage decreased: {decreased}")
else:
log(f"decreased={str(decreased).lower()}")
log(f"diff={diff}")
elif args.command == 'generate-comment':
log("Generating coverage comparison comment")
comment = generate_comment(
args.base_ext_cov, args.pr_ext_cov, args.ext_decreased, args.ext_diff,
args.base_web_cov, args.pr_web_cov, args.web_decreased, args.web_diff
)
# Output the comment to stdout
log(comment)
elif args.command == 'post-comment':
log(f"Posting comment from {args.comment_path} to PR #{args.pr_number} in {args.repo}")
post_comment(args.comment_path, args.pr_number, args.repo, args.token)
elif args.command == 'run-coverage':
log(f"Running coverage command: {args.coverage_cmd}")
log(f"Output file: {args.output_file}")
log(f"Coverage type: {args.type}")
coverage_pct = run_coverage(args.coverage_cmd, args.output_file, args.type)
if args.github_output:
set_github_output(f"{args.type}_coverage", coverage_pct)
else:
log(f"Coverage: {coverage_pct}%")
elif args.command == 'process-workflow':
log("Processing coverage workflow")
log(f"Base branch: {args.base_branch}")
if args.pr_number:
log(f"PR number: {args.pr_number}")
if args.repo:
log(f"Repository: {args.repo}")
process_coverage_workflow(args)
elif args.command == 'set-github-output':
log(f"Setting GitHub output: {args.name}={args.value}")
set_github_output(args.name, args.value)
else:
log("No command specified")
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()
@@ -0,0 +1,265 @@
"""
Coverage extraction module.
This module handles extracting coverage percentages from coverage report files.
"""
import os
import re
import sys
import shlex
import subprocess
import traceback
from .util import log, file_exists, get_file_size, list_directory, is_safe_command, run_command
# Global verbose flag
verbose = False
def set_verbose(value):
"""Set the global verbose flag."""
global verbose
verbose = value
def print_debug_output(content, coverage_type):
"""
Print debug information about the coverage output.
Args:
content: The content of the coverage file
coverage_type: Type of coverage report (extension or webview)
"""
if not verbose:
return
# Extract and print only the coverage summary section
if coverage_type == "extension":
# Look for the coverage summary section
summary_match = re.search(r'=============================== Coverage summary ===============================\n(.*?)\n=+', content, re.DOTALL)
if summary_match:
sys.stdout.write("\n##[group]EXTENSION COVERAGE SUMMARY\n")
sys.stdout.write("=============================== Coverage summary ===============================\n")
sys.stdout.write(summary_match.group(1) + "\n")
sys.stdout.write("================================================================================\n")
sys.stdout.write("##[endgroup]\n")
sys.stdout.flush()
else:
sys.stdout.write("\n##[warning]No coverage summary found in extension coverage file\n")
sys.stdout.flush()
else: # webview
# Look for the coverage table - specifically the "All files" row
table_match = re.search(r'% Coverage report from v8.*?-+\|.*?\n.*?\n(All files.*?)(?:\n[^\n]*\|)', content, re.DOTALL)
if table_match:
sys.stdout.write("\n##[group]WEBVIEW COVERAGE SUMMARY\n")
sys.stdout.write("% Coverage report from v8\n")
sys.stdout.write("-------------------|---------|----------|---------|---------|-------------------\n")
sys.stdout.write("File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s \n")
sys.stdout.write("-------------------|---------|----------|---------|---------|-------------------\n")
sys.stdout.write(table_match.group(1) + "\n")
sys.stdout.write("-------------------|---------|----------|---------|---------|-------------------\n")
sys.stdout.write("##[endgroup]\n")
sys.stdout.flush()
else:
sys.stdout.write("\n##[warning]No coverage table found in webview coverage file\n")
sys.stdout.flush()
def extract_coverage(file_path, coverage_type="extension"):
"""
Extract coverage percentage from a coverage report file.
Args:
file_path: Path to the coverage report file
coverage_type: Type of coverage report (extension or webview)
Returns:
Coverage percentage as a float
"""
# Always print file path for debugging
log(f"Checking coverage file: {file_path}")
# Check if file exists and get its size
if not file_exists(file_path):
sys.stdout.write(f"\n##[error]File {file_path} does not exist\n")
sys.stdout.flush()
log(f"Error: File {file_path} does not exist")
# Check if the directory exists
dir_path = os.path.dirname(file_path)
if not os.path.exists(dir_path):
sys.stdout.write(f"\n##[error]Directory {dir_path} does not exist\n")
sys.stdout.flush()
log(f"Error: Directory {dir_path} does not exist")
else:
# List directory contents for debugging
log(f"Directory {dir_path} exists, listing contents:")
try:
dir_contents = list_directory(dir_path)
for name, size in dir_contents:
log(f" {name} - {size}")
sys.stdout.write(f" {name} - {size}\n")
sys.stdout.flush()
except Exception as e:
log(f"Error listing directory: {e}")
return 0.0
file_size = get_file_size(file_path)
log(f"File size: {file_size} bytes")
sys.stdout.write(f"\n##[info]Coverage file {file_path} exists, size: {file_size} bytes\n")
sys.stdout.flush()
if file_size == 0:
sys.stdout.write(f"\n##[warning]File {file_path} is empty\n")
sys.stdout.flush()
log(f"Warning: File {file_path} is empty")
return 0.0
# List directory contents for debugging
dir_path = os.path.dirname(file_path)
log(f"Directory contents of {dir_path}:")
try:
dir_contents = list_directory(dir_path)
for name, size in dir_contents:
log(f" {name} - {size}")
except Exception as e:
log(f"Error listing directory: {e}")
with open(file_path, 'r') as f:
content = f.read()
# Print debug information if verbose
print_debug_output(content, coverage_type)
# Extract coverage percentage based on coverage type
if coverage_type == "extension":
# Extract the percentage from the "Lines" row in the coverage summary
# Pattern: Lines : xx.xx% ( xxxxxxx/xxxxxxx )
lines_match = re.search(r'Lines\s*:\s*(\d+\.\d+)%', content)
if lines_match:
coverage_pct = float(lines_match.group(1))
if verbose:
sys.stdout.write(f"Pattern matched (Lines percentage): {coverage_pct}\n")
sys.stdout.flush()
return coverage_pct
else:
# No coverage data found, log full content for debugging
log("No coverage data found. Full file content:")
log("=== Full file content ===")
log(content)
log("=== End file content ===")
else: # webview
# Extract the percentage from the "% Lines" column in the "All files" row
# Pattern: All files | xx.xx | xx.xx | xx.xx | xx.xx |
all_files_match = re.search(r'All files\s+\|\s+\d+\.\d+\s+\|\s+\d+\.\d+\s+\|\s+\d+\.\d+\s+\|\s+(\d+\.\d+)', content)
if all_files_match:
coverage_pct = float(all_files_match.group(1))
if verbose:
sys.stdout.write(f"Pattern matched (All files % Lines): {coverage_pct}\n")
sys.stdout.flush()
return coverage_pct
else:
# No coverage data found, log full content for debugging
log("No coverage data found. Full file content:")
log("=== Full file content ===")
log(content)
log("=== End file content ===")
# If no match found, return 0.0
return 0.0
def compare_coverage(base_cov, pr_cov):
"""
Compare coverage percentages between base and PR branches.
Args:
base_cov: Base branch coverage percentage
pr_cov: PR branch coverage percentage
Returns:
Tuple of (decreased, diff)
"""
try:
base_cov = float(base_cov)
pr_cov = float(pr_cov)
except ValueError:
sys.stdout.write(f"Error: Invalid coverage values - base: {base_cov}, PR: {pr_cov}\n")
sys.stdout.flush()
return False, 0
diff = pr_cov - base_cov
decreased = diff < 0
return decreased, abs(diff)
def run_coverage(command, output_file, coverage_type="extension"):
"""
Run a coverage command and extract the coverage percentage.
Args:
command: Command to run
output_file: File to save the output to
coverage_type: Type of coverage report (extension or webview)
Returns:
Coverage percentage as a float
Raises:
SystemExit: If the output file is not created or is empty
"""
try:
# Run the command and capture output
if not is_safe_command(command):
error_msg = f"ERROR: Unsafe command detected: {command}"
log(error_msg)
sys.stdout.write(f"\n##[error]{error_msg}\n")
sys.stdout.flush()
sys.exit(1)
# Run command using safe execution from util
returncode, stdout, stderr = run_command(command)
# Log command result
log(f"Command exit code: {returncode}")
log(f"Command stdout length: {len(stdout)} bytes")
log(f"Command stderr length: {len(stderr)} bytes")
# Save output to file
log(f"Saving command output to {output_file}")
with open(output_file, 'w') as f:
f.write(stdout)
if stderr:
f.write("\n\n=== STDERR ===\n")
f.write(stderr)
# Verify file was created and has content
if not file_exists(output_file):
error_msg = f"ERROR: Output file {output_file} was not created"
log(error_msg)
sys.stdout.write(f"\n##[error]{error_msg}\n")
sys.stdout.flush()
sys.exit(1) # Exit with error code to fail the workflow
file_size = get_file_size(output_file)
if file_size == 0:
error_msg = f"ERROR: Output file {output_file} is empty"
log(error_msg)
sys.stdout.write(f"\n##[error]{error_msg}\n")
sys.stdout.flush()
sys.exit(1) # Exit with error code to fail the workflow
log(f"Output file size: {file_size} bytes")
# Extract coverage percentage
coverage_pct = extract_coverage(output_file, coverage_type)
log(f"{coverage_type.capitalize()} coverage: {coverage_pct}%")
return coverage_pct
except Exception as e:
error_msg = f"Error running coverage command: {e}"
log(error_msg)
sys.stdout.write(f"\n##[error]{error_msg}\n")
sys.stdout.flush()
# Print stack trace for debugging
log(traceback.format_exc())
sys.exit(1) # Exit with error code to fail the workflow
@@ -0,0 +1,177 @@
"""
GitHub API module.
This module handles interactions with the GitHub API for posting comments to PRs.
"""
import os
import requests
from .util import log, file_exists
def generate_comment(base_ext_cov, pr_ext_cov, ext_decreased, ext_diff,
base_web_cov, pr_web_cov, web_decreased, web_diff):
"""
Generate a PR comment with coverage comparison.
Args:
base_ext_cov: Base branch extension coverage
pr_ext_cov: PR branch extension coverage
ext_decreased: Whether extension coverage decreased
ext_diff: Extension coverage difference
base_web_cov: Base branch webview coverage
pr_web_cov: PR branch webview coverage
web_decreased: Whether webview coverage decreased
web_diff: Webview coverage difference
Returns:
Comment text
"""
from datetime import datetime
# Convert string inputs to appropriate types
try:
base_ext_cov = float(base_ext_cov)
pr_ext_cov = float(pr_ext_cov)
# Handle ext_decreased as either string or boolean
if isinstance(ext_decreased, str):
ext_decreased = ext_decreased.lower() == 'true'
else:
ext_decreased = bool(ext_decreased)
ext_diff = float(ext_diff)
base_web_cov = float(base_web_cov)
pr_web_cov = float(pr_web_cov)
# Handle web_decreased as either string or boolean
if isinstance(web_decreased, str):
web_decreased = web_decreased.lower() == 'true'
else:
web_decreased = bool(web_decreased)
web_diff = float(web_diff)
except ValueError as e:
log(f"Error converting input values: {e}")
return ""
# Add a unique identifier to find this comment later
comment = '<!-- COVERAGE_REPORT -->\n'
comment += '## Coverage Report\n\n'
# Extension coverage
comment += '### Extension Coverage\n\n'
comment += f'Base branch: {base_ext_cov:.0f}%\n\n'
comment += f'PR branch: {pr_ext_cov:.0f}%\n\n'
if ext_decreased:
comment += f'⚠️ **Warning: Coverage decreased by {ext_diff:.2f}%**\n\n'
comment += 'Consider adding tests to cover your changes.\n\n'
else:
comment += '✅ Coverage increased or remained the same\n\n'
# Webview coverage
comment += '### Webview Coverage\n\n'
comment += f'Base branch: {base_web_cov:.0f}%\n\n'
comment += f'PR branch: {pr_web_cov:.0f}%\n\n'
if web_decreased:
comment += f'⚠️ **Warning: Coverage decreased by {web_diff:.2f}%**\n\n'
comment += 'Consider adding tests to cover your changes.\n\n'
else:
comment += '✅ Coverage increased or remained the same\n\n'
# Overall assessment
comment += '### Overall Assessment\n\n'
if ext_decreased or web_decreased:
comment += '⚠️ **Test coverage has decreased in this PR**\n\n'
comment += 'Please consider adding tests to maintain or improve coverage.\n\n'
else:
comment += '✅ **Test coverage has been maintained or improved**\n\n'
# Add timestamp
comment += f'\n\n<sub>Last updated: {datetime.now().isoformat()}</sub>'
return comment
def post_comment(comment_path, pr_number, repo, token=None):
"""
Post a comment to a GitHub PR.
Args:
comment_path: Path to the file containing the comment text
pr_number: PR number
repo: Repository in the format "owner/repo"
token: GitHub token
"""
if not file_exists(comment_path):
log(f"Error: Comment file {comment_path} does not exist")
return
with open(comment_path, 'r') as f:
comment_body = f.read()
if not token:
token = os.environ.get('GITHUB_TOKEN')
if not token:
log("Error: GitHub token not provided")
return
# Find existing comment
headers = {
'Authorization': f'token {token}',
'Accept': 'application/vnd.github.v3+json'
}
# Get all comments
comments_url = f'https://api.github.com/repos/{repo}/issues/{pr_number}/comments'
log(f"Getting comments from: {comments_url}")
response = requests.get(comments_url, headers=headers)
if response.status_code != 200:
log(f"Error getting comments: {response.status_code} - {response.text}")
return
comments = response.json()
log(f"Found {len(comments)} existing comments")
# Find comment with our identifier
comment_id = None
for comment in comments:
if '<!-- COVERAGE_REPORT -->' in comment['body']:
comment_id = comment['id']
log(f"Found existing coverage report comment with ID: {comment_id}")
break
if comment_id:
# Update existing comment
update_url = f'https://api.github.com/repos/{repo}/issues/comments/{comment_id}'
log(f"Updating existing comment at: {update_url}")
response = requests.patch(update_url, headers=headers, json={'body': comment_body})
if response.status_code == 200:
log(f"Successfully updated existing comment: {comment_id}")
else:
log(f"Error updating comment: {response.status_code} - {response.text}")
else:
# Create new comment
log(f"Creating new comment at: {comments_url}")
response = requests.post(comments_url, headers=headers, json={'body': comment_body})
if response.status_code == 201:
log("Successfully created new comment")
else:
log(f"Error creating comment: {response.status_code} - {response.text}")
def set_github_output(name, value):
"""
Set GitHub Actions output variable.
Args:
name: Output variable name
value: Output variable value
"""
# Write to the GitHub output file if available
if 'GITHUB_OUTPUT' in os.environ:
with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
f.write(f"{name}={value}\n")
else:
# Fallback to the deprecated method for backward compatibility
log(f"::set-output name={name}::{value}")
# Also print for human readability
log(f"{name}: {value}")
+245
View File
@@ -0,0 +1,245 @@
"""
Utility module.
This module provides utility functions used across the coverage check scripts.
"""
import os
import sys
import re
import shlex
import subprocess
import traceback
from typing import List, Tuple, Dict, Any, Optional, Union
# List of allowed commands and their arguments
ALLOWED_COMMANDS = {
'xvfb-run': ['-a'],
'npm': ['run', 'test:coverage', 'ci', 'install', '--no-save', '@vitest/coverage-v8', 'check-types', 'lint', 'format', 'compile'],
'cd': ['webview-ui'],
'python': ['-m', 'coverage_check'],
'git': ['fetch', 'checkout', 'origin'],
}
def is_safe_command(command: Union[str, List[str]]) -> bool:
"""
Check if a command is safe to execute.
Args:
command: Command to check (string or list)
Returns:
True if command is safe, False otherwise
"""
# Convert string command to list
if isinstance(command, str):
try:
cmd_parts = shlex.split(command)
except ValueError:
return False
else:
cmd_parts = command
if not cmd_parts:
return False
# Get base command
base_cmd = os.path.basename(cmd_parts[0])
# Check if command is in allowed list
if base_cmd not in ALLOWED_COMMANDS:
return False
# For each argument, check for suspicious patterns
for arg in cmd_parts[1:]:
# Check for shell metacharacters
if re.search(r'[;&|`$]', arg):
return False
# Check for path traversal
if '..' in arg and not (base_cmd == 'npm' and arg.startswith('@')):
return False
return True
def log(message: str) -> None:
"""
Write a message to stdout and flush.
Args:
message: The message to write
"""
sys.stdout.write(f"{message}\n")
sys.stdout.flush()
def file_exists(file_path: str) -> bool:
"""
Check if a file exists.
Args:
file_path: Path to the file
Returns:
True if the file exists, False otherwise
"""
return os.path.exists(file_path) and os.path.isfile(file_path)
def get_file_size(file_path: str) -> int:
"""
Get the size of a file in bytes.
Args:
file_path: Path to the file
Returns:
Size of the file in bytes, or 0 if the file doesn't exist
"""
if file_exists(file_path):
return os.path.getsize(file_path)
return 0
def list_directory(dir_path: str) -> List[Tuple[str, Union[int, str]]]:
"""
List the contents of a directory.
Args:
dir_path: Path to the directory
Returns:
List of (name, size) tuples for each file/directory in the directory
"""
if not os.path.exists(dir_path) or not os.path.isdir(dir_path):
return []
contents = []
for item in os.listdir(dir_path):
item_path = os.path.join(dir_path, item)
if os.path.isfile(item_path):
contents.append((item, os.path.getsize(item_path)))
else:
contents.append((item, "DIR"))
return contents
def read_file_content(file_path: str, default: str = "") -> str:
"""
Read file content with error handling.
Args:
file_path: Path to the file
default: Default value to return if file cannot be read
Returns:
File content or default value
"""
if not file_exists(file_path):
log(f"File does not exist: {file_path}")
return default
try:
with open(file_path, 'r') as f:
return f.read()
except Exception as e:
log(f"Error reading file {file_path}: {e}")
return default
def write_file_content(file_path: str, content: str) -> bool:
"""
Write content to file with error handling.
Args:
file_path: Path to the file
content: Content to write
Returns:
True if successful, False otherwise
"""
try:
# Create directory if it doesn't exist
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, 'w') as f:
f.write(content)
return True
except Exception as e:
log(f"Error writing to file {file_path}: {e}")
return False
def run_command(command: Union[str, List[str]], capture_output: bool = True) -> Tuple[int, str, str]:
"""
Run a command and return the result.
Args:
command: Command to run (string or list)
capture_output: Whether to capture stdout/stderr
Returns:
Tuple of (returncode, stdout, stderr)
"""
if not is_safe_command(command):
error_msg = f"Unsafe command detected: {command}"
log(error_msg)
return 1, "", error_msg
log(f"Running command: {command}")
try:
# Convert string command to list
if isinstance(command, str):
cmd_list = shlex.split(command)
else:
cmd_list = command
result = subprocess.run(
cmd_list,
shell=False, # Never use shell=True for security
capture_output=capture_output,
text=True
)
log(f"Command exit code: {result.returncode}")
return result.returncode, result.stdout, result.stderr
except Exception as e:
log(f"Error running command: {e}")
log(traceback.format_exc())
return 1, "", str(e)
def find_pattern(content: str, pattern: str, group: int = 0,
default: Optional[str] = None) -> Optional[str]:
"""
Find a pattern in content and return the specified group.
Args:
content: Text content to search
pattern: Regex pattern to search for
group: Group number to return (default: 0 for entire match)
default: Default value to return if pattern not found
Returns:
Matched text or default value
"""
match = re.search(pattern, content, re.DOTALL)
if match:
return match.group(group)
return default
def get_env_var(name: str, default: Optional[str] = None) -> Optional[str]:
"""
Get environment variable with default value.
Args:
name: Environment variable name
default: Default value if not set
Returns:
Environment variable value or default
"""
return os.environ.get(name, default)
def format_exception(e: Exception) -> str:
"""
Format an exception with traceback for logging.
Args:
e: Exception to format
Returns:
Formatted exception string
"""
return f"{type(e).__name__}: {str(e)}\n{traceback.format_exc()}"
+432
View File
@@ -0,0 +1,432 @@
"""
Workflow module.
This module handles the main workflow logic for running coverage tests and processing results.
"""
import os
import re
import sys
import subprocess
import traceback
from .extraction import run_coverage, compare_coverage, extract_coverage
from .github_api import generate_comment, post_comment, set_github_output
from .util import log, file_exists, get_file_size, list_directory, run_command
def is_valid_branch_name(branch_name: str) -> bool:
"""
Validate a git branch name.
Args:
branch_name: Branch name to validate
Returns:
True if valid, False otherwise
"""
# Check for common branch name patterns
if not re.match(r'^[a-zA-Z0-9_\-./]+$', branch_name):
return False
# Check for path traversal
if '..' in branch_name:
return False
# Check for shell metacharacters
if re.search(r'[;&|`$]', branch_name):
return False
return True
def checkout_branch(branch_name: str) -> None:
"""
Checkout a branch for testing.
Args:
branch_name: Branch name to checkout
Raises:
RuntimeError: If branch checkout fails
ValueError: If branch name is invalid
"""
if not is_valid_branch_name(branch_name):
raise ValueError(f"Invalid branch name: {branch_name}")
log(f"=== Checking out branch: {branch_name} ===")
# Fetch the branch
returncode, stdout, stderr = run_command(['git', 'fetch', 'origin', branch_name])
if returncode != 0:
log(f"ERROR: Failed to fetch branch {branch_name}")
log(f"Error details: {stderr}")
raise RuntimeError(f"Git fetch failed: {stderr}")
# Checkout the branch
returncode, stdout, stderr = run_command(['git', 'checkout', branch_name])
if returncode != 0:
log(f"ERROR: Failed to checkout branch {branch_name}")
log(f"Error details: {stderr}")
raise RuntimeError(f"Git checkout failed: {stderr}")
log(f"Successfully checked out branch: {branch_name}")
def extract_extension_coverage_from_file(file_path):
"""Extract extension coverage from file when run_coverage returns 0."""
if not file_exists(file_path):
log(f"File {file_path} does not exist, cannot extract extension coverage")
return 0.0
file_size = get_file_size(file_path)
if file_size == 0:
log(f"File {file_path} is empty, cannot extract extension coverage")
return 0.0
log(f"Extension coverage is 0.0, trying to read from file directly: {file_path} (size: {file_size} bytes)")
with open(file_path, 'r') as f:
content = f.read()
# Extract the percentage from the "Lines" row in the coverage summary
# Pattern: Lines : xx.xx% ( xxxxxxx/xxxxxxx )
lines_match = re.search(r'Lines\s*:\s*(\d+\.\d+)%', content)
if lines_match:
coverage = float(lines_match.group(1))
log(f"Found extension coverage in file: {coverage}%")
return coverage
return 0.0
def extract_webview_coverage_from_file(file_path):
"""Extract webview coverage from file when run_coverage returns 0."""
if not file_exists(file_path):
log(f"File {file_path} does not exist, cannot extract webview coverage")
return 0.0
file_size = get_file_size(file_path)
if file_size == 0:
log(f"File {file_path} is empty, cannot extract webview coverage")
return 0.0
log(f"Webview coverage is 0.0, trying to read from file directly: {file_path} (size: {file_size} bytes)")
with open(file_path, 'r') as f:
content = f.read()
# Extract the percentage from the "% Lines" column in the "All files" row
# Pattern: All files | xx.xx | xx.xx | xx.xx | xx.xx |
all_files_match = re.search(r'All files\s+\|\s+\d+\.\d+\s+\|\s+\d+\.\d+\s+\|\s+\d+\.\d+\s+\|\s+(\d+\.\d+)', content)
if all_files_match:
coverage = float(all_files_match.group(1))
log(f"Found webview coverage in file: {coverage}%")
return coverage
return 0.0
def run_extension_coverage(branch_name=None):
"""Run extension coverage tests and extract results."""
prefix = 'base_' if branch_name else ''
file_path = f"{prefix}extension_coverage.txt"
# Run coverage tests
ext_cov = run_coverage(
["xvfb-run", "-a", "npm", "run", "test:coverage"],
file_path,
"extension"
)
# If coverage is 0.0, try to extract from file directly
if ext_cov == 0.0:
ext_cov = extract_extension_coverage_from_file(file_path)
return ext_cov
def run_webview_coverage(branch_name=None):
"""Run webview coverage tests and extract results."""
prefix = 'base_' if branch_name else ''
file_path = f"{prefix}webview_coverage.txt"
# Save current directory
original_dir = os.getcwd()
try:
# Change to webview-ui directory
os.chdir('webview-ui')
# Install coverage dependency
returncode, stdout, stderr = run_command(["npm", "install", "--no-save", "@vitest/coverage-v8"])
if returncode != 0:
log(f"Failed to install coverage dependency: {stderr}")
return 0.0
# Run coverage tests from webview-ui directory
web_cov = run_coverage(
["npm", "run", "test:coverage"],
os.path.join('..', file_path),
"webview"
)
finally:
# Always change back to original directory
os.chdir(original_dir)
# If coverage is 0.0, try to extract from file directly
if web_cov == 0.0:
web_cov = extract_webview_coverage_from_file(file_path)
return web_cov
def run_branch_coverage(branch_name=None):
"""
Run coverage tests for a branch.
Args:
branch_name: Name of the branch to checkout before running tests (optional)
Returns:
Tuple of (extension_coverage, webview_coverage)
"""
# Checkout branch if specified
if branch_name:
checkout_branch(branch_name)
# Run coverage tests
log(f"=== Running coverage tests{' for ' + branch_name if branch_name else ''} ===")
# Run extension and webview coverage
ext_cov = run_extension_coverage(branch_name)
web_cov = run_webview_coverage(branch_name)
return ext_cov, web_cov
def find_potential_coverage_files():
"""Find potential coverage files in the current directory and webview-ui."""
log("Searching for potential coverage files...")
# Find files in current directory
current_dir_files = list_directory('.')
for name, size in current_dir_files:
if 'coverage' in name.lower() and size != "DIR":
log(f"Found potential coverage file: {name} (size: {size} bytes)")
# Find files in webview-ui directory
if os.path.exists('webview-ui') and os.path.isdir('webview-ui'):
webview_files = list_directory('webview-ui')
for name, size in webview_files:
if 'coverage' in name.lower() and size != "DIR":
log(f"Found potential webview coverage file: webview-ui/{name} (size: {size} bytes)")
else:
log("webview-ui directory not found")
def generate_warnings(base_ext_cov, pr_ext_cov, ext_decreased, ext_diff,
base_web_cov, pr_web_cov, web_decreased, web_diff):
"""Generate warnings for coverage decreases."""
if not (ext_decreased or web_decreased):
return []
warnings = [
"Test coverage has decreased in this PR",
f"Extension coverage: {base_ext_cov}% -> {pr_ext_cov}% (Diff: {ext_diff}%)",
f"Webview coverage: {base_web_cov}% -> {pr_web_cov}% (Diff: {web_diff}%)"
]
# Additional warning for significant decrease (more than 1%)
if ext_decreased and ext_diff > 1.0:
warnings.append(f"Extension coverage decreased by more than 1% ({ext_diff}%). Consider adding tests to cover your changes.")
if web_decreased and web_diff > 1.0:
warnings.append(f"Webview coverage decreased by more than 1% ({web_diff}%). Consider adding tests to cover your changes.")
return warnings
def output_warnings(warnings):
"""Output warnings to GitHub step summary and console."""
if not warnings:
return
# Get the GitHub step summary file path from environment variable
github_step_summary = os.environ.get('GITHUB_STEP_SUMMARY')
# Write to GitHub step summary if available
if github_step_summary:
with open(github_step_summary, 'a') as f:
f.write("## Coverage Warnings\n\n")
for warning in warnings:
f.write(f"⚠️ {warning}\n\n")
# Also output to console with ::warning:: syntax for backward compatibility
for warning in warnings:
log(f"::warning::{warning}")
def output_github_results(pr_ext_cov, pr_web_cov, base_ext_cov, base_web_cov,
ext_decreased, ext_diff, web_decreased, web_diff):
"""Output results for GitHub Actions."""
set_github_output("pr_extension_coverage", pr_ext_cov)
set_github_output("pr_webview_coverage", pr_web_cov)
set_github_output("base_extension_coverage", base_ext_cov)
set_github_output("base_webview_coverage", base_web_cov)
set_github_output("extension_decreased", str(ext_decreased).lower())
set_github_output("extension_diff", ext_diff)
set_github_output("webview_decreased", str(web_decreased).lower())
set_github_output("webview_diff", web_diff)
def extract_pr_coverage_from_artifacts():
"""
Extract PR branch coverage from artifact files.
Returns:
Tuple of (extension_coverage, webview_coverage)
Raises:
SystemExit: If the coverage files don't exist
"""
log("=== Extracting PR branch coverage from artifacts ===")
# Check if the coverage files exist
ext_file_path = "extension_coverage.txt"
web_file_path = "webview-ui/webview_coverage.txt"
# Extract extension coverage
log(f"Extracting extension coverage from {ext_file_path}")
if not file_exists(ext_file_path):
error_msg = f"ERROR: PR extension coverage file {ext_file_path} not found"
log(error_msg)
# List directory contents for debugging
log("Current directory contents:")
try:
dir_contents = list_directory('.')
for name, size in dir_contents:
log(f" {name} - {size}\n")
except Exception as e:
log(f"Error listing directory: {e}")
sys.exit(1) # Exit with error code to fail the workflow
ext_cov = extract_extension_coverage_from_file(ext_file_path)
log(f"PR extension coverage from artifact: {ext_cov}%")
# Extract webview coverage
log(f"Extracting webview coverage from {web_file_path}")
if not file_exists(web_file_path):
error_msg = f"ERROR: PR webview coverage file {web_file_path} not found"
log(error_msg)
# Check if the webview-ui directory exists
if not os.path.exists('webview-ui'):
log("ERROR: webview-ui directory not found")
else:
# List webview-ui directory contents for debugging
log("webview-ui directory contents:")
try:
dir_contents = list_directory('webview-ui')
for name, size in dir_contents:
log(f" {name} - {size}")
except Exception as e:
log(f"Error listing directory: {e}")
sys.exit(1) # Exit with error code to fail the workflow
web_cov = extract_webview_coverage_from_file(web_file_path)
log(f"PR webview coverage from artifact: {web_cov}%")
return ext_cov, web_cov
def process_coverage_workflow(args):
"""
Process the entire coverage workflow.
Args:
args: Command line arguments
"""
# Initialize all variables at the start
pr_ext_cov = 0.0
pr_web_cov = 0.0
base_ext_cov = 0.0
base_web_cov = 0.0
ext_decreased = False
ext_diff = 0.0
web_decreased = False
web_diff = 0.0
try:
# Validate branch name
if not is_valid_branch_name(args.base_branch):
raise ValueError(f"Invalid base branch name: {args.base_branch}")
# Check if we're running in GitHub Actions
is_github_actions = 'GITHUB_ACTIONS' in os.environ
if is_github_actions:
log("Running in GitHub Actions environment")
# Extract PR branch coverage from artifacts (from test job)
pr_ext_cov, pr_web_cov = extract_pr_coverage_from_artifacts()
# Verify PR coverage values
if pr_ext_cov == 0.0:
log("WARNING: PR extension coverage is 0.0, this may indicate an issue with the coverage report")
find_potential_coverage_files()
if pr_web_cov == 0.0:
log("WARNING: PR webview coverage is 0.0, this may indicate an issue with the coverage report")
find_potential_coverage_files()
# Run base branch coverage
log(f"=== Running base branch coverage for {args.base_branch} ===")
base_ext_cov, base_web_cov = run_branch_coverage(args.base_branch)
# Verify base coverage values
if base_ext_cov == 0.0:
log("WARNING: Base extension coverage is 0.0, this may indicate an issue with the coverage report")
if base_web_cov == 0.0:
log("WARNING: Base webview coverage is 0.0, this may indicate an issue with the coverage report")
# Compare coverage
log("=== Comparing extension coverage ===")
ext_decreased, ext_diff = compare_coverage(base_ext_cov, pr_ext_cov)
log("=== Comparing webview coverage ===")
web_decreased, web_diff = compare_coverage(base_web_cov, pr_web_cov)
# Print summary of coverage values
log("\n=== Coverage Summary ===")
log(f"PR extension coverage: {pr_ext_cov}%")
log(f"Base extension coverage: {base_ext_cov}%")
log(f"Extension coverage change: {'+' if not ext_decreased else '-'}{ext_diff}%")
log(f"PR webview coverage: {pr_web_cov}%")
log(f"Base webview coverage: {base_web_cov}%")
log(f"Webview coverage change: {'+' if not web_decreased else '-'}{web_diff}%")
# Generate and output warnings
warnings = generate_warnings(
base_ext_cov, pr_ext_cov, ext_decreased, ext_diff,
base_web_cov, pr_web_cov, web_decreased, web_diff
)
output_warnings(warnings)
# Generate comment
log("=== Generating comment ===")
comment = generate_comment(
base_ext_cov, pr_ext_cov, str(ext_decreased).lower(), ext_diff,
base_web_cov, pr_web_cov, str(web_decreased).lower(), web_diff
)
# Save comment to file
with open("coverage_comment.md", "w") as f:
f.write(comment)
# Post comment if PR number is provided
if args.pr_number:
log(f"=== Posting comment to PR #{args.pr_number} ===")
post_comment("coverage_comment.md", args.pr_number, args.repo, args.token)
# Output results for GitHub Actions
output_github_results(
pr_ext_cov, pr_web_cov, base_ext_cov, base_web_cov,
ext_decreased, ext_diff, web_decreased, web_diff
)
except Exception as e:
log(f"ERROR in process_coverage_workflow: {e}")
traceback.print_exc()
# Try to output results even if there was an error
try:
output_github_results(
pr_ext_cov, pr_web_cov, base_ext_cov, base_web_cov,
ext_decreased, ext_diff, web_decreased, web_diff
)
except Exception as e2:
log(f"ERROR outputting GitHub results: {e2}")
@@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""
Tests for coverage_check script.
"""
import os
import sys
import unittest
import subprocess
import tempfile
from unittest.mock import patch, MagicMock, call, mock_open
# Add parent directory to path so we can import coverage modules
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from coverage_check import extract_coverage, compare_coverage, set_verbose, generate_comment, post_comment, set_github_output
from coverage_check.util import log, file_exists, get_file_size, list_directory
class TestCoverage(unittest.TestCase):
# Class variables to store coverage files
temp_dir = None
extension_coverage_file = None
webview_coverage_file = None
@classmethod
def setUpClass(cls):
"""Set up test environment once for all tests."""
# Create temporary directory for test files
cls.temp_dir = tempfile.TemporaryDirectory()
cls.extension_coverage_file = os.path.join(cls.temp_dir.name, 'extension_coverage.txt')
cls.webview_coverage_file = os.path.join(cls.temp_dir.name, 'webview_coverage.txt')
# Run actual tests to generate coverage reports
cls.generate_coverage_reports()
# Verify files exist and are not empty
assert os.path.exists(cls.extension_coverage_file), \
f"Extension coverage file {cls.extension_coverage_file} does not exist"
assert os.path.getsize(cls.extension_coverage_file) > 0, \
f"Extension coverage file {cls.extension_coverage_file} is empty"
assert os.path.exists(cls.webview_coverage_file), \
f"Webview coverage file {cls.webview_coverage_file} does not exist"
assert os.path.getsize(cls.webview_coverage_file) > 0, \
f"Webview coverage file {cls.webview_coverage_file} is empty"
@classmethod
def tearDownClass(cls):
"""Clean up test environment after all tests."""
if cls.temp_dir:
cls.temp_dir.cleanup()
@classmethod
def generate_coverage_reports(cls):
"""Generate real coverage reports by running tests."""
log("Generating coverage reports (this may take a while)...")
# Run extension tests with coverage
try:
# Get absolute paths
root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..'))
webview_dir = os.path.join(root_dir, 'webview-ui')
# Use xvfb-run on Linux
if sys.platform.startswith('linux'):
cmd = f"cd {root_dir} && xvfb-run -a npm run test:coverage > {cls.extension_coverage_file} 2>&1"
else:
cmd = f"cd {root_dir} && npm run test:coverage > {cls.extension_coverage_file} 2>&1"
log("Running extension tests...")
log(f"Command: {cmd}")
result = subprocess.run(cmd, shell=True, check=False, capture_output=True, text=True)
log(f"Extension tests exit code: {result.returncode}")
# Run webview tests with coverage
log("Running webview tests...")
cmd = f"cd {webview_dir} && npm run test:coverage > {cls.webview_coverage_file} 2>&1"
log(f"Command: {cmd}")
result = subprocess.run(cmd, shell=True, check=False, capture_output=True, text=True)
log(f"Webview tests exit code: {result.returncode}")
# Verify files were created
if file_exists(cls.extension_coverage_file):
ext_size = get_file_size(cls.extension_coverage_file)
log(f"Extension coverage file created: {cls.extension_coverage_file} (size: {ext_size} bytes)")
else:
log(f"WARNING: Extension coverage file was not created: {cls.extension_coverage_file}")
if file_exists(cls.webview_coverage_file):
web_size = get_file_size(cls.webview_coverage_file)
log(f"Webview coverage file created: {cls.webview_coverage_file} (size: {web_size} bytes)")
else:
log(f"WARNING: Webview coverage file was not created: {cls.webview_coverage_file}")
log("Coverage reports generation completed.")
except Exception as e:
log(f"Error generating coverage reports: {e}")
import traceback
log(traceback.format_exc())
# Create empty files if tests fail
log("Creating fallback coverage files...")
with open(cls.extension_coverage_file, 'w') as f:
f.write("No coverage data available")
with open(cls.webview_coverage_file, 'w') as f:
f.write("No coverage data available")
def test_extract_coverage(self):
"""Test extract_coverage function with both extension and webview coverage."""
# Check if verbose mode is enabled
if '-v' in sys.argv or '--verbose' in sys.argv:
set_verbose(True)
# Verify files exist before testing
self.assertTrue(file_exists(self.extension_coverage_file),
f"Extension coverage file does not exist: {self.extension_coverage_file}")
self.assertTrue(file_exists(self.webview_coverage_file),
f"Webview coverage file does not exist: {self.webview_coverage_file}")
# Log file sizes
ext_size = get_file_size(self.extension_coverage_file)
web_size = get_file_size(self.webview_coverage_file)
log(f"Extension coverage file size: {ext_size} bytes")
log(f"Webview coverage file size: {web_size} bytes")
# Test extension coverage
log("Testing extension coverage extraction...")
ext_coverage_pct = extract_coverage(self.extension_coverage_file, 'extension')
# Check that coverage percentage is a float
self.assertIsInstance(ext_coverage_pct, float)
# Check that coverage percentage is between 0 and 100
self.assertGreaterEqual(ext_coverage_pct, 0)
self.assertLessEqual(ext_coverage_pct, 100)
# Log coverage percentage for debugging
log(f"Extension coverage: {ext_coverage_pct}%")
# Test webview coverage
log("Testing webview coverage extraction...")
web_coverage_pct = extract_coverage(self.webview_coverage_file, 'webview')
# Convert to float if it's an integer
if isinstance(web_coverage_pct, int):
web_coverage_pct = float(web_coverage_pct)
# Check that coverage percentage is a float
self.assertIsInstance(web_coverage_pct, float)
# Check that coverage percentage is between 0 and 100
self.assertGreaterEqual(web_coverage_pct, 0)
self.assertLessEqual(web_coverage_pct, 100)
# Log coverage percentage for debugging
log(f"Webview coverage: {web_coverage_pct}%")
def test_compare_coverage(self):
"""Test compare_coverage function."""
# Test with coverage increase
decreased, diff = compare_coverage(80, 90)
self.assertFalse(decreased)
self.assertEqual(diff, 10)
# Test with coverage decrease
decreased, diff = compare_coverage(90, 80)
self.assertTrue(decreased)
self.assertEqual(diff, 10)
# Test with no change
decreased, diff = compare_coverage(80, 80)
self.assertFalse(decreased)
self.assertEqual(diff, 0)
def test_generate_comment(self):
"""Test generate_comment function."""
comment = generate_comment(
80, 90, 'false', 10,
70, 75, 'false', 5
)
# Check that comment contains expected sections
self.assertIn('Coverage Report', comment)
self.assertIn('Extension Coverage', comment)
self.assertIn('Webview Coverage', comment)
self.assertIn('Overall Assessment', comment)
# Check that comment contains coverage percentages
self.assertIn('Base branch: 80%', comment)
self.assertIn('PR branch: 90%', comment)
self.assertIn('Base branch: 70%', comment)
self.assertIn('PR branch: 75%', comment)
# Check that comment contains correct assessment
self.assertIn('Coverage increased or remained the same', comment)
self.assertIn('Test coverage has been maintained or improved', comment)
@patch('coverage_check.requests.get')
@patch('coverage_check.requests.post')
@patch('coverage_check.requests.patch')
def test_post_comment_new(self, mock_patch, mock_post, mock_get):
"""Test post_comment function when creating a new comment."""
# Create a temporary comment file
comment_file = os.path.join(self.temp_dir.name, 'comment.md')
with open(comment_file, 'w') as f:
f.write('<!-- COVERAGE_REPORT -->\nTest comment')
# Mock the API responses
mock_get.return_value = MagicMock(status_code=200, json=lambda: [])
mock_post.return_value = MagicMock(status_code=201)
# Test post_comment function
post_comment(comment_file, '123', 'owner/repo', 'token')
# Check that the correct API calls were made
mock_get.assert_called_once()
mock_post.assert_called_once()
mock_patch.assert_not_called()
@patch('coverage_check.requests.get')
@patch('coverage_check.requests.post')
@patch('coverage_check.requests.patch')
def test_post_comment_update(self, mock_patch, mock_post, mock_get):
"""Test post_comment function when updating an existing comment."""
# Create a temporary comment file
comment_file = os.path.join(self.temp_dir.name, 'comment.md')
with open(comment_file, 'w') as f:
f.write('<!-- COVERAGE_REPORT -->\nTest comment')
# Mock the API responses
mock_get.return_value = MagicMock(
status_code=200,
json=lambda: [{'id': 456, 'body': '<!-- COVERAGE_REPORT -->\nOld comment'}]
)
mock_patch.return_value = MagicMock(status_code=200)
# Test post_comment function
post_comment(comment_file, '123', 'owner/repo', 'token')
# Check that the correct API calls were made
mock_get.assert_called_once()
mock_patch.assert_called_once()
mock_post.assert_not_called()
def test_set_github_output(self):
"""Test set_github_output function."""
# Capture stdout
with patch('sys.stdout', new=MagicMock()) as mock_stdout:
# Mock environment without GITHUB_OUTPUT
with patch.dict('os.environ', {}, clear=True):
set_github_output('test_name', 'test_value')
# Check that the correct output was printed to stdout
mock_stdout.assert_has_calls([
# GitHub Actions output format (deprecated method)
call.write('::set-output name=test_name::test_value\n'),
call.flush(),
# Human readable format
call.write('test_name: test_value\n'),
call.flush()
], any_order=False)
# Reset mock for next test
mock_stdout.reset_mock()
# Test with GITHUB_OUTPUT environment variable
with patch.dict('os.environ', {'GITHUB_OUTPUT': '/tmp/github_output'}), \
patch('builtins.open', mock_open()) as mock_file:
set_github_output('test_name', 'test_value')
# Check that file was written to
mock_file.assert_called_once_with('/tmp/github_output', 'a')
mock_file().write.assert_called_once_with('test_name=test_value\n')
# Check that human readable output was printed
mock_stdout.assert_has_calls([
call.write('test_name: test_value\n'),
call.flush()
], any_order=False)
if __name__ == '__main__':
unittest.main()
+435
View File
@@ -0,0 +1,435 @@
name: cli-publish
on:
schedule:
- cron: "0 12 * * *"
workflow_dispatch:
inputs:
publish_target:
description: "Which publish flow to run"
required: true
default: "main"
type: choice
options:
- main
- nightly
git_tag:
description: "Existing release tag to publish when publish_target=main, for example cli-v0.1.0"
required: false
type: string
confirm_publish:
description: 'Required when publish_target=main. Type "publish" to confirm release publish.'
required: false
type: string
force_nightly_publish:
description: "Force nightly publish even with no commits in last 24h"
required: false
type: boolean
default: false
permissions:
contents: read
id-token: write
defaults:
run:
working-directory: .
jobs:
publish-main:
name: Publish cline
permissions:
contents: write
id-token: write
if: |
github.repository == 'cline/cline' &&
github.ref == 'refs/heads/main' &&
github.event_name == 'workflow_dispatch' &&
github.event.inputs.publish_target == 'main' &&
github.event.inputs.confirm_publish == 'publish' &&
!endsWith(github.actor, '[bot]')
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.git_tag }}
fetch-depth: 0
fetch-tags: true
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24.x"
registry-url: "https://registry.npmjs.org"
- name: Verify publish tooling
run: |
NPM_VERSION=$(npm --version)
echo "npm ${NPM_VERSION}"
IFS=. read -r major minor patch <<EOF
${NPM_VERSION}
EOF
if [ "$major" -lt 11 ] || { [ "$major" -eq 11 ] && [ "$minor" -lt 5 ]; } || { [ "$major" -eq 11 ] && [ "$minor" -eq 5 ] && [ "$patch" -lt 1 ]; }; then
echo "npm 11.5.1 or newer is required for trusted publishing"
exit 1
fi
- name: Install dependencies
run: bun install
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Validate release tag
id: version
env:
TAG: ${{ github.event.inputs.git_tag }}
run: |
if [ -z "$TAG" ]; then
echo "git_tag is required when publish_target=main"
exit 1
fi
if ! printf "%s\n" "$TAG" | grep -Eq '^cli-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "git_tag must look like cli-vX.Y.Z, got: ${TAG}"
exit 1
fi
VERSION="${TAG#cli-v}"
PACKAGE_VERSION=$(node -p "require('./apps/cli/package.json').version")
if [ "$PACKAGE_VERSION" != "$VERSION" ]; then
echo "apps/cli/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
exit 1
fi
if ! printf "%s\n" "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
echo "apps/cli/package.json has invalid version: ${VERSION}"
exit 1
fi
TAG_COMMIT=$(git rev-parse "${TAG}^{commit}")
HEAD_COMMIT=$(git rev-parse HEAD)
if [ "$TAG_COMMIT" != "$HEAD_COMMIT" ]; then
echo "${TAG} does not point at the checked out commit"
exit 1
fi
git fetch origin +main:refs/remotes/origin/main
if ! git merge-base --is-ancestor "$HEAD_COMMIT" origin/main; then
echo "${TAG} is not reachable from origin/main"
exit 1
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
- name: Build SDK packages
run: bun run build:sdk
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
- name: Run tests
run: bun run test
- name: Build platform binaries
run: bun script/build.ts --install-native-variants --skip-sdk-build
working-directory: apps/cli
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
- name: Verify build output
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
EXPECTED=(
"@cline/cli-darwin-arm64"
"@cline/cli-darwin-x64"
"@cline/cli-linux-arm64"
"@cline/cli-linux-x64"
"@cline/cli-windows-arm64"
"@cline/cli-windows-x64"
)
for package_name in "${EXPECTED[@]}"; do
dir="apps/cli/dist/${package_name#@cline/}"
if [ ! -f "$dir/package.json" ]; then
echo "Missing package manifest: $dir/package.json"
exit 1
fi
actual_name=$(node -p "require('./$dir/package.json').name")
actual_version=$(node -p "require('./$dir/package.json').version")
if [ "$actual_name" != "$package_name" ]; then
echo "Expected $package_name, got $actual_name"
exit 1
fi
if [ "$actual_version" != "$VERSION" ]; then
echo "Expected $package_name@$VERSION, got $actual_version"
exit 1
fi
ls -lh "$dir/bin/"
done
- name: Publish to NPM with latest tag
env:
NPM_CONFIG_PROVENANCE: "true"
run: bun script/publish-npm.ts --tag latest
working-directory: apps/cli
- name: Get Previous CLI Tag
id: prev_tag
env:
CURRENT_TAG: ${{ steps.version.outputs.tag }}
run: |
PREV_TAG=$(git describe --tags --abbrev=0 --match 'cli-v*' "$CURRENT_TAG^" 2>/dev/null || echo "")
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Get Changelog Entry
id: changelog
run: |
# Grab content between the first "## " header and the next one in apps/cli/CHANGELOG.md
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' apps/cli/CHANGELOG.md)
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ steps.version.outputs.tag }}
name: "CLI v${{ steps.version.outputs.version }}"
body: |
${{ steps.changelog.outputs.content }}
${{ steps.prev_tag.outputs.prev_tag != '' && format('**Full Changelog**: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.tag) || '' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Summary
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
echo "Published cline@${VERSION} to npm with dist-tag 'latest'"
echo "Install with: npm install -g cline"
- name: Post release to Slack
uses: slackapi/slack-github-action@v3.0.1
with:
method: chat.postMessage
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
payload: |
channel: "C0APVKGGZFC"
text: "Cline CLI v${{ steps.version.outputs.version }}"
blocks:
- type: "section"
text:
type: "mrkdwn"
text: "Cline CLI v${{ steps.version.outputs.version }}"
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.content) }}
- type: "context"
elements:
- type: "mrkdwn"
text: "<https://www.npmjs.com/package/cline/v/${{ steps.version.outputs.version }}|View on npm>${{ steps.prev_tag.outputs.prev_tag != '' && format(' | Full Changelog: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.tag) || '' }}"
publish-nightly:
name: Publish cline nightly
permissions:
contents: read
id-token: write
if: |
github.repository == 'cline/cline' &&
github.ref == 'refs/heads/main' &&
(
github.event_name == 'schedule' ||
(
github.event_name == 'workflow_dispatch' &&
github.event.inputs.publish_target == 'nightly'
)
)
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check for recent commits
id: check_commits
env:
FORCE_PUBLISH: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force_nightly_publish == 'true' }}
run: |
if [ "$FORCE_PUBLISH" = "true" ]; then
echo "force_nightly_publish enabled, proceeding with publish"
echo "skip=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$(git rev-list --count HEAD --since='24 hours ago')" -eq 0 ]; then
echo "No commits in last 24 hours, skipping publish"
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "Found recent commits, proceeding with publish"
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Setup Bun
if: steps.check_commits.outputs.skip != 'true'
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Setup Node.js
if: steps.check_commits.outputs.skip != 'true'
uses: actions/setup-node@v4
with:
node-version: "24.x"
registry-url: "https://registry.npmjs.org"
- name: Verify publish tooling
if: steps.check_commits.outputs.skip != 'true'
run: |
NPM_VERSION=$(npm --version)
echo "npm ${NPM_VERSION}"
IFS=. read -r major minor patch <<EOF
${NPM_VERSION}
EOF
if [ "$major" -lt 11 ] || { [ "$major" -eq 11 ] && [ "$minor" -lt 5 ]; } || { [ "$major" -eq 11 ] && [ "$minor" -eq 5 ] && [ "$patch" -lt 1 ]; }; then
echo "npm 11.5.1 or newer is required for trusted publishing"
exit 1
fi
- name: Install dependencies
if: steps.check_commits.outputs.skip != 'true'
run: bun install
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build SDK packages
if: steps.check_commits.outputs.skip != 'true'
run: bun run build:sdk
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
- name: Run tests
if: steps.check_commits.outputs.skip != 'true'
run: bun run test
- name: Generate nightly version
if: steps.check_commits.outputs.skip != 'true'
id: version
run: |
BASE_VERSION=$(node -p "require('./apps/cli/package.json').version")
TIMESTAMP=$(date +%s)
VERSION="${BASE_VERSION}-nightly.${TIMESTAMP}"
echo "Base version: ${BASE_VERSION}"
echo "Generated nightly version: ${VERSION}"
echo "base_version=${BASE_VERSION}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
- name: Update nightly package version
if: steps.check_commits.outputs.skip != 'true'
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
node -e '
const fs = require("node:fs");
const path = "apps/cli/package.json";
const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
pkg.version = process.env.VERSION;
fs.writeFileSync(path, `${JSON.stringify(pkg, null, "\t")}\n`);
'
cat apps/cli/package.json | grep '"version"'
- name: Build platform binaries
if: steps.check_commits.outputs.skip != 'true'
run: bun script/build.ts --install-native-variants --skip-sdk-build
working-directory: apps/cli
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
- name: Verify build output
if: steps.check_commits.outputs.skip != 'true'
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
EXPECTED=(
"@cline/cli-darwin-arm64"
"@cline/cli-darwin-x64"
"@cline/cli-linux-arm64"
"@cline/cli-linux-x64"
"@cline/cli-windows-arm64"
"@cline/cli-windows-x64"
)
for package_name in "${EXPECTED[@]}"; do
dir="apps/cli/dist/${package_name#@cline/}"
if [ ! -f "$dir/package.json" ]; then
echo "Missing package manifest: $dir/package.json"
exit 1
fi
actual_name=$(node -p "require('./$dir/package.json').name")
actual_version=$(node -p "require('./$dir/package.json').version")
if [ "$actual_name" != "$package_name" ]; then
echo "Expected $package_name, got $actual_name"
exit 1
fi
if [ "$actual_version" != "$VERSION" ]; then
echo "Expected $package_name@$VERSION, got $actual_version"
exit 1
fi
ls -lh "$dir/bin/"
done
- name: Publish to NPM with nightly tag
if: steps.check_commits.outputs.skip != 'true'
env:
NPM_CONFIG_PROVENANCE: "true"
run: bun script/publish-npm.ts --tag nightly
working-directory: apps/cli
- name: Summary
if: steps.check_commits.outputs.skip != 'true'
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
echo "Published cline@${VERSION} to npm with dist-tag 'nightly'"
echo "Install with: npm install -g cline@nightly"
@@ -0,0 +1,100 @@
name: ext-jb-test-integration
on:
pull_request_target:
types: [opened, reopened]
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: read
concurrency:
group: jetbrains-trigger-${{ github.event.pull_request.number || github.event.issue.number }}
cancel-in-progress: true
jobs:
trigger-integration-test:
name: Run Tests
runs-on: ubuntu-latest
# Auto-run only for trusted PR authors. Anyone else needs a maintainer
# to opt their PR in by commenting /test-jetbrains.
if: |
(github.event_name == 'pull_request_target' &&
contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.pull_request.author_association)) ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '/test-jetbrains') &&
contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.comment.author_association))
steps:
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ vars.CLINE_JETBRAINS_APP_ID }}
private-key: ${{ secrets.CLINE_JETBRAINS_APP_KEY }}
owner: cline
repositories: intellij-plugin
- name: Get PR details (for issue_comment trigger)
id: pr-details
if: github.event_name == 'issue_comment'
env:
GH_TOKEN: ${{ github.token }}
run: |
PR_DATA=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.issue.number }})
echo "head_ref=$(echo "$PR_DATA" | jq -r '.head.ref')" >> $GITHUB_OUTPUT
echo "head_sha=$(echo "$PR_DATA" | jq -r '.head.sha')" >> $GITHUB_OUTPUT
echo "title=$(echo "$PR_DATA" | jq -r '.title')" >> $GITHUB_OUTPUT
echo "html_url=$(echo "$PR_DATA" | jq -r '.html_url')" >> $GITHUB_OUTPUT
- name: Sanitize untrusted inputs
id: sanitize
env:
RAW_BRANCH_NAME: ${{ github.event_name == 'pull_request_target' && github.head_ref || steps.pr-details.outputs.head_ref }}
RAW_PR_TITLE: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.title || steps.pr-details.outputs.title }}
run: |
# Sanitize branch name for JSON
BRANCH_NAME_JSON=$(jq -n --arg b "$RAW_BRANCH_NAME" '$b')
echo "branch_name=$BRANCH_NAME_JSON" >> $GITHUB_OUTPUT
# Sanitize PR title for JSON
PR_TITLE_JSON=$(jq -n --arg t "$RAW_PR_TITLE" '$t')
echo "pr_title=$PR_TITLE_JSON" >> $GITHUB_OUTPUT
- name: Trigger IntelliJ Plugin Integration Test
env:
BRANCH_NAME: ${{ steps.sanitize.outputs.branch_name }}
PR_TITLE: ${{ steps.sanitize.outputs.pr_title }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
PR_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || steps.pr-details.outputs.head_sha }}
PR_URL: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.html_url || steps.pr-details.outputs.html_url }}
run: |
curl -X POST \
-H "Authorization: Bearer ${{ steps.app-token.outputs.token }}" \
-H "Accept: application/vnd.github.v3+json" \
-H "User-Agent: cline-pr-trigger" \
-H "Content-Type: application/json" \
https://api.github.com/repos/cline/intellij-plugin/dispatches \
-d @- <<EOF
{
"event_type": "cline-pr-check",
"client_payload": {
"pr_number": "$PR_NUMBER",
"branch_name": $BRANCH_NAME,
"action": "${{ github.event.action }}",
"sha": "$PR_SHA",
"pr_title": $PR_TITLE,
"pr_url": "$PR_URL"
}
}
EOF
- name: Log trigger details
env:
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
PR_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || steps.pr-details.outputs.head_sha }}
run: |
echo "Triggered IntelliJ Plugin integration test for:"
echo " PR #$PR_NUMBER"
echo " Trigger: ${{ github.event_name }}"
echo " Action: ${{ github.event.action }}"
echo " SHA: $PR_SHA"
@@ -0,0 +1,294 @@
name: ext-vscode-publish-legacy
# Publishes the legacy (pre-SDK-migration) VS Code extension from the
# `legacy-extension` branch. This branch holds the npm-based 3.89.x codebase,
# rolled forward under a 4.0.x version so existing 4.0.0 users still receive
# the update. The main `ext-vscode-publish-stable.yml` workflow (bun-based)
# stays the path for releasing main once the SDK migration is solid.
#
# This workflow lives on and is dispatched from `main` (so it satisfies the
# default-branch dispatch requirement), but it checks out and builds the
# `legacy-extension` branch.
on:
workflow_dispatch:
inputs:
release-type:
description: "Choose release type (release or pre-release)"
required: true
default: "release"
type: choice
options:
- pre-release
- release
branch:
description: "Branch holding the legacy extension code"
required: true
default: "legacy-extension"
type: string
permissions:
contents: write
packages: write
checks: write
pull-requests: write
concurrency:
group: ext-vscode-publish-legacy-${{ github.event.inputs.branch }}
cancel-in-progress: false
jobs:
# Gate the publish on the legacy branch's own npm-based test suite. We can't
# reuse ./.github/workflows/ext-vscode-test.yml here — on main that's the
# bun-based suite and it would test main, not the legacy branch — so the
# essential quality + test steps are inlined against the checked-out legacy
# branch.
test:
name: Test Legacy Extension
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/vscode
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
cache-dependency-path: |
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci
- name: Run Quality Checks (lint + typecheck)
run: npm run ci:check-all
- name: Build Tests and Extension
id: build_step
run: npm run ci:build
- name: Unit Tests
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
run: npm run test:unit
- name: Extension Integration Tests
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
run: xvfb-run -a npm run test:coverage
- name: Webview Tests
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
run: |
cd webview-ui
npm run test:coverage
publish:
needs: test
name: Publish Legacy Extension
runs-on: ubuntu-latest
environment: publish
defaults:
run:
working-directory: apps/vscode
steps:
# Check out the legacy branch (NOT main). fetch-depth: 0 + tags so we
# can create/push the release tag and compute the previous tag.
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch }}
fetch-depth: 0
fetch-tags: true
lfs: true
- name: Resolve Release Tag
id: resolve_tag
working-directory: ${{ github.workspace }}
env:
BRANCH: ${{ github.event.inputs.branch }}
run: |
# Tag is derived from the package version on the legacy branch.
VERSION=$(node -p "require('./apps/vscode/package.json').version")
TAG="v$VERSION"
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]]; then
echo "Error: derived tag '$TAG' does not match vX.Y.Z"
exit 1
fi
TAG_REF="refs/tags/$TAG"
HEAD_SHA=$(git rev-parse HEAD)
if git show-ref --verify --quiet "$TAG_REF"; then
TAG_SHA=$(git rev-list -n 1 "$TAG_REF^{commit}")
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
echo "Error: tag '$TAG' already exists at $TAG_SHA, not at branch head ($HEAD_SHA)"
exit 1
fi
echo "Tag '$TAG' already exists at branch head. Continuing."
else
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag "$TAG" "$HEAD_SHA"
git push origin "$TAG_REF"
echo "Created and pushed tag '$TAG' from $BRANCH head $HEAD_SHA."
fi
echo "tag=$TAG" >> $GITHUB_OUTPUT
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode install --include=optional
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui install --include=optional
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
- name: Get Version
id: get_version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Verify Tag Matches Package Version
run: |
TAG="${{ steps.resolve_tag.outputs.tag }}"
VERSION="v${{ steps.get_version.outputs.version }}"
if [[ "$TAG" != "$VERSION" ]]; then
echo "Error: tag '$TAG' does not match package version '$VERSION'"
exit 1
fi
echo "Tag and package version match: $TAG"
- name: Verify Changelog Entry
working-directory: ${{ github.workspace }}
run: |
EXPECTED_HEADING="## [${{ steps.get_version.outputs.version }}]"
FIRST_HEADING=$(grep -m 1 '^## \[' CHANGELOG.md || true)
if [[ "$FIRST_HEADING" != "$EXPECTED_HEADING" ]]; then
echo "Error: CHANGELOG.md must start with '$EXPECTED_HEADING' before publishing."
echo "Current first release heading: ${FIRST_HEADING:-<none>}"
exit 1
fi
echo "Found changelog entry for ${{ steps.get_version.outputs.version }}"
- name: Verify Marketplace Tokens
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
run: |
if [[ -z "$VSCE_PAT" ]]; then
echo "Error: VSCE_PAT is required to publish the stable VS Code extension."
exit 1
fi
if [[ -z "$OVSX_PAT" ]]; then
echo "Error: OVSX_PAT is required to publish the stable Open VSX extension."
exit 1
fi
echo "Marketplace publish tokens are configured."
- name: Package and Publish Extension
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
# OpenTelemetry production defaults (can be overridden at runtime)
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
RELEASE_TYPE: ${{ github.event.inputs.release-type }}
run: |
# Swap README.marketplace.md into README.md so both the GitHub
# release artifact (vsce package below) and the marketplace
# publish (npm run publish:marketplace below, which swaps
# internally as an idempotent no-op) ship the same README.
node scripts/marketplace-readme.mjs swap-in
trap 'node scripts/marketplace-readme.mjs restore' EXIT
# Required to generate the .vsix
vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
if [ "$RELEASE_TYPE" = "pre-release" ]; then
npm run publish:marketplace:prerelease
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
else
npm run publish:marketplace
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
fi
- name: Get Previous Tag
id: prev_tag
working-directory: ${{ github.workspace }}
run: |
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
PREV_TAG=$(git describe --tags --abbrev=0 "$CURRENT_TAG^" 2>/dev/null || echo "")
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Get Changelog Entry
id: changelog
working-directory: ${{ github.workspace }}
run: |
# Get content between first ## [ and second ## [
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ steps.resolve_tag.outputs.tag }}
files: "apps/vscode/*.vsix"
body: |
${{ steps.changelog.outputs.content }}
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}
prerelease: ${{ github.event.inputs.release-type == 'pre-release' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Post release to Slack
uses: slackapi/slack-github-action@v3.0.1
with:
method: chat.postMessage
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
payload: |
channel: "C0APVKGGZFC"
text: "Cline ${{ steps.resolve_tag.outputs.tag }} (legacy)"
blocks:
- type: "section"
text:
type: "mrkdwn"
text: "*Cline ${{ steps.resolve_tag.outputs.tag }} (legacy)*"
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.content) }}
- type: "context"
elements:
- type: "mrkdwn"
text: "Full Changelog: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}"
@@ -0,0 +1,138 @@
name: ext-vscode-publish-nightly
on:
schedule:
# Every day at 4:00 AM PST (12:00 UTC)
- cron: "0 12 * * *"
workflow_dispatch:
run-name: "Publish Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
# Prevent concurrent publish runs on the same branch. The nightly publish script
# generates the extension version from a seconds-resolution timestamp, so parallel
# runs on the same ref can collide on the same version and cause publish failures
# or inconsistent tagging. Runs on different branches proceed independently.
concurrency:
group: ext-vscode-publish-nightly-${{ github.ref }}
cancel-in-progress: false
permissions: {}
jobs:
test:
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
permissions:
contents: read
pull-requests: read
uses: ./.github/workflows/ext-vscode-test.yml
publish:
needs: test
permissions:
contents: write
name: Publish Cline (Nightly) Extension
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
runs-on: ubuntu-latest
environment: PublishNightly
# The VS Code extension's package.json and lockfiles live under apps/vscode/
# (the repo root has no package.json). Mirror ext-vscode-test.yml so install
# and publish steps run in the correct workspace.
defaults:
run:
working-directory: apps/vscode
steps:
- name: Checkout selected branch
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
lfs: true
persist-credentials: false
- name: Show build source
working-directory: ${{ github.workspace }}
run: |
echo "Building ref: $GITHUB_REF"
echo "Building sha: $GITHUB_SHA"
git --no-pager log -1 --oneline
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
# Node is still REQUIRED in the publish job (not just for install): the publish
# scripts run as `node ./scripts/publish-*.mjs` and shell out to `npx ovsx`.
# setup-bun does not provide a Node runtime, so keep setup-node here.
# Pinned to Node 22 because newer LTS (Node 24 / npm 11) can make vsce's
# `npm list` dependency detection fail with ELSPROBLEMS during packaging.
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
# Single root install resolves the whole bun workspace at once (replaces the
# per-package `npm ci` steps for apps/vscode + webview-ui).
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
run: bun install --frozen-lockfile
# @cline/* are local workspace symlinks to source packages; build dist/ before
# packaging/publishing the extension.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
run: bun run build:sdk
- name: Assert better-sqlite3 native binary present
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
echo "(bun trustedDependencies postinstall likely did not run)"
exit 1
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
# vsce is now a workspace devDependency (on node_modules/.bin), but ovsx is not
# vendored and the publish script invokes it via `npx ovsx`, so install ovsx
# globally here (npm is available via setup-node). vsce is installed globally
# too to preserve the script's existing PATH expectations.
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
- name: Publish Nightly Extension
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
CLINE_ENVIRONMENT: production
# OpenTelemetry production defaults (can be overridden at runtime)
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
# The script itself runs under `node ./scripts/publish-nightly.mjs`; bun run
# just launches it. Node + npm (for `npx ovsx`) are provided by setup-node above.
run: bun run publish:marketplace:nightly
- name: Tag published commit
working-directory: ${{ github.workspace }}
env:
GH_TOKEN: ${{ github.token }}
run: |
SAFE_REF=$(echo "$GITHUB_REF_NAME" | tr '/[:upper:]' '-[:lower:]' | tr -cd 'a-z0-9._-')
SHORT_SHA=$(git rev-parse --short=12 HEAD)
TIMESTAMP=$(date -u +"%Y%m%d%H%M%S")
TAG="nightly-${SAFE_REF}-${TIMESTAMP}-${SHORT_SHA}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "$TAG" -m "Cline Nightly published from ${GITHUB_REF_NAME} at ${GITHUB_SHA}"
# Use an explicit HTTPS remote with GH_TOKEN because checkout was run with
# persist-credentials: false, so actions/checkout did not persist a git credential helper.
git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${TAG}"
echo "Tagged published commit: $TAG"
@@ -0,0 +1,310 @@
name: ext-vscode-publish-stable
on:
workflow_dispatch:
inputs:
release-type:
description: "Choose release type (release or pre-release)"
required: true
default: "release"
type: choice
options:
- pre-release
- release
auto_create_tag_from_main:
description: "Auto-create and push the provided tag from the tested main commit (recommended)"
required: true
default: true
type: boolean
tag:
description: "Tag to publish (required in both modes, e.g., v3.1.2)"
required: true
type: string
permissions:
contents: write
packages: write
checks: write
pull-requests: write
concurrency:
group: ext-vscode-publish-stable-${{ github.event.inputs.tag }}
cancel-in-progress: false
jobs:
test:
uses: ./.github/workflows/ext-vscode-test.yml
publish:
needs: test
name: Publish Extension
runs-on: ubuntu-latest
environment: publish
defaults:
run:
working-directory: apps/vscode
steps:
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0
fetch-tags: true
lfs: true
- name: Resolve Release Tag
id: resolve_tag
working-directory: ${{ github.workspace }}
env:
TAG: ${{ github.event.inputs.tag }}
AUTO_CREATE: ${{ github.event.inputs.auto_create_tag_from_main }}
run: |
TESTED_SHA="${{ github.sha }}"
WORKFLOW_REF="${{ github.ref }}"
if [[ -z "$TAG" ]]; then
echo "Error: tag input is required"
exit 1
fi
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]]; then
echo "Error: tag must match vX.Y.Z (optionally with -suffix or .suffix)"
exit 1
fi
TAG_REF="refs/tags/$TAG"
git fetch origin main --tags
if [[ "$AUTO_CREATE" == "true" ]]; then
if [[ "$WORKFLOW_REF" != "refs/heads/main" ]]; then
echo "Error: auto-create mode requires dispatching from main (current ref: $WORKFLOW_REF)"
exit 1
fi
echo "Auto-create enabled. Using tested workflow SHA: $TESTED_SHA"
if ! git merge-base --is-ancestor "$TESTED_SHA" origin/main; then
echo "Error: tested SHA $TESTED_SHA is not on origin/main"
exit 1
fi
if git show-ref --verify --quiet "$TAG_REF"; then
TAG_SHA=$(git rev-list -n 1 "$TAG_REF^{commit}")
if [[ "$TAG_SHA" != "$TESTED_SHA" ]]; then
echo "Error: tag '$TAG' already exists at $TAG_SHA, not at tested SHA ($TESTED_SHA)"
exit 1
fi
echo "Tag '$TAG' already exists at tested SHA. Continuing."
else
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag "$TAG" "$TESTED_SHA"
git push origin "$TAG_REF"
echo "Created and pushed tag '$TAG' from tested SHA $TESTED_SHA."
fi
else
if ! git show-ref --verify --quiet "$TAG_REF"; then
echo "Error: tag '$TAG' does not exist in the repository"
exit 1
fi
TAG_SHA=$(git rev-list -n 1 "$TAG_REF^{commit}")
if [[ "$TAG_SHA" != "$TESTED_SHA" ]]; then
echo "Error: existing tag '$TAG' points to $TAG_SHA, but this workflow tested $TESTED_SHA"
echo "Dispatch from the tag ref, or from the exact main commit the tag points to."
exit 1
fi
echo "Using existing tag '$TAG' at tested SHA $TESTED_SHA."
fi
git checkout --detach "$TAG_REF^{commit}"
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "resolved_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
# Node is still REQUIRED in the publish job (not just for install): the
# publish scripts run as `node scripts/publish-*.mjs`, the version step uses
# `node -p`, and `npx ovsx` needs npm. setup-bun does not provide a Node
# runtime, so keep setup-node. Pinned to Node 22 because newer LTS
# (Node 24 / npm 11) can make vsce's `npm list` detection fail with
# ELSPROBLEMS during packaging.
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
# Single root install resolves the whole bun workspace at once (replaces the
# per-package `npm install` steps for apps/vscode + webview-ui).
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
run: bun install --frozen-lockfile
# @cline/* are local workspace symlinks to source packages; build dist/ before
# packaging/publishing the extension.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
run: bun run build:sdk
- name: Assert better-sqlite3 native binary present
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
echo "(bun trustedDependencies postinstall likely did not run)"
exit 1
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
# vsce is a workspace devDependency (on node_modules/.bin), but ovsx is not
# vendored and the publish script invokes it via `npx ovsx`, so install ovsx
# globally (npm is available via setup-node). vsce is installed globally too
# to preserve the script's existing PATH expectations.
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
- name: Get Version
id: get_version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Verify Tag Matches Package Version
run: |
TAG="${{ steps.resolve_tag.outputs.tag }}"
VERSION="v${{ steps.get_version.outputs.version }}"
if [[ "$TAG" != "$VERSION" ]]; then
echo "Error: tag '$TAG' does not match package version '$VERSION'"
exit 1
fi
echo "Tag and package version match: $TAG"
- name: Verify Changelog Entry
working-directory: ${{ github.workspace }}
run: |
EXPECTED_HEADING="## [${{ steps.get_version.outputs.version }}]"
FIRST_HEADING=$(grep -m 1 '^## \[' CHANGELOG.md || true)
if [[ "$FIRST_HEADING" != "$EXPECTED_HEADING" ]]; then
echo "Error: CHANGELOG.md must start with '$EXPECTED_HEADING' before publishing."
echo "Current first release heading: ${FIRST_HEADING:-<none>}"
exit 1
fi
echo "Found changelog entry for ${{ steps.get_version.outputs.version }}"
- name: Verify Marketplace Tokens
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
run: |
if [[ -z "$VSCE_PAT" ]]; then
echo "Error: VSCE_PAT is required to publish the stable VS Code extension."
exit 1
fi
if [[ -z "$OVSX_PAT" ]]; then
echo "Error: OVSX_PAT is required to publish the stable Open VSX extension."
exit 1
fi
echo "Marketplace publish tokens are configured."
- name: Get Previous Tag
id: prev_tag
working-directory: ${{ github.workspace }}
run: |
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
PREV_TAG=$(
git tag --merged "$CURRENT_TAG^" --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$' \
| head -n 1 || true
)
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Get Changelog Entry
id: changelog
working-directory: ${{ github.workspace }}
run: |
# Get content between the matching version heading and the next release heading.
CONTENT=$(awk -v version="${{ steps.get_version.outputs.version }}" '
$0 == "## [" version "]" { found=1; next }
found && /^## \[/ { exit }
found { print }
END { if (!found) exit 1 }
' CHANGELOG.md)
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Package and Publish Extension
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
# OpenTelemetry production defaults (can be overridden at runtime)
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
RELEASE_TYPE: ${{ github.event.inputs.release-type }}
run: |
# Swap README.marketplace.md into README.md so both the GitHub
# release artifact (vsce package below) and the marketplace
# publish (npm run publish:marketplace below, which swaps
# internally as an idempotent no-op) ship the same README.
node scripts/marketplace-readme.mjs swap-in
trap 'node scripts/marketplace-readme.mjs restore' EXIT
# Required to generate the .vsix. --no-dependencies: the extension
# is fully esbuild-bundled, and under the bun workspace the @cline/*
# deps are symlinks pointing outside the package, so without this vsce
# would walk them and pull the whole monorepo into the .vsix.
vsce package --no-dependencies --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
# These scripts run under `node scripts/publish-marketplace.mjs`;
# bun run just launches them. Node + npm (for `npx ovsx`) come from
# setup-node above.
if [ "$RELEASE_TYPE" = "pre-release" ]; then
bun run publish:marketplace:prerelease
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
else
bun run publish:marketplace
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
fi
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ steps.resolve_tag.outputs.tag }}
files: "apps/vscode/*.vsix"
body: |
${{ steps.changelog.outputs.content }}
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}
prerelease: ${{ github.event.inputs.release-type == 'pre-release' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Post release to Slack
uses: slackapi/slack-github-action@v3.0.1
with:
method: chat.postMessage
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
payload: |
channel: "C0APVKGGZFC"
text: "Cline ${{ steps.resolve_tag.outputs.tag }}"
blocks:
- type: "section"
text:
type: "mrkdwn"
text: "*Cline ${{ steps.resolve_tag.outputs.tag }}*"
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.content) }}
- type: "context"
elements:
- type: "mrkdwn"
text: "Full Changelog: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}"
+179
View File
@@ -0,0 +1,179 @@
name: ext-vscode-test-e2e
on:
push:
branches:
- main
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: read
jobs:
detect-changes:
runs-on: ubuntu-latest
name: Detect Changes
outputs:
e2e: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.e2e == 'true' }}
steps:
- id: force
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
run: echo "run_all=true" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
if: steps.force.outputs.run_all != 'true'
- uses: dorny/paths-filter@v3
if: steps.force.outputs.run_all != 'true'
id: filter
with:
filters: |
e2e:
- 'apps/vscode/src/**'
- 'apps/vscode/webview-ui/**'
- 'apps/vscode/proto/**'
- 'apps/vscode/tests/**'
- 'apps/vscode/scripts/**'
- 'apps/vscode/standalone/**'
- 'apps/vscode/assets/**'
- 'apps/vscode/walkthrough/**'
- 'apps/vscode/package.json'
- 'apps/vscode/webview-ui/package.json'
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
- 'bun.lock'
# SDK source packages are local workspace symlinks (@cline/*), so SDK changes affect the build.
- 'sdk/packages/**'
- 'apps/vscode/buf.yaml'
- 'apps/vscode/tsconfig*.json'
- 'apps/vscode/biome.jsonc'
- 'apps/vscode/esbuild.mjs'
- 'apps/vscode/bunfig.toml'
- 'apps/vscode/.vscode-test.mjs'
- 'apps/vscode/.vscodeignore'
- 'apps/vscode/playwright*.ts'
- '.github/workflows/ext-vscode-test-e2e.yml'
matrix_prep:
needs: detect-changes
if: needs.detect-changes.outputs.e2e == 'true'
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- id: set-matrix
run: |
echo 'matrix=[{"runner":"ubuntu"},{"runner":"windows"},{"runner":"macos"}]' >> $GITHUB_OUTPUT
e2e:
needs: [detect-changes, matrix_prep]
if: needs.detect-changes.outputs.e2e == 'true'
strategy:
fail-fast: false
matrix:
include: ${{ fromJson(needs.matrix_prep.outputs.matrix) }}
runs-on: ${{ matrix.runner }}-latest
timeout-minutes: 20
permissions:
id-token: write
contents: read
defaults:
run:
working-directory: apps/vscode
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
# Cache Bun's global install cache - keyed on the authoritative root bun.lock.
- name: Cache Bun install cache
uses: actions/cache@v4
id: bun-cache
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-
# Cache VS Code installation
- name: Cache VS Code
uses: actions/cache@v4
id: vscode-cache
with:
path: apps/vscode/.vscode-test
key: vscode-${{ runner.os }}-stable-${{ hashFiles('apps/vscode/.vscode-test.mjs', 'apps/vscode/package.json') }}
restore-keys: |
vscode-${{ runner.os }}-stable-
# Cache Playwright browsers
- name: Cache Playwright browsers
uses: actions/cache@v4
id: playwright-cache
with:
path: |
~/.cache/ms-playwright
~/Library/Caches/ms-playwright
~/AppData/Local/ms-playwright
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('bun.lock') }}
restore-keys: |
playwright-browsers-${{ runner.os }}-
# Single root install resolves the entire bun workspace at once (replaces
# the per-package `npm ci` steps for apps/vscode + webview-ui).
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
run: bun install --frozen-lockfile
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# @cline/* are local workspace symlinks to source packages; build dist/
# before building/packaging the extension for E2E.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
run: bun run build:sdk
- name: Assert better-sqlite3 native binary present
# Force bash: the Windows runner defaults to pwsh, which can't parse this
# POSIX test. Git Bash ships on GitHub's windows-latest images.
shell: bash
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
echo "(bun trustedDependencies postinstall likely did not run)"
exit 1
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
# NOTE: vsce is no longer installed globally. @vscode/vsce is a workspace
# devDependency of apps/vscode (resolved into node_modules/.bin), and the
# `test:e2e:build` script invokes `vsce` via `bun run`, which puts the local
# .bin on PATH. No global install needed.
- name: Install xvfb on Linux
if: matrix.runner == 'ubuntu'
run: sudo apt-get update && sudo apt-get install -y xvfb
# Run optimized E2E tests (eliminates redundant builds)
- name: Run E2E tests - Linux
if: matrix.runner == 'ubuntu'
run: xvfb-run -a bun run test:e2e:optimal
- name: Run E2E tests - Non-Linux
if: matrix.runner != 'ubuntu'
run: bun run test:e2e:optimal
- uses: actions/upload-artifact@v4
if: ${{ failure() }}
with:
name: playwright-recordings-${{ matrix.runner }}
path: |
test-results/playwright/
+428
View File
@@ -0,0 +1,428 @@
name: ext-vscode-test
on:
push:
branches:
- main
workflow_dispatch:
pull_request:
branches:
- main
workflow_call:
# Set default permissions for all jobs
permissions:
contents: read # Needed to check out code
pull-requests: read # Needed for changed-file detection on pull requests
jobs:
detect-changes:
runs-on: ubuntu-latest
name: Detect Changes
outputs:
vscode: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.vscode == 'true' }}
testing_platform: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.testing_platform == 'true' }}
steps:
- id: force
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call'
run: echo "run_all=true" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
if: steps.force.outputs.run_all != 'true'
- uses: dorny/paths-filter@v3
if: steps.force.outputs.run_all != 'true'
id: filter
with:
filters: |
vscode:
- 'apps/vscode/src/**'
- 'apps/vscode/webview-ui/**'
- 'apps/vscode/proto/**'
- 'apps/vscode/tests/**'
- 'apps/vscode/scripts/**'
- 'apps/vscode/standalone/**'
- 'apps/vscode/assets/**'
- 'apps/vscode/walkthrough/**'
- 'apps/vscode/package.json'
- 'apps/vscode/webview-ui/package.json'
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
- 'bun.lock'
# SDK source packages are local workspace symlinks (@cline/*), so SDK changes affect the build.
- 'sdk/packages/**'
- 'apps/vscode/buf.yaml'
- 'apps/vscode/tsconfig*.json'
- 'apps/vscode/biome.jsonc'
- 'apps/vscode/esbuild.mjs'
- 'apps/vscode/bunfig.toml'
- 'apps/vscode/.vscode-test.mjs'
- 'apps/vscode/test-setup.js'
- '.github/workflows/ext-vscode-test.yml'
testing_platform:
- 'apps/vscode/src/**'
- 'apps/vscode/proto/**'
- 'apps/vscode/standalone/**'
- 'apps/vscode/testing-platform/**'
- 'apps/vscode/testing-platform/package.json'
- 'apps/vscode/tests/specs/**'
- 'apps/vscode/package.json'
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
- 'bun.lock'
# SDK source packages are local workspace symlinks (@cline/*), so SDK changes affect the build.
- 'sdk/packages/**'
- 'apps/vscode/buf.yaml'
- 'apps/vscode/tsconfig*.json'
- 'apps/vscode/esbuild.mjs'
- 'apps/vscode/.vscodeignore'
- 'apps/vscode/scripts/**'
- '.github/workflows/ext-vscode-test.yml'
quality-checks:
needs: detect-changes
if: needs.detect-changes.outputs.vscode == 'true' || needs.detect-changes.outputs.testing_platform == 'true'
runs-on: ubuntu-latest
name: Quality Checks
defaults:
run:
working-directory: apps/vscode
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
# Single root install resolves the entire bun workspace (apps/vscode,
# webview-ui, testing-platform and the @cline/* SDK symlinks) at once,
# so the previous per-package `npm ci` steps collapse into one.
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
run: bun install --frozen-lockfile
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# @cline/* are local workspace symlinks to source packages; their dist/
# output must be built before the extension can type-check/compile.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
run: bun run build:sdk
- name: Assert better-sqlite3 native binary present
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
echo "(bun trustedDependencies postinstall likely did not run)"
exit 1
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
- name: Run Quality Checks (Parallel)
run: bun run ci:check-all
vscode-test:
needs: [detect-changes, quality-checks]
if: needs.detect-changes.outputs.vscode == 'true'
env:
VSCODE_TEST_VERSION: 1.103.0
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
name: ${{ matrix.os == 'ubuntu-latest' && 'vscode test' || format('vscode test ({0})', matrix.os) }}
defaults:
run:
shell: bash
working-directory: apps/vscode
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
# Single root install resolves the entire bun workspace at once (replaces
# the per-package `npm ci` steps for apps/vscode + webview-ui).
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
run: bun install --frozen-lockfile
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# @cline/* are local workspace symlinks to source packages; build dist/
# before compiling/testing the extension.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
run: bun run build:sdk
- name: Assert better-sqlite3 native binary present
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
echo "(bun trustedDependencies postinstall likely did not run)"
exit 1
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
# NOTE: The old `npm config set script-shell bash` step is intentionally
# removed. Scripts are now launched with `bun run`, which uses Bun's own
# built-in cross-platform shell rather than npm's configured script-shell,
# so that npm-specific Windows workaround no longer applies. Bash-dependent
# scripts (e.g. scripts/proto-lint.sh, standalone/runclinecore.sh) are
# invoked explicitly via `bash ...` from within the package scripts, and
# this job's `defaults.run.shell: bash` (Git Bash on Windows) still covers
# the workflow `run:` blocks below.
- name: Cache VS Code test runtime
if: runner.os == 'Windows'
uses: actions/cache@v4
with:
path: .vscode-test
key: vscode-test-runtime-${{ runner.os }}-${{ env.VSCODE_TEST_VERSION }}
# Build the extension and tests (without redundant checks)
- name: Build Tests and Extension
id: build_step
run: bun run ci:build
- name: Vitest Suites (SDK adapter + model catalog)
id: vitest_tests
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
# The vitest config sets passWithNoTests: true, so a broken glob/alias
# would "pass" with zero tests. Capture output and assert a non-zero
# test count to guard against silent skips.
run: |
set -o pipefail
bun run test:vitest 2>&1 | tee vitest-output.log
# Strip ANSI color codes before matching — vitest colorizes the
# "Tests N passed" summary, so the count is not adjacent to the
# "Tests" label in the raw bytes.
if ! sed -r 's/\x1b\[[0-9;]*m//g' vitest-output.log | grep -Eq 'Tests[[:space:]]+[0-9]*[1-9][0-9]* (passed|failed)'; then
echo "ERROR: vitest reported zero tests (possible silent skip)."
exit 1
fi
- name: Unit Tests (bun) - Linux
id: unit_tests_linux
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
# The node-side unit suite (.mocharc spec set) now runs under `bun test`
# via scripts/run-bun-unit-tests.ts (one isolated bun process per file).
# The runner exits non-zero on any failure and prints a final
# "Files: N Pass: P Fail: F" summary; assert a non-zero pass count to
# guard against an empty glob silently "passing".
run: |
set -o pipefail
bun run test:unit 2>&1 | tee unit-output.log
if ! grep -Eq 'Pass:[[:space:]]+[0-9]*[1-9][0-9]*' unit-output.log; then
echo "ERROR: bun unit runner reported zero passing tests (possible silent skip)."
exit 1
fi
- name: Unit Tests (bun) - Non-Linux
id: unit_tests_non_linux
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os != 'Linux' }}
run: |
bun run test:unit
- name: Extension Integration Tests - Linux
id: integration_tests_linux
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
run: xvfb-run -a bun run test:coverage
- name: Extension Integration Tests - Non-Linux
id: integration_tests_non_linux
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os != 'Linux' }}
run: |
for attempt in 1 2 3; do
echo "Running extension integration tests (attempt ${attempt}/3)"
if bun run test:integration; then
exit 0
fi
if [ "$attempt" -eq 3 ]; then
echo "Extension integration tests failed after 3 attempts"
exit 1
fi
echo "Extension integration tests failed; retrying after short delay"
sleep 5
done
- name: Webview Tests with Coverage
id: webview_tests
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
run: |
cd webview-ui
bun run test:coverage
- name: Save Coverage Reports
uses: actions/upload-artifact@v4
# Only upload artifacts on Linux - We only need coverage from one OS
if: runner.os == 'Linux'
with:
name: pr-coverage-reports
path: |
apps/vscode/webview-ui/coverage/lcov.info
test-platform-integration:
needs: [detect-changes, quality-checks]
if: needs.detect-changes.outputs.testing_platform == 'true'
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/vscode
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
# Single root install resolves the whole bun workspace, including the
# testing-platform package, so the separate per-package `npm ci` steps
# (extension + webview-ui + testing-platform) collapse into one.
- name: Install workspace dependencies
working-directory: ${{ github.workspace }}
run: bun install --frozen-lockfile
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# @cline/* are local workspace symlinks to source packages; build dist/
# before compiling the standalone core.
- name: Build SDK packages
working-directory: ${{ github.workspace }}
run: bun run build:sdk
- name: Assert better-sqlite3 native binary present
run: |
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
if [ ! -f "$NODE_FILE" ]; then
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
echo "(bun trustedDependencies postinstall likely did not run)"
exit 1
fi
echo "Found better-sqlite3 native binary: $NODE_FILE"
- name: Download ripgrep binaries
run: bun run download-ripgrep
- name: Compile Standalone
run: bun run compile-standalone
- name: Running testing platform integration spec tests
timeout-minutes: 7
run: bun run test:tp-orchestrator -- tests/specs/ --count=1 --coverage
- name: Save Coverage Reports
uses: actions/upload-artifact@v4
with:
name: test-platform-integration-core-coverage
path: apps/vscode/coverage/**/lcov.info
# Keep the required "test" check as a tiny aggregate gate instead of the conditional
# VS Code matrix. GitHub treats conditionally skipped jobs as successful required
# checks, so the gate below preserves the old required check name while making sure
# whichever filtered test jobs were selected actually passed.
test:
needs: [detect-changes, quality-checks, vscode-test, test-platform-integration]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
name: test
steps:
- name: Verify selected test jobs
env:
DETECT_CHANGES_RESULT: ${{ needs.detect-changes.result }}
QUALITY_CHECKS_RESULT: ${{ needs.quality-checks.result }}
VSCODE_CHANGED: ${{ needs.detect-changes.outputs.vscode }}
TESTING_PLATFORM_CHANGED: ${{ needs.detect-changes.outputs.testing_platform }}
VSCODE_TEST_RESULT: ${{ needs.vscode-test.result }}
TEST_PLATFORM_RESULT: ${{ needs.test-platform-integration.result }}
run: |
if [ "$DETECT_CHANGES_RESULT" != "success" ]; then
echo "detect-changes did not succeed: $DETECT_CHANGES_RESULT"
exit 1
fi
if [ "$VSCODE_CHANGED" != "true" ] && [ "$TESTING_PLATFORM_CHANGED" != "true" ]; then
echo "No root test paths changed; skipping root test requirements."
exit 0
fi
if [ "$QUALITY_CHECKS_RESULT" != "success" ]; then
echo "quality-checks did not succeed: $QUALITY_CHECKS_RESULT"
exit 1
fi
if [ "$VSCODE_CHANGED" = "true" ] && [ "$VSCODE_TEST_RESULT" != "success" ]; then
echo "vscode-test did not succeed: $VSCODE_TEST_RESULT"
exit 1
fi
if [ "$TESTING_PLATFORM_CHANGED" = "true" ] && [ "$TEST_PLATFORM_RESULT" != "success" ]; then
echo "test-platform-integration did not succeed: $TEST_PLATFORM_RESULT"
exit 1
fi
echo "Selected root test jobs passed."
qlty:
needs: [detect-changes, quality-checks, vscode-test, test-platform-integration]
if: ${{ !cancelled() && needs.quality-checks.result == 'success' && (needs.vscode-test.result == 'success' || needs.vscode-test.result == 'skipped') && (needs.test-platform-integration.result == 'success' || needs.test-platform-integration.result == 'skipped') && (needs.detect-changes.outputs.vscode == 'true' || needs.detect-changes.outputs.testing_platform == 'true') }}
runs-on: ubuntu-latest
# Run on PRs to main, pushes to main, and manual dispatches
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download unit tests coverage reports
if: needs.detect-changes.outputs.vscode == 'true'
uses: actions/download-artifact@v4
with:
name: pr-coverage-reports
path: apps/vscode
- name: Upload core unit tests coverage to Qlty
if: needs.detect-changes.outputs.vscode == 'true'
uses: qltysh/qlty-action/coverage@v2
with:
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
# we can merge multiple files if necessary
files: |
apps/vscode/coverage-unit/lcov.info
tag: unit:core
- name: Upload webview-ui unit tests coverage to Qlty
if: needs.detect-changes.outputs.vscode == 'true'
uses: qltysh/qlty-action/coverage@v2
with:
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
# we can merge multiple files if necessary
files: |
apps/vscode/webview-ui/coverage/lcov.info
tag: unit:webview-ui
add-prefix: webview-ui/
- name: Download test platform integration core coverage artifact
if: needs.detect-changes.outputs.testing_platform == 'true'
uses: actions/download-artifact@v4
continue-on-error: true
id: download-integration-coverage
with:
name: test-platform-integration-core-coverage
path: apps/vscode/integration-core-coverage-reports
- name: Upload core integration tests coverage to Qlty
if: needs.detect-changes.outputs.testing_platform == 'true' && steps.download-integration-coverage.outcome == 'success'
uses: qltysh/qlty-action/coverage@v2
with:
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
files: apps/vscode/integration-core-coverage-reports/**/lcov.info
tag: integration:core
+65
View File
@@ -0,0 +1,65 @@
name: repo-label-issues
on:
issues:
types: [opened, edited]
jobs:
label:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/github-script@v7
with:
script: |
const body = context.payload.issue.body || '';
const labels = context.payload.issue.labels.map(l => l.name);
// Check if JetBrains Plugin is selected
if (body.match(/###\s*Cline Surface\s*\n+JetBrains Plugin/i)) {
if (!labels.includes('JetBrains')) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['JetBrains']
});
}
}
// Check if VSCode Extension is selected
if (body.match(/###\s*Cline Surface\s*\n+VSCode Extension/i)) {
if (!labels.includes('VS Code')) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['VS Code']
});
}
}
// Check if CLI is selected
if (body.match(/###\s*Cline Surface\s*\n+CLI/i)) {
if (!labels.includes('CLI')) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['CLI']
});
}
}
// Check if beta version checkbox is checked
if (body.includes('- [X] I am using a beta version of Cline') || body.includes('- [x] I am using a beta version of Cline')) {
if (!labels.includes('beta')) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['beta']
});
}
}
+25
View File
@@ -0,0 +1,25 @@
# This workflow will only label and/or close 30 issues at a time in order to avoid exceeding a rate limit.
# More info: https://docs.github.com/en/actions/use-cases-and-examples/project-management/closing-inactive-issues
name: repo-stale-issues
on:
schedule:
- cron: "30 1 * * *"
jobs:
close-issues:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9
with:
days-before-issue-stale: 60
days-before-issue-close: 14
stale-issue-label: "stale"
stale-issue-message: "This issue is stale because it has been open for 60 days with no activity."
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
days-before-pr-stale: -1
days-before-pr-close: -1
exempt-issue-labels: "pinned,security"
repo-token: ${{ secrets.GITHUB_TOKEN }}
+282
View File
@@ -0,0 +1,282 @@
name: sdk-publish
on:
workflow_dispatch:
inputs:
channel:
description: "Publish channel"
required: true
type: choice
options:
- nightly
- latest
default: nightly
force_publish:
description: "Force publish even if there are no commits in the last 24 hours"
required: false
type: boolean
default: false
confirm_publish:
description: 'Required when channel=latest. Type "publish" to confirm release publish.'
required: false
type: string
schedule:
# Run nightly at 2:00 AM UTC
- cron: "0 2 * * *"
defaults:
run:
working-directory: .
jobs:
test:
permissions:
contents: read
uses: ./.github/workflows/sdk-test.yml
publish-sdk:
needs: test
name: Publish SDK Packages
permissions:
contents: write
id-token: write
if: |
github.repository == 'cline/cline' &&
github.ref == 'refs/heads/main' &&
(
github.event_name != 'workflow_dispatch' ||
inputs.channel != 'latest' ||
(
inputs.confirm_publish == 'publish' &&
!endsWith(github.actor, '[bot]')
)
)
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Determine publish channel
id: channel
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_CHANNEL: ${{ inputs.channel }}
run: |
# Default to nightly for scheduled runs
if [ "$EVENT_NAME" = "schedule" ]; then
echo "channel=nightly" >> $GITHUB_OUTPUT
else
echo "channel=$INPUT_CHANNEL" >> $GITHUB_OUTPUT
fi
- name: Check for recent commits
id: check_commits
env:
CHANNEL: ${{ steps.channel.outputs.channel }}
FORCE_PUBLISH: ${{ inputs.force_publish }}
run: |
# Always publish for latest (production) releases
if [ "$CHANNEL" = "latest" ]; then
echo "Production release requested, proceeding with publish"
echo "skip=false" >> $GITHUB_OUTPUT
exit 0
fi
if [ "$FORCE_PUBLISH" = "true" ]; then
echo "force_publish enabled, proceeding with publish"
echo "skip=false" >> $GITHUB_OUTPUT
exit 0
fi
if [ "$(git rev-list --count HEAD --since="24 hours ago")" -eq 0 ]; then
echo "No commits in last 24 hours, skipping publish"
echo "skip=true" >> $GITHUB_OUTPUT
else
echo "Found recent commits, proceeding with publish"
echo "skip=false" >> $GITHUB_OUTPUT
fi
- name: Verify trusted publishing context
if: steps.check_commits.outputs.skip != 'true'
run: |
if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then
echo "GitHub OIDC request environment is unavailable. Ensure this job has id-token: write for npm trusted publishing."
exit 1
fi
echo "GitHub OIDC request environment is available for npm trusted publishing."
- name: Setup Bun
if: steps.check_commits.outputs.skip != 'true'
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Setup Node.js
if: steps.check_commits.outputs.skip != 'true'
uses: actions/setup-node@v4
with:
node-version: "24.x"
registry-url: "https://registry.npmjs.org"
- name: Verify publish tooling
if: steps.check_commits.outputs.skip != 'true'
run: |
NPM_VERSION=$(npm --version)
echo "npm ${NPM_VERSION}"
IFS=. read -r major minor patch <<EOF
${NPM_VERSION}
EOF
if [ "$major" -lt 11 ] || { [ "$major" -eq 11 ] && [ "$minor" -lt 5 ]; } || { [ "$major" -eq 11 ] && [ "$minor" -eq 5 ] && [ "$patch" -lt 1 ]; }; then
echo "npm 11.5.1 or newer is required for trusted publishing"
exit 1
fi
- name: Install dependencies
if: steps.check_commits.outputs.skip != 'true'
run: bun install
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build SDK
if: steps.check_commits.outputs.skip != 'true'
run: bun run build:sdk
- name: Generate shared version
if: steps.check_commits.outputs.skip != 'true'
id: version
env:
CHANNEL: ${{ steps.channel.outputs.channel }}
run: |
BASE_VERSION=$(node -p "require('./sdk/packages/llms/package.json').version")
if [ "$CHANNEL" = "nightly" ]; then
TIMESTAMP=$(date +%s)
VERSION="${BASE_VERSION}-nightly.${TIMESTAMP}"
else
VERSION="$BASE_VERSION"
fi
echo "Base version: $BASE_VERSION"
echo "Channel: $CHANNEL"
echo "Publish version: $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Update all package versions and lockfile
if: steps.check_commits.outputs.skip != 'true'
env:
VERSION: ${{ steps.version.outputs.version }}
run: bun sdk/scripts/version.ts "$VERSION"
- name: Verify publishability
if: steps.check_commits.outputs.skip != 'true'
run: bun sdk/scripts/check-publish.ts
- name: Prepare package tarball directory
if: steps.check_commits.outputs.skip != 'true'
run: mkdir -p "$RUNNER_TEMP/sdk-npm-packs"
# Pack with Bun so workspace/catalog protocols are resolved in the tarball,
# then publish that tarball with npm so npm trusted publishing can use GitHub OIDC.
# Publish sequentially in dependency order: shared → llms → agents → core → sdk
- name: Publish @cline/shared
if: steps.check_commits.outputs.skip != 'true'
env:
NPM_CONFIG_PROVENANCE: "true"
CHANNEL: ${{ steps.channel.outputs.channel }}
VERSION: ${{ steps.version.outputs.version }}
run: |
echo "Publishing @cline/shared@${VERSION} with tag '${CHANNEL}'..."
cd sdk/packages/shared
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
- name: Publish @cline/llms
if: steps.check_commits.outputs.skip != 'true'
env:
NPM_CONFIG_PROVENANCE: "true"
CHANNEL: ${{ steps.channel.outputs.channel }}
VERSION: ${{ steps.version.outputs.version }}
run: |
echo "Publishing @cline/llms@${VERSION} with tag '${CHANNEL}'..."
cd sdk/packages/llms
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
- name: Publish @cline/agents
if: steps.check_commits.outputs.skip != 'true'
env:
NPM_CONFIG_PROVENANCE: "true"
CHANNEL: ${{ steps.channel.outputs.channel }}
VERSION: ${{ steps.version.outputs.version }}
run: |
echo "Publishing @cline/agents@${VERSION} with tag '${CHANNEL}'..."
cd sdk/packages/agents
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
- name: Publish @cline/core
if: steps.check_commits.outputs.skip != 'true'
env:
NPM_CONFIG_PROVENANCE: "true"
CHANNEL: ${{ steps.channel.outputs.channel }}
VERSION: ${{ steps.version.outputs.version }}
run: |
echo "Publishing @cline/core@${VERSION} with tag '${CHANNEL}'..."
cd sdk/packages/core
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
- name: Publish @cline/sdk
if: steps.check_commits.outputs.skip != 'true'
env:
NPM_CONFIG_PROVENANCE: "true"
CHANNEL: ${{ steps.channel.outputs.channel }}
VERSION: ${{ steps.version.outputs.version }}
run: |
echo "Publishing @cline/sdk@${VERSION} with tag '${CHANNEL}'..."
cd sdk/packages/sdk
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
- name: Create package tags for production publish
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
for PKG in shared llms agents core sdk; do
TAG="sdk/${PKG}/v${VERSION}"
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
echo "Tag already exists locally: ${TAG}"
else
git tag -a "${TAG}" -m "@cline/${PKG}@${VERSION}"
echo "Created tag: ${TAG}"
fi
# Ensure remote has the tag; this is idempotent if tag already exists remotely.
git push origin "refs/tags/${TAG}"
done
- name: Summary
if: steps.check_commits.outputs.skip != 'true'
env:
VERSION: ${{ steps.version.outputs.version }}
CHANNEL: ${{ steps.channel.outputs.channel }}
run: |
echo "Published SDK packages with tag '${CHANNEL}':"
echo " - @cline/shared@${VERSION}"
echo " - @cline/llms@${VERSION}"
echo " - @cline/agents@${VERSION}"
echo " - @cline/core@${VERSION}"
echo " - @cline/sdk@${VERSION}"
if [ "$CHANNEL" = "latest" ]; then
echo "Created git tags:"
echo " - sdk/shared/v${VERSION}"
echo " - sdk/llms/v${VERSION}"
echo " - sdk/agents/v${VERSION}"
echo " - sdk/core/v${VERSION}"
echo " - sdk/sdk/v${VERSION}"
fi
+112
View File
@@ -0,0 +1,112 @@
name: sdk-test
on:
push:
branches:
- main
paths:
- "sdk/**"
- ".github/workflows/sdk-test.yml"
workflow_dispatch:
pull_request:
branches:
- main
paths:
- "sdk/**"
- ".github/workflows/sdk-test.yml"
workflow_call:
permissions:
contents: read
defaults:
run:
working-directory: .
jobs:
quality-checks:
runs-on: ubuntu-latest
name: Quality Checks
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Install dependencies
run: bun install
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Typecheck
run: |
bun run build:sdk
bun run -F @cline/cli build
bun run types
- name: Lint & Format
run: bun run lint
test:
needs: quality-checks
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
node-version: "24.x"
- os: windows-latest
node-version: "24.x"
runs-on: ${{ matrix.os }}
name: Test (${{ matrix.os }}, Node ${{ matrix.node-version }})
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.13"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: bun install
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build SDK
id: build_sdk_step
run: bun run build:sdk
- name: Build CLI
id: build_cli_step
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' }}
run: bun -F @cline/cli build
- name: Run Tests
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os != 'windows-latest' }}
run: bun run test
- name: Run SDK Tests (Windows)
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os == 'windows-latest' }}
run: bun -F './sdk/packages/**' test
- name: Smoke test SQLite under Node
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && matrix.os != 'windows-latest' }}
timeout-minutes: 10
run: bun sdk/scripts/ci-node-smoke.ts
- name: Run TUI e2e tests
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os == 'ubuntu-latest' && matrix.node-version == '24.x' }}
run: bun -F @cline/cli test:e2e:cli:tui
- name: Verify packages are publishable
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os == 'ubuntu-latest' && matrix.node-version == '24.x' }}
run: bun sdk/scripts/check-publish.ts