diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f947253..18dcf4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,3 +75,6 @@ jobs: - name: Run tests run: npm test + + - name: Run release metadata checks + run: npm run release:check diff --git a/.gitignore b/.gitignore index b66a1a8..0126d19 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ Library/ library/ dist/ build/ +.release-tmp/ +releases/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d7ae80..7e12ee3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ This project follows a simple changelog format inspired by [Keep a Changelog](ht ## [Unreleased] +## [0.3.1] - 2026-05-20 + +### Added + +- Added a documented release workflow and release checklist for Cocos extension publishing. +- Added release packaging scripts that generate a Cocos extension zip, release manifest, checksum file, and per-release README. +- Added CI validation for release metadata. + ## [0.3.0] - 2026-05-20 ### Added diff --git a/README.md b/README.md index 07b88a2..ff9bc09 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ git clone https://github.com/FunplayAI/funplay-cocos-mcp.git extensions/funplay- Then restart Cocos Creator or reload extensions from the editor. +For a non-git install, download `Funplay.CocosMcp.v.zip` from the GitHub Releases page, unzip it, and move the extracted `funplay-cocos-mcp` folder into your project `extensions/` directory. + You can also install it globally by copying the folder into your Cocos Creator user extensions directory. ### 2. Start the MCP Server @@ -362,12 +364,22 @@ The server speaks MCP-style HTTP JSON-RPC 2.0 and supports tools, resources, res ## Development -Run a syntax check before publishing changes: +Run checks before publishing changes: ```bash npm run check +npm test +npm run release:check ``` +To generate a GitHub Release-ready extension package: + +```bash +npm run release:package +``` + +The package is written to `releases//` with a zip, manifest, checksum file, and release README. See [RELEASE_WORKFLOW.md](./RELEASE_WORKFLOW.md) and [RELEASE_CHECKLIST.md](./RELEASE_CHECKLIST.md) for the full process. + ## License MIT License. See [LICENSE](./LICENSE). diff --git a/README_CN.md b/README_CN.md index 9724272..c975295 100644 --- a/README_CN.md +++ b/README_CN.md @@ -51,6 +51,8 @@ git clone https://github.com/FunplayAI/funplay-cocos-mcp.git extensions/funplay- 然后重启 Cocos Creator,或在编辑器里重新加载扩展。 +如果不想用 git 安装,可以从 GitHub Releases 下载 `Funplay.CocosMcp.v.zip`,解压后把 `funplay-cocos-mcp` 目录移动到项目的 `extensions/` 目录。 + 你也可以把目录复制到 Cocos Creator 的全局用户扩展目录中。 ### 2. 启动 MCP Server @@ -362,12 +364,22 @@ Cocos Creator Extension ## 开发 -发布改动前可以跑语法检查: +发布改动前可以跑检查: ```bash npm run check +npm test +npm run release:check ``` +生成可上传到 GitHub Release 的扩展包: + +```bash +npm run release:package +``` + +产物会写入 `releases//`,包含 zip、manifest、checksum 和 release README。完整流程见 [RELEASE_WORKFLOW.md](./RELEASE_WORKFLOW.md) 和 [RELEASE_CHECKLIST.md](./RELEASE_CHECKLIST.md)。 + ## 协议 MIT License。详见 [LICENSE](./LICENSE)。 diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md new file mode 100644 index 0000000..ec9fc15 --- /dev/null +++ b/RELEASE_CHECKLIST.md @@ -0,0 +1,73 @@ +# Release Checklist + +Use this checklist before publishing a new release of Funplay MCP for Cocos. + +## 1. Repository Hygiene + +- [ ] `git status` contains only intended release changes +- [ ] No tracked local junk is present (`.DS_Store`, `.idea/`, `node_modules/`, `Library/`, `Temp/`, `dist/`, `build/`) +- [ ] `package.json` version matches the intended release +- [ ] `CHANGELOG.md` includes the release notes for the target version +- [ ] `README.md` and `README_CN.md` match the current product behavior + +## 2. Automated Verification + +- [ ] `npm run check` passes +- [ ] `npm test` passes +- [ ] `npm run release:check` passes +- [ ] `npm run release:package` creates `releases//` +- [ ] `shasum -a 256 -c releases//SHA256SUMS.txt` passes + +## 3. Package Contents + +- [ ] The zip is named `Funplay.CocosMcp.v.zip` +- [ ] The zip contains a single top-level `funplay-cocos-mcp/` folder +- [ ] The zip contains runtime files: `package.json`, `browser.js`, `scene.js`, `panel/`, and `lib/` +- [ ] The zip includes docs: `README.md`, `README_CN.md`, `CHANGELOG.md`, `CONTRIBUTING.md`, and `LICENSE` +- [ ] The zip does not contain `.git/`, `.github/`, `.DS_Store`, `node_modules/`, `Library/`, `Temp/`, `dist/`, `build/`, `test/`, or `scripts/` +- [ ] `release-manifest.json` references the correct GitHub download URL +- [ ] `SHA256SUMS.txt` includes the zip, manifest, and release README + +## 4. Cocos Smoke Test + +- [ ] Test in a clean Cocos Creator `3.8+` project +- [ ] Install from the generated zip into `/extensions/funplay-cocos-mcp` +- [ ] Restart Cocos Creator or reload extensions +- [ ] Open `Funplay > MCP Server` +- [ ] Start the MCP server successfully +- [ ] If the configured port is already in use, verify automatic fallback is reported clearly +- [ ] Run a read-only tool such as `get_project_info` +- [ ] Run a scene inspection tool such as `get_scene_info` +- [ ] Run a screenshot tool when the editor has a visible scene or preview +- [ ] Verify interaction logs appear in the MCP Server panel + +## 5. MCP Client Verification + +- [ ] Verify at least one primary client can connect (`Claude Code`, `Cursor`, `Codex`, etc.) +- [ ] Confirm `tools/list` returns the expected `core` profile tools +- [ ] Confirm a tool call succeeds end-to-end from the external client +- [ ] Verify one-click config output still matches the documented config snippets + +## 6. GitHub Release Readiness + +- [ ] CI passes on `main` +- [ ] Release commit message is `Release v` +- [ ] Tag is `v` +- [ ] GitHub Release title is `v` +- [ ] GitHub Release includes the zip, manifest, checksum file, and release README +- [ ] Public GitHub Release page renders the release notes and assets correctly + +## 7. Publish + +- [ ] Commit the release changes +- [ ] Create and push the release tag +- [ ] Create or update the GitHub Release +- [ ] Upload generated release assets +- [ ] Verify the GitHub Release asset list + +## 8. Post-Release + +- [ ] Re-test installation from the public GitHub Release zip +- [ ] Check the update checker reports the new latest version +- [ ] Check README install instructions and download links +- [ ] Announce the release where appropriate diff --git a/RELEASE_WORKFLOW.md b/RELEASE_WORKFLOW.md new file mode 100644 index 0000000..ebd82f3 --- /dev/null +++ b/RELEASE_WORKFLOW.md @@ -0,0 +1,228 @@ +# Funplay Cocos MCP Release Workflow + +This document records the release workflow for publishing Funplay MCP for Cocos to: + +- Git tags +- GitHub Releases +- Downloadable Cocos Creator extension zip packages + +## Published Identity + +- GitHub repository: `https://github.com/FunplayAI/funplay-cocos-mcp` +- Git tag format: `v` +- GitHub Release tag: `v` +- Extension package asset: `Funplay.CocosMcp.v.zip` +- Cocos extension folder name inside the zip: `funplay-cocos-mcp` +- Default local MCP endpoint: `http://127.0.0.1:8765/` + +## Version Alignment Rule + +Keep these versions aligned: + +- `package.json` `version` +- `CHANGELOG.md` release section +- Git tag `v` +- GitHub Release `v` +- `releases//release-manifest.json` `version` +- Release zip filename `Funplay.CocosMcp.v.zip` + +Example: + +- `package.json`: `0.3.1` +- Git tag: `v0.3.1` +- GitHub Release: `v0.3.1` +- Release asset: `Funplay.CocosMcp.v0.3.1.zip` + +## Files To Update For A New Release + +Update: + +1. `package.json` + - `"version": ""` +2. `CHANGELOG.md` + - add a dated release notes block + +Optional but recommended: + +3. `README.md` +4. `README_CN.md` +5. GitHub Release notes text + +## Release Steps + +### 1. Verify Working Tree + +```bash +git status --short --branch +``` + +The tree should contain only intentional release changes. + +### 2. Update Versions And Notes + +Update `package.json` and `CHANGELOG.md`. + +Use semantic versions such as `0.3.1`, and keep release headings in this format: + +```markdown +## [0.3.1] - 2026-05-20 +``` + +### 3. Run Release Verification + +```bash +npm run release:verify +``` + +This runs: + +- JavaScript syntax checks +- Node.js tests +- release metadata validation +- release package generation + +The generated local artifacts are written to: + +```text +releases// +``` + +Expected contents: + +- `Funplay.CocosMcp.v.zip` +- `release-manifest.json` +- `SHA256SUMS.txt` +- `README.md` + +### 4. Inspect The Package + +The release script validates that every archive path stays under: + +```text +funplay-cocos-mcp/ +``` + +The package must not contain local/build content such as: + +- `.git/` +- `.github/` +- `.DS_Store` +- `node_modules/` +- `Library/` +- `Temp/` +- `dist/` +- `build/` +- `test/` +- `scripts/` + +Verify checksums: + +```bash +cd releases/ +shasum -a 256 -c SHA256SUMS.txt +``` + +### 5. Commit, Tag, And Push + +```bash +git add . +git commit -m "Release v" +git tag v +git push origin main +git push origin v +``` + +### 6. Create GitHub Release + +Regenerate the final release artifacts from the tagged clean commit: + +```bash +npm run release:package -- --strict-tag +``` + +If creating a new release: + +```bash +gh release create v \ + -R FunplayAI/funplay-cocos-mcp \ + --title "v" \ + --notes-file /path/to/release-notes.md \ + releases//Funplay.CocosMcp.v.zip \ + releases//release-manifest.json \ + releases//SHA256SUMS.txt \ + releases//README.md +``` + +If the release already exists and only assets need to be replaced: + +```bash +gh release upload v \ + -R FunplayAI/funplay-cocos-mcp \ + --clobber \ + releases//Funplay.CocosMcp.v.zip \ + releases//release-manifest.json \ + releases//SHA256SUMS.txt \ + releases//README.md +``` + +### 7. Verify GitHub Release + +```bash +gh release view v \ + -R FunplayAI/funplay-cocos-mcp \ + --json url,assets,isDraft,isPrerelease,publishedAt +``` + +Confirm the release has all four assets. + +### 8. Post-Release Smoke Test + +Test the package from the public GitHub Release: + +1. Download `Funplay.CocosMcp.v.zip`. +2. Unzip it. +3. Move `funplay-cocos-mcp` into a Cocos project `extensions/` directory. +4. Restart Cocos Creator or reload extensions. +5. Open `Funplay > MCP Server`. +6. Start the MCP server. +7. Connect an MCP client and call `get_project_info`. + +## Current Verification Commands + +```bash +npm run release:verify +gh release view v -R FunplayAI/funplay-cocos-mcp --json url,assets +``` + +## Common Failure Cases + +### Release validation says the changelog section is missing + +Cause: + +- `CHANGELOG.md` does not contain `## [] - YYYY-MM-DD`. + +Fix: + +- Add a dated release section before packaging. + +### `zip` command is missing + +Cause: + +- The local environment does not have the `zip` CLI installed. + +Fix: + +- Install `zip`, then rerun `npm run release:package`. + +### GitHub Release upload replaces the wrong assets + +Cause: + +- The version directory or release tag does not match `package.json` version. + +Fix: + +- Rerun `npm run release:check`. +- Confirm the command uses `releases//` and `v`. diff --git a/package.json b/package.json index 750b5d2..e97121f 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,17 @@ { "name": "funplay-cocos-mcp", "package_version": 2, - "version": "0.3.0", + "version": "0.3.1", "description": "Embedded MCP server for Cocos Creator with scene script execution, project resources, prompts, and file/scene tools.", "author": "Funplay", "license": "MIT", "main": "browser.js", "scripts": { - "check": "node --check browser.js && node --check scene.js && node --check panel/index.js && node --check lib/assets.js && node --check lib/client-config.js && node --check lib/config.js && node --check lib/diagnostics.js && node --check lib/electron-tools.js && node --check lib/input.js && node --check lib/interaction-log.js && node --check lib/logs.js && node --check lib/path-safety.js && node --check lib/prefabs.js && node --check lib/project-instructions.js && node --check lib/prompts.js && node --check lib/resources.js && node --check lib/runtime-log.js && node --check lib/screenshots.js && node --check lib/server.js && node --check lib/tool-registry.js && node --check lib/update-checker.js && node --check lib/utils.js", - "test": "node --test" + "check": "node --check browser.js && node --check scene.js && node --check panel/index.js && node --check lib/assets.js && node --check lib/client-config.js && node --check lib/config.js && node --check lib/diagnostics.js && node --check lib/electron-tools.js && node --check lib/input.js && node --check lib/interaction-log.js && node --check lib/logs.js && node --check lib/path-safety.js && node --check lib/prefabs.js && node --check lib/project-instructions.js && node --check lib/prompts.js && node --check lib/resources.js && node --check lib/runtime-log.js && node --check lib/screenshots.js && node --check lib/server.js && node --check lib/tool-registry.js && node --check lib/update-checker.js && node --check lib/utils.js && node --check scripts/release.js", + "test": "node --test", + "release:check": "node scripts/release.js check", + "release:package": "node scripts/release.js package", + "release:verify": "npm run check && npm test && npm run release:check && npm run release:package" }, "panels": { "default": { diff --git a/scripts/release.js b/scripts/release.js new file mode 100644 index 0000000..b51a140 --- /dev/null +++ b/scripts/release.js @@ -0,0 +1,505 @@ +#!/usr/bin/env node +'use strict'; + +const childProcess = require('child_process'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const PACKAGE_DIR_NAME = 'funplay-cocos-mcp'; +const RELEASES_DIR = path.join(ROOT, 'releases'); +const TEMP_DIR = path.join(ROOT, '.release-tmp'); +const ZIP_PREFIX = 'Funplay.CocosMcp'; +const REPOSITORY_URL = 'https://github.com/FunplayAI/funplay-cocos-mcp'; + +const REQUIRED_REPO_FILES = [ + 'package.json', + 'README.md', + 'README_CN.md', + 'RELEASE_WORKFLOW.md', + 'RELEASE_CHECKLIST.md', + 'CHANGELOG.md', + 'CONTRIBUTING.md', + 'LICENSE', + 'browser.js', + 'scene.js', + 'panel/index.js', + 'lib/server.js', + 'lib/tool-registry.js' +]; + +const PACKAGE_INCLUDES = [ + 'package.json', + 'README.md', + 'README_CN.md', + 'CHANGELOG.md', + 'CONTRIBUTING.md', + 'LICENSE', + 'browser.js', + 'scene.js', + 'panel', + 'lib' +]; + +const FORBIDDEN_TRACKED_SEGMENTS = new Set([ + '.idea', + 'node_modules', + 'Library', + 'library', + 'Temp', + 'temp', + 'dist', + 'build', + 'coverage', + 'releases', + '.release-tmp' +]); + +const FORBIDDEN_ARCHIVE_SEGMENTS = new Set([ + '.git', + '.github', + '.idea', + 'node_modules', + 'Library', + 'library', + 'Temp', + 'temp', + 'dist', + 'build', + 'coverage', + 'releases', + '.release-tmp', + 'scripts', + 'test' +]); + +const FORBIDDEN_NAMES = new Set([ + '.DS_Store' +]); + +function main() { + const command = process.argv[2] || 'check'; + const options = parseOptions(process.argv.slice(3)); + + if (command === 'check') { + const context = checkRelease(options); + console.log(`Release check passed for v${context.version}.`); + return; + } + + if (command === 'package') { + const context = checkRelease(options); + const artifacts = packageRelease(context); + console.log(`Release package ready: ${path.relative(ROOT, artifacts.releaseDir)}`); + console.log(`- ${artifacts.zipName}`); + console.log('- release-manifest.json'); + console.log('- SHA256SUMS.txt'); + console.log('- README.md'); + return; + } + + printUsage(); + process.exitCode = 2; +} + +function parseOptions(args) { + const options = { + version: '', + strictTag: false + }; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === '--version' && args[i + 1]) { + options.version = args[i + 1]; + i += 1; + } else if (arg === '--strict-tag') { + options.strictTag = true; + } else { + throw new Error(`Unknown release option: ${arg}`); + } + } + + return options; +} + +function checkRelease(options = {}) { + const errors = []; + const packageJson = readJson(path.join(ROOT, 'package.json'), errors); + const version = options.version || (packageJson && packageJson.version) || ''; + const tag = `v${version}`; + + if (!packageJson) { + throwErrors(errors); + } + + if (options.version && options.version !== packageJson.version) { + errors.push(`--version ${options.version} does not match package.json version ${packageJson.version}.`); + } + + if (packageJson.name !== 'funplay-cocos-mcp') { + errors.push('package.json name must be funplay-cocos-mcp.'); + } + + if (!Number.isInteger(packageJson.package_version) || packageJson.package_version <= 0) { + errors.push('package.json package_version must be a positive integer.'); + } + + if (!packageJson.main || !fs.existsSync(path.join(ROOT, packageJson.main))) { + errors.push('package.json main must point to an existing file.'); + } + + if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) { + errors.push(`package.json version must be semver-like, got: ${version}`); + } + + for (const relative of REQUIRED_REPO_FILES) { + if (!fs.existsSync(path.join(ROOT, relative))) { + errors.push(`Missing required repository file: ${relative}`); + } + } + + const changelogPath = path.join(ROOT, 'CHANGELOG.md'); + const changelog = fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, 'utf8') : ''; + if (version && !new RegExp(`^## \\[${escapeRegExp(version)}\\] - \\d{4}-\\d{2}-\\d{2}`, 'm').test(changelog)) { + errors.push(`CHANGELOG.md is missing a dated ## [${version}] release section.`); + } + + const trackedFiles = gitLines(['ls-files']); + const forbiddenTracked = trackedFiles.filter(isForbiddenTrackedPath); + if (forbiddenTracked.length > 0) { + errors.push(`Tracked local/build junk must not be committed:\n- ${forbiddenTracked.join('\n- ')}`); + } + + for (const relative of PACKAGE_INCLUDES) { + const fullPath = path.join(ROOT, relative); + if (!fs.existsSync(fullPath)) { + errors.push(`Package include path is missing: ${relative}`); + } + } + + if (options.strictTag && !gitTagExists(tag)) { + errors.push(`Git tag ${tag} does not exist. Create it before publishing.`); + } + + throwErrors(errors); + + return { + packageJson, + version, + tag, + changelogNotes: extractChangelogNotes(changelog, version), + gitCommit: gitText(['rev-parse', 'HEAD']).trim(), + gitDirty: gitText(['status', '--porcelain']).trim() !== '' + }; +} + +function packageRelease(context) { + ensureCommand('zip'); + + const releaseDir = path.join(RELEASES_DIR, context.version); + const stagingRoot = path.join(TEMP_DIR, PACKAGE_DIR_NAME); + const zipName = `${ZIP_PREFIX}.v${context.version}.zip`; + const zipPath = path.join(releaseDir, zipName); + + fs.rmSync(releaseDir, { recursive: true, force: true }); + fs.rmSync(TEMP_DIR, { recursive: true, force: true }); + fs.mkdirSync(releaseDir, { recursive: true }); + fs.mkdirSync(stagingRoot, { recursive: true }); + + for (const relative of PACKAGE_INCLUDES) { + copyIntoPackage(relative, stagingRoot); + } + + const stagedFiles = collectFiles(stagingRoot) + .map((filePath) => path.relative(TEMP_DIR, filePath).split(path.sep).join('/')); + validateArchivePaths(stagedFiles); + + run('zip', ['-qr', zipPath, PACKAGE_DIR_NAME], { cwd: TEMP_DIR }); + validateZipListing(zipPath); + + const zipSha256 = sha256File(zipPath); + const zipSize = fs.statSync(zipPath).size; + const manifest = buildManifest(context, { + zipName, + zipSha256, + zipSize, + fileCount: stagedFiles.length + }); + + const manifestPath = path.join(releaseDir, 'release-manifest.json'); + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + + const readmePath = path.join(releaseDir, 'README.md'); + fs.writeFileSync(readmePath, buildReleaseReadme(context, manifest)); + + const checksums = [ + checksumLine(zipPath, zipName), + checksumLine(manifestPath, 'release-manifest.json'), + checksumLine(readmePath, 'README.md') + ].join(''); + fs.writeFileSync(path.join(releaseDir, 'SHA256SUMS.txt'), checksums); + + fs.rmSync(TEMP_DIR, { recursive: true, force: true }); + + return { + releaseDir, + zipName + }; +} + +function copyIntoPackage(relative, stagingRoot) { + const source = path.join(ROOT, relative); + const destination = path.join(stagingRoot, relative); + fs.cpSync(source, destination, { + recursive: true, + force: true, + filter(sourcePath) { + const name = path.basename(sourcePath); + if (FORBIDDEN_NAMES.has(name)) { + return false; + } + const relativeSource = path.relative(ROOT, sourcePath).split(path.sep); + return !relativeSource.some((part) => FORBIDDEN_ARCHIVE_SEGMENTS.has(part)); + } + }); +} + +function buildManifest(context, artifact) { + return { + version: context.version, + generatedAt: new Date().toISOString(), + repository: { + url: REPOSITORY_URL, + source: 'github' + }, + git: { + tag: context.tag, + commit: context.gitCommit, + dirty: context.gitDirty + }, + package: { + name: context.packageJson.name, + version: context.packageJson.version, + main: context.packageJson.main, + packageVersion: context.packageJson.package_version + }, + artifacts: { + extensionZip: { + file: artifact.zipName, + sha256: artifact.zipSha256, + sizeBytes: artifact.zipSize, + fileCount: artifact.fileCount, + installDirectory: 'extensions/funplay-cocos-mcp', + githubDownloadUrl: `${REPOSITORY_URL}/releases/download/${context.tag}/${artifact.zipName}` + } + }, + notes: firstMeaningfulLine(context.changelogNotes) + }; +} + +function buildReleaseReadme(context, manifest) { + const zip = manifest.artifacts.extensionZip; + return `# Funplay MCP for Cocos ${context.tag} + +This folder contains the generated release artifacts for Funplay MCP for Cocos ${context.tag}. + +## Artifacts + +- \`${zip.file}\` - Cocos Creator extension package. +- \`release-manifest.json\` - Machine-readable release metadata. +- \`SHA256SUMS.txt\` - SHA-256 checksums for release artifacts. + +## Install + +1. Unzip \`${zip.file}\`. +2. Move the extracted \`${PACKAGE_DIR_NAME}\` folder into your Cocos project \`extensions/\` directory. +3. Restart Cocos Creator or reload extensions. +4. Open \`Funplay > MCP Server\`. + +## Verify + +\`\`\`bash +shasum -a 256 -c SHA256SUMS.txt +\`\`\` +`; +} + +function validateArchivePaths(paths) { + const bad = []; + const prefix = `${PACKAGE_DIR_NAME}/`; + + for (const archivePath of paths) { + const normalized = archivePath.replace(/\\/g, '/'); + const parts = normalized.split('/').filter(Boolean); + if (!normalized.startsWith(prefix)) { + bad.push(`${archivePath} (must stay under ${PACKAGE_DIR_NAME}/)`); + continue; + } + if (parts.some((part) => part === '..' || part === '.')) { + bad.push(`${archivePath} (contains unsafe relative path segments)`); + continue; + } + if (parts.some((part) => FORBIDDEN_ARCHIVE_SEGMENTS.has(part) || FORBIDDEN_NAMES.has(part))) { + bad.push(`${archivePath} (contains forbidden release content)`); + } + } + + if (bad.length > 0) { + throw new Error(`Release archive contains invalid paths:\n- ${bad.join('\n- ')}`); + } +} + +function validateZipListing(zipPath) { + const result = childProcess.spawnSync('unzip', ['-Z1', zipPath], { + cwd: ROOT, + encoding: 'utf8' + }); + + if (result.error && result.error.code === 'ENOENT') { + console.warn('Warning: unzip is not available; skipped zip listing validation.'); + return; + } + + if (result.status !== 0) { + throw new Error(`Failed to inspect ${zipPath}:\n${result.stderr || result.stdout}`); + } + + const listing = result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + validateArchivePaths(listing); +} + +function isForbiddenTrackedPath(relative) { + const parts = relative.split('/'); + return parts.some((part) => FORBIDDEN_TRACKED_SEGMENTS.has(part) || FORBIDDEN_NAMES.has(part)); +} + +function readJson(filePath, errors) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (error) { + errors.push(`${path.relative(ROOT, filePath)} is not valid JSON: ${error.message}`); + return null; + } +} + +function collectFiles(directory) { + const entries = fs.readdirSync(directory, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...collectFiles(fullPath)); + } else if (entry.isFile()) { + files.push(fullPath); + } + } + return files; +} + +function extractChangelogNotes(changelog, version) { + const heading = new RegExp(`^## \\[${escapeRegExp(version)}\\] - \\d{4}-\\d{2}-\\d{2}\\s*$`, 'm'); + const match = heading.exec(changelog); + if (!match) { + return ''; + } + const start = match.index + match[0].length; + const rest = changelog.slice(start); + const next = rest.search(/^## /m); + return (next >= 0 ? rest.slice(0, next) : rest).trim(); +} + +function firstMeaningfulLine(text) { + const line = text + .split(/\r?\n/) + .map((value) => value.trim()) + .find((value) => value && !value.startsWith('###') && !value.startsWith('-')); + if (line) { + return line; + } + const bullet = text + .split(/\r?\n/) + .map((value) => value.trim()) + .find((value) => value.startsWith('- ')); + return bullet ? bullet.slice(2) : ''; +} + +function checksumLine(filePath, displayName) { + return `${sha256File(filePath)} ${displayName}\n`; +} + +function sha256File(filePath) { + return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex'); +} + +function ensureCommand(name) { + const result = childProcess.spawnSync(name, ['-v'], { encoding: 'utf8' }); + if (result.error && result.error.code === 'ENOENT') { + throw new Error(`Required command not found: ${name}`); + } +} + +function run(command, args, options = {}) { + const result = childProcess.spawnSync(command, args, { + cwd: options.cwd || ROOT, + encoding: 'utf8' + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} failed:\n${result.stderr || result.stdout}`); + } + return result.stdout; +} + +function gitLines(args) { + const text = gitText(args); + return text ? text.split(/\r?\n/).filter(Boolean) : []; +} + +function gitText(args) { + const result = childProcess.spawnSync('git', args, { + cwd: ROOT, + encoding: 'utf8' + }); + if (result.error || result.status !== 0) { + return ''; + } + return result.stdout; +} + +function gitTagExists(tag) { + const result = childProcess.spawnSync('git', ['rev-parse', '-q', '--verify', `refs/tags/${tag}`], { + cwd: ROOT, + encoding: 'utf8' + }); + return result.status === 0; +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function throwErrors(errors) { + if (errors.length > 0) { + throw new Error(`Release validation failed:\n- ${errors.join('\n- ')}`); + } +} + +function printUsage() { + console.error(`Usage: + node scripts/release.js check [--version ] [--strict-tag] + node scripts/release.js package [--version ] [--strict-tag]`); +} + +try { + main(); +} catch (error) { + console.error(error.message); + process.exitCode = 1; +}