diff --git a/lib/domains/publish.mjs b/lib/domains/publish.mjs index 1323ed0..26365d9 100644 --- a/lib/domains/publish.mjs +++ b/lib/domains/publish.mjs @@ -1,7 +1,7 @@ import { repo } from '../git.mjs' import { brief, write } from '../brief.mjs' import { name } from '../input.mjs' -import { repository } from '../github.mjs' +import { repository, token } from '../github.mjs' import { pushAndOpenPr, briefStateCommit, pushTip } from '../steps.mjs' export async function planData(r, o) { @@ -14,6 +14,8 @@ export async function planData(r, o) { } export async function execute(r, o, x, b) { + // 同 release:先在能建 PR 的前提下再推分支,避免缺凭证时把分支推上去、PR 却建不出来 + token() const z = await pushAndOpenPr(r, x, b) write(b) const briefCommit = briefStateCommit(r, b, `docs(shadow): brief 最终态——published, PR #${z.number}`) diff --git a/lib/domains/release.mjs b/lib/domains/release.mjs index 1b3a0dc..54d1c98 100644 --- a/lib/domains/release.mjs +++ b/lib/domains/release.mjs @@ -1,7 +1,7 @@ import { repo } from '../git.mjs' import { brief, write } from '../brief.mjs' import { name, fileList } from '../input.mjs' -import { repository } from '../github.mjs' +import { repository, token } from '../github.mjs' import { commitStep, pushAndOpenPr, briefStateCommit, pushTip } from '../steps.mjs' export async function planData(r, o) { @@ -29,6 +29,9 @@ export function persistPlan(b, e) { } export async function execute(r, o, x, b) { + // 前置凭证校验:commit 是不可逆的本地动作,必须在它之前确认能推送/建 PR, + // 否则缺 token 会留下「已提交、未推送」的半状态(见 lib/plan.mjs norm 的 2026-10-12 实证)。 + token() const commit = ['committed', 'published'].includes(b.data.status) ? b.data.workflow.checkpoint : commitStep(r, x, b) const z = await pushAndOpenPr(r, x, b) write(b) diff --git a/lib/plan.mjs b/lib/plan.mjs index 4941ce4..325d5bc 100644 --- a/lib/plan.mjs +++ b/lib/plan.mjs @@ -10,22 +10,27 @@ export function canon(v) { export function hash(v) { return createHash('sha256').update(canon(v)).digest('hex') } // plan 对比前剥离 plan 产物本身,保证 plan 与 execute 重算结果一致。 -// 剥离边界=「plan 自身的副作用」:workflow 凭证字段,以及 repo 的易变 worktree 快照 -// (changedFiles/clean 会因 plan 写回 brief 而变化;dirty 行为检查仍由 sync 等命令域在 planData 内联执行,安全语义不丢) +// 剥离边界=「plan 自身的副作用」与「CLI 自己的状态推进」,两者都不是用户参数: +// · workflow 账簿字段(planHash/release/issuePlan/commit/checkpoint/operation/updatedAt/lastError)—— +// plan 与 execute 每次都会写它们,把它们算进哈希等于让门自己把自己判红; +// · repo 的易变快照(changedFiles/clean/head)——dirty 检查仍由 sync 等命令域在 planData 内联执行,安全语义不丢。 +// 2026-10-12 实证:release 半失败(commit 成功、push 因缺 GITHUB_TOKEN 失败)会推进 HEAD, +// 旧写法下 head 参与哈希 → 重试被 PLAN_HASH_INVALID 拒绝 → 只能重跑 plan,于是同一个提交被造了两遍 +// (PR #50 里两条同 message 的 commit 即此)。真正要守的是「参数须与 plan 一致」, +// 即 files/message/title/body 与 brief 正文;快照推进不属参数变化。 export function norm(d) { if (!d || typeof d !== 'object') return d + const WORKFLOW_VOLATILE = ['planHash', 'release', 'issuePlan', 'commit', 'checkpoint', 'operation', 'updatedAt', 'lastError'] + const REPO_VOLATILE = ['changedFiles', 'clean', 'head'] let v = d + if (d.head !== undefined) { v = { ...v }; delete v.head } if (d.brief && d.brief.workflow) { v = { ...v, brief: { ...d.brief, workflow: { ...d.brief.workflow } } } - delete v.brief.workflow.planHash - delete v.brief.workflow.release - delete v.brief.workflow.issuePlan - delete v.brief.workflow.commit + for (const k of WORKFLOW_VOLATILE) delete v.brief.workflow[k] } - if (d.repo && (d.repo.changedFiles !== undefined || d.repo.clean !== undefined)) { + if (d.repo && REPO_VOLATILE.some(k => d.repo[k] !== undefined)) { v = { ...v, repo: { ...v.repo } } - delete v.repo.changedFiles - delete v.repo.clean + for (const k of REPO_VOLATILE) delete v.repo[k] } return v } diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 3e8c374..8a634a2 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -6,6 +6,7 @@ import { platform, tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import test from 'node:test' +import { norm, hash } from '../lib/plan.mjs' const CLI = fileURLToPath(new URL('../cli.mjs', import.meta.url)) @@ -1826,3 +1827,67 @@ test('workflow capability contract: link refuses an artifact requiring commands assert.equal(JSON.parse(refused.stdout).error.code, 'ARTIFACT_INCOMPATIBLE') assert.equal(existsSync(join(prefixDir, 'LINK')), false, 'LINK is not written on refusal') }) + +test('planHash 只认参数内容:CLI 自身状态推进与 HEAD 快照不作废 plan', () => { + const base = { + name: 'sample', + files: ['src/example.js'], + message: 'feat: x', + title: 'sample', + body: '', + branch: 'feat/sample', + baseBranch: 'main', + head: 'aaa1111', + brief: { name: 'sample', status: 'reviewed', workflow: { operation: null, checkpoint: null, planHash: null, updatedAt: null, lastError: null } }, + repo: { root: '/tmp/x', branch: 'feat/sample', head: 'aaa1111', clean: true, changedFiles: [] }, + } + const h = (d) => hash({ command: 'release', data: norm(d) }) + const planned = h(base) + + // 1) CLI 自己的账簿推进 + HEAD 前移(半失败后再重试的真实形态)不得作废 plan + const advanced = JSON.parse(JSON.stringify(base)) + advanced.head = 'bbb2222' + advanced.repo.head = 'bbb2222' + advanced.repo.clean = false + advanced.repo.changedFiles = ['shadow-docs/changes/sample/brief.md'] + advanced.brief.workflow = { + operation: 'release', + checkpoint: 'bbb2222', + planHash: planned, + updatedAt: '2026-10-12T00:00:00.000Z', + lastError: 'GIT_PUSH_FAILED', + release: { files: ['src/example.js'], message: 'feat: x', title: 'sample', body: '' }, + } + assert.equal(h(advanced), planned, '账簿字段与 HEAD 快照不应参与 planHash 重算') + + // 2) 真正的参数变化仍必须判红 + for (const mutate of [ + (d) => { d.files = ['src/other.js'] }, + (d) => { d.message = 'feat: y' }, + (d) => { d.title = 'other' }, + (d) => { d.body = 'Closes #1' }, + ]) { + const changed = JSON.parse(JSON.stringify(base)) + mutate(changed) + assert.notEqual(h(changed), planned, '参数变化必须改变 planHash') + } +}) + +test('release execute 缺凭证时不落提交(避免已提交未推送的半状态)', () => { + const root = fixture() + execFileSync('git', ['remote', 'add', 'origin', 'git@github.com:owner/repo.git'], { cwd: root }) + execFileSync('git', ['add', '--', 'shadow-docs'], { cwd: root }) + execFileSync('git', ['commit', '-m', 'docs: seed brief'], { cwd: root }) + const cleanEnv = { GITHUB_TOKEN: '', GH_TOKEN: '' } + const before = execFileSync('git', ['rev-list', '--count', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim() + + const planned = run(['release', 'plan', '--name', 'sample', '--files', 'README.md', '--message', 'chore: x', '--title', 'sample', '--json'], root, cleanEnv) + assert.equal(planned.status, 0, planned.stderr) + + const executed = run(['release', 'execute', '--name', 'sample', '--plan-hash', JSON.parse(planned.stdout).planHash, '--confirm', '--json'], root, cleanEnv) + assert.notEqual(executed.status, 0) + assert.equal(JSON.parse(executed.stdout).error.code, 'GITHUB_TOKEN_REQUIRED') + + const after = execFileSync('git', ['rev-list', '--count', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim() + assert.equal(after, before, '缺凭证失败不得留下任何本地提交') +})