Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,6 @@ jobs:
- name: 校验 TS7、Vue 类型、静态规则及产物合同
run: pnpm check

- name: Validate the native Twitter Module Federation snapshot
run: >-
node scripts/verify-native-extension-distribution.mjs inspect-local
--package extensions/twitter/package.json
--core-package packages/core/package.json
--artifact-directory extensions/twitter/dist/client-web

- name: Verify independent MF publication
run: >-
node --test scripts/test-extension-publication.mjs scripts/test-core-sdk-publication.mjs
Expand Down
1 change: 1 addition & 0 deletions docs/30-unit-tdd/native-extension-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Host 与 Web 扩展通过现有 Module Federation 配置共享 `@inkcre/ui-web`
并由每个生产者的 package.json 声明自身锁定的 UI 版本要求,构建不得用 Host 的 UI 版本覆盖它。UI 的 i18n、路由和表单上下文使用模块内的 Vue 注入 key;
如果扩展另用一份 UI 运行时,即使外观相同,也无法继承 Host 的这些上下文。
独立 playground 仍可使用包自身的 fallback 模块,样式由各自应用入口加载。
如果生产入口没有消费 UI JavaScript,构建产物可以没有 UI share;声明开发依赖并不代表生产入口使用了该模块。产物一旦包含 UI share,就必须只有一个,并使用生产者声明的版本。

The producer emits the native `mf-manifest.json`, Remote entry, and referenced shared/exposed asset
closure with relative asset semantics. The Registry Release records an immutable manifest
Expand Down
1 change: 1 addition & 0 deletions docs/40-deployment/native-extension-delivery.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ manifest structure, and the exact bytes of every referenced asset.

The executable contract is
[`scripts/verify-native-extension-distribution.mjs`](../../scripts/verify-native-extension-distribution.mjs).
`pnpm check:package` 在本地与 CI 中检查所有生产扩展的真实 MF 产物,并确认 Twitter 这类使用 UI 组件的扩展保留共享 UI 模块。
Verify locally without publishing:

