diff --git a/apps/desktop/publishing/ci/README.i18n.yaml b/apps/desktop/publishing/ci/README.i18n.yaml index 0b5d6d47ce..2c78a42189 100644 --- a/apps/desktop/publishing/ci/README.i18n.yaml +++ b/apps/desktop/publishing/ci/README.i18n.yaml @@ -27,8 +27,8 @@ en: f7c12d343891e547 zh: da596ee1366c2046 /desktop-ci-publication/recovery-and-records: - en: 2e1e6b16d0d6bd41 - zh: 6ff4ad5a8f1f57b4 + en: b62a7484929f9941 + zh: c9dd06c77098e6ef /desktop-ci-publication/restore-a-channel: en: d82e2ccf70cce397 zh: d93dcb77cc4b463e diff --git a/apps/desktop/publishing/ci/README.md b/apps/desktop/publishing/ci/README.md index b769f78d84..8e6c95a726 100644 --- a/apps/desktop/publishing/ci/README.md +++ b/apps/desktop/publishing/ci/README.md @@ -84,7 +84,7 @@ After resolving a failure, re-run the failed publishing job to keep its prepared The sibling `upload-records/` directory retains an operation identity, newline-delimited events and terminal result. Feed-write intents retain both previous and requested YAML. Failures record their stage, safe error classification and diagnostic; COS failures also identify `GetObject` or `PutObject`, plus a validated HTTP status and request ID when available. Access-denied reports name the permission to inspect. Raw provider responses and credentials are excluded. Audit initialization becomes visible only after its files are complete; abandoned `.initializing-*` directories are not scanned for completed publication. Writes and readback are recorded before proceeding; missing terminal results mean interruption, not success. Actions also keeps its operation record as a diagnostic artifact for 30 days. These local records are not tamper-proof. COS readback verifies storage bytes, not public CDN behavior; public download acceptance is separate. -If the feed completed but tagging failed, the result is `published-tag-pending` and the job fails visibly. On retry, durable feed-completion evidence permits only tag recovery, even if a later release has advanced the channel. It never re-publishes old YAML. An existing conflicting tag requires administrator investigation and is never moved automatically. +If the feed completed but tagging failed, the result is `published-tag-pending` and the job fails visibly. On retry, durable feed-completion evidence permits only tag recovery, even if a later release has advanced the channel. It never re-publishes old YAML. An existing conflicting tag requires administrator investigation and is never moved automatically. A rejected tag creation retains its HTTP status and validated GitHub request ID; a follow-up lookup does not replace that failure with a missing-tag error. A repository-rule rejection directs the operator to the matching rulesets without logging the raw response. Normal publication and manual restoration acquire the same local channel lock. A crash can leave a lock directory under the sibling `publish-locks/`; verify no writer is running before manually removing it. A runner label alone does not coordinate writers on other machines. Keep one publishing machine per channel and preserve its records when migrating. diff --git a/apps/desktop/publishing/ci/README.zh.md b/apps/desktop/publishing/ci/README.zh.md index 26a141aff2..27aabeaf82 100644 --- a/apps/desktop/publishing/ci/README.zh.md +++ b/apps/desktop/publishing/ci/README.zh.md @@ -84,7 +84,7 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任 同级 `upload-records/` 目录保留操作标识、逐行事件和最终结果。频道写入意图同时记录先前和目标 YAML。失败记录阶段、安全错误分类与诊断;COS 失败还标明 `GetObject` 或 `PutObject`,并在可用时记录经过校验的 HTTP 状态码和 request ID。访问拒绝提示会指出需检查的权限。不保存原始服务响应或凭据。审计初始化在文件完整后才可见;遗留的 `.initializing-*` 目录不参与已完成发布的扫描。写入和回读在继续操作前落盘;没有最终结果代表中断,不代表成功。Actions 还将操作记录保存为诊断 artifact,保留 30 天。这些本地记录不防篡改。COS 回读验证存储字节,不代表公开 CDN 行为;公开下载需单独验收。 -频道更新完成但 tag 失败时,结果为 `published-tag-pending`,job 明确失败。重试时,已落盘的频道完成证据只允许恢复 tag,即使后来发布已推进频道,也不会重新发布旧 YAML。已有冲突 tag 需要管理员调查,不会自动移动。 +频道更新完成但 tag 失败时,结果为 `published-tag-pending`,job 明确失败。重试时,已落盘的频道完成证据只允许恢复 tag,即使后来发布已推进频道,也不会重新发布旧 YAML。已有冲突 tag 需要管理员调查,不会自动移动。tag 创建被拒绝时保留其 HTTP 状态及经过校验的 GitHub request ID,后续查询不会用 tag 不存在的错误替换创建失败。仓库规则拒绝会提示操作人员检查匹配的 ruleset,不记录原始响应。 正常发布和手动恢复获取同一把本地频道锁。崩溃可能在同级 `publish-locks/` 下留下锁目录;手动删除前确认没有写入任务运行。runner 标签本身不能协调其他机器的写入。每个频道保持单一发布机器,迁移时保留记录。 diff --git a/apps/desktop/scripts/desktop-publish-tag.ts b/apps/desktop/scripts/desktop-publish-tag.ts index f47cc698a6..d30316296d 100644 --- a/apps/desktop/scripts/desktop-publish-tag.ts +++ b/apps/desktop/scripts/desktop-publish-tag.ts @@ -40,8 +40,31 @@ export async function publishDesktopTag( body: JSON.stringify({ ref: `refs/tags/${tag}`, sha: commit }), redirect: 'error', signal: AbortSignal.timeout(30_000), }) if (response.status === 201) { await matches(response); return 'created' } - // A parallel platform release may have created the same version tag after our first read. - if (response.status === 422) { await response.body?.cancel(); await matches(await get()); return 'present' } + const requestId = response.headers.get('x-github-request-id') + const identity = requestId !== null && /^[A-Fa-f0-9:]{1,128}$/u.test(requestId) ? `; GitHub request ${requestId}` : '' + const failure = `desktop tag: creation failed (HTTP ${response.status}${identity})` + // A 422 can reject repository rules as well as report a concurrent tag creation. + if (response.status === 422) { + let detail: unknown + try { detail = await response.json() } catch (error) { + // Non-JSON error responses still retain the original HTTP status and request ID. + detail = undefined + } + const rulesRejected = typeof detail === 'object' && detail !== null && 'message' in detail + && typeof detail.message === 'string' && detail.message.startsWith('Repository rule violations found') + const guidance = rulesRejected ? 'repository rules rejected tag creation; inspect the matching tag rulesets' + : 'inspect repository rule insights and App permissions before retrying' + let reconciled: Response + try { reconciled = await get() } catch (cause) { + throw new Error(`${failure}; follow-up tag lookup failed; ${guidance}`, { cause }) + } + if (!reconciled.ok) { + await reconciled.body?.cancel() + throw new Error(`${failure}; follow-up tag lookup returned HTTP ${reconciled.status}; ${guidance}`) + } + await matches(reconciled) + return 'present' + } await response.body?.cancel() - throw new Error(`desktop tag: creation failed (HTTP ${response.status}); reconcile the tag before retrying`) + throw new Error(`${failure}; reconcile the tag before retrying`) } diff --git a/apps/desktop/tests/desktop-publish-tag.spec.ts b/apps/desktop/tests/desktop-publish-tag.spec.ts index 7a13daba54..9c030f634a 100644 --- a/apps/desktop/tests/desktop-publish-tag.spec.ts +++ b/apps/desktop/tests/desktop-publish-tag.spec.ts @@ -3,13 +3,13 @@ import { publishDesktopTag } from '../scripts/desktop-publish-tag.ts' const commit = 'a'.repeat(40) const tag = (sha = commit) => ({ object: { type: 'commit', sha } }) -function transport(replies: Array<{ status: number; body?: object }>) { +function transport(replies: Array<{ status: number; body?: object; headers?: Record }>) { const calls: Array<{ url: string; init: RequestInit | undefined }> = [] const request: typeof fetch = async (url, init) => { calls.push({ url: typeof url === 'string' ? url : url instanceof URL ? url.href : url.url, init }) const reply = replies.shift() if (!reply) throw new Error('unexpected HTTP request') - return new Response(JSON.stringify(reply.body ?? {}), { status: reply.status }) + return new Response(JSON.stringify(reply.body ?? {}), { status: reply.status, headers: reply.headers ?? {} }) } return { calls, request } } @@ -57,3 +57,26 @@ it('explains an existing annotated tag without replacing it', async () => { await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).rejects.toThrow(/not a lightweight commit tag/u) expect(http.calls).toHaveLength(1) }) + +it('keeps the creation status and safe rule diagnostic when no competing tag exists', async () => { + const http = transport([{ status: 404 }, { status: 422, + headers: { 'x-github-request-id': 'ABCD:1234' }, + body: { message: 'Repository rule violations found for refs/tags/desktop-v1.2.3', errors: ['secret-value'] } }, { status: 404 }]) + const failure = publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request) + await expect(failure).rejects.toThrow('creation failed (HTTP 422; GitHub request ABCD:1234); follow-up tag lookup returned HTTP 404; repository rules rejected') + await expect(failure).rejects.not.toThrow('secret-value') + expect(http.calls.map(call => call.init?.method ?? 'GET')).toEqual(['GET', 'POST', 'GET']) +}) + +it('does not echo arbitrary GitHub response text or unrecognized request IDs', async () => { + const http = transport([{ status: 404 }, { status: 422, + headers: { 'x-github-request-id': 'fixture-token' }, body: { message: 'fixture-token' } }, { status: 403 }]) + const failure = publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request) + await expect(failure).rejects.toThrow('creation failed (HTTP 422); follow-up tag lookup returned HTTP 403') + await expect(failure).rejects.not.toThrow('fixture-token') +}) + +it('still rejects a competing tag on a different commit', async () => { + const http = transport([{ status: 404 }, { status: 422 }, { status: 200, body: tag('b'.repeat(40)) }]) + await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).rejects.toThrow('refusing to move') +})