diff --git a/.github/workflows/know-code.yml b/.github/workflows/know-code.yml index bf0d7cb..8126c49 100644 --- a/.github/workflows/know-code.yml +++ b/.github/workflows/know-code.yml @@ -1,11 +1,13 @@ name: know-code -# PR-only: grounded verification needs a merge-base ahead of HEAD. On a push -# to main, HEAD *is* origin/main (zero commits ahead), so there is no range to -# recompute and verify can never match a grounded hash. +# PR: checkout the tip (not pull/N/merge) and run default verify. +# Push: walk github.event.before..HEAD so landings on main still match +# per-run trailers (origin/main == HEAD has no merge-base range). on: pull_request: types: [opened, synchronize, reopened] + push: + branches: [main] jobs: verify: @@ -16,7 +18,8 @@ jobs: fetch-depth: 0 # Default pull_request checkout is a merge commit (no trailers). # Verify must run on the PR tip that carries Know-Code-Verified. - ref: ${{ github.event.pull_request.head.sha }} + # On push, head.sha is empty so this is github.sha (the new tip). + ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: actions/setup-node@v4 with: @@ -35,4 +38,14 @@ jobs: printf '{\n "level": "standard",\n "baseBranch": "main",\n "requireTrailer": true\n}\n' > .know-code/config.json - name: Verify Know-Code-Verified trailer - run: know-code verify + run: | + if [ "${{ github.event_name }}" = "push" ]; then + BEFORE="${{ github.event.before }}" + if [[ "$BEFORE" =~ ^0+$ ]]; then + echo "know-code: new branch push — skip walk" + exit 0 + fi + know-code verify --from "$BEFORE" + else + know-code verify + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index f3d0eed..0c5feda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### CI verify on push (stacked-run walker) +- **`know-code verify --from `** walks `from..HEAD`, splits by `Know-Code-Verified` hash, and checks each run as a historical tree-pair (parent-of-first tree → last non-merge). Trailerless merges attach to the run but are not the hash tip, so a GitHub merge commit still matches after `main` moved. Linear commits without a trailer fail closed. One-non-merge runs also accept the empty-tree (index) hash of that feature tip. +- **Workflow + `init --workflow` + composite action** trigger on `push` to the base branch and pass `github.event.before`. PR verify is unchanged (`head.sha`, no `--from`). All-zeros `before` skips the walk. +- **Docs** describe the push walk and stop claiming verify cannot run after merge. + ## 0.3.0 ### Security — comprehensive exploit hardening (E01–E28) diff --git a/action/README.md b/action/README.md index 0365a0e..aa8e359 100644 --- a/action/README.md +++ b/action/README.md @@ -4,31 +4,42 @@ Verify `Know-Code-Verified` commit trailers in CI. ## Usage -Trigger the workflow on `pull_request` only. On a direct push to the base -branch, `HEAD` equals the base so there is no merge-base range to verify — -gate direct pushes locally with the pre-push hook instead. +On `pull_request`, checkout the PR tip. On `push` to the base branch, pass +`from:` with `github.event.before` so verify walks each landed run (HEAD +equals the base, so there is no merge-base range to recompute). ```yaml on: pull_request: + push: + branches: [main] # ... +- uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: chtnnh/know-code/action@v0.3.0 with: base-branch: main + from: ${{ github.event_name == 'push' && github.event.before || '' }} require-all: false require-range-trailers: false version: "^0.3.0" ``` +All-zeros `github.event.before` (new branch) skips the walk. + ## Inputs | Input | Default | Description | |-------|---------|-------------| | `base-branch` | `main` | Base branch for diff hashing | +| `from` | _(empty)_ | Previous tip for push jobs (`github.event.before`). Empty on `pull_request`. | | `require-all` | `false` | Stricter verify messaging | -| `require-range-trailers` | `false` | Every commit in range must have trailer (rewrite teams) | +| `require-range-trailers` | `false` | Every commit in range must have trailer (rewrite teams; PR path) | | `version` | `^0.3.0` | npm version when not building from monorepo checkout | ## Quick add diff --git a/action/action.yml b/action/action.yml index ca9f217..ac16009 100644 --- a/action/action.yml +++ b/action/action.yml @@ -1,9 +1,10 @@ name: know-code verify description: >- - Verify Know-Code-Verified commit trailers against grounded tree hashes - (index and merge-base→write-tree). Callers must checkout the PR tip - (ref: github.event.pull_request.head.sha) with fetch-depth: 0 — the - default merge commit has no trailers. Use on pull_request only. + Verify Know-Code-Verified commit trailers against grounded tree hashes. + On pull_request, checkout the PR tip (ref: github.event.pull_request.head.sha) + with fetch-depth: 0 — the default merge commit has no trailers. On push to + the base branch, pass from: github.event.before so verify walks each landed + run instead of treating HEAD as the merge-base. author: chtnnh branding: icon: shield @@ -22,6 +23,12 @@ inputs: description: If true, verify every commit in the range has Know-Code-Verified trailers required: false default: "false" + from: + description: >- + Previous tip SHA for push jobs (github.event.before). Empty on + pull_request. All-zeros skips the walk (new branch). + required: false + default: "" version: description: npm version range for know-code when not building from this repo required: false @@ -63,7 +70,15 @@ runs: - name: Verify Know-Code-Verified trailer shell: bash run: | + FROM="${{ inputs.from }}" + if [[ "$FROM" =~ ^0+$ ]]; then + echo "know-code: new branch push (zero before SHA) — skip walk" + exit 0 + fi ARGS="" + if [[ -n "$FROM" ]]; then + ARGS="$ARGS --from $FROM" + fi if [[ "${{ inputs.require-all }}" == "true" ]]; then ARGS="$ARGS --require-all" fi diff --git a/packages/cli/src/cli-surface.test.ts b/packages/cli/src/cli-surface.test.ts index 60b9e61..2ed4559 100644 --- a/packages/cli/src/cli-surface.test.ts +++ b/packages/cli/src/cli-surface.test.ts @@ -217,4 +217,19 @@ describe("cli surface (spawned)", () => { cleanup(); } }); + + it("verify --from requires a SHA; help lists the flag", () => { + const { root, cleanup } = withTempRepo("kc-cli-from-"); + try { + setupRepo(root); + const missing = kc(root, ["verify", "--from"]); + assert.equal(missing.status, 1); + assert.match(missing.stderr, /requires a commit SHA/); + const help = kc(root, ["help"]); + assert.equal(help.status, 0); + assert.match(help.stdout, /verify \[--from \]/); + } finally { + cleanup(); + } + }); }); diff --git a/packages/cli/src/commands-core.test.ts b/packages/cli/src/commands-core.test.ts index d5b51f9..5d112ff 100644 --- a/packages/cli/src/commands-core.test.ts +++ b/packages/cli/src/commands-core.test.ts @@ -4,11 +4,13 @@ import { existsSync, mkdirSync, mkdtempSync, + readFileSync, rmSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { runCheck } from "./commands/check.js"; import { buildAmendArgs } from "./commands/amend.js"; @@ -250,15 +252,40 @@ describe("commands: config / init / quiz / doctor / reset / ship", () => { } }); - it("consumerWorkflowYaml pins action, base branch, and is PR-only", () => { + it("consumerWorkflowYaml pins action, base branch, PR tip, and push walk", () => { const yml = consumerWorkflowYaml("develop"); assert.match(yml, /base-branch: develop/); assert.match(yml, /chtnnh\/know-code\/action@v0\.3\.0/); - // Push-to-base has no merge-base ahead of HEAD — verify must be PR-only. assert.match(yml, /pull_request:/); - assert.doesNotMatch(yml, /push:/); - // Default PR checkout is a merge commit without trailers — pin the tip. - assert.match(yml, /github\.event\.pull_request\.head\.sha/); + assert.match(yml, /push:/); + assert.match(yml, /branches:\n {6}- develop/); + assert.match(yml, /github\.event\.pull_request\.head\.sha \|\| github\.sha/); + assert.match(yml, /github\.event\.before/); + }); + + it("monorepo workflow and composite action wire push --from", () => { + const repoRoot = join( + dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "..", + ); + const workflow = readFileSync( + join(repoRoot, ".github", "workflows", "know-code.yml"), + "utf8", + ); + assert.match(workflow, /push:/); + assert.match(workflow, /branches: \[main\]/); + assert.match(workflow, /verify --from/); + assert.match(workflow, /github\.event\.before/); + assert.match(workflow, /github\.event\.pull_request\.head\.sha \|\| github\.sha/); + assert.match(workflow, /new branch push/); + assert.match(workflow, /else\n know-code verify\n/); + + const action = readFileSync(join(repoRoot, "action", "action.yml"), "utf8"); + assert.match(action, /^ {2}from:/m); + assert.match(action, /zero before SHA/); + assert.match(action, /--from \$FROM/); }); it("validateQuiz happy and sad", () => { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 636c398..370c605 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -13,12 +13,14 @@ const DOCS = "https://kc.chtnnhfoundation.org"; const ACTION_REF = "chtnnh/know-code/action@v0.3.0"; export function consumerWorkflowYaml(baseBranch: string): string { - // PR-only: on a push to the base branch there is no merge-base ahead of - // HEAD, so grounded verification has no range to recompute. + // PR: checkout the tip (not pull/N/merge). Push: walk github.event.before..HEAD. return `name: know-code on: pull_request: + push: + branches: + - ${baseBranch} jobs: verify: @@ -27,11 +29,12 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - ref: \${{ github.event.pull_request.head.sha }} + ref: \${{ github.event.pull_request.head.sha || github.sha }} - uses: ${ACTION_REF} with: base-branch: ${baseBranch} + from: \${{ github.event_name == 'push' && github.event.before || '' }} `; } diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts index c784c55..17f7a24 100644 --- a/packages/cli/src/commands/verify.ts +++ b/packages/cli/src/commands/verify.ts @@ -14,6 +14,14 @@ import { primaryVerifyCandidate, type VerifyHashCandidate, } from "../verify-helpers.js"; +import { + assertFromAncestorOfHead, + groundedHashesForSegment, + isZeroOid, + partitionPushWalk, + resolveFromCommit, + segmentTrailerMatches, +} from "../verify-walk.js"; import type { QuizContext } from "../types.js"; function trailersInRange(repoRoot: string, from: string, to: string): string[] { @@ -39,6 +47,7 @@ export interface VerifyResult { exitCode: number; messages: string[]; errors: string[]; + warnings?: string[]; primary?: VerifyHashCandidate; matched?: VerifyHashCandidate | { label: string }; ctx?: QuizContext; @@ -50,6 +59,7 @@ export function runVerify( requireAll?: boolean; requireRangeTrailers?: boolean; rangeSeal?: boolean; + from?: string; } = {}, ): VerifyResult { const config = readConfig(repoRoot); @@ -59,6 +69,10 @@ export function runVerify( const messages: string[] = []; const errors: string[] = []; + if (opts.from !== undefined) { + return runVerifyWalk(repoRoot, opts.from, { messages, errors, primary, ctx }); + } + if (opts.rangeSeal) { const seal = readRangeSeal(repoRoot); if (!seal) { @@ -196,14 +210,107 @@ export function runVerify( return { ok: false, exitCode: 1, messages, errors, primary, ctx }; } +function runVerifyWalk( + repoRoot: string, + fromArg: string, + parts: { + messages: string[]; + errors: string[]; + primary: VerifyHashCandidate; + ctx: QuizContext; + }, +): VerifyResult { + const { messages, errors, primary, ctx } = parts; + messages.push(`know-code verify --from ${fromArg.slice(0, 12)}`); + + const resolved = resolveFromCommit(repoRoot, fromArg); + if (!resolved.ok) { + errors.push(resolved.error); + return { ok: false, exitCode: 1, messages, errors, primary, ctx }; + } + + if (isZeroOid(resolved.oid)) { + messages.push( + "know-code: --from is the zero SHA (new branch); nothing to walk", + ); + return { ok: true, exitCode: 0, messages, errors, primary, ctx }; + } + + const anc = assertFromAncestorOfHead(repoRoot, resolved.oid); + if (!anc.ok) { + errors.push(anc.error); + return { ok: false, exitCode: 1, messages, errors, primary, ctx }; + } + + if (resolved.oid === anc.head) { + const warnings = [ + "know-code: warning — --from is HEAD; nothing to walk", + ]; + return { ok: true, exitCode: 0, messages, errors, warnings, primary, ctx }; + } + + const part = partitionPushWalk(repoRoot, resolved.oid, anc.head); + if (!part.ok) { + errors.push(part.error); + return { ok: false, exitCode: 1, messages, errors, primary, ctx }; + } + + if (part.segments.length === 0) { + errors.push( + `know-code: no commits in ${resolved.oid.slice(0, 12)}..HEAD`, + ); + return { ok: false, exitCode: 1, messages, errors, primary, ctx }; + } + + messages.push( + ` walking ${resolved.oid.slice(0, 12)}..HEAD (${part.segments.length} run${part.segments.length === 1 ? "" : "s"})`, + ); + + for (const [i, seg] of part.segments.entries()) { + if (!segmentTrailerMatches(repoRoot, seg)) { + const { rangeHash, indexHash } = groundedHashesForSegment(repoRoot, seg); + errors.push( + `know-code: run ${i + 1} ${seg.fromOid.slice(0, 12)}..${seg.toOid.slice(0, 12)} trailer ${seg.trailerHash.slice(0, 12)}… does not match tree pair ${rangeHash.slice(0, 12)}…` + + (indexHash ? ` (or index ${indexHash.slice(0, 12)}…)` : ""), + ); + return { ok: false, exitCode: 1, messages, errors, primary, ctx }; + } + const hashes = groundedHashesForSegment(repoRoot, seg); + const kind = + hashes.indexHash && + seg.trailerHash === hashes.indexHash && + seg.trailerHash !== hashes.rangeHash + ? "index" + : "tree-pair"; + messages.push( + `know-code: run ${i + 1} verified (${kind}, ${seg.oids.length} commit${seg.oids.length === 1 ? "" : "s"})`, + ); + } + + messages.push( + `know-code: push walk verified (${part.segments.length} run${part.segments.length === 1 ? "" : "s"})`, + ); + return { + ok: true, + exitCode: 0, + messages, + errors, + primary, + matched: { label: "push-walk" }, + ctx, + }; +} + export function cmdVerify(opts: { requireAll?: boolean; requireRangeTrailers?: boolean; rangeSeal?: boolean; + from?: string; }): void { const repoRoot = findGitRoot(); const result = runVerify(repoRoot, opts); for (const m of result.messages) console.log(m); + for (const w of result.warnings ?? []) console.error(w); for (const e of result.errors) console.error(e); process.exit(result.exitCode); } diff --git a/packages/cli/src/git.ts b/packages/cli/src/git.ts index b6e0ff4..4290543 100644 --- a/packages/cli/src/git.ts +++ b/packages/cli/src/git.ts @@ -75,6 +75,24 @@ export function mergeBase(repoRoot: string, baseRef: string, headRef: string): s return baseRef; } +/** True when `ancestor` is an ancestor of `descendant` (equal counts as yes). */ +export function isAncestor( + repoRoot: string, + ancestor: string, + descendant: string, +): boolean { + try { + execFileSync("git", ["merge-base", "--is-ancestor", ancestor, descendant], { + cwd: repoRoot, + stdio: "ignore", + env: knowCodeGitEnv(), + }); + return true; + } catch { + return false; + } +} + export function diffStat(repoRoot: string, from: string, to: string): string { return git(["diff", "--stat", `${from}...${to}`], repoRoot, { allowFail: true }); } diff --git a/packages/cli/src/hash.ts b/packages/cli/src/hash.ts index 074f9d4..14e8c09 100644 --- a/packages/cli/src/hash.ts +++ b/packages/cli/src/hash.ts @@ -12,12 +12,37 @@ import { findGitRoot } from "./paths.js"; import { readRangeSession } from "./range.js"; import type { Config, DiffContext, QuizContext } from "./types.js"; -const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; +/** Git's well-known empty-tree OID. */ +export const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; export function sha256(input: string): string { return createHash("sha256").update(input, "utf8").digest("hex"); } +function treeOid(repoRoot: string, oid: string): string { + if (!oid || oid === EMPTY_TREE) return EMPTY_TREE; + return ( + git(["rev-parse", `${oid}^{tree}`], repoRoot, { allowFail: true }) || + EMPTY_TREE + ); +} + +/** + * Historical tree-pair hash: `sha256("diff:" + git diff FROM_TREE TO_TREE)`. + * Uses commit trees (`^{tree}`), never live `write-tree`, so a dirty index + * cannot change the result. Push-walk verify hashes each run this way. + */ +export function computeTreePairHash( + repoRoot: string, + fromOid: string, + toOid: string, +): string { + const diff = git(["diff", treeOid(repoRoot, fromOid), treeOid(repoRoot, toOid)], repoRoot, { + allowFail: true, + }); + return sha256(`diff:${diff}`); +} + /** * Hash is the patch from the empty tree to the **index** (HEAD + staged). */ @@ -69,11 +94,7 @@ export function computeRangeDiffContext( const headLabel = headRef === EMPTY_TREE ? "HEAD" : headRef; const commitCount = revListCount(repoRoot, fromOid, headLabel); - const fromTree = - fromOid === EMPTY_TREE - ? EMPTY_TREE - : git(["rev-parse", `${fromOid}^{tree}`], repoRoot, { allowFail: true }) || - EMPTY_TREE; + const fromTree = treeOid(repoRoot, fromOid); const indexTree = indexTreeOid(repoRoot) || EMPTY_TREE; const diff = git(["diff", fromTree, indexTree], repoRoot, { allowFail: true, diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 6c2076f..760ba9f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -71,7 +71,7 @@ Usage: know-code override know-code status [--json] [--next] know-code hash [--explain] [--json] - know-code verify [--require-all] [--require-range-trailers] [--range-seal] + know-code verify [--from ] [--require-all] [--require-range-trailers] [--range-seal] know-code hooks install know-code hooks uninstall [--agents claude,cursor,codex] know-code skills [--global] [--agents …] [-y] @@ -338,10 +338,17 @@ function main(): void { }); break; case "verify": + if (flags.from === true) { + console.error( + "know-code: --from requires a commit SHA (github.event.before)", + ); + process.exit(1); + } cmdVerify({ requireAll: flags["require-all"] === true, requireRangeTrailers: flags["require-range-trailers"] === true, rangeSeal: flags["range-seal"] === true, + from: typeof flags.from === "string" ? flags.from : undefined, }); break; case "ask": diff --git a/packages/cli/src/verify-walk.test.ts b/packages/cli/src/verify-walk.test.ts new file mode 100644 index 0000000..3625621 --- /dev/null +++ b/packages/cli/src/verify-walk.test.ts @@ -0,0 +1,513 @@ +import { strict as assert } from "node:assert"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { writeConfig } from "./config.js"; +import { runVerify } from "./commands/verify.js"; +import { + computeRangeDiffContext, + computeTreePairHash, + EMPTY_TREE, +} from "./hash.js"; +import { applyTrailerToRange } from "./trailers.js"; +import { DEFAULT_CONFIG } from "./types.js"; +import { + partitionPushWalk, + groundedHashesForSegment, +} from "./verify-walk.js"; + +function git(cwd: string, args: string[]): string { + return execFileSync("git", ["-c", "commit.gpgsign=false", ...args], { + cwd, + encoding: "utf8", + env: { + ...process.env, + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_SYSTEM: "/dev/null", + }, + }).trim(); +} + +function ciVerifyConfig() { + return { + ...DEFAULT_CONFIG, + level: "lite" as const, + baseBranch: "main", + requireTrailer: true, + requireAttest: false, + enforcePipeline: false, + }; +} + +function initLab(prefix: string): string { + const repo = mkdtempSync(join(tmpdir(), prefix)); + git(repo, ["init", "-b", "main", "--template="]); + git(repo, ["config", "user.email", "t@test"]); + git(repo, ["config", "user.name", "t"]); + mkdirSync(join(repo, ".know-code"), { recursive: true }); + writeConfig(repo, ciVerifyConfig()); + return repo; +} + +function commitFile( + repo: string, + file: string, + body: string, + message: string, +): string { + writeFileSync(join(repo, file), body); + git(repo, ["add", file]); + git(repo, ["commit", "-m", message]); + return git(repo, ["rev-parse", "HEAD"]); +} + +function stampTrailer(repo: string, hash: string, subject: string): string { + git(repo, ["commit", "--amend", "-m", `${subject}\n\nKnow-Code-Verified: ${hash}\n`]); + return git(repo, ["rev-parse", "HEAD"]); +} + +function pinOriginMain(repo: string, oid: string): void { + const remotes = git(repo, ["remote"]); + if (!remotes.split("\n").filter(Boolean).includes("origin")) { + git(repo, ["remote", "add", "origin", "https://example.invalid/repo.git"]); + } + git(repo, ["update-ref", "refs/remotes/origin/main", oid]); +} + +describe("computeTreePairHash", () => { + it("matches a clean-index range hash and ignores a dirty index", () => { + const repo = initLab("kc-tree-pair-"); + try { + const base = commitFile(repo, "a.txt", "base\n", "base"); + commitFile(repo, "a.txt", "feat\n", "feat"); + const cfg = ciVerifyConfig(); + const range = computeRangeDiffContext(repo, cfg, base).diffHash; + const pair = computeTreePairHash(repo, base, git(repo, ["rev-parse", "HEAD"])); + assert.equal(pair, range); + + writeFileSync(join(repo, "dirty.txt"), "staged\n"); + git(repo, ["add", "dirty.txt"]); + assert.equal( + computeTreePairHash(repo, base, git(repo, ["rev-parse", "HEAD"])), + pair, + ); + assert.notEqual( + computeRangeDiffContext(repo, cfg, base).diffHash, + pair, + ); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); +}); + +describe("partitionPushWalk", () => { + it("splits stacked trailer hashes into independent runs", () => { + const repo = initLab("kc-part-stack-"); + try { + const base = commitFile(repo, "a.txt", "0\n", "base"); + commitFile(repo, "a.txt", "1\n", "one"); + const s1 = stampTrailer(repo, "a".repeat(64), "one"); + commitFile(repo, "a.txt", "2\n", "two"); + const s2 = stampTrailer(repo, "b".repeat(64), "two"); + + const part = partitionPushWalk(repo, base, s2); + assert.equal(part.ok, true); + if (!part.ok) return; + assert.equal(part.segments.length, 2); + assert.equal(part.segments[0].trailerHash, "a".repeat(64)); + assert.equal(part.segments[0].oids.length, 1); + assert.equal(part.segments[1].trailerHash, "b".repeat(64)); + assert.equal(part.segments[0].fromOid, base); + assert.equal(part.segments[1].fromOid, s1); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + it("fails closed on a linear commit without a trailer", () => { + const repo = initLab("kc-part-linear-"); + try { + const base = commitFile(repo, "a.txt", "0\n", "base"); + const head = commitFile(repo, "a.txt", "1\n", "no trailer"); + const part = partitionPushWalk(repo, base, head); + assert.equal(part.ok, false); + if (part.ok) return; + assert.match(part.error, /no Know-Code-Verified trailer/); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + it("attaches a trailerless merge to the current run", () => { + const repo = initLab("kc-part-merge-"); + try { + const base = commitFile(repo, "a.txt", "0\n", "base"); + git(repo, ["checkout", "-b", "feat"]); + commitFile(repo, "feat.txt", "f\n", "feat"); + const tip = stampTrailer(repo, "c".repeat(64), "feat"); + git(repo, ["checkout", "main"]); + git(repo, ["merge", "--no-ff", tip, "-m", "Merge pull request #1 from owner/feat"]); + const merge = git(repo, ["rev-parse", "HEAD"]); + + const part = partitionPushWalk(repo, base, merge); + assert.equal(part.ok, true); + if (!part.ok) return; + assert.equal(part.segments.length, 1); + assert.equal(part.segments[0].oids.length, 2); + assert.equal(part.segments[0].toOid, merge); + assert.equal(part.segments[0].fromOid, base); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + it("fails when a merge has no current run to attach to", () => { + const repo = initLab("kc-part-orphan-merge-"); + try { + const base = commitFile(repo, "a.txt", "0\n", "base"); + git(repo, ["checkout", "-b", "feat"]); + commitFile(repo, "feat.txt", "f\n", "feat"); + const tip = stampTrailer(repo, "c".repeat(64), "feat"); + git(repo, ["checkout", "main"]); + git(repo, ["merge", "--no-ff", tip, "-m", "Merge pull request #1 from owner/feat"]); + const merge = git(repo, ["rev-parse", "HEAD"]); + + const part = partitionPushWalk(repo, tip, merge); + assert.equal(part.ok, false); + if (part.ok) return; + assert.match(part.error, /not attached to a verified run/); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); +}); + +describe("verify --from walk", () => { + it("exits 0 when --from is HEAD or the zero SHA", () => { + const repo = initLab("kc-walk-empty-"); + try { + commitFile(repo, "a.txt", "0\n", "base"); + const head = git(repo, ["rev-parse", "HEAD"]); + const same = runVerify(repo, { from: head }); + assert.equal(same.ok, true, same.errors.join("\n")); + assert.equal(same.exitCode, 0); + assert.match((same.warnings ?? []).join("\n"), /warning — --from is HEAD/); + + const zero = runVerify(repo, { from: "0".repeat(40) }); + assert.equal(zero.ok, true, zero.errors.join("\n")); + assert.match(zero.messages.join("\n"), /zero SHA/); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + it("fails closed when --from is not an ancestor or not a commit", () => { + const repo = initLab("kc-walk-anc-"); + try { + commitFile(repo, "a.txt", "0\n", "base"); + git(repo, ["checkout", "-b", "side"]); + const side = commitFile(repo, "side.txt", "s\n", "side"); + git(repo, ["checkout", "main"]); + commitFile(repo, "main.txt", "m\n", "main moves"); + + const missing = runVerify(repo, { from: "a".repeat(40) }); + assert.equal(missing.ok, false); + assert.match(missing.errors.join("\n"), /not a commit/); + + const diverged = runVerify(repo, { from: side }); + assert.equal(diverged.ok, false); + assert.match(diverged.errors.join("\n"), /not an ancestor/); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + it("accepts a single landing against the tree-pair or the index hash", () => { + const repo = initLab("kc-walk-one-"); + try { + const base = commitFile(repo, "a.txt", "0\n", "base"); + commitFile(repo, "a.txt", "hotfix\n", "hotfix"); + const head = git(repo, ["rev-parse", "HEAD"]); + const rangeHash = computeTreePairHash(repo, base, head); + const indexHash = computeTreePairHash(repo, EMPTY_TREE, head); + assert.notEqual(rangeHash, indexHash); + + stampTrailer(repo, rangeHash, "hotfix"); + const viaRange = runVerify(repo, { from: base }); + assert.equal(viaRange.ok, true, viaRange.errors.join("\n")); + assert.equal(viaRange.matched?.label, "push-walk"); + assert.match(viaRange.messages.join("\n"), /tree-pair/); + + stampTrailer(repo, indexHash, "hotfix"); + const viaIndex = runVerify(repo, { from: base }); + assert.equal(viaIndex.ok, true, viaIndex.errors.join("\n")); + assert.match(viaIndex.messages.join("\n"), /index/); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + it("verifies stacked ranges independently (combined patch is not a candidate)", () => { + const repo = initLab("kc-walk-stack-"); + try { + const base = commitFile(repo, "a.txt", "0\n", "base"); + commitFile(repo, "r1.txt", "one\n", "one"); + let s1 = git(repo, ["rev-parse", "HEAD"]); + const h1 = computeTreePairHash(repo, base, s1); + s1 = stampTrailer(repo, h1, "one"); + + commitFile(repo, "r2.txt", "two\n", "two"); + let s2 = git(repo, ["rev-parse", "HEAD"]); + const h2 = computeTreePairHash(repo, s1, s2); + s2 = stampTrailer(repo, h2, "two"); + + const combined = computeTreePairHash(repo, base, s2); + assert.notEqual(combined, h1); + assert.notEqual(combined, h2); + + const result = runVerify(repo, { from: base }); + assert.equal(result.ok, true, result.errors.join("\n")); + assert.match(result.messages.join("\n"), /2 runs/); + const part = partitionPushWalk(repo, base, s2); + assert.equal(part.ok, true); + if (!part.ok) return; + assert.equal(part.segments.length, 2); + assert.equal(groundedHashesForSegment(repo, part.segments[0]).rangeHash, h1); + assert.equal(groundedHashesForSegment(repo, part.segments[1]).rangeHash, h2); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + it("accepts a merge landing whose feature commit carries the tree-pair trailer", () => { + const repo = initLab("kc-walk-merge-"); + try { + const base = commitFile(repo, "a.txt", "0\n", "base"); + git(repo, ["checkout", "-b", "feat"]); + commitFile(repo, "feat.txt", "f\n", "feat"); + let tip = git(repo, ["rev-parse", "HEAD"]); + const h = computeTreePairHash(repo, base, tip); + tip = stampTrailer(repo, h, "feat"); + git(repo, ["checkout", "main"]); + git(repo, [ + "merge", + "--no-ff", + tip, + "-m", + "Merge pull request #1 from owner/feat\n\nfeat", + ]); + const result = runVerify(repo, { from: base }); + assert.equal(result.ok, true, result.errors.join("\n")); + assert.match(result.messages.join("\n"), /push walk verified/); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + it("accepts a merge landing after main moved (hash last non-merge, not merge tree)", () => { + const repo = initLab("kc-walk-merge-stale-"); + try { + const base = commitFile(repo, "a.txt", "0\n", "base"); + git(repo, ["checkout", "-b", "feat"]); + commitFile(repo, "feat.txt", "f\n", "feat"); + let tip = git(repo, ["rev-parse", "HEAD"]); + const h = computeTreePairHash(repo, base, tip); + tip = stampTrailer(repo, h, "feat"); + git(repo, ["checkout", "main"]); + const before = commitFile(repo, "main.txt", "moved\n", "main moved"); + git(repo, [ + "merge", + "--no-ff", + tip, + "-m", + "Merge pull request #1 from owner/feat\n\nfeat", + ]); + const merge = git(repo, ["rev-parse", "HEAD"]); + assert.notEqual(computeTreePairHash(repo, base, merge), h); + + const part = partitionPushWalk(repo, before, merge); + assert.equal(part.ok, true); + if (!part.ok) return; + assert.equal(part.segments.length, 1); + assert.equal(part.segments[0].toOid, merge); + const grounded = groundedHashesForSegment(repo, part.segments[0]); + assert.equal(grounded.rangeHash, h); + assert.equal(grounded.indexHash, computeTreePairHash(repo, EMPTY_TREE, tip)); + + const result = runVerify(repo, { from: before }); + assert.equal(result.ok, true, result.errors.join("\n")); + assert.match(result.messages.join("\n"), /tree-pair/); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + it("rejects a forged trailer and ignores a dirty index", () => { + const repo = initLab("kc-walk-forge-"); + try { + const base = commitFile(repo, "a.txt", "0\n", "base"); + commitFile(repo, "a.txt", "feat\n", "feat"); + stampTrailer(repo, "d".repeat(64), "feat"); + const forged = runVerify(repo, { from: base }); + assert.equal(forged.ok, false); + assert.match(forged.errors.join("\n"), /does not match tree pair/); + + const head = git(repo, ["rev-parse", "HEAD"]); + const h = computeTreePairHash(repo, base, head); + stampTrailer(repo, h, "feat"); + writeFileSync(join(repo, "dirty.txt"), "nope\n"); + git(repo, ["add", "dirty.txt"]); + const dirty = runVerify(repo, { from: base }); + assert.equal(dirty.ok, true, dirty.errors.join("\n")); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + it("fails closed on a receipt-style range (trailer only on the tip)", () => { + const repo = initLab("kc-walk-receipt-"); + try { + const base = commitFile(repo, "a.txt", "0\n", "base"); + commitFile(repo, "a.txt", "1\n", "mid"); + commitFile(repo, "a.txt", "2\n", "tip"); + const tip = git(repo, ["rev-parse", "HEAD"]); + const h = computeTreePairHash(repo, base, tip); + stampTrailer(repo, h, "tip"); + const result = runVerify(repo, { from: base }); + assert.equal(result.ok, false); + assert.match(result.errors.join("\n"), /no Know-Code-Verified trailer/); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + it("treats a rewrite range (same trailer on every commit) as one run", () => { + const repo = initLab("kc-walk-rewrite-"); + try { + const base = commitFile(repo, "a.txt", "0\n", "base"); + commitFile(repo, "d.txt", "d\n", "d"); + commitFile(repo, "e.txt", "e\n", "e"); + const tip = git(repo, ["rev-parse", "HEAD"]); + const h = computeTreePairHash(repo, base, tip); + applyTrailerToRange(repo, base, h); + const head = git(repo, ["rev-parse", "HEAD"]); + const part = partitionPushWalk(repo, base, head); + assert.equal(part.ok, true); + if (!part.ok) return; + assert.equal(part.segments.length, 1); + assert.equal(part.segments[0].oids.length, 2); + const result = runVerify(repo, { from: base }); + assert.equal(result.ok, true, result.errors.join("\n")); + assert.match(result.messages.join("\n"), /1 run/); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + it("rejects an index hash on a multi-commit run", () => { + const repo = initLab("kc-walk-idx-multi-"); + try { + const base = commitFile(repo, "a.txt", "0\n", "base"); + commitFile(repo, "d.txt", "d\n", "d"); + commitFile(repo, "e.txt", "e\n", "e"); + const tip = git(repo, ["rev-parse", "HEAD"]); + const indexHash = computeTreePairHash(repo, EMPTY_TREE, tip); + applyTrailerToRange(repo, base, indexHash); + const result = runVerify(repo, { from: base }); + assert.equal(result.ok, false); + assert.match(result.errors.join("\n"), /does not match tree pair/); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + it("accepts an index-hash hotfix after a trailerless merge attaches", () => { + const repo = initLab("kc-walk-idx-merge-"); + try { + const base = commitFile(repo, "a.txt", "0\n", "base"); + git(repo, ["checkout", "-b", "feat"]); + commitFile(repo, "feat.txt", "f\n", "feat"); + let tip = git(repo, ["rev-parse", "HEAD"]); + const indexHash = computeTreePairHash(repo, EMPTY_TREE, tip); + tip = stampTrailer(repo, indexHash, "feat"); + git(repo, ["checkout", "main"]); + git(repo, ["merge", "--no-ff", tip, "-m", "Merge pull request #1 from owner/feat"]); + const result = runVerify(repo, { from: base }); + assert.equal(result.ok, true, result.errors.join("\n")); + assert.match(result.messages.join("\n"), /index/); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + it("bare verify fails on the base tip while --from still passes", () => { + const repo = initLab("kc-walk-base-tip-"); + try { + const base = commitFile(repo, "a.txt", "0\n", "base"); + commitFile(repo, "a.txt", "land\n", "land"); + let head = git(repo, ["rev-parse", "HEAD"]); + const h = computeTreePairHash(repo, base, head); + head = stampTrailer(repo, h, "land"); + pinOriginMain(repo, head); + const bare = runVerify(repo, {}); + assert.equal(bare.ok, false); + assert.match(bare.messages.join("\n"), /on base tip/); + const walk = runVerify(repo, { from: base }); + assert.equal(walk.ok, true, walk.errors.join("\n")); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + it("ignores an indented trailer (column-0 only)", () => { + const repo = initLab("kc-walk-indent-"); + try { + const base = commitFile(repo, "a.txt", "0\n", "base"); + commitFile(repo, "a.txt", "1\n", "feat"); + const head = git(repo, ["rev-parse", "HEAD"]); + const h = computeTreePairHash(repo, base, head); + git(repo, [ + "commit", + "--amend", + "-m", + `feat\n\n Know-Code-Verified: ${h}\n`, + ]); + const result = runVerify(repo, { from: base }); + assert.equal(result.ok, false); + assert.match(result.errors.join("\n"), /no Know-Code-Verified trailer/); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + it("matches a cumulative range hash when --from is a later landing (second push)", () => { + const repo = initLab("kc-walk-second-push-"); + try { + const base = commitFile(repo, "a.txt", "0\n", "base"); + commitFile(repo, "d.txt", "d\n", "first landing"); + let first = git(repo, ["rev-parse", "HEAD"]); + first = stampTrailer(repo, computeTreePairHash(repo, base, first), "first landing"); + + commitFile(repo, "e.txt", "e\n", "second landing"); + let second = git(repo, ["rev-parse", "HEAD"]); + const cumulative = computeTreePairHash(repo, base, second); + const incremental = computeTreePairHash(repo, first, second); + assert.notEqual(cumulative, incremental); + second = stampTrailer(repo, cumulative, "second landing"); + + const result = runVerify(repo, { from: first }); + assert.equal(result.ok, true, result.errors.join("\n")); + assert.match(result.messages.join("\n"), /tree-pair/); + + stampTrailer(repo, computeTreePairHash(repo, base, first), "second landing"); + const copied = runVerify(repo, { from: first }); + assert.equal(copied.ok, false); + assert.match(copied.errors.join("\n"), /does not match tree pair/); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/verify-walk.ts b/packages/cli/src/verify-walk.ts new file mode 100644 index 0000000..8cb22fe --- /dev/null +++ b/packages/cli/src/verify-walk.ts @@ -0,0 +1,205 @@ +import { currentHead, git, isAncestor } from "./git.js"; +import { computeTreePairHash, EMPTY_TREE } from "./hash.js"; +import { trailerHashFromMessage } from "./trailers.js"; + +/** GitHub `github.event.before` for a newly created branch. */ +export const ZERO_OID_RE = /^0+$/; + +export function isZeroOid(oid: string): boolean { + return ZERO_OID_RE.test(oid.trim()); +} + +export interface WalkSegment { + /** First parent of the first commit in the run (range start). */ + fromOid: string; + /** Last commit in the run (range tip, including attached merges). */ + toOid: string; + trailerHash: string; + oids: string[]; +} + +export type PartitionResult = + | { ok: true; segments: WalkSegment[] } + | { ok: false; error: string }; + +function firstParentOid(repoRoot: string, oid: string): string { + const line = git(["rev-list", "--parents", "-n", "1", oid], repoRoot, { + allowFail: true, + }); + const parts = line.split(/\s+/).filter(Boolean); + return parts[1] ?? EMPTY_TREE; +} + +function parentCount(repoRoot: string, oid: string): number { + const line = git(["rev-list", "--parents", "-n", "1", oid], repoRoot, { + allowFail: true, + }); + const parts = line.split(/\s+/).filter(Boolean); + return Math.max(0, parts.length - 1); +} + +/** + * Split `fromOid..toOid` into runs that share a `Know-Code-Verified` hash. + * Merge commits without a trailer attach to the current run; linear commits + * without a trailer fail closed. + */ +export function partitionPushWalk( + repoRoot: string, + fromOid: string, + toOid: string, +): PartitionResult { + const commits = git( + ["rev-list", "--reverse", "--topo-order", `${fromOid}..${toOid}`], + repoRoot, + { allowFail: true }, + ) + .split("\n") + .filter(Boolean); + + const segments: WalkSegment[] = []; + let current: WalkSegment | null = null; + + const flush = () => { + if (current) { + segments.push(current); + current = null; + } + }; + + for (const oid of commits) { + const msg = git(["log", "-1", "--format=%B", oid], repoRoot, { + allowFail: true, + }); + const hash = trailerHashFromMessage(msg); + const merge = parentCount(repoRoot, oid) > 1; + + if (hash) { + if (current && current.trailerHash === hash) { + current.oids.push(oid); + current.toOid = oid; + } else { + flush(); + current = { + fromOid: firstParentOid(repoRoot, oid), + toOid: oid, + trailerHash: hash, + oids: [oid], + }; + } + continue; + } + + if (merge) { + if (!current) { + return { + ok: false, + error: `know-code: merge ${oid.slice(0, 12)} has no Know-Code-Verified trailer and is not attached to a verified run`, + }; + } + current.oids.push(oid); + current.toOid = oid; + continue; + } + + return { + ok: false, + error: `know-code: commit ${oid.slice(0, 12)} has no Know-Code-Verified trailer`, + }; + } + + flush(); + return { ok: true, segments }; +} + +function nonMergeOids(repoRoot: string, oids: string[]): string[] { + return oids.filter((oid) => parentCount(repoRoot, oid) <= 1); +} + +/** + * Tree the trailer was stamped against: last non-merge in the run. + * Attached trailerless merges (GitHub "Create a merge commit") stay in + * `toOid` for bookkeeping but must not be the hash tip — when `main` + * moved, the merge tree includes unrelated mainline files. + */ +function segmentHashToOid(repoRoot: string, segment: WalkSegment): string { + const linear = nonMergeOids(repoRoot, segment.oids); + return linear[linear.length - 1] ?? segment.toOid; +} + +/** Grounded hashes a run's trailer may match. */ +export function groundedHashesForSegment( + repoRoot: string, + segment: WalkSegment, +): { rangeHash: string; indexHash?: string; hashes: string[] } { + const hashTo = segmentHashToOid(repoRoot, segment); + const rangeHash = computeTreePairHash(repoRoot, segment.fromOid, hashTo); + const seen = new Set([rangeHash]); + let indexHash: string | undefined; + // One logical landing: a single non-merge, optionally plus trailerless + // merges glued on (GitHub "Create a merge commit" of a 1-commit PR). + if (nonMergeOids(repoRoot, segment.oids).length === 1) { + indexHash = computeTreePairHash(repoRoot, EMPTY_TREE, hashTo); + seen.add(indexHash); + } + // Same range session, later push: trailer is still range-begin → tip, + // but --from is the previous landing. Walk first-parent to the root. + const visited = new Set([segment.fromOid]); + let a = segment.fromOid; + for (;;) { + const parent = firstParentOid(repoRoot, a); + if (!parent || parent === EMPTY_TREE || visited.has(parent)) break; + visited.add(parent); + seen.add(computeTreePairHash(repoRoot, parent, hashTo)); + a = parent; + } + return { rangeHash, indexHash, hashes: [...seen] }; +} + +export function segmentTrailerMatches( + repoRoot: string, + segment: WalkSegment, +): boolean { + const { hashes } = groundedHashesForSegment(repoRoot, segment); + return hashes.includes(segment.trailerHash); +} + +export function resolveFromCommit( + repoRoot: string, + from: string, +): { ok: true; oid: string } | { ok: false; error: string } { + const trimmed = from.trim(); + if (!trimmed) { + return { ok: false, error: "know-code: --from requires a commit SHA" }; + } + if (isZeroOid(trimmed)) { + return { ok: true, oid: "0".repeat(Math.max(trimmed.length, 40)) }; + } + const oid = git(["rev-parse", "--verify", `${trimmed}^{commit}`], repoRoot, { + allowFail: true, + }); + if (!oid) { + return { + ok: false, + error: `know-code: --from ${trimmed.slice(0, 12)} is not a commit in this repository`, + }; + } + return { ok: true, oid }; +} + +export function assertFromAncestorOfHead( + repoRoot: string, + fromOid: string, +): { ok: true; head: string } | { ok: false; error: string } { + const head = currentHead(repoRoot); + if (!head || head === EMPTY_TREE) { + return { ok: false, error: "know-code: --from requires a commit at HEAD" }; + } + if (!isAncestor(repoRoot, fromOid, head)) { + return { + ok: false, + error: + "know-code: --from is not an ancestor of HEAD (refusing rewritten history)", + }; + } + return { ok: true, head }; +} diff --git a/packages/cli/src/verify.test.ts b/packages/cli/src/verify.test.ts index a7adf27..49ed3f7 100644 --- a/packages/cli/src/verify.test.ts +++ b/packages/cli/src/verify.test.ts @@ -12,14 +12,93 @@ import { writeRangeSeal, writeRangeSession, } from "./range.js"; +import { trailerHashFromMessage } from "./trailers.js"; import { DEFAULT_CONFIG } from "./types.js"; import { collectVerifyHashCandidates, matchHeadTrailer, } from "./verify-helpers.js"; +import { runVerify } from "./commands/verify.js"; function git(cwd: string, args: string[]): string { - return execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + return execFileSync("git", ["-c", "commit.gpgsign=false", ...args], { + cwd, + encoding: "utf8", + env: { + ...process.env, + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_SYSTEM: "/dev/null", + }, + }).trim(); +} + +/** CI workflow writes requireTrailer: true and resolves merge-base via origin/main. */ +function ciVerifyConfig() { + return { + ...DEFAULT_CONFIG, + level: "lite" as const, + baseBranch: "main", + requireTrailer: true, + requireAttest: false, + enforcePipeline: false, + }; +} + +function initLab(prefix: string): string { + const repo = mkdtempSync(join(tmpdir(), prefix)); + git(repo, ["init", "-b", "main", "--template="]); + git(repo, ["config", "user.email", "t@test"]); + git(repo, ["config", "user.name", "t"]); + mkdirSync(join(repo, ".know-code"), { recursive: true }); + writeConfig(repo, ciVerifyConfig()); + return repo; +} + +function pinOriginMain(repo: string, oid: string): void { + const remotes = git(repo, ["remote"]); + if (!remotes.split("\n").filter(Boolean).includes("origin")) { + git(repo, ["remote", "add", "origin", "https://example.invalid/repo.git"]); + } + git(repo, ["update-ref", "refs/remotes/origin/main", oid]); +} + +function parentOids(repo: string, rev = "HEAD"): string[] { + return git(repo, ["rev-list", "--parents", "-n", "1", rev]).split(" ").slice(1); +} + +/** + * GitHub default merge-message shapes (new-repo presets: + * squash COMMIT_OR_PR_TITLE + COMMIT_MESSAGES, merge MERGE_MESSAGE + PR_TITLE). + * Local git only — these strings are fixtures, not a live API. + */ +function githubPullMergeRefMessage(headOid: string, baseOid: string): string { + return `Merge ${headOid} into ${baseOid}`; +} + +function githubSquashSingleMessage( + title: string, + pr: number, + hash: string, +): string { + return `${title} (#${pr})\n\nKnow-Code-Verified: ${hash}\n\nCo-authored-by: know-code-lab `; +} + +function githubSquashMultiMessage( + title: string, + pr: number, + subjects: string[], + hash: string, +): string { + const bullets = subjects.map((s) => `* ${s}`).join("\n\n"); + return `${title} (#${pr})\n\n${bullets}\n\nKnow-Code-Verified: ${hash}\n\n---------\n\nCo-authored-by: know-code-lab `; +} + +function githubMergeLandingMessage( + pr: number, + headRef: string, + prTitle: string, +): string { + return `Merge pull request #${pr} from owner/${headRef}\n\n${prTitle}`; } describe("verify hash candidates", () => { @@ -408,4 +487,312 @@ describe("verify hash candidates", () => { rmSync(repo, { recursive: true, force: true }); } }); + + it("verify still matches after GitHub Update branch (merge main into feat)", () => { + const repo = initLab("kc-verify-gh-update-branch-"); + const cfg = ciVerifyConfig(); + try { + writeFileSync(join(repo, "readme.txt"), "base\n"); + git(repo, ["add", "readme.txt"]); + git(repo, ["commit", "-m", "base"]); + + git(repo, ["checkout", "-b", "feat/merge-update"]); + writeFileSync(join(repo, "feat.txt"), "pr\n"); + git(repo, ["add", "feat.txt"]); + const fromOid = git(repo, ["rev-parse", "main"]); + const passHash = computeRangeDiffContext(repo, cfg, fromOid).diffHash; + git(repo, [ + "commit", + "-m", + `merge-update feat\n\nKnow-Code-Verified: ${passHash}\n`, + ]); + + git(repo, ["checkout", "main"]); + writeFileSync(join(repo, "unrelated.txt"), "main\n"); + git(repo, ["add", "unrelated.txt"]); + git(repo, ["commit", "-m", "main moves"]); + const movedMain = git(repo, ["rev-parse", "HEAD"]); + git(repo, ["checkout", "feat/merge-update"]); + git(repo, ["merge", "main", "-m", "Merge branch 'main' into feat/merge-update"]); + + assert.equal(parentOids(repo).length, 2, "HEAD must be a merge commit"); + const headMsg = git(repo, ["log", "-1", "--format=%B", "HEAD"]); + assert.equal(trailerHashFromMessage(headMsg), null); + pinOriginMain(repo, movedMain); + + const result = runVerify(repo, {}); + assert.equal(result.ok, true, result.errors.join("\n")); + assert.equal(result.matched?.label, "merge-base..HEAD"); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + it("verify matches GitHub pull/N/merge (Actions github.sha) via range trailers", () => { + const repo = initLab("kc-verify-gh-pull-merge-ref-"); + const cfg = ciVerifyConfig(); + try { + writeFileSync(join(repo, "readme.txt"), "base\n"); + git(repo, ["add", "readme.txt"]); + git(repo, ["commit", "-m", "base"]); + + git(repo, ["checkout", "-b", "feat"]); + writeFileSync(join(repo, "feat.txt"), "pr\n"); + git(repo, ["add", "feat.txt"]); + const fromOid = git(repo, ["rev-parse", "main"]); + const passHash = computeRangeDiffContext(repo, cfg, fromOid).diffHash; + git(repo, [ + "commit", + "-m", + `feat\n\nKnow-Code-Verified: ${passHash}\n`, + ]); + const headOid = git(repo, ["rev-parse", "HEAD"]); + + git(repo, ["checkout", "main"]); + writeFileSync(join(repo, "unrelated.txt"), "main\n"); + git(repo, ["add", "unrelated.txt"]); + git(repo, ["commit", "-m", "main moves"]); + const baseOid = git(repo, ["rev-parse", "HEAD"]); + git(repo, [ + "merge", + "--no-ff", + "feat", + "-m", + githubPullMergeRefMessage(headOid, baseOid), + ]); + + const parents = parentOids(repo); + assert.equal(parents.length, 2); + assert.equal(parents[0], baseOid); + assert.equal(parents[1], headOid); + const headMsg = git(repo, ["log", "-1", "--format=%B", "HEAD"]); + assert.equal(trailerHashFromMessage(headMsg), null); + pinOriginMain(repo, baseOid); + + const result = runVerify(repo, {}); + assert.equal(result.ok, true, result.errors.join("\n")); + assert.equal(result.matched?.label, "merge-base..HEAD"); + assert.match(result.messages.join("\n"), /verified \(range/); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + it("verify matches GitHub squash of a single-commit PR (COMMIT_OR_PR_TITLE)", () => { + const repo = initLab("kc-verify-gh-squash-single-"); + const cfg = ciVerifyConfig(); + try { + writeFileSync(join(repo, "readme.txt"), "base\n"); + git(repo, ["add", "readme.txt"]); + git(repo, ["commit", "-m", "base"]); + + git(repo, ["checkout", "-b", "feat/squash-single"]); + writeFileSync(join(repo, "squash-single.txt"), "one\n"); + git(repo, ["add", "squash-single.txt"]); + const originalMain = git(repo, ["rev-parse", "main"]); + const passHash = computeRangeDiffContext(repo, cfg, originalMain).diffHash; + git(repo, [ + "commit", + "-m", + `squash single\n\nKnow-Code-Verified: ${passHash}\n`, + ]); + + git(repo, ["checkout", "main"]); + writeFileSync(join(repo, "unrelated.txt"), "main\n"); + git(repo, ["add", "unrelated.txt"]); + git(repo, ["commit", "-m", "main moves"]); + const movedMain = git(repo, ["rev-parse", "HEAD"]); + + git(repo, ["checkout", "feat/squash-single"]); + pinOriginMain(repo, movedMain); + const onPr = runVerify(repo, {}); + assert.equal(onPr.ok, true, onPr.errors.join("\n")); + + git(repo, ["checkout", "-B", "squash-land", movedMain]); + git(repo, ["merge", "--squash", "feat/squash-single"]); + git(repo, [ + "commit", + "-m", + githubSquashSingleMessage("squash single", 1, passHash), + ]); + assert.equal(parentOids(repo).length, 1); + const landingMsg = git(repo, ["log", "-1", "--format=%B", "HEAD"]); + assert.equal(trailerHashFromMessage(landingMsg), passHash); + pinOriginMain(repo, movedMain); + const vsParent = runVerify(repo, {}); + assert.equal(vsParent.ok, true, vsParent.errors.join("\n")); + assert.equal(vsParent.matched?.label, "merge-base..HEAD"); + + const landed = git(repo, ["rev-parse", "HEAD"]); + pinOriginMain(repo, landed); + const onMain = runVerify(repo, {}); + assert.equal(onMain.ok, false); + assert.match(onMain.messages.join("\n"), /on base tip/); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + it("verify matches GitHub squash of a multi-commit PR (COMMIT_MESSAGES + hoisted trailer)", () => { + const repo = initLab("kc-verify-gh-squash-multi-"); + const cfg = ciVerifyConfig(); + try { + writeFileSync(join(repo, "readme.txt"), "base\n"); + git(repo, ["add", "readme.txt"]); + git(repo, ["commit", "-m", "base"]); + const originalMain = git(repo, ["rev-parse", "HEAD"]); + + git(repo, ["checkout", "-b", "feat/squash-multi"]); + writeFileSync(join(repo, "squash-a.txt"), "a\n"); + git(repo, ["add", "squash-a.txt"]); + git(repo, ["commit", "-m", "wip"]); + writeFileSync(join(repo, "squash-b.txt"), "b\n"); + git(repo, ["add", "squash-b.txt"]); + const passHash = computeRangeDiffContext(repo, cfg, originalMain).diffHash; + git(repo, [ + "commit", + "-m", + `squash multi tip\n\nKnow-Code-Verified: ${passHash}\n`, + ]); + + git(repo, ["checkout", "main"]); + writeFileSync(join(repo, "unrelated.txt"), "main\n"); + git(repo, ["add", "unrelated.txt"]); + git(repo, ["commit", "-m", "main moves"]); + const movedMain = git(repo, ["rev-parse", "HEAD"]); + + git(repo, ["checkout", "feat/squash-multi"]); + pinOriginMain(repo, movedMain); + const onPr = runVerify(repo, {}); + assert.equal(onPr.ok, true, onPr.errors.join("\n")); + + git(repo, ["checkout", "-B", "squash-land", movedMain]); + git(repo, ["merge", "--squash", "feat/squash-multi"]); + git(repo, [ + "commit", + "-m", + githubSquashMultiMessage( + "squash multi commit", + 2, + ["wip", "squash multi tip"], + passHash, + ), + ]); + assert.equal(parentOids(repo).length, 1); + const landingMsg = git(repo, ["log", "-1", "--format=%B", "HEAD"]); + assert.match(landingMsg, /^\* wip$/m); + assert.match(landingMsg, /^---------$/m); + assert.equal(trailerHashFromMessage(landingMsg), passHash); + pinOriginMain(repo, movedMain); + const vsParent = runVerify(repo, {}); + assert.equal(vsParent.ok, true, vsParent.errors.join("\n")); + const headMatch = matchHeadTrailer( + repo, + "HEAD", + collectVerifyHashCandidates(repo, cfg), + ); + assert.ok(headMatch); + assert.equal(headMatch!.hash, passHash); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + it("verify still matches after GitHub rebase-and-merge onto an unrelated main update", () => { + const repo = initLab("kc-verify-gh-rebase-"); + const cfg = ciVerifyConfig(); + try { + writeFileSync(join(repo, "readme.txt"), "base\n"); + git(repo, ["add", "readme.txt"]); + git(repo, ["commit", "-m", "base"]); + + git(repo, ["checkout", "-b", "feat/rebase"]); + writeFileSync(join(repo, "rebase.txt"), "rebased\n"); + git(repo, ["add", "rebase.txt"]); + const fromOid = git(repo, ["rev-parse", "main"]); + const passHash = computeRangeDiffContext(repo, cfg, fromOid).diffHash; + git(repo, [ + "commit", + "-m", + `rebase feat\n\nKnow-Code-Verified: ${passHash}\n`, + ]); + + git(repo, ["checkout", "main"]); + writeFileSync(join(repo, "unrelated.txt"), "main\n"); + git(repo, ["add", "unrelated.txt"]); + git(repo, ["commit", "-m", "main moves"]); + const movedMain = git(repo, ["rev-parse", "HEAD"]); + git(repo, ["checkout", "feat/rebase"]); + git(repo, ["rebase", "main"]); + + assert.equal(parentOids(repo).length, 1, "rebased tip must not be a merge"); + const headMsg = git(repo, ["log", "-1", "--format=%B", "HEAD"]); + assert.equal(trailerHashFromMessage(headMsg), passHash); + pinOriginMain(repo, movedMain); + + const result = runVerify(repo, {}); + assert.equal(result.ok, true, result.errors.join("\n")); + const headMatch = matchHeadTrailer( + repo, + "HEAD", + collectVerifyHashCandidates(repo, cfg), + ); + assert.ok(headMatch); + assert.equal(headMatch!.hash, passHash); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); + + it("verify matches a GitHub merge-commit landing (MERGE_MESSAGE + PR_TITLE)", () => { + const repo = initLab("kc-verify-gh-merge-landing-"); + const cfg = ciVerifyConfig(); + try { + writeFileSync(join(repo, "readme.txt"), "base\n"); + git(repo, ["add", "readme.txt"]); + git(repo, ["commit", "-m", "base"]); + + git(repo, ["checkout", "-b", "feat/merge-update"]); + writeFileSync(join(repo, "merge-update.txt"), "merged\n"); + git(repo, ["add", "merge-update.txt"]); + const fromOid = git(repo, ["rev-parse", "main"]); + const passHash = computeRangeDiffContext(repo, cfg, fromOid).diffHash; + git(repo, [ + "commit", + "-m", + `merge-update feat\n\nKnow-Code-Verified: ${passHash}\n`, + ]); + + git(repo, ["checkout", "main"]); + writeFileSync(join(repo, "unrelated.txt"), "main\n"); + git(repo, ["add", "unrelated.txt"]); + git(repo, ["commit", "-m", "main moves"]); + git(repo, ["checkout", "feat/merge-update"]); + git(repo, ["merge", "main", "-m", "Merge branch 'main' into feat/merge-update"]); + const featHead = git(repo, ["rev-parse", "HEAD"]); + + git(repo, ["checkout", "main"]); + const mainBefore = git(repo, ["rev-parse", "HEAD"]); + git(repo, [ + "merge", + "--no-ff", + featHead, + "-m", + githubMergeLandingMessage(4, "feat/merge-update", "merge update branch"), + ]); + const parents = parentOids(repo); + assert.equal(parents.length, 2); + assert.equal(parents[0], mainBefore); + const landingMsg = git(repo, ["log", "-1", "--format=%B", "HEAD"]); + assert.equal(trailerHashFromMessage(landingMsg), null); + pinOriginMain(repo, mainBefore); + + const result = runVerify(repo, {}); + assert.equal(result.ok, true, result.errors.join("\n")); + assert.equal(result.matched?.label, "merge-base..HEAD"); + assert.match(result.messages.join("\n"), /verified \(range/); + } finally { + rmSync(repo, { recursive: true, force: true }); + } + }); }); diff --git a/scripts/smoke-verify-ci.sh b/scripts/smoke-verify-ci.sh index 4e697a5..afcaf2b 100755 --- a/scripts/smoke-verify-ci.sh +++ b/scripts/smoke-verify-ci.sh @@ -98,6 +98,15 @@ echo "$OUT" test "$code" -eq 0 echo "$OUT" | grep -q "HEAD trailer verified" +BEFORE="$(git rev-parse HEAD^)" +set +e +WALK="$(node "$KC" verify --from "$BEFORE" 2>&1)" +walk=$? +set -e +echo "$WALK" +test "$walk" -eq 0 +echo "$WALK" | grep -q "push walk verified" + # Negative: fake trailer must fail. git -c commit.gpgsign=false commit --amend --no-verify -m "$(cat <` | Push walk: previous tip (`github.event.before`). Omit on PRs. | +| `--require-all` | Stricter missing-trailer messaging (PR path) | +| `--require-range-trailers` | Every commit in merge-base..HEAD shares the same trailer (rewrite / PR) | +| `--range-seal` | Check local signed `range-seal.json` (not used in CI) | + +`--from` without a SHA is an error. All-zeros SHA skips the walk (new branch). Details: [Verification design](verify.md). + ## Environment | Variable | Meaning | diff --git a/website/docs/how-it-works.md b/website/docs/how-it-works.md index fcb83bb..9c2d7e6 100644 --- a/website/docs/how-it-works.md +++ b/website/docs/how-it-works.md @@ -84,7 +84,7 @@ flowchart TB | **Agent shell hooks** | Agent tries `git commit`, `git add`, `git merge`, … | Deny bypass patterns; run `know-code check` for allowed paths | | **Git pre-commit** | Any `git commit` / `know-code commit` | Gate open, trailer grounded, tree matches `gatedTreeOid` | | **Git pre-push** | `git push` | Trailer on HEAD, tree still matches gate | -| **CI `verify`** | Pull request / push to main | `Know-Code-Verified` hash matches computed diff | +| **CI `verify`** | Pull request / push to main | PR: trailer matches merge-base..HEAD (or index). Push: `--from` previous tip, per-run tree-pair | If commit is blocked after you passed, run `know-code status` — usually the diff changed (new edits, unstaged files, or legacy gate without `gatedTreeOid`). @@ -110,7 +110,7 @@ know-code config --json # shows active scope | **tipHash** | Current `know-code hash` (may differ after commits) | | **trailerHash** | Value in `Know-Code-Verified:` on commit messages | -**Tree-stable tip:** after `pass`, the agent may land several commits. With the tree-canonical formula, `tipHash` matches `passHash` while the gated tree is unchanged. The gate stays open via `gatedTreeOid` until you change staged content or the working tree. `commitDrift` is for legacy/mismatched gates — not what CI uses. +**Tree-stable tip:** after `pass`, the agent may land several commits. With the tree-canonical formula, `tipHash` matches `passHash` while the gated tree is unchanged. The same formula is why CI still matches after you merge or rebase onto an unrelated `main` update. The gate stays open via `gatedTreeOid` until you change staged content or the working tree. `commitDrift` is for legacy/mismatched gates — not what CI uses. ```mermaid flowchart LR diff --git a/website/docs/troubleshooting.md b/website/docs/troubleshooting.md index b77d11e..2e28f4e 100644 --- a/website/docs/troubleshooting.md +++ b/website/docs/troubleshooting.md @@ -73,7 +73,40 @@ If `npx skills add` fails, install manually from [skills.md](skills.md) or clone ## Stale seals after rebase / pull -Hash changes invalidate receipts. Run `know-code status --json` to see blockers. Re-run the pipeline from `taught` or `know-code reset` to clear artifacts. +Local `.know-code` receipts (taught / grade / gate / range-seal) are keyed to a diff hash. Rebasing or pulling **feature file** changes invalidates them. Run `know-code status --json` and re-run from `taught`, or `know-code reset`. + +Rebasing onto an **unrelated** `main` update does **not** change the tree-canonical range hash. CI `verify` should still match the existing trailer. If local seals look stale but `know-code hash` is unchanged, you do not need a new quiz — only CI cares about the trailer. + +## CI: merged `main` into the PR (Update branch) + +HEAD is `Merge branch 'main' into …` with no `Know-Code-Verified`. That is expected. Verify scans trailers in `merge-base..HEAD`. It fails only if the trailer is an old (pre–tree-canonical) hash, or the merge changed the feature patch. Restamp with `know-code commit` after a new pass if the tree changed. + +## CI: rebased onto updated `main` + +Author rebase, then push. Replayed commits keep the trailer text; new SHAs are fine. Verify should pass. If it fails, the rebase resolved conflicts in feature files — re-pass. + +GitHub **rebase and merge** is the same shape after the fact; CI already ran on the PR tip. + +## CI: squash and merge + +Verify ran on the PR, then again on **push** for the squash commit (`--from` the previous main tip). A red check after the squash lands is this push job if the landing commit has no grounded trailer (`PR_TITLE` + `BLANK` drops it). Message `trailers: skipped full-history scan (on base tip)` means someone ran bare `know-code verify` on the default branch without `--from`. + +## CI: `on base tip` / no matching trailer on `main` + +Bare `know-code verify` on the base branch (zero commits ahead of `origin/main`) has no range. The push job must pass `--from` (previous tip). Locally: `know-code verify --from HEAD^` after a single landing. + +## CI: push walk failed + +```text +commit abc has no Know-Code-Verified trailer +run N … does not match tree pair +--from is not an ancestor of HEAD +``` + +- **No trailer on a linear commit:** receipt-mode range (tip only) landed via merge or rebase-and-merge. Squash instead, or `range seal --rewrite` so every commit carries the tip hash. The trailer must start at column 0 (GitHub’s default squash hoist does; local `git merge --squash` indents the body). +- **Trailer does not match the tree-pair:** the landing tree changed, or you expected one hash for a stacked push. Each run is hashed separately; the combined `before..HEAD` patch is not a candidate. A second push in the **same** range session is OK: the walker also tries the tip against ancestors of the previous landing (the original `range begin`). A GitHub merge commit after `main` moved is hashed to the feature tip (last non-merge), not the merge tree — if that still fails, the merge edited feature files. +- **Not an ancestor:** the push rewrote history (`before` is not in `HEAD`’s ancestry). Fail closed. +- **All-zeros `before`:** new branch — the job skips the walk on purpose. ## Gate open but `range seal` blocked (pre-0.2.1) @@ -139,12 +172,14 @@ The shell hook only gates the **parsed** `command` field — not incidental text ## CI failed: no matching trailer +HEAD has no grounded `Know-Code-Verified`, or the trailer does not match `merge-base..HEAD` / index. Typical causes: files changed after `pass`, a pre–tree-canonical trailer after merging `main`, or checkout of the ephemeral merge commit without the range fallback finding a feature trailer. + ```bash know-code hash know-code commit -m "your message" ``` -Amending without changing the tree keeps the same hash; changing files requires a new quiz. +Amending without changing the tree keeps the same hash; changing files requires a new quiz. See [Verification design](verify.md#github-merge-methods) for merge-button behavior. ## Quiz timed out diff --git a/website/docs/verify.md b/website/docs/verify.md index 0d0c50f..def4da7 100644 --- a/website/docs/verify.md +++ b/website/docs/verify.md @@ -7,6 +7,22 @@ title: Verification design This page is the contract for **`know-code verify`** — what CI can prove, how hashes are computed, and how to reproduce CI locally. For the broader product loop see [How it works](how-it-works.md). For what local gates *cannot* guarantee, see the repo’s [threat model](https://github.com/chtnnh/know-code/blob/main/security/threat-model.md) (internal). +## Two jobs + +| Job | When | Command | What it proves | +|-----|------|---------|----------------| +| **PR** | `pull_request` | `know-code verify` | The PR tip (or an ancestor) carries a trailer that matches merge-base → tree | +| **Push** | `push` to the base branch | `know-code verify --from` previous tip | Each landed **run** in `before..HEAD` matches its trailer | + +Without `--from`, a checkout of new `main` has `aheadCount` 0 (`on base tip`) and range verify cannot match. That is why the push job always passes `github.event.before`. All-zeros `before` (new branch) skips the walk. + +Locally: + +```bash +know-code verify # PR-shaped: you are ahead of origin/main +know-code verify --from HEAD^ # push-shaped: one landing on the base +``` + ## Threat boundary ```mermaid @@ -34,12 +50,13 @@ flowchart LR | Artifact | Trusted in CI? | Why | |----------|----------------|-----| -| Trailers on the PR tip | **yes** | Public commit objects | -| `merge-base(origin/base, HEAD)` → index tree hash | **yes** | Recomputed on the runner | +| Trailers on the PR tip / landing commits | **yes** | Public commit objects | +| `merge-base(origin/base, HEAD)` → index tree hash | **yes** | Recomputed on the runner (PR job) | +| Historical tree-pair (`--from` walk) | **yes** | Recomputed from commit trees (push job) | | `.know-code/gate.json`, `range-seal.json` | **no** | Gitignored; agent-writable | | Quiz score / taught seals | **no** | Local attestation only | -**Honest claim:** CI proves “this tip carries a trailer that matches a grounded hash of the tree ahead of base.” It does **not** prove a human understood the diff, and it does not stop a same-UID agent from forging local seals. +**Honest claim:** CI proves “this tip (PR) or each landed run (push) carries a trailer that matches a grounded tree hash.” It does **not** prove a human understood the diff, and it does not stop a same-UID agent from forging local seals. ## Hash formulas @@ -59,8 +76,18 @@ sha256("diff:" + git diff FROM_TREE INDEX_TREE) **Tree-canonical:** the same resulting tree hashes the same whether the delta is still staged or already committed. That is required for receipt-mode CI: `know-code commit` stamps the pass-time hash, and CI must recompute that hash from history alone (no `staged:` material, no local seal). +The formula is a **patch between two trees**. Unrelated files that exist on both sides of the range cancel out. That is why a trailer stamped against old `main` still matches after you merge or rebase onto an unrelated `main` update — as long as the feature patch itself did not change. + Sliced pathspec commits keep the same range hash while the index tree still equals `gatedTreeOid` from pass. +### Push walk (historical, no write-tree) + +```text +sha256("diff:" + git diff FROM_TREE TO_TREE) +``` + +`FROM_TREE` / `TO_TREE` are the trees of the run start parent and the **last non-merge** in the run (the feature tip the trailer was stamped on). Attached trailerless merges stay in the run but are not the hash tip — otherwise a GitHub merge commit after `main` moved would include unrelated mainline files. A dirty index cannot change this. A run with exactly one non-merge commit (optional trailerless merges attached) also accepts the empty-tree → last-non-merge hash. + ## What `verify` accepts `collectVerifyHashCandidates` builds grounded hashes only (never “whatever string is on HEAD”): @@ -71,20 +98,72 @@ Sliced pathspec commits keep the same range hash while the index tree still equa 4. **range-seal** / **range-seal-pass** — only when local seal files exist and `HEAD === sealedHeadOid` (**not** available in CI) 5. **commit-drift** — local only, when a legacy/mismatched gate hash still matches a stable gated tree -Match order: HEAD trailer against candidates; if missing, scan trailers in `merge-base..HEAD` (squash-friendly PR branches). +Match order: HEAD trailer against candidates; if missing, scan trailers in `merge-base..HEAD` (PR branches whose tip is a merge commit, or a squash-bound branch whose trailer sits on an ancestor). If HEAD **is** the base tip (`aheadCount` is 0), that scan is skipped — there is no range to recompute. That is the **PR** path (`know-code verify` with no `--from`). + +**Push path:** `know-code verify --from ` (CI passes `github.event.before`) walks `from..HEAD` and does **not** use merge-base resolution. See [Push walk](#push-walk). ### Receipt vs rewrite -| Mode | Trailer on commits | CI needs | -|------|--------------------|----------| -| **receipt** (default here after tree-canonical hash) | Pass-time hash from `know-code commit` | Tip trailer ∈ grounded candidates | -| **rewrite** | `range seal --rewrite` stamps tip hash on every commit | Same; use `--require-range-trailers` if every commit must match | +| Mode | Trailer on commits | PR job | Push job | +|------|--------------------|--------|----------| +| **receipt** (default here after tree-canonical hash) | Pass-time hash from `know-code commit` on the **tip** | Tip (or ancestor) trailer ∈ grounded candidates | Every **non-merge** in `before..HEAD` needs a trailer. GitHub **squash** (one landing) passes; a merge-commit of a tip-only PR fails | +| **rewrite** | `range seal --rewrite` stamps tip hash on every commit | Same; `--require-range-trailers` if you want that enforced on the PR | One run; tree-pair is parent-of-first → last non-merge (attached merges ignored for the hash) | + +## GitHub merge methods + +The **PR** job checks out the PR tip (`head.sha`). Default Actions checkout of `github.sha` on `pull_request` is the ephemeral `pull/N/merge` ref (a merge commit with no trailer); the workflow pins `head.sha` so HEAD is the tip that usually carries `Know-Code-Verified`. + +The **push** job (base branch) checks out the new tip and runs `know-code verify --from` with `github.event.before` — that **is** the landing commit. See [Push walk](#push-walk). + +The tree-canonical range hash plus the `merge-base..HEAD` trailer scan are what make every GitHub merge button work **on the PR**, without restamping after `main` moves. + +| What you did | What CI checks out | Trailer on HEAD? | Why verify matches | +| --- | --- | --- | --- | +| Ordinary PR tip | Feature commit(s) | yes | HEAD trailer equals the merge-base..HEAD hash | +| **Update branch** (merge `main` into the PR) | Merge commit, message like `Merge branch 'main' into feat` | **no** | Range scan finds the feature trailer; hash is unchanged if the feature patch is unchanged | +| Default Actions github.sha (`pull/N/merge`) | Merge commit, message like `Merge abc into def`, first parent = base | **no** | Same range scan (workflow avoids this checkout) | +| **Squash and merge** | The PR **before** squash | yes (on the tip, or an ancestor) | Landing commit is verified on **push** (`--from`), not by this PR job | +| **Rebase and merge** | PR tip (after an author rebase: replayed commits, new SHAs, original messages) | yes | Replay keeps the trailer text; hash vs the new merge-base still matches | +| **Create a merge commit** | PR tip (possibly already a merge if you updated the branch) | maybe | Range fallback if the merge commit has no trailer | + +GitHub’s default squash preset (`COMMIT_OR_PR_TITLE` + `COMMIT_MESSAGES`) usually **hoists** a column-0 `Know-Code-Verified` onto the squash landing commit (1-commit PRs keep the original body; 2+ commit PRs list `* subject` bullets, then the trailer, then `---------` / `Co-authored-by`). The PR job does not depend on that hoist; the **push** walker does (the landing commit is the run). + +`PR_TITLE` + `BLANK` squash drops the trailer on the landing commit. The PR job still passes. The push job **fails** unless some other commit in `before..HEAD` carries a matching trailer. + +## Assumptions + +- CI config has `requireTrailer: true` and `baseBranch` matching the default branch. +- The runner has `origin/main` (full fetch). Merge-base resolution prefers `origin/main` over local `main`. +- The feature patch did not change when `main` moved (no conflict resolution that edits feature files). +- Receipt mode: at least one commit in `merge-base..HEAD` carries a grounded trailer. `--require-range-trailers` is opt-in for rewrite teams. +- Bare `know-code verify` (no `--from`) on the base tip prints `trailers: skipped full-history scan (on base tip)` and fails. That is why the push job always passes `--from`. +- Push walk is stricter than the PR scan: every **non-merge** commit in `before..HEAD` needs a trailer. Rewrite ranges and GitHub squash landings pass. A merge-commit landing of a tip-only (receipt) PR fails on push unless those commits were rewritten. + +Local tests that mimic the PR path pin a dummy `origin` remote and set `refs/remotes/origin/main` to the parent SHA. They do not call GitHub. + +## Push walk + +After a push to the base branch, `origin/main` **is** HEAD. There is no merge-base range. GitHub still provides the previous tip as `github.event.before` (all-zeros only for a new branch). + +`know-code verify --from `: + +1. Fail closed if `` is not a commit, or not an ancestor of HEAD (rewritten history / missing object). +2. Exit 0 if `` is HEAD (warns `--from` is HEAD) or the zero SHA (nothing to walk). +3. Walk `from..HEAD` oldest-first (`rev-list --reverse --topo-order`). +4. Split into **runs** that share the same `Know-Code-Verified` hash. Merge commits with no trailer **attach** to the current run. A linear commit with no trailer **fails**. A merge with no current run **fails**. +5. Each run hashes the parent-of-first tree against the **last non-merge** (`computeTreePairHash` — historical trees, not live `write-tree`). Trailerless merges attach to the run but are not the hash tip, so an outdated PR landed with “Create a merge commit” still matches. The trailer must match that pair, **or** the same feature tip against a first-parent ancestor of the run start (a range that began before the previous landing — second push in the same session). A run with **exactly one non-merge** commit also accepts the empty-tree (index) hash of that feature tip. + +Several landings in one push (stacked squashes) are **separate** runs. The combined `before..HEAD` patch is not a candidate — it would not match any per-range trailer. + +A dirty index cannot change `--from` results. ## Workflow checklist ```yaml on: - pull_request: # not push to base + pull_request: + push: + branches: [main] jobs: verify: @@ -92,15 +171,20 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha }} # not the merge commit + ref: ${{ github.event.pull_request.head.sha || github.sha }} # … install know-code … - run: | mkdir -p .know-code printf '{\n "level": "standard",\n "baseBranch": "main",\n "requireTrailer": true\n}\n' > .know-code/config.json - - run: know-code verify + - run: | + if [ "${{ github.event_name }}" = "push" ]; then + know-code verify --from "${{ github.event.before }}" + else + know-code verify + fi ``` -`know-code init --workflow` generates the `head.sha` checkout. The composite action writes `requireTrailer: true` when it creates config; the monorepo workflow writes it explicitly. +`know-code init --workflow` generates this shape (the composite action takes a `from` input). The action skips an all-zeros `from` (new branch). The monorepo workflow writes `requireTrailer: true` explicitly. ## Reproduce CI locally @@ -109,7 +193,7 @@ npm run build npm run smoke:verify ``` -`scripts/smoke-verify-ci.sh` runs a full range quiz → `know-code commit`, then **deletes** gate/seal/taught artifacts and asserts `know-code verify` still exits 0. A forged trailer must fail. +`scripts/smoke-verify-ci.sh` runs a full range quiz → `know-code commit`, then **deletes** gate/seal/taught artifacts and asserts `know-code verify` still exits 0, then `know-code verify --from HEAD^`. A forged trailer must fail. ## See also diff --git a/website/docs/workflows.md b/website/docs/workflows.md index 617c071..0b91ee6 100644 --- a/website/docs/workflows.md +++ b/website/docs/workflows.md @@ -87,7 +87,7 @@ Hooks gate `gh pr create` and `glab mr create`. Complete the quiz pipeline **bef | **receipt** | Trailer on the **tip** is enough for CI | Writes signed `range-seal.json` (local only; CI ignores it) | | **rewrite** | Every commit in the range must share the same trailer | `range seal --rewrite` rewrites messages + `git push --force-with-lease` | -This repo’s `know-code.yml` is **receipt**: `know-code verify` on the PR tip. Opt into `--require-range-trailers` only when you also rewrite. +This repo’s `know-code.yml` is **receipt** on the PR (tip trailer is enough) plus a **push walk** on `main` (`verify --from` previous tip). Opt into `--require-range-trailers` on the PR job only when you also rewrite. The push walker requires a trailer on every non-merge in `before..HEAD` — squash landings and rewrite ranges pass; a merge-commit of a tip-only receipt PR does not. ## When to `range abort`