```bash
Expand Down
31 changes: 31 additions & 0 deletions scripts/check-package-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process'
import { access, readFile, readdir, realpath } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { inspectBrowserArtifact } from './browser-runtime-contract.mjs'
import { inspectNativeModuleFederation } from './verify-native-extension-distribution.mjs'

const repoRoot = fileURLToPath(new URL('..', import.meta.url))
const jsonOutput = process.argv.includes('--json')
Expand Down Expand Up @@ -46,6 +47,36 @@ await Promise.all(
].map((path) => requireFile(`${repoRoot}/${path}`))
)

for (const entry of await readdir(`${repoRoot}/extensions`, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
const directory = `${repoRoot}/extensions/${entry.name}`
let metadata
try {
metadata = JSON.parse(await readFile(`${directory}/package.json`, 'utf8'))
} catch (error) {
if (error.code === 'ENOENT') continue
throw error
}
if (!metadata.inkcre?.module_federation) continue
try {
await inspectNativeModuleFederation({
packagePath: `${directory}/package.json`,
corePackagePath: `${coreRoot}/package.json`,
artifactDirectory: `${directory}/dist/client-web`,
})
if (entry.name === 'twitter') {
const manifest = JSON.parse(
await readFile(`${directory}/dist/client-web/mf-manifest.json`, 'utf8')
)
if (!manifest.shared?.some((shared) => shared.name === '@inkcre/ui-web')) {
errors.push('Twitter UI components must share @inkcre/ui-web with the Host')
}
}
} catch (error) {
errors.push(`${metadata.name} native distribution inspection failed: ${error.message}`)
}
}

const twitterRemoteEntry = `${repoRoot}/extensions/twitter/dist/client-web/remoteEntry.js`
try {
const content = await readFile(twitterRemoteEntry, 'utf8')
Expand Down
49 changes: 49 additions & 0 deletions scripts/tests/independent-extension-release.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,55 @@ import os from 'node:os'
import path from 'node:path'
import { test } from 'node:test'
import { captureCandidates } from '../publish-first-party-extensions.mjs'
import { inspectNativeModuleFederation } from '../verify-native-extension-distribution.mjs'

void test('MF snapshots permit unused UI dependencies and validate emitted UI shares', async () => {
const workspace = await mkdtemp(path.join(os.tmpdir(), 'inkcre-ui-share-'))
const packagePath = path.join(workspace, 'package.json')
const corePackagePath = path.join(workspace, 'core.json')
const manifestPath = path.join(workspace, 'mf-manifest.json')
const metadata = {
version: '0.2.1',
devDependencies: { '@inkcre/ui-web': '2.3.0' },
inkcre: {
name: 'inkcre/capture',
nickname: 'Capture',
module_federation: { host_sdk: '@inkcre/core', host_sdk_version: '>=0.4.0 <0.5.0' },
},
}
const coreShare = { name: '@inkcre/core', requiredVersion: '>=0.4.0 <0.5.0' }
const uiShare = { name: '@inkcre/ui-web', requiredVersion: '2.3.0' }
try {
await writeFile(corePackagePath, JSON.stringify({ version: '0.4.3' }))
await writeFile(path.join(workspace, 'remoteEntry.js'), 'export default {}')
const inspect = () =>
inspectNativeModuleFederation({ packagePath, corePackagePath, artifactDirectory: workspace })
for (const [uiShares, declaresUi, valid] of [
[[], true, true],
[[uiShare], true, true],
[[{ ...uiShare, requiredVersion: '2.7.0' }], true, false],
[[uiShare, uiShare], true, false],
[[uiShare], false, false],
]) {
await writeFile(
packagePath,
JSON.stringify({ ...metadata, devDependencies: declaresUi ? metadata.devDependencies : {} })
)
await writeFile(
manifestPath,
JSON.stringify({
metaData: { publicPath: './', remoteEntry: { name: 'remoteEntry.js' } },
shared: [coreShare, ...uiShares],
exposes: [],
})
)
if (valid) assert.equal((await inspect()).name, 'inkcre/capture')
else await assert.rejects(inspect(), assert.AssertionError)
}
} finally {
await rm(workspace, { recursive: true, force: true })
}
})

void test('a prepared Mail release does not publish another producer with pending intent', async () => {
const workspace = await mkdtemp(path.join(os.tmpdir(), 'inkcre-independent-release-'))
Expand Down
28 changes: 27 additions & 1 deletion scripts/tests/webext-release.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { test } from 'node:test'
import { assertDraft, checkManifest, extensionId, preparedVersion } from '../webext-release.mjs'
import {
assertDraft,
checkManifest,
createDraftRelease,
extensionId,
preparedVersion,
} from '../webext-release.mjs'

const require = createRequire(new URL('../../apps/client-webext/package.json', import.meta.url))

Expand All @@ -21,6 +27,26 @@ void test('draft recovery cannot overwrite a public version or another source',
assertDraft(draft, draft.tag_name, source)
assert.throws(() => assertDraft({ ...draft, draft: false }, draft.tag_name, source))
assert.throws(() => assertDraft(draft, draft.tag_name, 'b'.repeat(40)))
assert.throws(() => assertDraft(undefined, draft.tag_name, source), /draft release was not found/)
})

void test('a new draft uses its creation response before release inventory catches up', async (t) => {
const source = 'a'.repeat(40)
const draft = { id: 42, tag_name: 'webext-v0.2.0', target_commitish: source, draft: true }
t.mock.method(globalThis, 'fetch', async (url, options) => {
assert.equal(url, 'https://api.github.com/repos/InKCre/impl-ts/releases')
assert.equal(options.method, 'POST')
const request = JSON.parse(options.body)
assert.equal(request.tag_name, draft.tag_name)
assert.equal(request.target_commitish, source)
assert.equal(request.draft, true)
assert.match(request.body, /发布说明/)
return Response.json(draft, { status: 201 })
})
assert.deepEqual(
await createDraftRelease({ tag: draft.tag_name, source, version: '0.2.0', news: '发布说明' }),
draft
)
})

void test('browser artifacts preserve identity, numeric version and readable toolbar variants', async () => {
Expand Down
8 changes: 5 additions & 3 deletions scripts/verify-native-extension-distribution.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,11 @@ export async function inspectNativeModuleFederation({
const uiVersion =
extensionPackage.dependencies?.['@inkcre/ui-web'] ??
extensionPackage.devDependencies?.['@inkcre/ui-web']
if (uiVersion) {
const uiShare = manifest.shared?.filter((shared) => shared.name === '@inkcre/ui-web') ?? []
assert.equal(uiShare.length, 1, 'manifest must share @inkcre/ui-web to inherit Host UI context')
const uiShare = manifest.shared?.filter((shared) => shared.name === '@inkcre/ui-web') ?? []
assert.ok(uiShare.length <= 1, 'manifest must not duplicate @inkcre/ui-web shares')
// 未消费 UI JavaScript 的生产入口不会在构建产物中生成 UI share。
if (uiShare.length) {
assert.ok(uiVersion, 'shared @inkcre/ui-web must declare the producer UI version')
assert.equal(uiShare[0].requiredVersion, uiVersion)
}
assert.equal(coreShare.length, 1, 'manifest must contain exactly one @inkcre/core shared module')
Expand Down
40 changes: 22 additions & 18 deletions scripts/webext-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,13 @@ export function checkManifest(manifest, version, browser, key) {
assert.ok(!manifest.permissions.includes('debugger'))
}

async function api(route) {
async function api(route, options = {}) {
const response = await fetch(`https://api.github.com/repos/${repository}/${route}`, {
...options,
headers: {
Authorization: `Bearer ${process.env.GH_TOKEN}`,
Accept: 'application/vnd.github+json',
'Content-Type': 'application/json',
},
signal: AbortSignal.timeout(30_000),
})
Expand Down Expand Up @@ -79,6 +81,7 @@ async function findRelease(tag) {
}

