Fail visibly when website preparation outputs are missing

This commit is contained in:
winewill
2026-11-03 16:09:48 +08:00
parent 2cda0156a8
commit df37050b48
11 changed files with 39 additions and 15 deletions
+2 -2
View File
@@ -171,7 +171,7 @@ jobs:
upload-website-installer:
needs: [route, prepare, publish]
if: inputs.upload_website && needs.prepare.outputs.preset == 'production'
if: inputs.upload_website && needs.prepare.outputs.preset != 'test'
runs-on: [self-hosted, '${{ needs.route.outputs.runner }}']
environment: desktop-production
timeout-minutes: 60
@@ -216,7 +216,7 @@ jobs:
purge-website-installer:
needs: [route, prepare, upload-website-installer]
if: inputs.upload_website && needs.prepare.outputs.preset == 'production'
if: inputs.upload_website && needs.prepare.outputs.preset != 'test'
runs-on: [self-hosted, '${{ needs.route.outputs.runner }}']
environment: desktop-production
timeout-minutes: 60
+2 -2
View File
@@ -27,8 +27,8 @@
en: 3f58698708671202
zh: be0ba215a67168d6
/desktop-ci-publication/recovery-and-records:
en: 8db82940f2005704
zh: a895e68d69ec44a5
en: 0d87b7a9c073993c
zh: 9f135e8c1a4b7d8e
/desktop-ci-publication/restore-a-channel:
en: d82e2ccf70cce397
zh: d93dcb77cc4b463e
+1 -1
View File
@@ -86,7 +86,7 @@ Production COS publication enables website installer upload and cache refresh by
Set the repository variable `DOWNLOAD_PROD_EO_ZONE_ID` to the production EdgeOne zone. The production COS credential pair also needs `teo:CreatePurgeTask` and `teo:DescribePurgeTasks`. Website uploads serialize by bucket/target in Actions and by bucket/object on the original machine. Cache observation runs separately. Version publication, website upload and cache refresh each use the production approval Environment; later jobs and reruns may require another approval. GitHub concurrency retains at most one pending job per group, so a newer pending job can cancel an older pending job; inspect canceled jobs before rerunning them.
Website state lives in `upload-records/website-<publication run ID>-<target>/state.json`, beside the retained releases root. macOS and Windows use the same record format on separate local disks. Website URLs and download filenames follow the [fixed-download rules](../README.md#fixed-downloads). Upload retries reconcile both bytes and that header; identical bytes with a missing or different header receive one upload and readback. A completed journal with different remote bytes stops recovery and requires a newly approved publication. Completion evidence survives failed metadata repairs. Each new upload intent clears the previous purge record, so repaired metadata requires a new cache refresh. Unknown writes are read before any retry; CDN header forwarding requires real-environment validation. Rerun only the purge job to inspect its saved task. A manually rerun purge may submit once after confirming the previous task failed. A lost submission response queries matching history instead of resubmitting. An unknown result with a task ID means submission was confirmed; without an ID it may never have been submitted. Inspect EdgeOne history before manual recovery, and a fresh publication run is one recovery option. Operators may instead use the documented [fixed-download purge command](../README.md#fixed-downloads); Windows operators use the existing DPAPI credential launcher described there. This submits only a fixed-URL purge: it does not observe completion or update this Actions run’s journal. Inspect the returned task in EdgeOne before considering recovery complete.
Website state lives in `upload-records/website-<publication run ID>-<target>/state.json`, beside the retained releases root. macOS and Windows use the same record format on separate local disks. Website URLs and download filenames follow the [fixed-download rules](../README.md#fixed-downloads). Upload retries reconcile both bytes and `Content-Disposition`; identical bytes with a missing or different header receive one upload and readback. A completed journal with different remote bytes stops recovery and requires a newly approved publication. Completion evidence survives failed metadata repairs. Each new upload intent clears the previous purge record, so repaired metadata requires a new cache refresh; inspect EdgeOne history for the previous task ID and diagnostics. Unknown writes are read before any retry; CDN header forwarding requires real-environment validation. Rerun only the purge job to inspect its saved task. With website upload enabled, website jobs skip only an explicitly selected test deployment; missing preparation outputs must pass local preflight instead of silently skipping. Restore upstream outputs by rerunning preparation before retrying publication. A manually rerun purge may submit once after confirming the previous task failed. A lost submission response queries matching history instead of resubmitting. An unknown result with a task ID means submission was confirmed; without an ID it may never have been submitted. Inspect EdgeOne history before manual recovery, and a fresh publication run is one recovery option. Operators may instead use the documented [fixed-download purge command](../README.md#fixed-downloads); Windows operators use the existing DPAPI credential launcher described there. This submits only a fixed-URL purge: it does not observe completion or update this Actions run’s journal. Inspect the returned task in EdgeOne before considering recovery complete.
Purge observation waits up to five minutes at ten-second intervals, with one additional query after an unknown result or observation deadline. Confirmed failed, timeout or canceled tasks fail the job. An unresolved result exits successfully with an Actions warning and a prominent summary; its durable state remains unknown, not completed. Diagnostic artifact archival is best effort; local records remain the recovery source. Simulated-provider tests cover these paths; release operators must verify production COS/EdgeOne permissions, job-id filtering, time-range queries, task type and timestamp formats, cross-attempt Actions recovery, and actual CDN behavior before relying on recovery in production.
+1 -1
View File
@@ -86,7 +86,7 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任
将仓库变量 `DOWNLOAD_PROD_EO_ZONE_ID` 配置为生产 EdgeOne 站点。生产 COS 凭据还需要 `teo:CreatePurgeTask` 和 `teo:DescribePurgeTasks` 权限。官网上传在 Actions 中按 bucket/平台互斥,在原机器上按 bucket/对象互斥;缓存观察单独运行。版本发布、官网上传和缓存刷新分别使用生产审批 Environment,后续 job 和重跑可能需要再次审批。GitHub 每个并发组最多保留一个排队 job,新排队 job 可能取消旧排队 job;重跑前应检查取消记录。
官网状态保存在 releases 根目录同级的 `upload-records/website-<发布 run ID>-<target>/state.json`。macOS 和 Windows 在各自本地磁盘使用相同记录格式。官网 URL 和保存文件名遵循[固定下载规则](../README.zh.md#fixed-downloads)。上传重跑同时核对字节和该响应头;字节相同但响应头缺失或错误时,上传一次并读回验证。记录已完成但远端字节改变时停止恢复,需要重新审批发布。完成标记在元数据修复失败后仍保留。每次新的上传意图清除旧刷新记录,因此修复元数据后需重新刷新缓存。未知写入先读取再决定重试;CDN 响应头透传仍需实测。仅重跑刷新 job 即可查询已保存任务;人工重跑确认旧任务失败后可提交一次。提交响应丢失时查询匹配的历史记录,不重新提交。unknown 带任务 ID 表示提交已确认;没有 ID 则可能从未提交。人工恢复前应检查 EdgeOne 历史,重新发起发布运行是恢复方式之一。操作者也可使用已有的[固定下载刷新命令](../README.zh.md#fixed-downloads);Windows 操作者使用该处说明的 DPAPI 凭据启动器。该命令只提交固定 URL 刷新,不观察完成状态,也不更新本次 Actions 运行的 journal。确认恢复完成前应在 EdgeOne 检查返回的任务。
官网状态保存在 releases 根目录同级的 `upload-records/website-<发布 run ID>-<target>/state.json`。macOS 和 Windows 在各自本地磁盘使用相同记录格式。官网 URL 和保存文件名遵循[固定下载规则](../README.zh.md#fixed-downloads)。上传重跑同时核对字节和 `Content-Disposition`;字节相同但响应头缺失或错误时,上传一次并读回验证。记录已完成但远端字节改变时停止恢复,需要重新审批发布。完成标记在元数据修复失败后仍保留。每次新的上传意图清除旧刷新记录,因此修复元数据后需重新刷新缓存;上一次任务 ID 和诊断需查 EdgeOne 历史。未知写入先读取再决定重试;CDN 响应头透传仍需实测。仅重跑刷新 job 即可查询已保存任务。启用官网上传时,官网 job 仅跳过明确选择的 test 环境;准备阶段输出缺失时仍须通过本地预检,不再静默跳过。重跑准备阶段恢复上游输出后再重试发布;人工重跑确认旧任务失败后可提交一次。提交响应丢失时查询匹配的历史记录,不重新提交。unknown 带任务 ID 表示提交已确认;没有 ID 则可能从未提交。人工恢复前应检查 EdgeOne 历史,重新发起发布运行是恢复方式之一。操作者也可使用已有的[固定下载刷新命令](../README.zh.md#fixed-downloads);Windows 操作者使用该处说明的 DPAPI 凭据启动器。该命令只提交固定 URL 刷新,不观察完成状态,也不更新本次 Actions 运行的 journal。确认恢复完成前应在 EdgeOne 检查返回的任务。
刷新观察每十秒查询一次,最长五分钟;结果未知或观察超时后额外查询一次。明确的 failed、timeout、canceled 结果使 job 失败。仍无法确认时成功退出,并输出 Actions warning 和醒目摘要;持久化状态保持 unknown,不标为完成。诊断 artifact 尽力归档,本地记录仍是恢复依据。模拟服务测试覆盖这些路径;发布操作者必须在生产依赖恢复能力前验证 COS/EdgeOne 权限、job-id 筛选、时间范围查询、任务类型和时间戳格式、Actions 跨 attempt 恢复及实际 CDN 行为。
@@ -6,6 +6,7 @@ import { desktopPublishQualificationError } from './desktop-publish-qualificatio
const positive = z.number().int().positive()
const runSchema = z.object({
id: positive, run_attempt: positive, head_sha: z.string().regex(/^[a-f0-9]{40}$/u),
// The package job establishes eligibility; archival failure does not disqualify its build.
path: z.string(), conclusion: z.string(), event: z.literal('workflow_dispatch'),
repository: z.object({ full_name: z.string() }),
})
@@ -6,12 +6,13 @@ import type { DesktopUploadPlan } from '../scripts/desktop-upload-plan.ts'
import { answer, cosError, startCosLoopback } from './cos-loopback.ts'
import { desktopPublicationFailure } from '../scripts/desktop-publication-failure.ts'
it('uses overwrite prevention, streams verified bytes, and hashes remote readback through the SDK', async () => {
it.each([true, false])('preserves download headers and verified bytes with overwrite prevention %s', async (forbidOverwrite) => {
const objects = new Map<string, Buffer>()
const server = await startCosLoopback((request, response) => {
if (request.path.includes('versioning')) { answer(response, 403, cosError('AccessDenied')); return }
if (request.method === 'PUT') {
expect(request.headers['x-cos-forbid-overwrite']).toBe('true')
expect(request.headers['x-cos-forbid-overwrite']).toBe(forbidOverwrite ? 'true' : undefined)
expect(request.headers['content-disposition']).toBe('attachment; filename="file.exe"')
objects.set(request.path, request.body)
answer(response)
} else {
@@ -21,7 +22,7 @@ it('uses overwrite prevention, streams verified bytes, and hashes remote readbac
}
})
try {
const artifact = { path: '', filename: 'file.exe', key: 'bin/file.exe', contents: 'fixture bytes', contentType: 'application/octet-stream', channelMetadata: false }
const artifact = { path: '', filename: 'file.exe', key: 'bin/file.exe', contents: 'fixture bytes', contentType: 'application/octet-stream', channelMetadata: !forbidOverwrite, contentDisposition: 'attachment; filename="file.exe"' }
const plan: DesktopUploadPlan = {
environment: 'test', target: 'win-x64', version: '1.0.0', bucket: 'fixture-1250000000', publicUrl: 'https://example.com/',
secretIdEnvName: 'ID', secretKeyEnvName: 'KEY', artifacts: [artifact],
@@ -31,10 +32,10 @@ it('uses overwrite prevention, streams verified bytes, and hashes remote readbac
server.redirect(client)
return client
})
expect(await transport.read(artifact.key, false)).toBeNull()
expect(await transport.read(artifact.key, !forbidOverwrite)).toBeNull()
const digest = await desktopArtifactDigest(artifact)
await transport.put(artifact, digest, true)
expect(await transport.read(artifact.key, false)).toEqual(digest)
await transport.put(artifact, digest, forbidOverwrite)
expect(await transport.read(artifact.key, !forbidOverwrite)).toEqual({ ...digest, ...(!forbidOverwrite ? { contents: 'fixture bytes' } : {}) })
await expect(transport.read('elsewhere/file.exe', false)).rejects.toThrow(/outside/u)
expect(server.requests.filter(request => request.method === 'PUT')).toHaveLength(1)
expect(server.requests.map(request => request.method)).toEqual(['GET', 'PUT', 'GET'])
@@ -43,3 +43,8 @@ it('runs the local preflight before dependency installation in both self-hosted
expect(section.indexOf('node apps/desktop/scripts/preflight-desktop-publish-local.mjs')).toBeLessThan(section.indexOf('pnpm install --frozen-lockfile'))
}
})
it('rejects missing website preparation outputs before reading local configuration', async () => {
await expect(preflightDesktopPublishLocal({ DSH_PACKAGING_CONFIG_DIR: tmpdir(), DESKTOP_PUBLISH_ROUTE: '' }))
.rejects.toThrow('selected package must identify a positive run ID and attempt')
})
@@ -88,3 +88,11 @@ it('matches Intel artifacts to the Intel workflow on a machine serving both arch
f.source.target = 'mac-arm64'
expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, routes)).toThrow(/does not match/u)
})
it('accepts a successful package when the attempt failed during archival', () => {
const f = fixture()
f.run.conclusion = 'failure'
f.jobs.push({ ...f.jobs[0]!, name: 'upload-artifact', conclusion: 'failure' })
expect(resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, f.routes))
.toMatchObject({ runId: '42', attempt: '2' })
})
@@ -21,7 +21,8 @@ async function fixture(target: DesktopPublishRoute['target'] = 'mac-arm64') {
const filename = `deepseek-harness-0.2.1-rc.5-${target}.${target === 'win-x64' ? 'exe' : 'dmg'}`
await writeFile(join(directory, filename), bytes)
const manifest: DesktopRetainedManifest = { schemaVersion: 1, source: {
...route, repository: 'owner/repo', workflowCommit: route.commit, productVersion: '0.2.1', version: '0.2.1-rc.5',
runId: route.runId, attempt: route.attempt, commit: route.commit, sourceRef: route.sourceRef,
channel: route.channel, runnerName: route.runnerName, target: route.target, preset: route.preset, repository: 'owner/repo', workflowCommit: route.commit, productVersion: '0.2.1', version: '0.2.1-rc.5',
testReleaseId: null, qualification: target === 'win-x64' ? 'signed' : 'notarized',
}, files: [{ name: filename, size: bytes.length, sha512: createHash('sha512').update(bytes).digest('base64') }] }
return { directory, bytes, route, manifest }
@@ -11,7 +11,7 @@ import { publishWebsiteCi, websiteApproval } from '../scripts/publish-website-ci
vi.mock('node:timers/promises', () => ({ setTimeout: async () => {} }))
afterEach(() => vi.restoreAllMocks())
it.each(['accepted', 'unconfirmed', 'upload', 'put', 'uploaded', 'metadata', 'metadata-retry', 'stable', 'approval'] as const)('preserves publication recovery for %s', async (scenario) => {
it.each(['accepted', 'unconfirmed', 'incomplete', 'wrong-journal', 'upload', 'put', 'uploaded', 'metadata', 'metadata-retry', 'stable', 'approval'] as const)('preserves publication recovery for %s', async (scenario) => {
const root = await mkdtemp(join(tmpdir(), 'website-ci-'))
try {
const config = join(root, 'packaging')
@@ -96,6 +96,14 @@ it.each(['accepted', 'unconfirmed', 'upload', 'put', 'uploaded', 'metadata', 'me
return
}
const submit = vi.spyOn(teo.v20220901.Client.prototype, 'CreatePurgeTask').mockResolvedValue({ JobId: 'job-1' })
if (scenario === 'incomplete' || scenario === 'wrong-journal') {
await writeFile(join(records, 'state.json'), JSON.stringify({ approval: scenario === 'wrong-journal' ? 'other' : approval, upload: 'upload-intent' }))
const query = vi.spyOn(teo.v20220901.Client.prototype, 'DescribePurgeTasks')
await expect(publishWebsiteCi('purge', env)).rejects.toThrow(scenario === 'incomplete' ? 'upload completion is not confirmed' : 'journal belongs to another approved build')
expect(submit).not.toHaveBeenCalled()
expect(query).not.toHaveBeenCalled()
return
}
const query = vi.spyOn(teo.v20220901.Client.prototype, 'DescribePurgeTasks').mockRejectedValue(new Error('fixture-key'))
if (scenario === 'unconfirmed') submit.mockRejectedValue(new Error('lost response'))
const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true)
+1 -1
View File
@@ -86,11 +86,11 @@ it('defaults to production website upload and separates cache-only recovery', ()
expect(workflow.jobs.prepare!.env?.DOWNLOAD_PROD_EO_ZONE_ID).toBe('${{ vars.DOWNLOAD_PROD_EO_ZONE_ID }}')
const upload = workflow.jobs['upload-website-installer']!
const purge = workflow.jobs['purge-website-installer']!
expect(upload.if).toContain("inputs.upload_website && needs.prepare.outputs.preset == 'production'")
expect(purge.needs).toEqual(['route', 'prepare', 'upload-website-installer'])
expect(upload.concurrency).toEqual({ group: 'desktop-website-upload-${{ needs.prepare.outputs.website-destination }}', 'cancel-in-progress': false })
expect(purge.concurrency).toEqual({ group: 'desktop-website-purge-${{ needs.prepare.outputs.website-destination }}', 'cancel-in-progress': false })
for (const job of [upload, purge]) {
expect(job.if).toBe("inputs.upload_website && needs.prepare.outputs.preset != 'test'")
expect(job.environment).toBe('desktop-production')
const preflight = job.steps.findIndex(step => step.run === 'node apps/desktop/scripts/preflight-desktop-publish-local.mjs')
expect(preflight).toBeGreaterThan(-1)