mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-21 19:10:41 +08:00
ci: rebuild release pipeline around OSS manifest protocol
Replace the broken yarn/Node 18 release workflow (wrong package manager, missing build/ dir, archived actions) with a manifest-based distribution flow modeled on yaklang/browser-binaries-mirror: - scripts/package-release.mjs: package the four variants, emit release-entry.json and per-artifact sha256 checksums - scripts/build-manifest.mjs: merge into a bounded public manifest (10 versions) with invariant validation - scripts/publish-oss.mjs: immutable artifacts (forbid-overwrite, one-year cache) and mutable manifest (5-minute cache, checksum published second), idempotent via head + sha256 comparison - scripts/verify-public.mjs: post-publish verification from the public endpoint (bytes, headers, zip layout) - release.yml: pnpm + Node 22, full verify:production, OSS publish, GitHub Release, separate public verify job, publish concurrency group - ci.yml: run verify:production on push/PR - single-source the extension version in package.json (wxt.config reads) - document the distribution protocol in README
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Test and build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Test, compile, build and audit all variants
|
||||
run: pnpm verify:production
|
||||
+145
-67
@@ -2,9 +2,146 @@ name: Build and Release
|
||||
|
||||
on: workflow_dispatch
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
# Serializes publishes so two runs can never interleave the
|
||||
# fetch-existing-manifest / upload-manifest sequence.
|
||||
concurrency:
|
||||
group: oss-extension-release
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
PUBLIC_BASE_URL: https://aliyun-oss.yaklang.com/chrome-extension
|
||||
OSS_ENDPOINT: https://oss-accelerate.aliyuncs.com
|
||||
OSS_BUCKET: yaklang
|
||||
MANIFEST_MAX_VERSIONS: '10'
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
publish:
|
||||
name: Build, package and publish to OSS
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Test, compile and build all variants
|
||||
run: pnpm verify:production
|
||||
|
||||
- name: Read version
|
||||
id: version
|
||||
run: |
|
||||
echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT"
|
||||
echo "build_time=$(date +'%Y-%m-%d %H:%M:%S')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Package release artifacts
|
||||
run: node scripts/package-release.mjs --dist=dist --public-base-url=${PUBLIC_BASE_URL}
|
||||
|
||||
- name: Upload release artifacts to OSS (immutable)
|
||||
run: |
|
||||
node scripts/publish-oss.mjs release \
|
||||
--release-entry=dist/release-entry.json \
|
||||
--dist=dist \
|
||||
--endpoint=${OSS_ENDPOINT} \
|
||||
--bucket=${OSS_BUCKET}
|
||||
env:
|
||||
OSS_KEY_ID: ${{ secrets.OSS_KEY_ID }}
|
||||
OSS_KEY_SECRET: ${{ secrets.OSS_KEY_SECRET }}
|
||||
|
||||
- name: Fetch existing manifest
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Query parameter busts the CDN's 5-minute manifest cache.
|
||||
url="${PUBLIC_BASE_URL}/manifest.json?mirror_build=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
code=$(curl -sS -o dist/existing-manifest.json -w '%{http_code}' --retry 4 --retry-all-errors "$url")
|
||||
if [ "$code" = "200" ]; then
|
||||
echo "Existing manifest fetched."
|
||||
elif [ "$code" = "404" ]; then
|
||||
rm -f dist/existing-manifest.json
|
||||
echo "No existing manifest (first release)."
|
||||
else
|
||||
echo "Unexpected HTTP ${code} fetching ${url}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build bounded manifest
|
||||
run: |
|
||||
existing=""
|
||||
if [ -f dist/existing-manifest.json ]; then
|
||||
existing="--existing-manifest=dist/existing-manifest.json"
|
||||
fi
|
||||
node scripts/build-manifest.mjs \
|
||||
--release-entry=dist/release-entry.json \
|
||||
${existing} \
|
||||
--max-versions=${MANIFEST_MAX_VERSIONS} \
|
||||
--output=dist/manifest.json \
|
||||
--checksum-output=dist/manifest.json.sha256.txt
|
||||
|
||||
- name: Publish manifest to OSS
|
||||
run: |
|
||||
node scripts/publish-oss.mjs manifest \
|
||||
--manifest=dist/manifest.json \
|
||||
--manifest-checksum=dist/manifest.json.sha256.txt \
|
||||
--endpoint=${OSS_ENDPOINT} \
|
||||
--bucket=${OSS_BUCKET}
|
||||
env:
|
||||
OSS_KEY_ID: ${{ secrets.OSS_KEY_ID }}
|
||||
OSS_KEY_SECRET: ${{ secrets.OSS_KEY_SECRET }}
|
||||
|
||||
- name: Upload release entry for verification
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-entry
|
||||
path: dist/release-entry.json
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: v${{ steps.version.outputs.version }}
|
||||
name: Release v${{ steps.version.outputs.version }}
|
||||
body: |
|
||||
Branch: ${{ github.ref_name }}
|
||||
Commit: ${{ github.sha }}
|
||||
Build Time: ${{ steps.version.outputs.build_time }}
|
||||
Manifest: ${{ env.PUBLIC_BASE_URL }}/manifest.json
|
||||
files: |
|
||||
dist/${{ steps.version.outputs.version }}/*
|
||||
dist/manifest.json
|
||||
dist/manifest.json.sha256.txt
|
||||
|
||||
- name: Write job summary
|
||||
run: |
|
||||
{
|
||||
echo "## Release v${{ steps.version.outputs.version }}"
|
||||
echo
|
||||
echo "- Manifest: ${PUBLIC_BASE_URL}/manifest.json"
|
||||
echo "- Commit: \`${{ github.sha }}\`"
|
||||
echo
|
||||
echo "| Variant | Size | SHA-256 |"
|
||||
echo "| --- | --- | --- |"
|
||||
jq -r '.artifacts[] | "| \(.variant) | \(.size) | \`\(.sha256)\` |"' dist/release-entry.json
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
verify:
|
||||
name: Verify public release
|
||||
needs: publish
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -12,72 +149,13 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'yarn'
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Build project
|
||||
run: yarn build
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(jq -r '.version' build/manifest.json)
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "build_time=$(date +'%Y-%m-%d %H:%M:%S')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Zip build artifacts
|
||||
run: |
|
||||
cd build
|
||||
zip -r ../extension.zip .
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Download release entry
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
tag_name: v${{ steps.version.outputs.version }}
|
||||
release_name: Release v${{ steps.version.outputs.version }}
|
||||
body: |
|
||||
Branch: ${{ github.ref_name }}
|
||||
Commit: ${{ github.sha }}
|
||||
Build Time: ${{ steps.version.outputs.build_time }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
- name: Upload Release Asset
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: ./extension.zip
|
||||
asset_name: yakit-chrome-extension-v${{ steps.version.outputs.version }}.zip
|
||||
asset_content_type: application/zip
|
||||
|
||||
- name: Upload Extension To OSS
|
||||
uses: tvrcgo/upload-to-oss@master
|
||||
with:
|
||||
key-id: ${{ secrets.OSS_KEY_ID }}
|
||||
key-secret: ${{ secrets.OSS_KEY_SECRET }}
|
||||
region: oss-accelerate
|
||||
bucket: yaklang
|
||||
assets: |
|
||||
extension.zip:/chrome-extension/yakit-chrome-extension-v${{ steps.version.outputs.version }}.zip
|
||||
|
||||
- name: Update OSS latest version file
|
||||
run: echo ${{ steps.version.outputs.version }} > ./extension-version.txt
|
||||
|
||||
- name: Upload Version File to OSS
|
||||
uses: tvrcgo/upload-to-oss@master
|
||||
with:
|
||||
key-id: ${{ secrets.OSS_KEY_ID }}
|
||||
key-secret: ${{ secrets.OSS_KEY_SECRET }}
|
||||
region: oss-accelerate
|
||||
bucket: yaklang
|
||||
assets: |
|
||||
./extension-version.txt:/chrome-extension/latest-version.txt
|
||||
name: release-entry
|
||||
path: dist
|
||||
|
||||
- name: Verify from public endpoint
|
||||
run: node scripts/verify-public.mjs --public-base-url=${PUBLIC_BASE_URL} --release-entry=dist/release-entry.json
|
||||
|
||||
@@ -328,6 +328,31 @@ Native Host 的构建与注册方式见 [native-host/README.md](./native-host/RE
|
||||
|
||||
Chrome Store 构建声明 Chrome 138+。用户需要在扩展详情页开启“允许用户脚本”,页面主世界能力才能正常工作;未开启时扩展会明确报告原因,不会静默降级为直接 Eval。
|
||||
|
||||
### 发布与下载
|
||||
|
||||
发布由 GitHub Actions 的 **Build and Release** workflow(手动触发)完成:执行 `verify:production` 全量校验后,将四个变体打包为不可变的版本化产物上传到 OSS,再发布机器可读的 manifest,并从公网侧回读验证。CI 在每次 push / PR 时运行同一套构建与审计。
|
||||
|
||||
**下载入口**(不要硬编码版本号):
|
||||
|
||||
```
|
||||
https://aliyun-oss.yaklang.com/chrome-extension/manifest.json
|
||||
```
|
||||
|
||||
manifest 的 `latest` 指向最新版本,`versions[0]` 为完整记录,最多保留 10 个历史版本。每个版本按 `variant`(`chrome-store` / `chrome-enterprise` / `firefox` / `firefox-amo`)匹配 artifact,字段包括 `url`、`filename`、`sha256`、`size` 与 `checksum_url`;manifest 自身的 SHA-256 在同目录的 `manifest.json.sha256.txt`。
|
||||
|
||||
推荐的消费流程:
|
||||
|
||||
1. 拉取 `manifest.json`(缓存 5 分钟),按需选择版本与变体;
|
||||
2. 下载 artifact(版本化 URL 永不变更,缓存一年)到临时文件;
|
||||
3. 校验 `size` 与 `sha256`(或对比 `checksum_url` 内容)后,解压并安装;
|
||||
4. 变体用途见上表“构建差异”。
|
||||
|
||||
**发布契约**:
|
||||
|
||||
- 版本化产物不可变:URL 形如 `…/chrome-extension/<version>/<variant>-<version>.zip`,重复发布同版本时内容一致则跳过、不一致则流水线报错拒绝覆盖;
|
||||
- `manifest.json` 可变、缓存 5 分钟,先发布 manifest 再发布其校验文件,消费方可用校验文件识别中间态;
|
||||
- 发布 job 结束前有独立的 verify job 从公网下载全部产物,复核 sha256、缓存头与 zip 内 `manifest.json` 版本。
|
||||
|
||||
## 权限与数据边界
|
||||
|
||||
扩展声明 `tabs`、`scripting`、`cookies`、`proxy`、`webRequest`、`webNavigation`、`debugger` 等权限,是为了在用户主动选择的目标页面上提供对应安全测试能力。`nativeMessaging` 是可选权限,仅在用户选择 Native 模式时请求。
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"packageManager": "[email protected]",
|
||||
"scripts": {
|
||||
"dev": "wxt",
|
||||
"dev:wsl": "node scripts/dev-wsl.mjs",
|
||||
@@ -49,10 +50,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsrsasign": "10.5.15",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@wxt-dev/module-react": "^1.2.2",
|
||||
"adm-zip": "^0.5.16",
|
||||
"ali-oss": "^6.21.0",
|
||||
"jose": "6.2.3",
|
||||
"jsencrypt": "3.5.4",
|
||||
"jsrsasign": "11.1.3",
|
||||
|
||||
Generated
+585
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Merges the freshly packaged release (dist/release-entry.json) into the
|
||||
* public manifest and writes manifest.json + manifest.json.sha256.txt.
|
||||
*
|
||||
* The manifest is the single entry point consumers read: `latest` plus a
|
||||
* bounded `versions[]` history. Artifact objects are immutable and their URLs
|
||||
* are never rewritten; only this manifest moves.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/build-manifest.mjs --release-entry=dist/release-entry.json \
|
||||
* [--existing-manifest=dist/existing-manifest.json] [--max-versions=10] \
|
||||
* --output=dist/manifest.json --checksum-output=dist/manifest.json.sha256.txt
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {};
|
||||
for (const arg of argv) {
|
||||
if (!arg.startsWith('--')) throw new Error(`unexpected argument: ${arg}`);
|
||||
const eq = arg.indexOf('=');
|
||||
const key = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
|
||||
out[key] = eq === -1 ? true : arg.slice(eq + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function artifactFingerprint(artifacts) {
|
||||
return artifacts.map((a) => `${a.variant}:${a.sha256}`).sort().join('|');
|
||||
}
|
||||
|
||||
function toVersionEntry(entry) {
|
||||
return {
|
||||
version: entry.version,
|
||||
published_at: entry.built_at,
|
||||
commit: entry.commit ?? null,
|
||||
artifacts: entry.artifacts.map((a) => ({
|
||||
variant: a.variant,
|
||||
browser: a.browser,
|
||||
mode: a.mode,
|
||||
filename: a.filename,
|
||||
url: a.url,
|
||||
sha256: a.sha256,
|
||||
size: a.size,
|
||||
checksum_url: a.checksum_url,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function validate(manifest) {
|
||||
if (!Array.isArray(manifest.versions) || manifest.versions.length === 0) {
|
||||
throw new Error('manifest must contain at least one version');
|
||||
}
|
||||
if (manifest.latest !== manifest.versions[0].version) {
|
||||
throw new Error(`manifest.latest (${manifest.latest}) must equal versions[0].version (${manifest.versions[0].version})`);
|
||||
}
|
||||
const seen = new Set();
|
||||
for (const versionEntry of manifest.versions) {
|
||||
if (seen.has(versionEntry.version)) throw new Error(`duplicate version in manifest: ${versionEntry.version}`);
|
||||
seen.add(versionEntry.version);
|
||||
if (!Array.isArray(versionEntry.artifacts) || versionEntry.artifacts.length === 0) {
|
||||
throw new Error(`version ${versionEntry.version} has no artifacts`);
|
||||
}
|
||||
const variants = new Set();
|
||||
for (const artifact of versionEntry.artifacts) {
|
||||
if (variants.has(artifact.variant)) throw new Error(`duplicate variant ${artifact.variant} in version ${versionEntry.version}`);
|
||||
variants.add(artifact.variant);
|
||||
if (!/^[0-9a-f]{64}$/.test(artifact.sha256)) throw new Error(`artifact ${artifact.filename}: bad sha256`);
|
||||
if (!Number.isInteger(artifact.size) || artifact.size <= 0) throw new Error(`artifact ${artifact.filename}: bad size`);
|
||||
if (!/^https?:\/\//.test(artifact.url)) throw new Error(`artifact ${artifact.filename}: url must be absolute`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!args['release-entry']) throw new Error('--release-entry is required');
|
||||
if (!args.output) throw new Error('--output is required');
|
||||
if (!args['checksum-output']) throw new Error('--checksum-output is required');
|
||||
|
||||
const entry = JSON.parse(await readFile(resolve(root, String(args['release-entry'])), 'utf8'));
|
||||
const maxVersions = Number.parseInt(String(args['max-versions'] ?? '10'), 10);
|
||||
if (!Number.isInteger(maxVersions) || maxVersions < 1) throw new Error('--max-versions must be a positive integer');
|
||||
|
||||
let versions = [];
|
||||
if (args['existing-manifest']) {
|
||||
try {
|
||||
const existing = JSON.parse(await readFile(resolve(root, String(args['existing-manifest'])), 'utf8'));
|
||||
versions = Array.isArray(existing.versions) ? existing.versions : [];
|
||||
} catch (err) {
|
||||
if (err?.code !== 'ENOENT') throw err;
|
||||
console.log('existing manifest not found; starting a fresh history');
|
||||
}
|
||||
}
|
||||
|
||||
const newEntry = toVersionEntry(entry);
|
||||
const idx = versions.findIndex((v) => v.version === entry.version);
|
||||
if (idx >= 0 && artifactFingerprint(versions[idx].artifacts) === artifactFingerprint(entry.artifacts)) {
|
||||
// Idempotent rerun: keep the original entry (published_at stays stable).
|
||||
console.log(`version ${entry.version} already in manifest with identical artifacts; kept as-is`);
|
||||
} else {
|
||||
if (idx >= 0) {
|
||||
versions.splice(idx, 1);
|
||||
console.log(`version ${entry.version} re-published with different artifacts; replaced entry`);
|
||||
}
|
||||
versions.unshift(newEntry);
|
||||
}
|
||||
versions = versions.slice(0, maxVersions);
|
||||
|
||||
const manifest = { latest: versions[0].version, updated_at: new Date().toISOString(), versions };
|
||||
validate(manifest);
|
||||
|
||||
const bytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
||||
await writeFile(resolve(root, String(args.output)), bytes);
|
||||
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
||||
await writeFile(resolve(root, String(args['checksum-output'])), `${sha256} manifest.json\n`);
|
||||
console.log(`manifest written: ${args.output} (latest=${manifest.latest}, ${versions.length} version(s) retained)`);
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Packages the release variants from .output into dist/<version>/ and writes
|
||||
* dist/release-entry.json recording filename/size/sha256/url for every
|
||||
* artifact, plus per-artifact .sha256.txt checksum files.
|
||||
*
|
||||
* The variant table must stay in sync with `verify:production` (package.json)
|
||||
* and scripts/audit-build.mjs — those define the published surface.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/package-release.mjs --public-base-url=https://aliyun-oss.yaklang.com/chrome-extension [--dist=dist]
|
||||
*/
|
||||
import { execFile } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { access } from 'node:fs/promises';
|
||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { promisify } from 'node:util';
|
||||
import AdmZip from 'adm-zip';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
|
||||
const VARIANTS = [
|
||||
{ variant: 'chrome-store', browser: 'chrome', mode: 'store', dir: '.output/chrome-mv3-store' },
|
||||
{ variant: 'chrome-enterprise', browser: 'chrome', mode: 'enterprise', dir: '.output/chrome-mv3-enterprise' },
|
||||
{ variant: 'firefox', browser: 'firefox', mode: 'production', dir: '.output/firefox-mv2' },
|
||||
{ variant: 'firefox-amo', browser: 'firefox', mode: 'store', dir: '.output/firefox-mv3-store' },
|
||||
];
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {};
|
||||
for (const arg of argv) {
|
||||
if (!arg.startsWith('--')) throw new Error(`unexpected argument: ${arg}`);
|
||||
const eq = arg.indexOf('=');
|
||||
const key = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
|
||||
out[key] = eq === -1 ? true : arg.slice(eq + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function exists(path) {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sha256File(path) {
|
||||
const hash = createHash('sha256');
|
||||
await pipeline(createReadStream(path), hash);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!args['public-base-url']) {
|
||||
throw new Error('--public-base-url is required (e.g. https://aliyun-oss.yaklang.com/chrome-extension)');
|
||||
}
|
||||
const baseUrl = String(args['public-base-url']).replace(/\/+$/, '');
|
||||
const distDir = resolve(root, String(args.dist ?? 'dist'));
|
||||
|
||||
const pkg = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8'));
|
||||
const { version } = pkg;
|
||||
|
||||
let commit = null;
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: root });
|
||||
commit = stdout.trim();
|
||||
} catch {
|
||||
// Not fatal: local runs outside a git worktree still package fine.
|
||||
}
|
||||
|
||||
const versionDir = resolve(distDir, version);
|
||||
await mkdir(versionDir, { recursive: true });
|
||||
|
||||
const artifacts = [];
|
||||
for (const target of VARIANTS) {
|
||||
const outputDir = resolve(root, target.dir);
|
||||
if (!(await exists(resolve(outputDir, 'manifest.json')))) {
|
||||
throw new Error(`${target.variant}: ${target.dir}/manifest.json missing — run the build first (pnpm verify:production)`);
|
||||
}
|
||||
const builtManifest = JSON.parse(await readFile(resolve(outputDir, 'manifest.json'), 'utf8'));
|
||||
if (builtManifest.version !== version) {
|
||||
throw new Error(`${target.variant}: built manifest version ${builtManifest.version} != package.json version ${version}`);
|
||||
}
|
||||
|
||||
const filename = `${target.variant}-${version}.zip`;
|
||||
const zipPath = resolve(versionDir, filename);
|
||||
// Entry paths are relative to the output dir so manifest.json sits at the
|
||||
// zip root, which is what browsers expect from a sideloaded extension.
|
||||
const zip = new AdmZip();
|
||||
zip.addLocalFolder(outputDir);
|
||||
await zip.writeZipPromise(zipPath);
|
||||
const sha256 = await sha256File(zipPath);
|
||||
const size = (await stat(zipPath)).size;
|
||||
await writeFile(resolve(versionDir, `${filename}.sha256.txt`), `${sha256} ${filename}\n`);
|
||||
|
||||
artifacts.push({
|
||||
variant: target.variant,
|
||||
browser: target.browser,
|
||||
mode: target.mode,
|
||||
filename,
|
||||
url: `${baseUrl}/${version}/${filename}`,
|
||||
sha256,
|
||||
size,
|
||||
checksum_url: `${baseUrl}/${version}/${filename}.sha256.txt`,
|
||||
});
|
||||
console.log(`packaged ${filename} (${size} bytes, sha256 ${sha256.slice(0, 12)}…)`);
|
||||
}
|
||||
|
||||
const entry = { version, commit, built_at: new Date().toISOString(), artifacts };
|
||||
await writeFile(resolve(distDir, 'release-entry.json'), `${JSON.stringify(entry, null, 2)}\n`);
|
||||
console.log(`release entry written: ${resolve(distDir, 'release-entry.json').slice(root.length + 1)} (version ${version})`);
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Publishes release artifacts and the manifest to Aliyun OSS.
|
||||
*
|
||||
* The contract mirrors yaklang/browser-binaries-mirror:
|
||||
* - versioned artifacts are immutable: one-year immutable cache headers,
|
||||
* sha256 user meta, x-oss-forbid-overwrite on upload; an existing object
|
||||
* with a different sha256 is a hard error, an identical one is skipped
|
||||
* - manifest.json is mutable: five-minute cache; it is published first and
|
||||
* its checksum second, so consumers can always detect a torn publish by
|
||||
* verifying the checksum file
|
||||
*
|
||||
* Credentials come from OSS_KEY_ID / OSS_KEY_SECRET (org-level secrets).
|
||||
*
|
||||
* Usage:
|
||||
* OSS_KEY_ID=… OSS_KEY_SECRET=… node scripts/publish-oss.mjs release \
|
||||
* --release-entry=dist/release-entry.json [--dist=dist]
|
||||
* [--endpoint=https://oss-accelerate.aliyuncs.com] [--bucket=yaklang] [--prefix=chrome-extension]
|
||||
* OSS_KEY_ID=… OSS_KEY_SECRET=… node scripts/publish-oss.mjs manifest \
|
||||
* --manifest=dist/manifest.json --manifest-checksum=dist/manifest.json.sha256.txt [endpoint/bucket/prefix]
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import OSSModule from 'ali-oss';
|
||||
|
||||
const OSS = OSSModule.default ?? OSSModule;
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
|
||||
const ARTIFACT_CACHE = 'public, max-age=31536000, immutable';
|
||||
const MANIFEST_CACHE = 'public, max-age=300, must-revalidate';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {};
|
||||
for (const arg of argv) {
|
||||
if (!arg.startsWith('--')) throw new Error(`unexpected argument: ${arg}`);
|
||||
const eq = arg.indexOf('=');
|
||||
const key = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
|
||||
out[key] = eq === -1 ? true : arg.slice(eq + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const [subcommand, ...rest] = process.argv.slice(2);
|
||||
const args = parseArgs(rest);
|
||||
const endpoint = String(args.endpoint ?? 'https://oss-accelerate.aliyuncs.com');
|
||||
const bucket = String(args.bucket ?? 'yaklang');
|
||||
const prefix = String(args.prefix ?? 'chrome-extension').replace(/^\/+|\/+$/g, '');
|
||||
|
||||
const accessKeyId = process.env.OSS_KEY_ID;
|
||||
const accessKeySecret = process.env.OSS_KEY_SECRET;
|
||||
if (!accessKeyId || !accessKeySecret) {
|
||||
throw new Error('OSS_KEY_ID and OSS_KEY_SECRET must be set in the environment');
|
||||
}
|
||||
if (subcommand !== 'release' && subcommand !== 'manifest') {
|
||||
throw new Error(`unknown subcommand: ${subcommand ?? '(none)'} — expected "release" or "manifest"`);
|
||||
}
|
||||
|
||||
const client = new OSS({ accessKeyId, accessKeySecret, bucket, endpoint, secure: true });
|
||||
|
||||
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex');
|
||||
|
||||
async function headObject(key) {
|
||||
try {
|
||||
const res = await client.head(key);
|
||||
return {
|
||||
size: Number(res.headers['content-length']),
|
||||
sha256: res.headers['x-oss-meta-sha256'] ?? null,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err && (err.status === 404 || err.code === 'NoSuchKey')) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function putObject(key, buffer, { mime, cacheControl, forbidOverwrite }) {
|
||||
const digest = sha256(buffer);
|
||||
await client.put(key, buffer, {
|
||||
mime,
|
||||
headers: {
|
||||
'Cache-Control': cacheControl,
|
||||
...(forbidOverwrite ? { 'x-oss-forbid-overwrite': 'true' } : {}),
|
||||
},
|
||||
meta: { sha256: digest },
|
||||
});
|
||||
const head = await headObject(key);
|
||||
if (!head) throw new Error(`upload verification failed, object missing: oss://${bucket}/${key}`);
|
||||
if (head.size !== buffer.length || head.sha256 !== digest) {
|
||||
throw new Error(`upload verification failed: oss://${bucket}/${key} (size ${head.size}/${buffer.length}, sha256 ${head.sha256}/${digest})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function putImmutable(key, buffer, mime) {
|
||||
const digest = sha256(buffer);
|
||||
const existing = await headObject(key);
|
||||
if (existing) {
|
||||
if (existing.size === buffer.length && existing.sha256 === digest) {
|
||||
console.log(`skip (identical object already published): oss://${bucket}/${key}`);
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`refusing to overwrite non-matching immutable object: oss://${bucket}/${key} ` +
|
||||
`(remote size=${existing.size} sha256=${existing.sha256 ?? 'unknown'}, local size=${buffer.length} sha256=${digest})`,
|
||||
);
|
||||
}
|
||||
await putObject(key, buffer, { mime, cacheControl: ARTIFACT_CACHE, forbidOverwrite: true });
|
||||
console.log(`uploaded: oss://${bucket}/${key} (${buffer.length} bytes)`);
|
||||
}
|
||||
|
||||
async function putMutable(key, buffer, mime) {
|
||||
await putObject(key, buffer, { mime, cacheControl: MANIFEST_CACHE, forbidOverwrite: false });
|
||||
console.log(`published: oss://${bucket}/${key}`);
|
||||
}
|
||||
|
||||
async function runRelease() {
|
||||
if (!args['release-entry']) throw new Error('release subcommand requires --release-entry');
|
||||
const entry = JSON.parse(await readFile(resolve(root, String(args['release-entry'])), 'utf8'));
|
||||
const versionDir = resolve(root, String(args.dist ?? 'dist'), entry.version);
|
||||
for (const artifact of entry.artifacts) {
|
||||
const zip = await readFile(resolve(versionDir, artifact.filename));
|
||||
const digest = sha256(zip);
|
||||
if (digest !== artifact.sha256) {
|
||||
throw new Error(`${artifact.filename}: on-disk sha256 ${digest} != release entry ${artifact.sha256}`);
|
||||
}
|
||||
await putImmutable(`${prefix}/${entry.version}/${artifact.filename}`, zip, 'application/zip');
|
||||
const checksum = await readFile(resolve(versionDir, `${artifact.filename}.sha256.txt`));
|
||||
await putImmutable(`${prefix}/${entry.version}/${artifact.filename}.sha256.txt`, checksum, 'text/plain; charset=utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
async function runManifest() {
|
||||
if (!args.manifest) throw new Error('manifest subcommand requires --manifest');
|
||||
if (!args['manifest-checksum']) throw new Error('manifest subcommand requires --manifest-checksum');
|
||||
const manifest = await readFile(resolve(root, String(args.manifest)));
|
||||
const checksum = await readFile(resolve(root, String(args['manifest-checksum'])), 'utf8');
|
||||
const expected = `${sha256(manifest)} manifest.json\n`;
|
||||
if (checksum !== expected) {
|
||||
throw new Error('manifest checksum file does not match manifest.json content');
|
||||
}
|
||||
await putMutable(`${prefix}/manifest.json`, manifest, 'application/json; charset=utf-8');
|
||||
await putMutable(`${prefix}/manifest.json.sha256.txt`, Buffer.from(checksum, 'utf8'), 'text/plain; charset=utf-8');
|
||||
}
|
||||
|
||||
if (subcommand === 'release') {
|
||||
await runRelease();
|
||||
} else {
|
||||
await runManifest();
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Verifies a freshly published release from the public endpoint: artifact
|
||||
* bytes and checksum files, cache headers, manifest consistency, and zip
|
||||
* layout (manifest.json at the zip root with the expected version).
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/verify-public.mjs --public-base-url=https://aliyun-oss.yaklang.com/chrome-extension \
|
||||
* --release-entry=dist/release-entry.json
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import AdmZip from 'adm-zip';
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {};
|
||||
for (const arg of argv) {
|
||||
if (!arg.startsWith('--')) throw new Error(`unexpected argument: ${arg}`);
|
||||
const eq = arg.indexOf('=');
|
||||
const key = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
|
||||
out[key] = eq === -1 ? true : arg.slice(eq + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex');
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!args['public-base-url']) throw new Error('--public-base-url is required');
|
||||
if (!args['release-entry']) throw new Error('--release-entry is required');
|
||||
const baseUrl = String(args['public-base-url']).replace(/\/+$/, '');
|
||||
|
||||
// Cache-busting query parameter: the manifest may be served from a 5-minute
|
||||
// CDN cache, and we must observe the state right after this publish.
|
||||
const bust = `verify=${Date.now()}`;
|
||||
|
||||
async function fetchOk(url) {
|
||||
const res = await fetch(`${url}?${bust}`);
|
||||
if (!res.ok) throw new Error(`GET ${url} -> ${res.status}`);
|
||||
return res;
|
||||
}
|
||||
|
||||
const entry = JSON.parse(await readFile(resolve(root, String(args['release-entry'])), 'utf8'));
|
||||
|
||||
for (const artifact of entry.artifacts) {
|
||||
const res = await fetchOk(artifact.url);
|
||||
const contentType = res.headers.get('content-type') ?? '';
|
||||
const cacheControl = res.headers.get('cache-control') ?? '';
|
||||
assert(contentType.startsWith('application/'), `${artifact.filename}: unexpected content-type "${contentType}"`);
|
||||
assert(cacheControl.includes('max-age=31536000') && cacheControl.includes('immutable'),
|
||||
`${artifact.filename}: unexpected cache-control "${cacheControl}" for an immutable artifact`);
|
||||
const body = Buffer.from(await res.arrayBuffer());
|
||||
assert(body.length === artifact.size, `${artifact.filename}: content-length ${body.length} != expected ${artifact.size}`);
|
||||
assert(sha256(body) === artifact.sha256, `${artifact.filename}: sha256 mismatch`);
|
||||
|
||||
const checksumRes = await fetchOk(artifact.checksum_url);
|
||||
assert((await checksumRes.text()) === `${artifact.sha256} ${artifact.filename}\n`,
|
||||
`${artifact.filename}: checksum file content mismatch`);
|
||||
|
||||
const zip = new AdmZip(body);
|
||||
const innerEntry = zip.getEntry('manifest.json');
|
||||
assert(innerEntry, `${artifact.filename}: manifest.json missing at zip root`);
|
||||
const innerManifest = JSON.parse(zip.readAsText(innerEntry));
|
||||
assert(innerManifest.version === entry.version,
|
||||
`${artifact.filename}: zip manifest version ${innerManifest.version} != ${entry.version}`);
|
||||
const backgroundEntry = zip.getEntry('background.js');
|
||||
assert(backgroundEntry && backgroundEntry.getData().length > 0,
|
||||
`${artifact.filename}: background.js missing or empty in zip`);
|
||||
|
||||
console.log(`verified ${artifact.filename} (${artifact.size} bytes)`);
|
||||
}
|
||||
|
||||
const manifestRes = await fetchOk(`${baseUrl}/manifest.json`);
|
||||
const manifestBytes = Buffer.from(await manifestRes.arrayBuffer());
|
||||
const manifestCache = manifestRes.headers.get('cache-control') ?? '';
|
||||
assert(manifestCache.includes('max-age=300'), `manifest.json: unexpected cache-control "${manifestCache}"`);
|
||||
const manifest = JSON.parse(manifestBytes.toString('utf8'));
|
||||
assert(manifest.latest === entry.version, `manifest.latest ${manifest.latest} != ${entry.version}`);
|
||||
const versionEntry = manifest.versions.find((v) => v.version === entry.version);
|
||||
assert(versionEntry, `manifest has no entry for version ${entry.version}`);
|
||||
assert(versionEntry.artifacts.length === entry.artifacts.length,
|
||||
`manifest artifacts count ${versionEntry.artifacts.length} != ${entry.artifacts.length}`);
|
||||
for (const artifact of entry.artifacts) {
|
||||
const remote = versionEntry.artifacts.find((a) => a.variant === artifact.variant);
|
||||
assert(remote, `manifest missing variant ${artifact.variant} for version ${entry.version}`);
|
||||
assert(remote.sha256 === artifact.sha256, `manifest sha256 mismatch for variant ${artifact.variant}`);
|
||||
assert(remote.url === artifact.url, `manifest url mismatch for variant ${artifact.variant}`);
|
||||
}
|
||||
|
||||
const checksumRes = await fetchOk(`${baseUrl}/manifest.json.sha256.txt`);
|
||||
assert((await checksumRes.text()) === `${sha256(manifestBytes)} manifest.json\n`,
|
||||
'manifest.json.sha256.txt does not match the served manifest');
|
||||
|
||||
console.log(`manifest verified: latest=${manifest.latest}, ${manifest.versions.length} version(s) in history`);
|
||||
+7
-1
@@ -1,5 +1,11 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { defineConfig } from 'wxt';
|
||||
|
||||
// package.json is the single source of truth for the version; release
|
||||
// packaging asserts the built manifest matches it.
|
||||
const { version } = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8'));
|
||||
|
||||
// See https://wxt.dev/api/config.html
|
||||
export default defineConfig({
|
||||
srcDir: 'src',
|
||||
@@ -12,7 +18,7 @@ export default defineConfig({
|
||||
manifest: ({ mode, browser }) => ({
|
||||
name: 'Yakit Browser Agent',
|
||||
description: 'Yakit 浏览器安全测试工具与 AI 上下文桥接',
|
||||
version: '0.2.0',
|
||||
version,
|
||||
action: {
|
||||
default_title: 'Yakit Browser Agent',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user