feat: connect publish pipeline to dootask-website API

- Refactor build.js: replace androidUpload/genericPublish/published with
  unified WebsitePublisher class using Authorization Bearer auth
- New CLI commands: upload-changelog, release
- Update auto-update URL to /api/download/update (legacy compat on website)
- publish.yml: use CHANGELOG.md for GitHub Release body, replace
  PUBLISH_KEY with UPLOAD_TOKEN + UPLOAD_URL
This commit is contained in:
kuaifan 2026-04-05 23:04:34 +00:00
parent d17f404853
commit a67fcd6f02
3 changed files with 242 additions and 246 deletions

View File

@ -52,53 +52,18 @@ jobs:
uses: actions/github-script@v7 uses: actions/github-script@v7
with: with:
script: | script: |
// 获取最新的 tag const fs = require('fs');
const { data: tags } = await github.rest.repos.listTags({ const version = '${{ needs.check-version.outputs.version }}';
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 1
});
// 获取提交日志 // 从 CHANGELOG.md 提取当前版本段落
let changelog = ''; let changelog = '';
if (tags.length > 0) { const changelogPath = 'CHANGELOG.md';
const { data: commits } = await github.rest.repos.compareCommits({ if (fs.existsSync(changelogPath)) {
owner: context.repo.owner, const content = fs.readFileSync(changelogPath, 'utf-8');
repo: context.repo.repo, const regex = new RegExp(`## \\[${version.replace(/\./g, '\\.')}\\][\\s\\S]*?(?=\\n## \\[|$)`);
base: tags[0].name, const match = content.match(regex);
head: 'HEAD' if (match) {
}); changelog = match[0].trim();
// 按类型分组提交
const groups = {
'feat:': { title: '## Features', commits: new Set() },
'fix:': { title: '## Bug Fixes', commits: new Set() },
'perf:': { title: '## Performance Improvements', commits: new Set() }
};
// 分类收集提交,使用 Set 去重
commits.commits.forEach(commit => {
const message = commit.commit.message.split('\n')[0].trim();
for (const [prefix, group] of Object.entries(groups)) {
if (message.startsWith(prefix)) {
// 移除前缀后添加到对应分组
const cleanMessage = message.slice(prefix.length).trim();
group.commits.add(cleanMessage); // 使用 Set.add 自动去重
break;
}
}
});
// 生成更新日志
const sections = [];
for (const group of Object.values(groups)) {
if (group.commits.size > 0) {
sections.push(`${group.title}\n\n${Array.from(group.commits).map(msg => `- ${msg}`).join('\n')}`);
}
}
if (sections.length > 0) {
changelog = '# Changelog\n\n' + sections.join('\n\n');
} }
} }
@ -106,8 +71,8 @@ jobs:
const { data } = await github.rest.repos.createRelease({ const { data } = await github.rest.repos.createRelease({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
tag_name: `v${{ needs.check-version.outputs.version }}`, tag_name: `v${version}`,
name: `${{ needs.check-version.outputs.version }}`, name: version,
body: changelog || 'No significant changes in this release.', body: changelog || 'No significant changes in this release.',
draft: true, draft: true,
prerelease: false prerelease: false
@ -215,7 +180,8 @@ jobs:
- name: (Android) Upload File - name: (Android) Upload File
if: matrix.build_type == 'android' if: matrix.build_type == 'android'
env: env:
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }} UPLOAD_TOKEN: ${{ secrets.UPLOAD_TOKEN }}
UPLOAD_URL: ${{ secrets.UPLOAD_URL }}
run: | run: |
node ./electron/build.js android-upload node ./electron/build.js android-upload
@ -253,7 +219,8 @@ jobs:
APPLEIDPASS: ${{ secrets.APPLEIDPASS }} APPLEIDPASS: ${{ secrets.APPLEIDPASS }}
CSC_LINK: ${{ secrets.CSC_LINK }} CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }} UPLOAD_TOKEN: ${{ secrets.UPLOAD_TOKEN }}
UPLOAD_URL: ${{ secrets.UPLOAD_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }} GITHUB_REPOSITORY: ${{ github.repository }}
run: | run: |
@ -263,7 +230,8 @@ jobs:
- name: (Windows) Build Client - name: (Windows) Build Client
if: matrix.build_type == 'windows' if: matrix.build_type == 'windows'
env: env:
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }} UPLOAD_TOKEN: ${{ secrets.UPLOAD_TOKEN }}
UPLOAD_URL: ${{ secrets.UPLOAD_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }} GITHUB_REPOSITORY: ${{ github.repository }}
shell: bash shell: bash
@ -294,11 +262,13 @@ jobs:
prerelease: false prerelease: false
}) })
- name: Publish Official - name: Upload Changelog & Publish to Website
env: env:
PUBLISH_KEY: ${{ secrets.PUBLISH_KEY }} UPLOAD_TOKEN: ${{ secrets.UPLOAD_TOKEN }}
UPLOAD_URL: ${{ secrets.UPLOAD_URL }}
run: | run: |
pushd electron || exit pushd electron || exit
npm install npm install
popd || exit popd || exit
node ./electron/build.js published node ./electron/build.js upload-changelog
node ./electron/build.js release

332
electron/build.js vendored
View File

@ -12,7 +12,7 @@ const utils = require('./lib/utils');
const config = require('../package.json') const config = require('../package.json')
const env = require('dotenv').config({ path: './.env' }) const env = require('dotenv').config({ path: './.env' })
const argv = process.argv; const argv = process.argv;
const {BUILD_FRONTEND, APPLEID, APPLEIDPASS, GITHUB_TOKEN, GITHUB_REPOSITORY, PUBLISH_KEY} = process.env; const {BUILD_FRONTEND, APPLEID, APPLEIDPASS, GITHUB_TOKEN, GITHUB_REPOSITORY, UPLOAD_TOKEN, UPLOAD_URL} = process.env;
const electronDir = path.resolve(__dirname, "public"); const electronDir = path.resolve(__dirname, "public");
const nativeCachePath = path.resolve(__dirname, ".native"); const nativeCachePath = path.resolve(__dirname, ".native");
@ -350,192 +350,166 @@ function axiosAutoTry(data) {
} }
/** /**
* 上传app应用 * 官网发布器
* @param url
*/ */
function androidUpload(url) { class WebsitePublisher {
if (!PUBLISH_KEY) { constructor({baseUrl, token, version}) {
console.error("缺少 PUBLISH_KEY 环境变量"); this.baseUrl = baseUrl
process.exit() this.token = token
this.version = version
} }
const releaseDir = path.resolve(__dirname, "../resources/mobile/platforms/android/eeuiApp/app/build/outputs/apk/release");
if (!fs.existsSync(releaseDir)) { /**
console.error("发布文件未找到"); * 上传单个文件
process.exit() * @param localFile 本地文件路径
} * @param options { platform, arch } 可选有则为安装包
fs.readdir(releaseDir, async (err, files) => { */
if (err) { async uploadPackage(localFile, options = {}) {
console.warn(err) const filename = path.basename(localFile)
} else { let spinner = ora(`Upload [0%] ${filename}`).start()
const uploadOras = {}
for (const filename of files) {
const localFile = path.join(releaseDir, filename)
if (/\.apk$/.test(filename) && fs.existsSync(localFile)) {
const fileStat = fs.statSync(localFile)
if (fileStat.isFile()) {
uploadOras[filename] = ora(`Upload [0%] ${filename}`).start()
const formData = new FormData() const formData = new FormData()
formData.append("file", fs.createReadStream(localFile)); formData.append("file", fs.createReadStream(localFile))
formData.append("action", "draft"); formData.append("version", this.version)
if (options.platform) {
formData.append("platform", options.platform)
if (options.arch) {
formData.append("arch", options.arch)
}
}
await axiosAutoTry({ await axiosAutoTry({
axios: { axios: {
method: 'post', method: 'post',
url: url, url: `${this.baseUrl}/api/upload/package`,
data: formData, data: formData,
headers: { headers: {
'Publish-Version': config.version, 'Authorization': `Bearer ${this.token}`,
'Publish-Key': PUBLISH_KEY,
'Content-Type': 'multipart/form-data;boundary=' + formData.getBoundary(), 'Content-Type': 'multipart/form-data;boundary=' + formData.getBoundary(),
}, },
onUploadProgress: progress => { onUploadProgress: progress => {
const complete = Math.min(99, Math.round(progress.loaded / progress.total * 100 | 0)) + '%' const complete = Math.min(99, Math.round(progress.loaded / progress.total * 100 | 0)) + '%'
uploadOras[filename].text = `Upload [${complete}] ${filename}` spinner.text = `Upload [${complete}] ${filename}`
}, },
}, },
onRetry: _ => { onRetry: _ => {
uploadOras[filename].warn(`Upload [retry] ${filename}`) spinner.warn(`Upload [retry] ${filename}`)
uploadOras[filename] = ora(`Upload [0%] ${filename}`).start() spinner = ora(`Upload [0%] ${filename}`).start()
}, },
retryNumber: 3 retryNumber: 3
}).then(({status, data}) => { }).then(({status, data}) => {
if (status !== 200) { if (status !== 200) {
uploadOras[filename].fail(`Upload [fail:${status}] ${filename}`) spinner.fail(`Upload [fail:${status}] ${filename}`)
return return
} }
if (!utils.isJson(data)) { if (!utils.isJson(data)) {
uploadOras[filename].fail(`Upload [fail:not json] ${filename}`) spinner.fail(`Upload [fail:not json] ${filename}`)
return return
} }
if (data.ret !== 1) { if (!data.success) {
uploadOras[filename].fail(`Upload [fail:ret ${data.ret}] ${filename}`) spinner.fail(`Upload [fail] ${filename}: ${data.message || JSON.stringify(data)}`)
return return
} }
uploadOras[filename].succeed(`Upload [100%] ${filename}`) spinner.succeed(`Upload [100%] ${filename}`)
}).catch(_ => { }).catch(_ => {
uploadOras[filename].fail(`Upload [fail] ${filename}`) spinner.fail(`Upload [fail] ${filename}`)
}) })
} }
/**
* 上传 changelog
*/
async uploadChangelog(content) {
const spinner = ora('Uploading changelog...').start()
await axiosAutoTry({
axios: {
method: 'post',
url: `${this.baseUrl}/api/upload/changelog`,
data: { content },
headers: {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json',
},
},
retryNumber: 3
}).then(({status, data}) => {
if (status !== 200 || !data.success) {
spinner.fail('Changelog upload failed')
return
} }
} spinner.succeed('Changelog uploaded')
} }).catch(_ => {
}); spinner.fail('Changelog upload failed')
})
} }
/** /**
* 通知发布完成 * 通知发布完成
* @param url
*/ */
async function published(url) { async release() {
if (!PUBLISH_KEY) { const spinner = ora('Publishing release...').start()
console.error("缺少 PUBLISH_KEY 环境变量");
process.exit()
}
const spinner = ora('完成发布...').start();
const formData = new FormData()
formData.append("action", "release");
await axiosAutoTry({ await axiosAutoTry({
axios: { axios: {
method: 'post', method: 'post',
url: url, url: `${this.baseUrl}/api/upload/release`,
data: formData, data: { version: this.version },
headers: { headers: {
'Publish-Version': config.version, 'Authorization': `Bearer ${this.token}`,
'Publish-Key': PUBLISH_KEY, 'Content-Type': 'application/json',
}, },
}, },
retryNumber: 3 retryNumber: 3
}).then(({status, data}) => { }).then(({status, data}) => {
if (status !== 200) { if (status !== 200 || !data.success) {
spinner.fail('发布失败, status: ' + status) spinner.fail(`Release failed: ${data?.message || status}`)
return return
} }
if (!utils.isJson(data)) { spinner.succeed('Release published')
spinner.fail('发布失败, not json')
return
}
if (data.ret !== 1) {
spinner.fail(`发布失败, ${JSON.stringify(data)}`)
return
}
spinner.succeed('发布完成')
}).catch(_ => { }).catch(_ => {
spinner.fail('发布失败') spinner.fail('Release failed')
})
}
}
// 安装包扩展名
const INSTALLER_EXTS = ['.dmg', '.exe', '.msi', '.appimage', '.deb', '.rpm', '.apk']
/**
* 创建 WebsitePublisher 实例如果环境变量齐全
*/
function createPublisher() {
if (!UPLOAD_TOKEN || !UPLOAD_URL) {
return null
}
return new WebsitePublisher({
baseUrl: UPLOAD_URL.replace(/\/+$/, ''),
token: UPLOAD_TOKEN,
version: config.version
}) })
} }
/** /**
* 通用发布 * 从文件名判断是否为安装包
* @param url
* @param key
* @param version
* @param output
*/ */
function genericPublish({url, key, version, output}) { function isInstaller(filename) {
if (!/https?:\/\//i.test(url)) { return INSTALLER_EXTS.some(ext => filename.toLowerCase().endsWith(ext))
console.warn("发布地址无效: " + url)
return
} }
const filePath = path.resolve(__dirname, output)
if (!fs.existsSync(filePath)) { /**
console.warn("发布文件未找到: " + filePath) * 从文件名提取 arch
return */
function parseArchFromFilename(filename) {
if (/-arm64[.-]/i.test(filename)) return 'arm64'
if (/-x64[.-]/i.test(filename)) return 'x64'
return null
} }
fs.readdir(filePath, async (err, files) => {
if (err) { /**
console.warn(err) * 将构建平台名映射为 API platform
} else { */
const uploadOras = {} function mapPlatform(buildPlatform) {
for (const filename of files) { if (buildPlatform.includes('mac')) return 'mac'
const localFile = path.join(filePath, filename) if (buildPlatform.includes('win')) return 'win'
if (fs.existsSync(localFile)) { if (buildPlatform.includes('linux')) return 'linux'
const fileStat = fs.statSync(localFile) return null
if (fileStat.isFile()) {
uploadOras[filename] = ora(`Upload [0%] ${filename}`).start()
const formData = new FormData()
formData.append("file", fs.createReadStream(localFile));
formData.append("action", "draft");
await axiosAutoTry({
axios: {
method: 'post',
url: url,
data: formData,
headers: {
'Publish-Version': version,
'Publish-Key': key,
'Content-Type': 'multipart/form-data;boundary=' + formData.getBoundary(),
},
onUploadProgress: progress => {
const complete = Math.min(99, Math.round(progress.loaded / progress.total * 100 | 0)) + '%'
uploadOras[filename].text = `Upload [${complete}] ${filename}`
},
},
onRetry: _ => {
uploadOras[filename].warn(`Upload [retry] ${filename}`)
uploadOras[filename] = ora(`Upload [0%] ${filename}`).start()
},
retryNumber: 3
}).then(({status, data}) => {
if (status !== 200) {
uploadOras[filename].fail(`Upload [fail:${status}] ${filename}`)
return
}
if (!utils.isJson(data)) {
uploadOras[filename].fail(`Upload [fail:not json] ${filename}`)
return
}
if (data.ret !== 1) {
uploadOras[filename].fail(`Upload [fail:ret ${data.ret}] ${filename}`)
return
}
uploadOras[filename].succeed(`Upload [100%] ${filename}`)
}).catch(_ => {
uploadOras[filename].fail(`Upload [fail] ${filename}`)
})
}
}
}
}
});
} }
/** /**
@ -703,13 +677,27 @@ async function startBuild(data) {
appConfig.build.directories.output = `${output}-generic`; appConfig.build.directories.output = `${output}-generic`;
fs.writeFileSync(packageFile, JSON.stringify(appConfig, null, 4), 'utf8'); fs.writeFileSync(packageFile, JSON.stringify(appConfig, null, 4), 'utf8');
child_process.execSync(`npm run ${platform}`, {stdio: "inherit", cwd: "electron"}); child_process.execSync(`npm run ${platform}`, {stdio: "inherit", cwd: "electron"});
if (publish === true && PUBLISH_KEY) { if (publish === true) {
genericPublish({ const publisher = createPublisher()
url: appConfig.build.publish.url, if (publisher) {
key: PUBLISH_KEY, const outputDir = path.resolve(__dirname, appConfig.build.directories.output)
version: config.version, if (fs.existsSync(outputDir)) {
output: appConfig.build.directories.output const apiPlatform = mapPlatform(platform)
}) const files = fs.readdirSync(outputDir)
for (const filename of files) {
const localFile = path.join(outputDir, filename)
const fileStat = fs.statSync(localFile)
if (!fileStat.isFile()) continue
if (isInstaller(filename) && apiPlatform) {
const arch = parseArchFromFilename(filename)
await publisher.uploadPackage(localFile, { platform: apiPlatform, arch })
} else {
await publisher.uploadPackage(localFile)
}
}
}
}
} }
// package.json Recovery // package.json Recovery
recoveryPackage(true) recoveryPackage(true)
@ -746,18 +734,56 @@ if (["dev"].includes(argv[2])) {
}) })
} else if (["android-upload"].includes(argv[2])) { } else if (["android-upload"].includes(argv[2])) {
// 上传安卓文件GitHub Actions // 上传安卓文件GitHub Actions
config.app.forEach(({publish}) => { (async () => {
if (publish.provider === 'generic') { const publisher = createPublisher()
androidUpload(publish.url) if (publisher) {
const releaseDir = path.resolve(__dirname, "../resources/mobile/platforms/android/eeuiApp/app/build/outputs/apk/release");
if (fs.existsSync(releaseDir)) {
const files = fs.readdirSync(releaseDir)
for (const filename of files) {
const localFile = path.join(releaseDir, filename)
if (/\.apk$/.test(filename) && fs.existsSync(localFile) && fs.statSync(localFile).isFile()) {
await publisher.uploadPackage(localFile, { platform: 'android' })
} }
})
} else if (["published"].includes(argv[2])) {
// 发布完成GitHub Actions
config.app.forEach(async ({publish}) => {
if (publish.provider === 'generic') {
await published(publish.url)
} }
}) } else {
console.error("发布文件未找到")
process.exit(1)
}
} else {
console.error("缺少 UPLOAD_TOKEN 或 UPLOAD_URL 环境变量")
process.exit(1)
}
})()
} else if (["release"].includes(argv[2])) {
// 通知官网发布完成GitHub Actions
(async () => {
const publisher = createPublisher()
if (publisher) {
await publisher.release()
} else {
console.error("缺少 UPLOAD_TOKEN 或 UPLOAD_URL 环境变量")
process.exit(1)
}
})()
} else if (["upload-changelog"].includes(argv[2])) {
// 上传 changelogGitHub Actions
(async () => {
const publisher = createPublisher()
if (publisher) {
const changelogPath = path.resolve(__dirname, "../CHANGELOG.md")
if (fs.existsSync(changelogPath)) {
const content = fs.readFileSync(changelogPath, 'utf8')
await publisher.uploadChangelog(content)
} else {
console.error("CHANGELOG.md 未找到")
process.exit(1)
}
} else {
console.error("缺少 UPLOAD_TOKEN 或 UPLOAD_URL 环境变量")
process.exit(1)
}
})()
} else if (["all", "win", "mac"].includes(argv[2])) { } else if (["all", "win", "mac"].includes(argv[2])) {
// 自动编译GitHub Actions // 自动编译GitHub Actions
platforms.filter(p => { platforms.filter(p => {
@ -907,8 +933,8 @@ if (["dev"].includes(argv[2])) {
// 发布判断环境变量 // 发布判断环境变量
if (answers.publish) { if (answers.publish) {
if (!PUBLISH_KEY && (!GITHUB_TOKEN || !utils.strExists(GITHUB_REPOSITORY, "/"))) { if (!(UPLOAD_TOKEN && UPLOAD_URL) && !(GITHUB_TOKEN && utils.strExists(GITHUB_REPOSITORY, "/"))) {
console.error("发布需要 PUBLISH_KEY 或 GitHub Token 和 Repository, 请检查环境变量!"); console.error("发布需要 UPLOAD_TOKEN + UPLOAD_URL 或 GITHUB_TOKEN + GITHUB_REPOSITORY, 请检查环境变量!");
process.exit() process.exit()
} }
} }

View File

@ -90,7 +90,7 @@
], ],
"publish": { "publish": {
"provider": "generic", "provider": "generic",
"url": "https://www.dootask.com/desktop/publish" "url": "https://www.dootask.com/api/download/update"
} }
} }
] ]