diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc5273d..00d66f2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,6 +40,9 @@ jobs: with: node-version: 20 registry-url: https://registry.npmjs.org + + - name: Update npm to latest + run: npm install -g npm@latest - name: Run release orchestrator env: diff --git a/RELEASE-HOWTO.md b/RELEASE-HOWTO.md index d2e6b74..4b6ff86 100644 --- a/RELEASE-HOWTO.md +++ b/RELEASE-HOWTO.md @@ -30,22 +30,32 @@ solidos/ (main branch) **Workflow:** ``` -You click "Run workflow" button in GitHub Actions +You click "Run workflow" button in GitHub Actions (mode=test or mode=stable) ↓ release.yml starts ↓ -Runs: node scripts/release-orchestrator.js --mode stable ... +Runs: node scripts/release-orchestrator.js --mode ... ↓ Script reads release.config.json (list of repos to release) ↓ For each repo listed: - Clone if missing (optional) + - Checkout branch (dev for test, main for stable) - npm install + - afterInstall with @test or @latest tags (with fallback) + + [Stable mode only: Check skip logic] + - Compare origin/dev vs main + - If dev has new commits → merge origin/dev into main with [skip ci] + - If no changes and --branch not specified → skip this repo + + [Test mode: always continues] + - npm test - npm run build - - npm version (bump patch/minor/major) - - npm publish (to npm registry) - - git push + tags (for stable only) + - npm version (bump patch/minor/major/prerelease) + - npm publish (to npm registry with @test or @latest tag) + - git push + tags (stable only) ↓ Generates release-summary.json ``` @@ -88,21 +98,27 @@ node scripts/release-orchestrator.js --mode test --dry-run=true - Publishes to npm with `@test` tag - Does NOT create git tags - Results in GitHub Actions logs and artifacts -- **Use case:** Pre-release versions for testing +- **Always publishes** (no skip logic) +- **Use case:** Pre-release versions for testing from dev branch **Scenario 3: GitHub Stable Release** - Click Actions → "Solidos Release" → Run workflow - Inputs: mode=stable, dry_run=false +- Automatically merges origin/dev → main if dev has new commits - Publishes to npm with `@latest` tag - Creates git tags and pushes to GitHub - Results in GitHub Actions logs and artifacts -- **Use case:** Production releases +- Skips if dev has no new commits (unless --branch=main specified) +- **Use case:** Production releases to @latest Local dry-run - Show the exact commands without running them: node scripts/release-orchestrator.js --mode test --dry-run - Override the branch: node scripts/release-orchestrator.js --mode test --dry-run --branch develop +- Force stable publish regardless of changes: + node scripts/release-orchestrator.js --mode stable --branch main +- Dry-run allows untracked files (ignored for convenience) CI runs (GitHub Actions) - Trigger workflow "Solidos Release" with inputs: @@ -118,6 +134,7 @@ Command-line Options - --dry-run: true or false (default: false) - --clone-missing: true or false (default: false) - --branch: override branch for all repos (optional) + - Also disables skip logic in stable mode (forces publish) - --summary-path: path to output summary file (default: release-summary.json) Branch Configuration @@ -128,39 +145,62 @@ By default, both test and stable modes use the `main` branch. To use different b ```json { "defaultBranch": "main", - "modes": { - "test": { - "branch": "develop", + "modes": [ + { + "name": "test", + "branch": "dev", "versionBump": "prerelease", - "preid": "test" + "preid": "test", + "npmTag": "test" }, - "stable": { + { + "name": "stable", "branch": "main", - "versionBump": "patch" + "versionBump": "patch", + "npmTag": "latest" } - }, + ], "repos": [ { "name": "solid-panes", - "path": "./workspaces/solid-panes" + "path": "./workspaces/solid-panes", + "afterInstall": [ + "npm install profile-pane" + ] } ] } ``` -Now: -- Test releases pull from `develop` branch -- Stable releases pull from `main` branch +Behavior: +- **Test mode:** + - Pulls from `dev` branch + - Always publishes (no skip) + - afterInstall `npm install profile-pane` becomes `npm install profile-pane@test || npm install profile-pane@latest` +- **Stable mode:** + - Pulls from `main` branch + - Auto-merges origin/dev if it has new commits + - Skips if no changes (unless --branch=main specified) Publish modes - test: + - Runs on: dev branch (or configured branch) - npm version prerelease --preid test - - npm publish --tag test + - npm publish --tag test --ignore-scripts - does NOT create git tags or push + - **Always publishes** (no skip check) + - If the prerelease version already exists, it auto-bumps again before publishing + - Temporarily disables preversion/version/postversion scripts during version bump + - afterInstall commands use @test tag with @latest fallback - stable: + - Runs on: main branch (or configured branch) + - Checks if origin/dev has commits that main doesn't + - If yes: auto-merges origin/dev → main (may fail on conflicts) - npm version patch (or configured bump) - - npm publish (latest) + - npm publish (latest) with --ignore-scripts - creates git tags and pushes by default + - Skips if no changes (unless --branch explicitly specified) + - afterInstall commands use @latest tag Multiple configs - Create additional config files (for example): @@ -170,15 +210,26 @@ Multiple configs - Use with: --config release.config.test.json Skip logic -- If there is no git diff vs origin/main (or configured branch), the repo is skipped. +- **Test mode:** Always publishes (no skip logic) +- **Stable mode:** + - Compares origin/dev vs main to detect unpublished changes + - If origin/dev has commits that main doesn't: merges and publishes + - If no changes: skips publishing + - Override: `--branch=main` forces publish regardless of changes + - Merge happens automatically before publish (fails if conflicts) + - Merge commit includes `[skip ci]` to prevent redundant ci.yml runs Summary output - A summary is printed at the end and written to release-summary.json. - Override with: --summary-path path/to/summary.json -npm install test builds -- npm install @test installs the latest package published under the "test" dist-tag. -- You can also install a specific test version by pinning it explicitly. +npm install with dist-tags +- **Test mode:** afterInstall commands automatically inject @test tags + - Example: `npm install solid-ui` becomes `npm install solid-ui@test` + - Fallback: If @test doesn't exist, tries @latest automatically + - Command: `npm install solid-ui@test || npm install solid-ui@latest` +- **Stable mode:** afterInstall commands use @latest tags (default npm behavior) +- Manual install: `npm install @test` to get test versions Config options (release.config.json) - defaultBranch: branch name used if repo does not override. @@ -225,6 +276,12 @@ Each workspace repo (solid-panes, folder-pane, etc.) has its own `ci.yml` workfl └─ [waiting for manual release trigger] └─ You click "Run workflow" in Actions └─ release.yml runs → publishes to npm + └─ (stable mode) auto-merges dev→main with [skip ci] + └─ pushes version tags and commits + └─ ci.yml does NOT run (prevented by [skip ci]) ``` -**Important:** Waiting PRs are NOT automatically published. You must manually trigger the release after merging. +**Important Notes:** +- Waiting PRs are NOT automatically published. You must manually trigger the release after merging. +- When stable mode merges dev→main automatically, it uses `[skip ci]` in the commit message to prevent redundant ci.yml runs in individual repos. +- Tests/builds already ran in the release orchestrator, so skipping ci.yml avoids duplicate work. diff --git a/scripts/release-orchestrator.js b/scripts/release-orchestrator.js index 4bfb474..e90c577 100644 --- a/scripts/release-orchestrator.js +++ b/scripts/release-orchestrator.js @@ -75,7 +75,7 @@ function ensureClean(repoDir, dryRun) { function ensureBranch(repoDir, branch, dryRun) { // Fetch all remote branches - run(`git fetch origin --all`, repoDir, dryRun); + run(`git fetch --all`, repoDir, dryRun); // Only verify branch exists if not dry-run (actual fetch happened) if (!dryRun) { @@ -145,42 +145,124 @@ function parseNpmInstallCmd(cmd, tag) { return mainCmd; } +function disableVersionScripts(repoDir) { + const pkgPath = path.join(repoDir, 'package.json'); + if (!fs.existsSync(pkgPath)) return null; + const pkg = readJson(pkgPath); + if (!pkg.scripts) return null; + + const original = { ...pkg.scripts }; + const updated = { ...pkg.scripts }; + let changed = false; + + for (const key of Object.keys(updated)) { + if (key === 'preversion' || key === 'postversion' || key === 'version') { + updated[`ignore:${key}`] = updated[key]; + delete updated[key]; + changed = true; + } + } + + if (!changed) return null; + + pkg.scripts = updated; + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); + return original; +} + +function restoreVersionScripts(repoDir, originalScripts) { + if (!originalScripts) return; + const pkgPath = path.join(repoDir, 'package.json'); + if (!fs.existsSync(pkgPath)) return; + const pkg = readJson(pkgPath); + pkg.scripts = originalScripts; + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); +} + +function packageVersionExists(name, version, repoDir) { + if (!name || !version) return false; + try { + const output = runQuiet(`npm view ${name}@${version} version`, repoDir); + return output.trim() !== ''; + } catch (err) { + return false; + } +} + function publishStable(repoDir, modeConfig, dryRun) { const bump = modeConfig.versionBump || 'patch'; - if (modeConfig.gitTag === false) { - run(`npm version ${bump} --no-git-tag-version`, repoDir, dryRun); - } else { - run(`npm version ${bump} -m "Release %s"`, repoDir, dryRun); + const originalScripts = disableVersionScripts(repoDir); + try { + if (modeConfig.gitTag === false) { + run(`npm version ${bump} --no-git-tag-version`, repoDir, dryRun); + } else { + run(`npm version ${bump} -m "Release %s"`, repoDir, dryRun); + } + } finally { + restoreVersionScripts(repoDir, originalScripts); } + const pkg = getPackageJson(repoDir); + const packageName = pkg ? pkg.name : null; const version = getPackageVersion(repoDir); const tag = modeConfig.npmTag && modeConfig.npmTag !== 'latest' ? `--tag ${modeConfig.npmTag}` : ''; - run(`npm publish ${tag}`.trim(), repoDir, dryRun); + // Ignore lifecycle scripts to avoid postpublish git pushes in CI. + console.log(`Publishing ${packageName || 'package'}@${version} with tag ${modeConfig.npmTag || 'latest'}...`); + run(`npm publish ${tag} --ignore-scripts --no-provenance`.trim(), repoDir, dryRun); if (modeConfig.gitPush !== false && modeConfig.gitTag !== false) { const branch = modeConfig.branch || 'main'; run(`git push origin ${branch} --follow-tags`, repoDir, dryRun); } - return { version, tag: modeConfig.npmTag || 'latest' }; + return { packageName, version, tag: modeConfig.npmTag || 'latest' }; } function publishTest(repoDir, modeConfig, dryRun) { const preid = modeConfig.preid || 'test'; - run(`npm version prerelease --preid ${preid} --no-git-tag-version`, repoDir, dryRun); + const originalScripts = disableVersionScripts(repoDir); + try { + run(`npm version prerelease --preid ${preid} --no-git-tag-version`, repoDir, dryRun); + } finally { + restoreVersionScripts(repoDir, originalScripts); + } - const version = getPackageVersion(repoDir); + const pkg = getPackageJson(repoDir); + const name = pkg ? pkg.name : null; + let version = getPackageVersion(repoDir); + let attempts = 0; + const maxAttempts = 5; + + if (!dryRun && name) { + while (attempts < maxAttempts && packageVersionExists(name, version, repoDir)) { + console.log(`Version ${version} already published. Bumping prerelease...`); + const retryOriginalScripts = disableVersionScripts(repoDir); + try { + run(`npm version prerelease --preid ${preid} --no-git-tag-version`, repoDir, dryRun); + } finally { + restoreVersionScripts(repoDir, retryOriginalScripts); + } + version = getPackageVersion(repoDir); + attempts += 1; + } + + if (attempts === maxAttempts && packageVersionExists(name, version, repoDir)) { + throw new Error(`Unable to find an unpublished prerelease version after ${maxAttempts} attempts.`); + } + } const tag = modeConfig.npmTag || 'test'; - run(`npm publish --tag ${tag}`, repoDir, dryRun); + console.log(`Publishing ${name || 'package'}@${version} with tag ${tag}...`); + // Ignore lifecycle scripts to avoid postpublish git pushes in CI. + run(`npm publish --tag ${tag} --ignore-scripts --no-provenance`, repoDir, dryRun); console.log('Note: test publish updated package.json/package-lock.json.'); console.log(' Use git restore to clean if you do not want to keep it.'); - return { version, tag }; + return { packageName: name, version, tag }; } function main() { @@ -259,21 +341,69 @@ function main() { runSteps(repo.afterInstall, repoDir, dryRun, npmTag); } - // Check for changes AFTER install (which may have modified files) - // Skip this check in dry-run mode (to show full workflow) - const skipIfNoDiff = repo.skipIfNoDiff ?? config.skipIfNoDiff ?? true; - const shouldCheckDiff = !dryRun && skipIfNoDiff; + // Check for changes AFTER install (only for stable mode) + // Test mode always publishes + // Stable mode skips if no diff, unless branch was explicitly specified or dry-run + let shouldMergeDev = false; - if (shouldCheckDiff) { - const { ahead: aheadAfterInstall } = getAheadBehind(repoDir, branch); - if (aheadAfterInstall === 0) { - console.log('No changes vs origin after install. Skipping publish.'); - summary.push({ - name: repo.name, - status: 'skipped', - reason: 'no-diff' - }); - continue; + if (mode === 'stable') { + const skipIfNoDiff = repo.skipIfNoDiff ?? config.skipIfNoDiff ?? true; + const shouldCheckDiff = !dryRun && skipIfNoDiff && !branchOverride; + + if (shouldCheckDiff) { + // For stable mode: check if dev branch has changes that main doesn't + const devBranch = config.modes.find(m => m.name === 'test')?.branch || 'dev'; + + // Ensure we have latest dev refs + try { + runQuiet(`git fetch origin ${devBranch}:refs/remotes/origin/${devBranch}`, repoDir); + } catch (err) { + console.log(`Warning: Could not fetch ${devBranch}: ${err.message}`); + } + + // Count commits that dev has but main doesn't + const commitsAhead = parseInt(runQuiet(`git rev-list --count ${branch}..origin/${devBranch}`, repoDir)) || 0; + + if (commitsAhead === 0) { + console.log(`No changes in origin/${devBranch} vs ${branch}. Skipping publish.`); + summary.push({ + name: repo.name, + status: 'skipped', + reason: 'no-diff' + }); + continue; + } else { + console.log(`Found ${commitsAhead} commit(s) in ${devBranch} not in ${branch}. Will merge and publish.`); + shouldMergeDev = true; + } + } + } + + // For stable mode: check if we need to merge dev (even if skipIfNoDiff is disabled) + if (mode === 'stable' && !shouldMergeDev) { + const devBranch = config.modes.find(m => m.name === 'test')?.branch || 'dev'; + + try { + runQuiet(`git fetch origin ${devBranch}:refs/remotes/origin/${devBranch}`, repoDir); + const commitsAhead = parseInt(runQuiet(`git rev-list --count ${branch}..origin/${devBranch}`, repoDir)) || 0; + + if (commitsAhead > 0) { + console.log(`Found ${commitsAhead} commit(s) in ${devBranch} not in ${branch}. Will merge before publish.`); + shouldMergeDev = true; + } + } catch (err) { + console.log(`Warning: Could not check ${devBranch}: ${err.message}`); + } + } + + // Merge dev into main before publishing (stable mode only) + if (mode === 'stable' && shouldMergeDev) { + const devBranch = config.modes.find(m => m.name === 'test')?.branch || 'dev'; + console.log(`Merging origin/${devBranch} into ${branch}...`); + try { + run(`git merge origin/${devBranch} -m "Merge ${devBranch} into ${branch} for release [skip ci]"`, repoDir, dryRun); + } catch (err) { + throw new Error(`Failed to merge origin/${devBranch} into ${branch}. Please resolve conflicts manually.`); } } @@ -306,16 +436,20 @@ function main() { summary.push({ name: repo.name, status: dryRun ? 'dry-run' : 'published', + packageName: result.packageName || null, version: result.version, - tag: result.tag + tag: result.tag, + publishedAs: result.packageName ? `${result.packageName}@${result.version}` : null }); } else if (mode === 'stable') { const result = publishStable(repoDir, effectiveModeConfig, dryRun); summary.push({ name: repo.name, status: dryRun ? 'dry-run' : 'published', + packageName: result.packageName || null, version: result.version, - tag: result.tag + tag: result.tag, + publishedAs: result.packageName ? `${result.packageName}@${result.version}` : null }); } else { throw new Error(`Unknown mode: ${mode}`);