diff --git a/.github/scripts/pre-release.mjs b/.github/scripts/pre-release.mjs new file mode 100755 index 00000000..15a191c0 --- /dev/null +++ b/.github/scripts/pre-release.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +import { readFileSync, existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + findRootDir, + runCommand, + getPackageVersion, + createAndPushTag +} from './utils.mjs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const rootDir = findRootDir(__dirname); +const extensionPkgPath = join(rootDir, 'apps', 'extension', 'package.json'); + +console.log('🚀 Starting pre-release process...'); + +// Check if we're in RC mode +const preJsonPath = join(rootDir, '.changeset', 'pre.json'); +if (!existsSync(preJsonPath)) { + console.error('⚠️ Not in RC mode. Run "npx changeset pre enter rc" first.'); + process.exit(1); +} + +try { + const preJson = JSON.parse(readFileSync(preJsonPath, 'utf8')); + if (preJson.tag !== 'rc') { + console.error(`⚠️ Not in RC mode. Current tag: ${preJson.tag}`); + process.exit(1); + } +} catch (error) { + console.error('Failed to read pre.json:', error.message); + process.exit(1); +} + +// Get current extension version +const extensionVersion = getPackageVersion(extensionPkgPath); +console.log(`Extension version: ${extensionVersion}`); + +// Run changeset publish for npm packages +console.log('📦 Publishing npm packages...'); +runCommand('npx', ['changeset', 'publish']); + +// Create tag for extension pre-release if it doesn't exist +const extensionTag = `extension-rc@${extensionVersion}`; +const tagCreated = createAndPushTag(extensionTag); + +if (tagCreated) { + console.log('This will trigger the extension-pre-release workflow...'); +} + +console.log('✅ Pre-release process completed!'); diff --git a/.github/scripts/release.mjs b/.github/scripts/release.mjs new file mode 100755 index 00000000..fddae5d9 --- /dev/null +++ b/.github/scripts/release.mjs @@ -0,0 +1,30 @@ +#!/usr/bin/env node +import { existsSync, unlinkSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { findRootDir, runCommand } from './utils.mjs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const rootDir = findRootDir(__dirname); + +console.log('🚀 Starting release process...'); + +// Double-check we're not in pre-release mode (safety net) +const preJsonPath = join(rootDir, '.changeset', 'pre.json'); +if (existsSync(preJsonPath)) { + console.log('⚠️ Warning: pre.json still exists. Removing it...'); + unlinkSync(preJsonPath); +} + +// Check if the extension version has changed and tag it +// This prevents changeset from trying to publish the private package +runCommand('node', [join(__dirname, 'tag-extension.mjs')]); + +// Run changeset publish for npm packages +runCommand('npx', ['changeset', 'publish']); + +console.log('✅ Release process completed!'); + +// The extension tag (if created) will trigger the extension-release workflow diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh deleted file mode 100755 index ce66c3d1..00000000 --- a/.github/scripts/release.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash -set -e - -echo "🚀 Starting release process..." - -# Double-check we're not in pre-release mode (safety net) -if [ -f .changeset/pre.json ]; then - echo "⚠️ Warning: pre.json still exists. Removing it..." - rm -f .changeset/pre.json -fi - -# Check if the extension version has changed and tag it -# This prevents changeset from trying to publish the private package -node .github/scripts/tag-extension.mjs - -# Run changeset publish for npm packages -npx changeset publish - -echo "✅ Release process completed!" - -# The extension tag (if created) will trigger the extension-release workflow \ No newline at end of file diff --git a/.github/scripts/tag-extension.mjs b/.github/scripts/tag-extension.mjs old mode 100644 new mode 100755 index 7282d756..e34d93bd --- a/.github/scripts/tag-extension.mjs +++ b/.github/scripts/tag-extension.mjs @@ -1,33 +1,13 @@ #!/usr/bin/env node import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { readFileSync, existsSync } from 'node:fs'; -import { join, dirname, resolve } from 'node:path'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { findRootDir, createAndPushTag } from './utils.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -// Find the root directory by looking for package.json -function findRootDir(startDir) { - let currentDir = resolve(startDir); - while (currentDir !== '/') { - if (existsSync(join(currentDir, 'package.json'))) { - // Verify it's the root package.json by checking for expected fields - try { - const pkg = JSON.parse( - readFileSync(join(currentDir, 'package.json'), 'utf8') - ); - if (pkg.name === 'task-master-ai' || pkg.repository) { - return currentDir; - } - } catch {} - } - currentDir = dirname(currentDir); - } - throw new Error('Could not find root directory'); -} - const rootDir = findRootDir(__dirname); // Read the extension's package.json @@ -43,95 +23,11 @@ try { process.exit(1); } -// Read root package.json for repository info -const rootPkgPath = join(rootDir, 'package.json'); -let rootPkg; -try { - const rootPkgContent = readFileSync(rootPkgPath, 'utf8'); - rootPkg = JSON.parse(rootPkgContent); -} catch (error) { - console.error('Failed to read root package.json:', error.message); - process.exit(1); -} - // Ensure we have required fields assert(pkg.name, 'package.json must have a name field'); assert(pkg.version, 'package.json must have a version field'); -assert(rootPkg.repository, 'root package.json must have a repository field'); const tag = `${pkg.name}@${pkg.version}`; -// Get repository URL from root package.json -// Get repository URL and clean it up for git ls-remote -let repoUrl = rootPkg.repository.url || rootPkg.repository; -if (typeof repoUrl === 'string') { - // Convert git+https://github.com/... to https://github.com/... - repoUrl = repoUrl.replace(/^git\+/, ''); - // Ensure it ends with .git for proper remote access - if (!repoUrl.endsWith('.git')) { - repoUrl += '.git'; - } -} - -console.log(`Checking remote repository: ${repoUrl} for tag: ${tag}`); - -let gitResult = spawnSync('git', ['ls-remote', repoUrl, tag], { - encoding: 'utf8', - env: { ...process.env } -}); - -if (gitResult.status !== 0) { - console.error('Git ls-remote failed:'); - console.error('Exit code:', gitResult.status); - console.error('Error:', gitResult.error); - console.error('Stderr:', gitResult.stderr); - console.error('Command:', `git ls-remote ${repoUrl} ${tag}`); - - // For CI environments, try using origin instead of the full URL - if (process.env.CI) { - console.log('Retrying with origin remote...'); - gitResult = spawnSync('git', ['ls-remote', 'origin', tag], { - encoding: 'utf8' - }); - - if (gitResult.status !== 0) { - throw new Error( - `Failed to check remote for tag ${tag}. Exit code: ${gitResult.status}` - ); - } - } else { - throw new Error( - `Failed to check remote for tag ${tag}. Exit code: ${gitResult.status}` - ); - } -} - -const exists = String(gitResult.stdout).trim() !== ''; - -if (!exists) { - console.log(`Creating new extension tag: ${tag}`); - - // Create the tag - const tagResult = spawnSync('git', ['tag', tag]); - if (tagResult.status !== 0) { - console.error( - 'Failed to create tag:', - tagResult.error || tagResult.stderr.toString() - ); - process.exit(1); - } - - // Push the tag - const pushResult = spawnSync('git', ['push', 'origin', tag]); - if (pushResult.status !== 0) { - console.error( - 'Failed to push tag:', - pushResult.error || pushResult.stderr.toString() - ); - process.exit(1); - } - - console.log(`✅ Successfully created and pushed tag: ${tag}`); -} else { - console.log(`Extension tag already exists: ${tag}`); -} +// Create and push the tag if it doesn't exist +createAndPushTag(tag); diff --git a/.github/scripts/utils.mjs b/.github/scripts/utils.mjs new file mode 100755 index 00000000..e40b6a3e --- /dev/null +++ b/.github/scripts/utils.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { join, dirname, resolve } from 'node:path'; + +// Find the root directory by looking for package.json with task-master-ai +export function findRootDir(startDir) { + let currentDir = resolve(startDir); + while (currentDir !== '/') { + const pkgPath = join(currentDir, 'package.json'); + try { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + if (pkg.name === 'task-master-ai' || pkg.repository) { + return currentDir; + } + } catch {} + currentDir = dirname(currentDir); + } + throw new Error('Could not find root directory'); +} + +// Run a command with proper error handling +export function runCommand(command, args = [], options = {}) { + console.log(`Running: ${command} ${args.join(' ')}`); + const result = spawnSync(command, args, { + encoding: 'utf8', + stdio: 'inherit', + ...options + }); + + if (result.status !== 0) { + console.error(`Command failed with exit code ${result.status}`); + process.exit(result.status); + } + + return result; +} + +// Get package version from a package.json file +export function getPackageVersion(packagePath) { + try { + const pkg = JSON.parse(readFileSync(packagePath, 'utf8')); + return pkg.version; + } catch (error) { + console.error( + `Failed to read package version from ${packagePath}:`, + error.message + ); + process.exit(1); + } +} + +// Check if a git tag exists on remote +export function tagExistsOnRemote(tag, remote = 'origin') { + const result = spawnSync('git', ['ls-remote', remote, tag], { + encoding: 'utf8' + }); + + return result.status === 0 && result.stdout.trim() !== ''; +} + +// Create and push a git tag if it doesn't exist +export function createAndPushTag(tag, remote = 'origin') { + // Check if tag already exists + if (tagExistsOnRemote(tag, remote)) { + console.log(`Tag ${tag} already exists on remote, skipping`); + return false; + } + + console.log(`Creating new tag: ${tag}`); + + // Create the tag locally + const tagResult = spawnSync('git', ['tag', tag]); + if (tagResult.status !== 0) { + console.error('Failed to create tag:', tagResult.error || tagResult.stderr); + process.exit(1); + } + + // Push the tag to remote + const pushResult = spawnSync('git', ['push', remote, tag]); + if (pushResult.status !== 0) { + console.error('Failed to push tag:', pushResult.error || pushResult.stderr); + process.exit(1); + } + + console.log(`✅ Successfully created and pushed tag: ${tag}`); + return true; +} diff --git a/.github/workflows/extension-pre-release.yml b/.github/workflows/extension-pre-release.yml new file mode 100644 index 00000000..44526b3b --- /dev/null +++ b/.github/workflows/extension-pre-release.yml @@ -0,0 +1,110 @@ +name: Extension Pre-Release + +on: + push: + tags: + - "extension-rc@*" + +permissions: + contents: write + +concurrency: extension-pre-release-${{ github.ref }} + +jobs: + publish-extension-rc: + runs-on: ubuntu-latest + environment: extension-release + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Cache node_modules + uses: actions/cache@v4 + with: + path: | + node_modules + */*/node_modules + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install Extension Dependencies + working-directory: apps/extension + run: npm ci + timeout-minutes: 5 + + - name: Type Check Extension + working-directory: apps/extension + run: npm run check-types + env: + FORCE_COLOR: 1 + + - name: Build Extension + working-directory: apps/extension + run: npm run build + env: + FORCE_COLOR: 1 + + - name: Package Extension + working-directory: apps/extension + run: npm run package + env: + FORCE_COLOR: 1 + + - name: Create VSIX Package (Pre-Release) + working-directory: apps/extension/vsix-build + run: npx vsce package --no-dependencies --pre-release + env: + FORCE_COLOR: 1 + + - name: Get VSIX filename + id: vsix-info + working-directory: apps/extension/vsix-build + run: | + VSIX_FILE=$(find . -maxdepth 1 -name "*.vsix" -type f | head -n1 | xargs basename) + if [ -z "$VSIX_FILE" ]; then + echo "Error: No VSIX file found" + exit 1 + fi + echo "vsix-filename=$VSIX_FILE" >> "$GITHUB_OUTPUT" + echo "Found VSIX: $VSIX_FILE" + + - name: Publish to VS Code Marketplace (Pre-Release) + working-directory: apps/extension/vsix-build + run: npx vsce publish --packagePath "${{ steps.vsix-info.outputs.vsix-filename }}" --pre-release + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + FORCE_COLOR: 1 + + - name: Install Open VSX CLI + run: npm install -g ovsx + + - name: Publish to Open VSX Registry (Pre-Release) + working-directory: apps/extension/vsix-build + run: ovsx publish "${{ steps.vsix-info.outputs.vsix-filename }}" --pre-release + env: + OVSX_PAT: ${{ secrets.OVSX_PAT }} + FORCE_COLOR: 1 + + - name: Upload Build Artifacts + uses: actions/upload-artifact@v4 + with: + name: extension-pre-release-${{ github.ref_name }} + path: | + apps/extension/vsix-build/*.vsix + apps/extension/dist/ + retention-days: 30 + + notify-success: + needs: publish-extension-rc + if: success() + runs-on: ubuntu-latest + steps: + - name: Success Notification + run: | + echo "🚀 Extension ${{ github.ref_name }} successfully published as pre-release!" + echo "📦 Available on VS Code Marketplace (Pre-Release)" + echo "🌍 Available on Open VSX Registry (Pre-Release)" \ No newline at end of file diff --git a/.github/workflows/extension-release.yml b/.github/workflows/extension-release.yml index 244c490f..111bc80f 100644 --- a/.github/workflows/extension-release.yml +++ b/.github/workflows/extension-release.yml @@ -89,32 +89,6 @@ jobs: OVSX_PAT: ${{ secrets.OVSX_PAT }} FORCE_COLOR: 1 - - name: Create GitHub Release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ github.ref_name }} - release_name: Extension ${{ github.ref_name }} - body: | - VS Code Extension Release ${{ github.ref_name }} - - **Marketplaces:** - - [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=Hamster.task-master-hamster) - - [Open VSX Registry](https://open-vsx.org/extension/Hamster/task-master-hamster) - draft: false - prerelease: false - - - name: Upload VSIX to Release - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: apps/extension/vsix-build/${{ steps.vsix-info.outputs.vsix-filename }} - asset_name: ${{ steps.vsix-info.outputs.vsix-filename }} - asset_content_type: application/zip - - name: Upload Build Artifacts uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 2a5de2f9..16347764 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -9,6 +9,7 @@ jobs: runs-on: ubuntu-latest # Only allow pre-releases on non-main branches if: github.ref != 'refs/heads/main' + environment: extension-release steps: - uses: actions/checkout@v4 with: @@ -50,10 +51,12 @@ jobs: - name: Create Release Candidate Pull Request or Publish Release Candidate to npm uses: changesets/action@v1 with: - publish: npm run release + publish: node ./.github/scripts/pre-release.mjs env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + VSCE_PAT: ${{ secrets.VSCE_PAT }} + OVSX_PAT: ${{ secrets.OVSX_PAT }} - name: Commit & Push changes uses: actions-js/push@master diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3c439f4d..0e21dce0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,7 +44,7 @@ jobs: - name: Create Release Pull Request or Publish to npm uses: changesets/action@v1 with: - publish: ./.github/scripts/release.sh + publish: node ./.github/scripts/release.mjs env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }}