From 3693f7dacb02aeb79fdbf70a740243d53ea416ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=BA=92?= Date: Thu, 4 Jun 2026 23:02:38 +0800 Subject: [PATCH] feat(release): CI-driven publish pipeline via GitHub Actions + npm OIDC Replace tools/release.mjs with two workflow_dispatch flows: - stable: production environment gate (Required Reviewers) + lightweight git tag. Trusted Publishing (OIDC) removes the need for an npm token. - channel beta: disposable 0.0.0-beta-- versions on the corresponding dist-tag, no tag, no commit. Any collaborator can dispatch without npm credentials. Pack-time scans via publint, attw, gitleaks; weekly Dependabot for npm + actions. --- .github/dependabot.yml | 18 ++ .github/workflows/ci.yml | 30 +++ .github/workflows/release-channel.yml | 47 ++++ .github/workflows/release-stable.yml | 43 +++ package.json | 3 +- tools/release.mjs | 370 -------------------------- tools/release/check.mjs | 51 ++++ tools/release/lib/git.mjs | 40 +++ tools/release/lib/npm.mjs | 34 +++ tools/release/lib/pack-scan.mjs | 43 +++ tools/release/lib/packages.mjs | 36 +++ tools/release/lib/proc.mjs | 35 +++ tools/release/lib/validate.mjs | 67 +++++ tools/release/publish-channel.mjs | 95 +++++++ tools/release/publish-stable.mjs | 85 ++++++ 15 files changed, 625 insertions(+), 372 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release-channel.yml create mode 100644 .github/workflows/release-stable.yml delete mode 100644 tools/release.mjs create mode 100644 tools/release/check.mjs create mode 100644 tools/release/lib/git.mjs create mode 100644 tools/release/lib/npm.mjs create mode 100644 tools/release/lib/pack-scan.mjs create mode 100644 tools/release/lib/packages.mjs create mode 100644 tools/release/lib/proc.mjs create mode 100644 tools/release/lib/validate.mjs create mode 100644 tools/release/publish-channel.mjs create mode 100644 tools/release/publish-stable.mjs diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..bc84cdd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 + groups: + dev-deps: + dependency-type: development + update-types: + - minor + - patch + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..40c7c80 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + check: + name: lint + typecheck + test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm run check + + - run: pnpm test diff --git a/.github/workflows/release-channel.yml b/.github/workflows/release-channel.yml new file mode 100644 index 0000000..521158f --- /dev/null +++ b/.github/workflows/release-channel.yml @@ -0,0 +1,47 @@ +name: Release (channel) + +on: + workflow_dispatch: + inputs: + channel: + description: "dist-tag (kebab-case, e.g. mcp/plugin/advisor). Reserved: latest/beta/alpha/next/rc/canary/dev." + required: true + type: string + +# Serialize channel releases per channel name to avoid racing tarball uploads. +concurrency: + group: release-channel-${{ inputs.channel }} + cancel-in-progress: false + +permissions: + contents: read # no tag, no Release; just publish + id-token: write # OIDC for npm Trusted Publishing + provenance + +jobs: + publish: + name: publish beta to npm + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + registry-url: "https://registry.npmjs.org/" + + - name: Install gitleaks + run: | + set -euo pipefail + GITLEAKS_VERSION=8.21.2 + curl -sSfL \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + | sudo tar -xz -C /usr/local/bin gitleaks + gitleaks version + + - run: pnpm install --frozen-lockfile + + - name: publish-channel + run: node tools/release/publish-channel.mjs --channel "${{ inputs.channel }}" diff --git a/.github/workflows/release-stable.yml b/.github/workflows/release-stable.yml new file mode 100644 index 0000000..e913389 --- /dev/null +++ b/.github/workflows/release-stable.yml @@ -0,0 +1,43 @@ +name: Release (stable) + +on: + workflow_dispatch: + +# Only one stable release at a time. +concurrency: + group: release-stable + cancel-in-progress: false + +permissions: + contents: write # push lightweight tag to origin + id-token: write # OIDC for npm Trusted Publishing + provenance + +jobs: + publish: + name: publish to npm + tag + runs-on: ubuntu-latest + environment: production # Required Reviewers gate + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + registry-url: "https://registry.npmjs.org/" + + - name: Install gitleaks + run: | + set -euo pipefail + GITLEAKS_VERSION=8.21.2 + curl -sSfL \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + | sudo tar -xz -C /usr/local/bin gitleaks + gitleaks version + + - run: pnpm install --frozen-lockfile + + - name: publish-stable + run: node tools/release/publish-stable.mjs diff --git a/package.json b/package.json index 934c9c3..d46a5bf 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,7 @@ "dev": "pnpm -F bailian-cli-core dev", "bl": "pnpm -F bailian-cli dev", "test": "vp test", - "release:check": "node tools/release.mjs check", - "release:publish": "node tools/release.mjs publish", + "release:check": "node tools/release/check.mjs", "wiki:crawl": "node tools/wiki-crawler/index.mjs", "test:stress": "node packages/cli/tests/stress/run.mjs" }, diff --git a/tools/release.mjs b/tools/release.mjs deleted file mode 100644 index 252ebce..0000000 --- a/tools/release.mjs +++ /dev/null @@ -1,370 +0,0 @@ -import { mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync } from "fs"; -import { tmpdir } from "os"; -import { dirname, join, relative, resolve } from "path"; -import { fileURLToPath } from "url"; -import { spawnSync } from "child_process"; -import { createInterface } from "readline/promises"; -import { stdin as input, stdout as output } from "process"; - -const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const PUBLIC_REGISTRY = "https://registry.npmjs.org/"; -const PACKAGES = [ - { key: "core", dir: "packages/core", name: "bailian-cli-core" }, - { key: "cli", dir: "packages/cli", name: "bailian-cli" }, -]; - -function log(message = "") { - process.stdout.write(`${message}\n`); -} - -function step(message) { - log(`\n==> ${message}`); -} - -function fail(message) { - throw new Error(message); -} - -function run(command, args, options = {}) { - const result = spawnSync(command, args, { - cwd: options.cwd ?? ROOT, - stdio: options.stdio ?? "inherit", - encoding: "utf-8", - }); - - if (result.status !== 0) { - const detail = result.stderr?.trim() || result.stdout?.trim(); - fail(`${command} ${args.join(" ")} failed${detail ? `\n${detail}` : ""}`); - } - - return result.stdout ?? ""; -} - -function readJson(path) { - return JSON.parse(readFileSync(path, "utf-8")); -} - -function packageJson(pkg) { - return readJson(join(ROOT, pkg.dir, "package.json")); -} - -function tarballName(name, version) { - return `${name.replace(/^@/, "").replace("/", "-")}-${version}.tgz`; -} - -/** - * Map semver version to npm dist-tag. - * 1.0.0 → latest - * 1.0.0-beta.0 → beta - * 1.0.0-rc.1 → rc - * 1.0.0-alpha.2 → alpha - * 1.0.0-next.5 → next - * Avoids accidentally tagging prereleases as latest. - */ -function deriveDistTag(version) { - const m = /-([a-z]+)\b/i.exec(version); - return m ? m[1].toLowerCase() : "latest"; -} - -function walkFiles(dir) { - const files = []; - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const path = join(dir, entry.name); - if (entry.isDirectory()) files.push(...walkFiles(path)); - else if (entry.isFile()) files.push(path); - } - return files; -} - -function assertPublishConfig(pkg, json) { - const registry = json.publishConfig?.registry; - if (registry !== PUBLIC_REGISTRY) { - fail(`${pkg.name} publishConfig.registry must be ${PUBLIC_REGISTRY}, got ${registry}`); - } -} - -function packPackage(pkg, tempDir) { - const json = packageJson(pkg); - const name = json.name; - const version = json.version; - - run("pnpm", ["--filter", name, "pack", "--pack-destination", tempDir]); - - const tarball = join(tempDir, tarballName(name, version)); - statSync(tarball); - return { pkg, json, tarball }; -} - -function extractTarball(tarball, tempDir, label) { - const extractDir = join(tempDir, `extract-${label}`); - run("tar", ["-xzf", tarball, "-C", tempDir], { stdio: "pipe" }); - const packageDir = join(tempDir, "package"); - renameSync(packageDir, extractDir); - return extractDir; -} - -function looksText(buffer) { - if (buffer.includes(0)) return false; - const sample = buffer.subarray(0, 4096).toString("utf-8"); - return !sample.includes("\uFFFD"); -} - -function scanPackageContents(label, extractDir) { - const denyPathPatterns = [ - /(^|\/)\.env($|\.)/, - /(^|\/)\.npmrc$/, - /(^|\/)\.yarnrc$/, - /(^|\/)\.pnpmfile\.cjs$/, - /(^|\/)\.DS_Store$/, - /(^|\/)npm-debug\.log$/, - /(^|\/)yarn-error\.log$/, - /\.(map|pem|key|crt|p12|pfx|log)$/i, - /(^|\/)id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/i, - ]; - const secretPatterns = [ - { name: "DashScope API key", re: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{18,}\b/g }, - { name: "Alibaba Cloud access key id", re: /\bLTAI[A-Za-z0-9]{12,}\b/g }, - { - name: "access key secret assignment", - re: /\b(?:access[_-]?key[_-]?secret|aliyun[_-]?access[_-]?key[_-]?secret|alibaba[_-]?cloud[_-]?access[_-]?key[_-]?secret)\b\s*[:=]\s*["'][^"']{12,}["']/gi, - }, - ]; - - const files = walkFiles(extractDir); - for (const file of files) { - const rel = relative(extractDir, file).replaceAll("\\", "/"); - if (denyPathPatterns.some((pattern) => pattern.test(rel))) { - fail(`${label} contains blocked file: ${rel}`); - } - - const size = statSync(file).size; - if (size > 2 * 1024 * 1024) continue; - - const buffer = readFileSync(file); - if (!looksText(buffer)) continue; - - const text = buffer.toString("utf-8"); - for (const pattern of secretPatterns) { - pattern.re.lastIndex = 0; - if (pattern.re.test(text)) { - fail(`${label} may contain ${pattern.name}: ${rel}`); - } - } - } -} - -function assertCliPackage(cliExtractDir, coreJson) { - const json = readJson(join(cliExtractDir, "package.json")); - const deps = json.dependencies ?? {}; - - if (deps["bailian-cli-core"] !== coreJson.version) { - fail(`CLI tarball must depend on bailian-cli-core@${coreJson.version}.`); - } - - if (JSON.stringify(json).includes("workspace:")) { - fail("CLI tarball package.json still contains workspace: dependency."); - } - - const binPath = json.bin?.bl; - if (binPath !== "dist/bailian.mjs") { - fail(`CLI bin.bl must be dist/bailian.mjs, got ${binPath}`); - } - - const bin = readFileSync(join(cliExtractDir, binPath), "utf-8"); - if (!bin.startsWith("#!/usr/bin/env node\n")) { - fail("CLI bin is missing #!/usr/bin/env node shebang."); - } - - if (!bin.includes('from"bailian-cli-core"') && !bin.includes('from "bailian-cli-core"')) { - fail("CLI bundle does not appear to import bailian-cli-core as an external package."); - } -} - -function assertCorePackage(coreExtractDir) { - for (const file of ["dist/index.mjs", "dist/index.d.mts"]) { - statSync(join(coreExtractDir, file)); - } -} - -function assertReadmeSync() { - step("Checking README sync between root and packages/cli"); - for (const file of ["README.md", "README_CN.md"]) { - const rootPath = join(ROOT, file); - const cliPath = join(ROOT, "packages/cli", file); - const rootBuf = readFileSync(rootPath); - const cliBuf = readFileSync(cliPath); - if (!rootBuf.equals(cliBuf)) { - fail( - `${file} differs between root and packages/cli. ` + - `Sync them manually (e.g. \`cp ${file} packages/cli/${file}\`).`, - ); - } - log(`${file}: in sync`); - } -} - -function validatePackages() { - const jsonByKey = new Map(); - - assertReadmeSync(); - - step("Checking package metadata"); - for (const pkg of PACKAGES) { - const json = packageJson(pkg); - if (json.name !== pkg.name) fail(`${pkg.dir} name must be ${pkg.name}`); - assertPublishConfig(pkg, json); - jsonByKey.set(pkg.key, json); - log(`${json.name}@${json.version}`); - } - - const coreJson = jsonByKey.get("core"); - const cliJson = jsonByKey.get("cli"); - if (cliJson.version !== coreJson.version) { - fail(`CLI and core versions should match, got ${cliJson.version} and ${coreJson.version}.`); - } - - const cliCoreDep = cliJson.dependencies?.["bailian-cli-core"]; - if (cliCoreDep !== "workspace:*") { - fail(`CLI source dependency should be "bailian-cli-core": "workspace:*", got ${cliCoreDep}`); - } - - return { coreJson, cliJson }; -} - -function packAndScan(coreJson) { - const tempDir = mkdtempSync(join(tmpdir(), "bailian-release-")); - try { - step("Packing and scanning npm tarballs"); - - const packed = PACKAGES.map((pkg) => packPackage(pkg, tempDir)); - const extracted = new Map(); - - for (const item of packed) { - const extractDir = extractTarball(item.tarball, tempDir, item.pkg.key); - extracted.set(item.pkg.key, extractDir); - scanPackageContents(item.json.name, extractDir); - log(`${item.json.name}: ok`); - } - - assertCorePackage(extracted.get("core")); - assertCliPackage(extracted.get("cli"), coreJson); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } -} - -function buildPackages() { - run("pnpm", ["--filter", "bailian-cli-core", "run", "build"]); - run("pnpm", ["--filter", "bailian-cli", "run", "build"]); -} - -async function releaseCheck() { - const { coreJson } = validatePackages(); - - step("Installing dependencies with frozen lockfile"); - run("pnpm", ["install", "--frozen-lockfile"]); - - step("Building packages"); - buildPackages(); - - step("Running format, lint, and type checks"); - run("pnpm", ["run", "check"]); - - packAndScan(coreJson); - - log("\nRelease check passed."); -} - -function npmWhoami() { - const result = spawnSync("npm", ["whoami", `--registry=${PUBLIC_REGISTRY}`], { - cwd: ROOT, - stdio: ["ignore", "pipe", "pipe"], - encoding: "utf-8", - }); - if (result.status !== 0) return null; - return result.stdout.trim() || null; -} - -function ensureNpmLogin() { - step("Checking npm login"); - - let user = npmWhoami(); - if (user) { - log(`Logged in as ${user}`); - return; - } - - log(`Not logged in to ${PUBLIC_REGISTRY}. Launching npm login...`); - const login = spawnSync("npm", ["login", `--registry=${PUBLIC_REGISTRY}`], { - cwd: ROOT, - stdio: "inherit", - }); - if (login.status !== 0) fail("npm login failed."); - - user = npmWhoami(); - if (!user) fail("npm login required before publishing."); - log(`Logged in as ${user}`); -} - -async function confirmPublish(coreJson, cliJson) { - log(""); - log(`About to publish ${coreJson.name}@${coreJson.version}`); - log(`Then publish ${cliJson.name}@${cliJson.version}`); - log(`Registry: ${PUBLIC_REGISTRY}`); - - const rl = createInterface({ input, output }); - const answer = await rl.question("\nType 'publish' to continue: "); - rl.close(); - - if (answer !== "publish") fail("Publish aborted."); -} - -async function releasePublish() { - const { coreJson, cliJson } = validatePackages(); - - step("Building packages"); - buildPackages(); - - packAndScan(coreJson); - - await confirmPublish(coreJson, cliJson); - - ensureNpmLogin(); - - // Derive dist-tag from version: 1.0.0 → latest, 1.0.0-beta.0 → beta, 1.0.0-rc.1 → rc - const distTag = deriveDistTag(coreJson.version); - log(`Publishing under dist-tag: ${distTag}`); - - step(`Publishing ${coreJson.name}`); - run("pnpm", [ - "--filter", - coreJson.name, - "publish", - `--registry=${PUBLIC_REGISTRY}`, - `--tag=${distTag}`, - "--no-git-checks", - ]); - - step(`Publishing ${cliJson.name}`); - run("pnpm", [ - "--filter", - cliJson.name, - "publish", - `--registry=${PUBLIC_REGISTRY}`, - `--tag=${distTag}`, - "--no-git-checks", - ]); - - log("\nPublish complete."); -} - -const command = process.argv[2]; - -try { - if (command === "check") await releaseCheck(); - else if (command === "publish") await releasePublish(); - else fail("Usage: node tools/release.mjs "); -} catch (error) { - process.stderr.write(`\nRelease failed: ${error.message}\n`); - process.exit(1); -} diff --git a/tools/release/check.mjs b/tools/release/check.mjs new file mode 100644 index 0000000..f05e956 --- /dev/null +++ b/tools/release/check.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +import { fileURLToPath } from "url"; + +import { packAndScan } from "./lib/pack-scan.mjs"; +import { run } from "./lib/proc.mjs"; +import { assertReadmeSync, loadAndValidatePackages } from "./lib/validate.mjs"; + +function log(msg = "") { + process.stdout.write(`${msg}\n`); +} + +function step(msg) { + log(`\n==> ${msg}`); +} + +/** + * Pure-validation pipeline. Reusable from publish-stable / publish-channel. + * Returns { coreJson, cliJson } for callers that need the parsed package.jsons. + */ +export async function runCheck() { + step("pnpm install --frozen-lockfile"); + run("pnpm", ["install", "--frozen-lockfile"]); + + step("metadata: README sync, version consistency, workspace:* dep"); + assertReadmeSync(); + const { coreJson, cliJson } = loadAndValidatePackages(); + log(`bailian-cli-core@${coreJson.version}`); + log(`bailian-cli@${cliJson.version}`); + + step("build bailian-cli-core"); + run("pnpm", ["--filter", "bailian-cli-core", "run", "build"]); + + step("build bailian-cli"); + run("pnpm", ["--filter", "bailian-cli", "run", "build"]); + + step("pack + scan (publint, attw, gitleaks)"); + packAndScan({ log }); + + log("\nrelease check passed."); + return { coreJson, cliJson }; +} + +const invokedDirectly = process.argv[1] === fileURLToPath(import.meta.url); +if (invokedDirectly) { + try { + await runCheck(); + } catch (error) { + process.stderr.write(`\nrelease check failed: ${error.message}\n`); + process.exit(1); + } +} diff --git a/tools/release/lib/git.mjs b/tools/release/lib/git.mjs new file mode 100644 index 0000000..eb4263c --- /dev/null +++ b/tools/release/lib/git.mjs @@ -0,0 +1,40 @@ +import { runCapture, tryRun, run } from "./proc.mjs"; + +export function headSha7() { + return runCapture("git", ["rev-parse", "--short=7", "HEAD"]); +} + +export function currentBranch() { + // GitHub Actions checks out a detached HEAD; `git rev-parse --abbrev-ref HEAD` + // returns "HEAD" there, so prefer GITHUB_REF_NAME when running in CI. + if (process.env.GITHUB_REF_NAME) return process.env.GITHUB_REF_NAME; + return runCapture("git", ["rev-parse", "--abbrev-ref", "HEAD"]); +} + +export function isWorkingTreeClean() { + return runCapture("git", ["status", "--porcelain"]) === ""; +} + +export function tagExists(tag, { remote = "origin" } = {}) { + // local + if (tryRun("git", ["rev-parse", "--verify", `refs/tags/${tag}`]).status === 0) return true; + // remote — actions/checkout fetch-depth:0 usually fetches tags, but ls-remote + // is the only authoritative source. + const r = tryRun("git", ["ls-remote", "--tags", remote, `refs/tags/${tag}`]); + return r.status === 0 && r.stdout !== ""; +} + +export function createTag(tag) { + // Lightweight tag: just a ref pointing at HEAD. No tagger identity needed, + // so the workflow doesn't need `git config user.name/email`. + run("git", ["tag", tag]); +} + +export function pushTag(tag, remote = "origin") { + run("git", ["push", remote, tag]); +} + +export function utcDateStamp() { + const iso = runCapture("date", ["-u", "+%Y%m%d"]); + return iso; +} diff --git a/tools/release/lib/npm.mjs b/tools/release/lib/npm.mjs new file mode 100644 index 0000000..a8877c7 --- /dev/null +++ b/tools/release/lib/npm.mjs @@ -0,0 +1,34 @@ +import { statSync } from "fs"; +import { join } from "path"; + +import { ROOT, tarballFileName } from "./packages.mjs"; +import { run, tryRun } from "./proc.mjs"; + +/** Returns true if the exact name@version already exists on the registry. */ +export function npmViewExists(name, version) { + const result = tryRun("npm", ["view", `${name}@${version}`, "version"]); + if (result.status === 0 && result.stdout.includes(version)) return true; + // npm view returns non-zero ("E404") for unknown versions; treat as "does not exist" + if (result.stderr.includes("E404") || result.stderr.includes("404")) return false; + if (result.status !== 0) { + throw new Error(`npm view ${name}@${version} failed: ${result.stderr || result.stdout}`); + } + return false; +} + +export function pnpmPack(pkg, destDir, json) { + run("pnpm", ["--filter", pkg.name, "pack", "--pack-destination", destDir], { cwd: ROOT }); + const tarball = join(destDir, tarballFileName(pkg.name, json.version)); + statSync(tarball); + return tarball; +} + +export function pnpmPublish(pkg, { tag, provenance = true, dryRun = false } = {}) { + const args = ["--filter", pkg.name, "publish", "--no-git-checks"]; + if (tag) args.push("--tag", tag); + // --provenance requires OIDC; suppress in dry-run so local devs can test + // the pipeline without GitHub Actions credentials. + if (provenance && !dryRun) args.push("--provenance"); + if (dryRun) args.push("--dry-run"); + run("pnpm", args, { cwd: ROOT }); +} diff --git a/tools/release/lib/pack-scan.mjs b/tools/release/lib/pack-scan.mjs new file mode 100644 index 0000000..f673d00 --- /dev/null +++ b/tools/release/lib/pack-scan.mjs @@ -0,0 +1,43 @@ +import { mkdtempSync, renameSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +import { PACKAGES, readPackageJson } from "./packages.mjs"; +import { pnpmPack } from "./npm.mjs"; +import { run } from "./proc.mjs"; + +function extractTarball(tarball, tempDir, key) { + run("tar", ["-xzf", tarball, "-C", tempDir], { stdio: "pipe" }); + const extractDir = join(tempDir, `extract-${key}`); + renameSync(join(tempDir, "package"), extractDir); + return extractDir; +} + +/** + * pnpm pack each package, then run publint / attw / gitleaks on the tarball + * or the extracted directory. attw only runs on packages that declare types. + */ +export function packAndScan({ log }) { + const tempDir = mkdtempSync(join(tmpdir(), "bailian-release-")); + try { + for (const pkg of PACKAGES) { + const json = readPackageJson(pkg); + log(`packing ${pkg.name}@${json.version}`); + const tarball = pnpmPack(pkg, tempDir, json); + const extractDir = extractTarball(tarball, tempDir, pkg.key); + + log(`publint ${pkg.name}`); + run("npx", ["--yes", "publint", extractDir]); + + if (json.types) { + log(`attw ${pkg.name}`); + run("npx", ["--yes", "@arethetypeswrong/cli", "--pack", extractDir]); + } + + log(`gitleaks ${pkg.name}`); + run("gitleaks", ["detect", "--source", extractDir, "--no-git", "--redact"]); + } + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} diff --git a/tools/release/lib/packages.mjs b/tools/release/lib/packages.mjs new file mode 100644 index 0000000..6416ea1 --- /dev/null +++ b/tools/release/lib/packages.mjs @@ -0,0 +1,36 @@ +import { readFileSync, writeFileSync } from "fs"; +import { dirname, join, resolve } from "path"; +import { fileURLToPath } from "url"; + +export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); + +export const PACKAGES = [ + { key: "core", dir: "packages/core", name: "bailian-cli-core" }, + { key: "cli", dir: "packages/cli", name: "bailian-cli" }, +]; + +export function readJson(path) { + return JSON.parse(readFileSync(path, "utf-8")); +} + +export function packageJsonPath(pkg) { + return join(ROOT, pkg.dir, "package.json"); +} + +export function readPackageJson(pkg) { + return readJson(packageJsonPath(pkg)); +} + +export function writePackageJson(pkg, json) { + writeFileSync(packageJsonPath(pkg), `${JSON.stringify(json, null, 2)}\n`); +} + +export function tarballFileName(name, version) { + return `${name.replace(/^@/, "").replace("/", "-")}-${version}.tgz`; +} + +export function findPackage(key) { + const pkg = PACKAGES.find((p) => p.key === key); + if (!pkg) throw new Error(`unknown package key: ${key}`); + return pkg; +} diff --git a/tools/release/lib/proc.mjs b/tools/release/lib/proc.mjs new file mode 100644 index 0000000..8c83fff --- /dev/null +++ b/tools/release/lib/proc.mjs @@ -0,0 +1,35 @@ +import { spawnSync } from "child_process"; + +import { ROOT } from "./packages.mjs"; + +export function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd ?? ROOT, + stdio: options.stdio ?? "inherit", + env: { ...process.env, ...options.env }, + encoding: "utf-8", + }); + if (result.status !== 0) { + const detail = result.stderr?.trim() || result.stdout?.trim(); + throw new Error(`${command} ${args.join(" ")} failed${detail ? `\n${detail}` : ""}`); + } + return result.stdout ?? ""; +} + +export function runCapture(command, args, options = {}) { + return run(command, args, { ...options, stdio: ["ignore", "pipe", "pipe"] }).trim(); +} + +export function tryRun(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd ?? ROOT, + stdio: options.stdio ?? ["ignore", "pipe", "pipe"], + env: { ...process.env, ...options.env }, + encoding: "utf-8", + }); + return { + status: result.status, + stdout: (result.stdout ?? "").trim(), + stderr: (result.stderr ?? "").trim(), + }; +} diff --git a/tools/release/lib/validate.mjs b/tools/release/lib/validate.mjs new file mode 100644 index 0000000..b7b7e3c --- /dev/null +++ b/tools/release/lib/validate.mjs @@ -0,0 +1,67 @@ +import { readFileSync } from "fs"; +import { join } from "path"; + +import { PACKAGES, ROOT, readPackageJson } from "./packages.mjs"; + +const README_FILES = ["README.md", "README_CN.md"]; + +export function assertReadmeSync() { + for (const file of README_FILES) { + const rootBuf = readFileSync(join(ROOT, file)); + const cliBuf = readFileSync(join(ROOT, "packages/cli", file)); + if (!rootBuf.equals(cliBuf)) { + throw new Error( + `${file} differs between root and packages/cli. ` + + `Sync them manually (e.g. \`cp ${file} packages/cli/${file}\`).`, + ); + } + } +} + +export function loadAndValidatePackages() { + const jsonByKey = new Map(); + for (const pkg of PACKAGES) { + const json = readPackageJson(pkg); + if (json.name !== pkg.name) { + throw new Error(`${pkg.dir} name must be ${pkg.name}, got ${json.name}`); + } + jsonByKey.set(pkg.key, json); + } + + const coreJson = jsonByKey.get("core"); + const cliJson = jsonByKey.get("cli"); + + if (cliJson.version !== coreJson.version) { + throw new Error( + `core and cli versions must match, got ${coreJson.version} and ${cliJson.version}.`, + ); + } + + const cliCoreDep = cliJson.dependencies?.["bailian-cli-core"]; + if (cliCoreDep !== "workspace:*") { + throw new Error( + `packages/cli source dependency on bailian-cli-core must be "workspace:*", got ${cliCoreDep}.`, + ); + } + + return { coreJson, cliJson }; +} + +const RESERVED_CHANNELS = new Set(["latest", "beta", "alpha", "next", "rc", "canary", "dev"]); +const CHANNEL_FORMAT = /^[a-z][a-z0-9-]{1,30}$/; + +export function assertChannel(channel) { + if (!channel || typeof channel !== "string") { + throw new Error("channel is required"); + } + if (!CHANNEL_FORMAT.test(channel)) { + throw new Error( + `channel "${channel}" must match ${CHANNEL_FORMAT} (lowercase letters/digits/dashes, start with a letter, 2-31 chars).`, + ); + } + if (RESERVED_CHANNELS.has(channel)) { + throw new Error( + `channel "${channel}" is reserved (${[...RESERVED_CHANNELS].join(", ")}); pick a different name.`, + ); + } +} diff --git a/tools/release/publish-channel.mjs b/tools/release/publish-channel.mjs new file mode 100644 index 0000000..4d30b89 --- /dev/null +++ b/tools/release/publish-channel.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from "fs"; +import { parseArgs } from "util"; + +import { runCheck } from "./check.mjs"; +import { headSha7, utcDateStamp } from "./lib/git.mjs"; +import { npmViewExists, pnpmPublish } from "./lib/npm.mjs"; +import { + findPackage, + packageJsonPath, + readPackageJson, + writePackageJson, +} from "./lib/packages.mjs"; +import { assertChannel } from "./lib/validate.mjs"; + +function log(msg = "") { + process.stdout.write(`${msg}\n`); +} + +function step(msg) { + log(`\n==> ${msg}`); +} + +const { values } = parseArgs({ + options: { + channel: { type: "string" }, + "dry-run": { type: "boolean", default: false }, + }, + allowPositionals: false, +}); +const channel = values.channel; +const dryRun = values["dry-run"]; +assertChannel(channel); + +if (!dryRun && !process.env.CI) { + process.stderr.write("publish-channel is CI-only. Pass --dry-run to test locally.\n"); + process.exit(1); +} + +const core = findPackage("core"); +const cli = findPackage("cli"); +const corePath = packageJsonPath(core); +const cliPath = packageJsonPath(cli); +const coreOriginal = readFileSync(corePath, "utf-8"); +const cliOriginal = readFileSync(cliPath, "utf-8"); + +function restoreOriginals() { + writeFileSync(corePath, coreOriginal); + writeFileSync(cliPath, cliOriginal); +} + +try { + step("compute channel version"); + const sha = headSha7(); + const date = utcDateStamp(); + const betaVersion = `0.0.0-beta-${sha}-${date}`; + log(`channel=${channel} version=${betaVersion}`); + + step("temporarily bump package.json (not committed)"); + const coreJson = readPackageJson(core); + const cliJson = readPackageJson(cli); + coreJson.version = betaVersion; + cliJson.version = betaVersion; + writePackageJson(core, coreJson); + writePackageJson(cli, cliJson); + // pnpm pack resolves `workspace:*` to the in-tree version, so CLI tarball + // will depend on bailian-cli-core@ after this bump. + + await runCheck(); + + step(`idempotency: check ${betaVersion} against registry`); + const corePublished = npmViewExists(core.name, betaVersion); + const cliPublished = npmViewExists(cli.name, betaVersion); + log(`${core.name}@${betaVersion}: ${corePublished ? "already published" : "to publish"}`); + log(`${cli.name}@${betaVersion}: ${cliPublished ? "already published" : "to publish"}`); + if (corePublished && cliPublished) { + log("\nboth packages already published; nothing to do."); + } else { + if (!corePublished) { + step(`publish ${core.name}@${betaVersion} (tag=${channel}, provenance)`); + pnpmPublish(core, { tag: channel, provenance: true, dryRun }); + } + if (!cliPublished) { + step(`publish ${cli.name}@${betaVersion} (tag=${channel}, provenance)`); + pnpmPublish(cli, { tag: channel, provenance: true, dryRun }); + } + } + + log(`\nchannel release complete: ${channel}@${betaVersion}`); +} catch (error) { + process.stderr.write(`\nrelease publish-channel failed: ${error.message}\n`); + process.exitCode = 1; +} finally { + restoreOriginals(); +} diff --git a/tools/release/publish-stable.mjs b/tools/release/publish-stable.mjs new file mode 100644 index 0000000..4891276 --- /dev/null +++ b/tools/release/publish-stable.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +import { parseArgs } from "util"; + +import { runCheck } from "./check.mjs"; +import { createTag, currentBranch, isWorkingTreeClean, pushTag, tagExists } from "./lib/git.mjs"; +import { npmViewExists, pnpmPublish } from "./lib/npm.mjs"; +import { findPackage } from "./lib/packages.mjs"; + +function log(msg = "") { + process.stdout.write(`${msg}\n`); +} + +function step(msg) { + log(`\n==> ${msg}`); +} + +const { values } = parseArgs({ + options: { + "dry-run": { type: "boolean", default: false }, + }, + allowPositionals: false, +}); +const dryRun = values["dry-run"]; + +try { + if (!dryRun && !process.env.CI) { + throw new Error("publish-stable is CI-only. Pass --dry-run to test locally."); + } + + if (!dryRun) { + step("preflight: working tree clean + on main"); + if (!isWorkingTreeClean()) { + throw new Error("git working tree is not clean; commit or stash first."); + } + const branch = currentBranch(); + if (branch !== "main") { + throw new Error(`must publish from main, currently on ${branch}.`); + } + } else { + log("[dry-run] skipping working-tree + branch preflight"); + } + + const { coreJson } = await runCheck(); + const version = coreJson.version; // === cliJson.version, asserted by runCheck + + step(`idempotency: check ${version} against registry`); + const core = findPackage("core"); + const cli = findPackage("cli"); + const corePublished = npmViewExists(core.name, version); + const cliPublished = npmViewExists(cli.name, version); + log(`${core.name}@${version}: ${corePublished ? "already published" : "to publish"}`); + log(`${cli.name}@${version}: ${cliPublished ? "already published" : "to publish"}`); + if (corePublished && cliPublished) { + log("\nboth packages already published; nothing to do."); + process.exit(0); + } + + if (!corePublished) { + step(`publish ${core.name}@${version} (tag=latest, provenance)`); + pnpmPublish(core, { tag: "latest", provenance: true, dryRun }); + } + if (!cliPublished) { + step(`publish ${cli.name}@${version} (tag=latest, provenance)`); + pnpmPublish(cli, { tag: "latest", provenance: true, dryRun }); + } + + if (dryRun) { + log("\n[dry-run] skipping git tag"); + process.exit(0); + } + + const tag = `v${version}`; + if (tagExists(tag)) { + log(`tag ${tag} already exists; skipping tag push`); + } else { + step(`tag ${tag} and push`); + createTag(tag); + pushTag(tag); + } + + log("\nstable release complete."); +} catch (error) { + process.stderr.write(`\nrelease publish-stable failed: ${error.message}\n`); + process.exit(1); +}