export function assertDraft(release, tag, source) {
assert.ok(release, 'GitHub draft release was not found')
assert.equal(release.tag_name, tag)
assert.equal(
release.draft,
Expand All @@ -92,6 +95,22 @@ export function assertDraft(release, tag, source) {
)
}

export async function createDraftRelease({ tag, source, version, news }) {
const body = `${news}\n\nChromium:下载 chrome ZIP,解压到固定目录加载,覆盖目录后重新加载更新,不要卸载。Firefox ZIP 未签名,仅供临时加载,不能作为正式安装包。source.tar.gz 为对应仓库源码,不含私有依赖,尚非 AMO 独立审查包。\n\n来源 ${source}。source.json 记录实际包的 SHA256。`
const release = await api('releases', {
method: 'POST',
body: JSON.stringify({
tag_name: tag,
target_commitish: source,
draft: true,
name: `WebExt ${version}`,
body,
}),
})
assertDraft(release, tag, source)
return release
}

async function verifyCandidate() {
const receipt = await readJson(`${directory}/source.json`)
assert.equal(receipt.name, '@inkcre/client-webext')
Expand Down Expand Up @@ -211,22 +230,7 @@ export async function main(command) {
if (!release) {
const changelog = await readFile(`${app}/CHANGELOG.md`, 'utf8')
const news = changelog.split(`## ${version}\n`)[1]?.split('\n## ')[0]?.trim() ?? ''
const body = `${news}\n\nChromium:下载 chrome ZIP,解压到固定目录加载,覆盖目录后重新加载更新,不要卸载。Firefox ZIP 未签名,仅供临时加载,不能作为正式安装包。source.tar.gz 为对应仓库源码,不含私有依赖,尚非 AMO 独立审查包。\n\n来源 ${receipt.source_revision}。source.json 记录实际包的 SHA256。`
gh([
'release',
'create',
tag,
'--repo',
repository,
'--target',
receipt.source_revision,
'--draft',
'--title',
`WebExt ${version}`,
'--notes',
body,
])
release = await findRelease(tag)
release = await createDraftRelease({ tag, source: receipt.source_revision, version, news })
}
assertDraft(release, tag, receipt.source_revision)
// Only an unpublished same-source draft may be completed/rebuilt after an interrupted upload.
Expand All @@ -240,7 +244,7 @@ export async function main(command) {
...receipt.assets.map((asset) => `${directory}/${asset.name}`),
`${directory}/source.json`,
])
release = await findRelease(tag)
release = await api(`releases/${release.id}`)
for (const asset of [
...receipt.assets,
{ name: 'source.json', sha256: sha256(await readFile(`${directory}/source.json`)) },
Expand Down
Loading