diff --git a/.gitea/workflows/miniapp-preview.yml b/.gitea/workflows/miniapp-preview.yml new file mode 100644 index 0000000..f00e7bc --- /dev/null +++ b/.gitea/workflows/miniapp-preview.yml @@ -0,0 +1,93 @@ +name: Publish Mini Program Dev Version + +on: + push: + branches: + - main + paths: + - "mini/**" + - ".gitea/workflows/miniapp-preview.yml" + +jobs: + publish: + runs-on: new-pet-node + steps: + - uses: https://gitea.neatcn.com/gouki/action-checkout@v4 + with: + fetch-depth: 20 + - name: Verify Node runtime + run: node --version + - name: Install dependencies + working-directory: mini + run: npm ci + - name: Run tests + working-directory: mini + run: npm test -- --runInBand + - name: Configure production API and app version + working-directory: mini + env: + MINIAPP_BUILD_NUMBER: ${{ gitea.run_number }} + MINIAPP_VERSION_OVERRIDE: ${{ vars.MINIAPP_VERSION_OVERRIDE }} + run: | + test -n "$MINIAPP_BUILD_NUMBER" + VERSION="$(node -e 'const {resolveVersion}=require("./ci/resolve-version.cjs"); process.stdout.write(resolveVersion(process.cwd()))')" + test -n "$VERSION" + mkdir -p ci-artifacts + printf '%s\n' "$VERSION" > ci-artifacts/resolved-version.txt + echo "Resolved Mini Program version for build+upload: $VERSION" + - name: Materialize the upload key + env: + WECHAT_CI_PRIVATE_KEY: ${{ secrets.WECHAT_CI_PRIVATE_KEY }} + run: | + test -n "$WECHAT_CI_PRIVATE_KEY" + umask 077 + printf '%s' "$WECHAT_CI_PRIVATE_KEY" > "$RUNNER_TEMP/wechat-ci.key" + - name: Upload WeChat development version + working-directory: mini + env: + MINIAPP_APPID: ${{ vars.MINIAPP_APPID }} + MINIAPP_ROBOT: ${{ vars.MINIAPP_ROBOT }} + MINIAPP_BUILD_NUMBER: ${{ gitea.run_number }} + MINIAPP_VERSION_OVERRIDE: ${{ vars.MINIAPP_VERSION_OVERRIDE }} + WECHAT_CI_PRIVATE_KEY_PATH: ${{ runner.temp }}/wechat-ci.key + MINIAPP_UPLOAD_RESULT: ${{ gitea.workspace }}/mini/ci-artifacts/upload-result.json + MINIAPP_VERSION_LABEL: ${{ gitea.sha }} + MINIAPP_UPLOAD_DESC_ITEMS: "3" + MINIAPP_UPLOAD_DESC_MAX: "100" + run: | + test -n "$MINIAPP_APPID" + test -n "$MINIAPP_BUILD_NUMBER" + test -s ci-artifacts/resolved-version.txt + RESOLVED_VERSION="$(tr -d '\n' < ci-artifacts/resolved-version.txt)" + export MINIAPP_VERSION_OVERRIDE="$RESOLVED_VERSION" + mkdir -p ci-artifacts + node -e 'const c=require("./project.config.json"); if(c.appid!==process.env.MINIAPP_APPID){console.error("MINIAPP_APPID mismatch with project.config.json", process.env.MINIAPP_APPID, c.appid); process.exit(1)}' + node -e 'const {resolveVersion}=require("./ci/resolve-version.cjs"); console.log("Resolved Mini Program version:", resolveVersion(process.cwd()))' + node -e 'const {resolveUploadDesc}=require("./ci/resolve-upload-desc.cjs"); console.log("Resolved upload remark:", resolveUploadDesc({repoRoot:require("path").resolve(".."), versionLabel:process.env.MINIAPP_VERSION_LABEL}))' + python3 - <<'PY' + from pathlib import Path + import os + key_path = Path(os.environ["WECHAT_CI_PRIVATE_KEY_PATH"]) + text = key_path.read_text() + if "\\n" in text and "BEGIN" in text: + text = text.replace("\\n", "\n") + text = text.replace("\r\n", "\n").strip() + "\n" + key_path.write_text(text) + content = key_path.read_text() + assert "BEGIN" in content and "PRIVATE KEY" in content, "WECHAT_CI_PRIVATE_KEY is not a PEM private key" + print("private_key_lines=", len(content.splitlines())) + PY + node ci/upload-ci.cjs + test -s ci-artifacts/upload-result.json + cat ci-artifacts/upload-result.json + - name: Persist upload result on runner host + run: | + test -s mini/ci-artifacts/upload-result.json + test -d /ci-artifacts + cp mini/ci-artifacts/upload-result.json "/ci-artifacts/miniapp-upload-${{ gitea.sha }}.json" + cp mini/ci-artifacts/upload-result.json /ci-artifacts/miniapp-upload-latest.json + ls -la /ci-artifacts/miniapp-upload-latest.json + echo "Upload result saved on runner host: /ci-artifacts/miniapp-upload-latest.json" + - name: Remove temporary credentials + if: always() + run: rm -f "$RUNNER_TEMP/wechat-ci.key" diff --git a/mini/ci/preview-ci.cjs b/mini/ci/preview-ci.cjs new file mode 100644 index 0000000..f005733 --- /dev/null +++ b/mini/ci/preview-ci.cjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node + +const fs = require("node:fs"); +const path = require("node:path"); +const process = require("node:process"); +const ci = require("miniprogram-ci"); + +function requireEnvironment(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required.`); + } + return value; +} + +async function main() { + const projectPath = process.cwd(); + const privateKeyPath = path.resolve( + requireEnvironment("WECHAT_CI_PRIVATE_KEY_PATH"), + ); + const qrcodeOutputDest = path.resolve( + requireEnvironment("MINIAPP_QR_OUTPUT"), + ); + const appid = requireEnvironment("MINIAPP_APPID"); + const robot = Number.parseInt(process.env.MINIAPP_ROBOT ?? "1", 10); + const versionLabel = requireEnvironment("MINIAPP_VERSION_LABEL"); + + if (!fs.existsSync(path.join(projectPath, "project.config.json"))) { + throw new Error("The working directory must contain project.config.json."); + } + if (!fs.existsSync(privateKeyPath)) { + throw new Error("The WeChat code-upload key file does not exist."); + } + if (!Number.isInteger(robot) || robot < 1 || robot > 30) { + throw new Error("MINIAPP_ROBOT must be an integer from 1 to 30."); + } + + const project = new ci.Project({ + appid, + type: "miniProgram", + projectPath, + privateKeyPath, + ignores: ["node_modules/**/*", ".git/**/*"], + }); + + await ci.preview({ + project, + desc: `Automated preview ${versionLabel}`.slice(0, 50), + robot, + setting: { + useProjectConfig: true, + es6: true, + es7: true, + enhance: true, + minify: true, + autoPrefixWXSS: true, + }, + qrcodeFormat: "image", + qrcodeOutputDest, + onProgressUpdate: (progress) => { + if (typeof progress === "string") { + console.log(progress); + } + }, + }); + + console.log(`Preview QR code created: ${qrcodeOutputDest}`); +} + +main().catch((error) => { + console.error(`Mini Program preview failed: ${error.message}`); + process.exitCode = 1; +}); diff --git a/mini/ci/resolve-upload-desc.cjs b/mini/ci/resolve-upload-desc.cjs new file mode 100644 index 0000000..36ff76d --- /dev/null +++ b/mini/ci/resolve-upload-desc.cjs @@ -0,0 +1,151 @@ +/** + * Build the WeChat upload "项目备注" from recent change summaries. + * Prefer recent git commit subjects (Chinese commit messages in this repo); + * fall back to the latest CHANGELOG bullets when git history is unavailable. + */ + +const fs = require("node:fs"); +const path = require("node:path"); +const { execFileSync } = require("node:child_process"); + +const DEFAULT_MAX_LENGTH = 100; + +function cleanCommitSubject(subject) { + return String(subject || "") + .trim() + .replace( + /^(feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert)(\([^)]*\))?:\s*/i, + "", + ) + .replace(/\s+/g, " ") + .trim(); +} + +function truncateDesc(text, maxLength = DEFAULT_MAX_LENGTH) { + const value = String(text || "").trim(); + if (!value) { + return ""; + } + if ([...value].length <= maxLength) { + return value; + } + const chars = [...value].slice(0, Math.max(0, maxLength - 1)); + return `${chars.join("")}…`; +} + +function collectRecentCommitSubjects(repoRoot, limit = 5) { + try { + const output = execFileSync( + "git", + [ + "log", + `-${Math.max(1, limit * 3)}`, + "--pretty=%s", + "--", + "mini/", + "CHANGELOG.md", + "Features.md", + ], + { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }, + ); + + const subjects = []; + const seen = new Set(); + for (const line of output.split("\n")) { + const cleaned = cleanCommitSubject(line); + if (!cleaned || seen.has(cleaned)) { + continue; + } + // Skip pure docs/chore noise when better subjects exist. + if (/^(说明|文档|记录)/.test(cleaned) && subjects.length > 0) { + continue; + } + seen.add(cleaned); + subjects.push(cleaned); + if (subjects.length >= limit) { + break; + } + } + return subjects; + } catch { + return []; + } +} + +function collectRecentChangelogBullets(repoRoot, limit = 5) { + const changelogPath = path.join(repoRoot, "CHANGELOG.md"); + if (!fs.existsSync(changelogPath)) { + return []; + } + + const lines = fs.readFileSync(changelogPath, "utf8").split(/\r?\n/); + const bullets = []; + const seen = new Set(); + + for (const line of lines) { + const match = line.match(/^\s*-\s+(.+)\s*$/); + if (!match) { + continue; + } + const bullet = match[1].replace(/\s+/g, " ").trim(); + if (!bullet || seen.has(bullet)) { + continue; + } + seen.add(bullet); + bullets.push(bullet); + if (bullets.length >= limit) { + break; + } + } + + return bullets; +} + +function resolveUploadDesc({ + repoRoot, + versionLabel = "", + maxLength = DEFAULT_MAX_LENGTH, + maxItems = 3, +} = {}) { + const root = repoRoot || process.cwd(); + const fromGit = collectRecentCommitSubjects(root, maxItems); + const fromChangelog = collectRecentChangelogBullets(root, maxItems); + const parts = fromGit.length > 0 ? fromGit : fromChangelog; + + if (parts.length === 0) { + const shortSha = String(versionLabel || "") + .trim() + .slice(0, 7); + return truncateDesc( + shortSha ? `CI upload ${shortSha}` : "CI upload", + maxLength, + ); + } + + // Fit as many summaries as possible within the WeChat remark limit. + const selected = []; + for (const part of parts) { + const candidate = [...selected, part].join(";"); + if ([...candidate].length > maxLength) { + if (selected.length === 0) { + return truncateDesc(part, maxLength); + } + break; + } + selected.push(part); + } + + return truncateDesc(selected.join(";"), maxLength); +} + +module.exports = { + cleanCommitSubject, + collectRecentChangelogBullets, + collectRecentCommitSubjects, + resolveUploadDesc, + truncateDesc, +}; diff --git a/mini/ci/resolve-version.cjs b/mini/ci/resolve-version.cjs new file mode 100644 index 0000000..c8c00c6 --- /dev/null +++ b/mini/ci/resolve-version.cjs @@ -0,0 +1,95 @@ +/** + * Resolve the WeChat Mini Program upload version. + * + * WeChat requires x.y.z with each segment in 0..999. + * Server and miniapp versions are independent on purpose. + * + * Priority: + * 1. MINIAPP_VERSION_OVERRIDE — full manual version (rare / release pinning) + * 2. MINIAPP_BUILD_NUMBER — auto version from CI run number + * 3. package.json version — local / fallback + */ + +const fs = require("node:fs"); +const path = require("node:path"); + +function readPackageVersion(projectPath) { + const packageJson = JSON.parse( + fs.readFileSync(path.join(projectPath, "package.json"), "utf8"), + ); + if (!packageJson.version) { + throw new Error("package.json version is missing."); + } + return String(packageJson.version); +} + +function parseSemverParts(version) { + const parts = String(version) + .trim() + .split(".") + .map((part) => Number.parseInt(part, 10)); + if ( + parts.length < 1 || + parts.some((part) => !Number.isInteger(part) || part < 0 || part > 999) + ) { + throw new Error( + `Invalid version "${version}". WeChat requires x.y.z with each segment 0-999.`, + ); + } + return { + major: parts[0] ?? 0, + minor: parts[1] ?? 0, + patch: parts[2] ?? 0, + }; +} + +/** + * Encode a monotonic CI build number into WeChat x.y.z. + * Keeps package.json major; puts build into minor.patch with base 100. + * Examples (major=1): build 99 → 1.0.99, build 100 → 1.1.0, build 101 → 1.1.1 + */ +function versionFromBuildNumber(major, buildNumber) { + if (!Number.isInteger(buildNumber) || buildNumber < 1) { + throw new Error("MINIAPP_BUILD_NUMBER must be a positive integer."); + } + if (!Number.isInteger(major) || major < 0 || major > 999) { + throw new Error("package.json major version must be an integer 0-999."); + } + + const minor = Math.floor(buildNumber / 100) % 1000; + const patch = buildNumber % 100; + return `${major}.${minor}.${patch}`; +} + +/** @param {Record} [env] */ +function resolveVersion(projectPath, env = process.env) { + const override = (env.MINIAPP_VERSION_OVERRIDE || "").trim(); + if (override) { + parseSemverParts(override); + return override; + } + + const packageVersion = readPackageVersion(projectPath); + const { major } = parseSemverParts(packageVersion); + + const buildRaw = (env.MINIAPP_BUILD_NUMBER || "").trim(); + if (buildRaw) { + const buildNumber = Number.parseInt(buildRaw, 10); + return versionFromBuildNumber(major, buildNumber); + } + + // Legacy fixed override — prefer not to use; keeps version stuck across uploads. + const legacy = (env.MINIAPP_VERSION || "").trim(); + if (legacy) { + parseSemverParts(legacy); + return legacy; + } + + return packageVersion; +} + +module.exports = { + parseSemverParts, + resolveVersion, + versionFromBuildNumber, +}; diff --git a/mini/ci/upload-ci.cjs b/mini/ci/upload-ci.cjs new file mode 100644 index 0000000..fa51248 --- /dev/null +++ b/mini/ci/upload-ci.cjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node + +const fs = require("node:fs"); +const path = require("node:path"); +const process = require("node:process"); +const ci = require("miniprogram-ci"); +const { resolveVersion } = require("./resolve-version.cjs"); +const { resolveUploadDesc } = require("./resolve-upload-desc.cjs"); + +function requireEnvironment(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required.`); + } + return value; +} + +async function main() { + const projectPath = process.cwd(); + const repoRoot = path.resolve(projectPath, ".."); + const privateKeyPath = path.resolve( + requireEnvironment("WECHAT_CI_PRIVATE_KEY_PATH"), + ); + const appid = requireEnvironment("MINIAPP_APPID"); + const robot = Number.parseInt(process.env.MINIAPP_ROBOT ?? "1", 10); + const versionLabel = requireEnvironment("MINIAPP_VERSION_LABEL"); + const version = resolveVersion(projectPath); + const resultPath = path.resolve( + process.env.MINIAPP_UPLOAD_RESULT || + path.join(projectPath, "ci-artifacts", "upload-result.json"), + ); + + if (!fs.existsSync(path.join(projectPath, "project.config.json"))) { + throw new Error("The working directory must contain project.config.json."); + } + if (!fs.existsSync(privateKeyPath)) { + throw new Error("The WeChat code-upload key file does not exist."); + } + if (!Number.isInteger(robot) || robot < 1 || robot > 30) { + throw new Error("MINIAPP_ROBOT must be an integer from 1 to 30."); + } + + const project = new ci.Project({ + appid, + type: "miniProgram", + projectPath, + privateKeyPath, + ignores: ["node_modules/**/*", ".git/**/*", "ci-artifacts/**/*"], + }); + + const desc = resolveUploadDesc({ + repoRoot, + versionLabel, + maxLength: Number.parseInt(process.env.MINIAPP_UPLOAD_DESC_MAX || "100", 10), + maxItems: Number.parseInt(process.env.MINIAPP_UPLOAD_DESC_ITEMS || "3", 10), + }); + console.log(`Uploading Mini Program version=${version} robot=${robot}`); + console.log(`Upload remark: ${desc}`); + + const uploadResult = await ci.upload({ + project, + version, + desc, + robot, + setting: { + useProjectConfig: true, + es6: true, + es7: true, + enhance: true, + minify: true, + autoPrefixWXSS: true, + }, + onProgressUpdate: (progress) => { + if (typeof progress === "string") { + console.log(progress); + } + }, + }); + + fs.mkdirSync(path.dirname(resultPath), { recursive: true }); + fs.writeFileSync( + resultPath, + `${JSON.stringify( + { + appid, + version, + robot, + desc, + versionLabel, + uploadedAt: new Date().toISOString(), + result: uploadResult, + }, + null, + 2, + )}\n`, + ); + + console.log(`Upload succeeded: version ${version}`); + console.log(`Upload result saved: ${resultPath}`); + console.log( + "If this robot upload is already selected as 体验版 in WeChat admin, it becomes the new experience version automatically. Otherwise open 版本管理 → 开发版本 → 选为体验版 once.", + ); +} + +main().catch((error) => { + console.error(`Mini Program upload failed: ${error.message}`); + process.exitCode = 1; +}); diff --git a/mini/package.json b/mini/package.json new file mode 100644 index 0000000..b2990be --- /dev/null +++ b/mini/package.json @@ -0,0 +1,14 @@ +{ + "name": "lunar-mini", + "version": "1.0.0", + "description": "祈福小助手 - 万年历微信小程序", + "private": true, + "scripts": { + "test": "echo \"No tests yet\" && exit 0", + "upload": "node ci/upload-ci.cjs", + "preview": "node ci/preview-ci.cjs" + }, + "devDependencies": { + "miniprogram-ci": "^2.0.0" + } +}