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
4 changes: 3 additions & 1 deletion lib/domains/publish.mjs
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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}`)
Expand Down
5 changes: 4 additions & 1 deletion lib/domains/release.mjs
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
23 changes: 14 additions & 9 deletions lib/plan.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
65 changes: 65 additions & 0 deletions test/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down Expand Up @@ -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, '缺凭证失败不得留下任何本地提交')
})
Loading