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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,9 @@ jobs:
env:
KNOW_CODE_ATTEST_PASSPHRASE: ci-smoke-attest-passphrase
KNOW_CODE_ATTEST_HOME: ${{ runner.temp }}/know-code-attest

- name: CLI smoke (CI verify, no local seals)
run: npm run smoke:verify
env:
KNOW_CODE_ATTEST_PASSPHRASE: ci-smoke-verify-passphrase
KNOW_CODE_ATTEST_HOME: ${{ runner.temp }}/know-code-verify-attest
8 changes: 8 additions & 0 deletions .github/workflows/know-code.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ jobs:
- uses: actions/checkout@v4
with:
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 }}

- uses: actions/setup-node@v4
with:
Expand All @@ -26,5 +29,10 @@ jobs:
npm run build
npm install -g ./packages/cli

- name: Ensure requireTrailer for CI
run: |
mkdir -p .know-code
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
8 changes: 4 additions & 4 deletions action/action.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
name: know-code verify
description: >-
Verify Know-Code-Verified commit trailers. Primary check: HEAD message
matches the current tree hash (empty-tree→index). Secondary: trailers on
commits ahead of the base branch. Use on pull_request only — on a direct
push to the base branch there is no merge-base range to verify.
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.
author: chtnnh
branding:
icon: shield
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"build:docs": "npm run build -w website",
"test": "npm run test -w @chtnnh/know-code",
"smoke": "bash scripts/smoke-enforcement.sh",
"smoke:verify": "bash scripts/smoke-verify-ci.sh",
"link-skills": "node scripts/link-skills.mjs",
"sync-hooks": "node scripts/sync-hooks.mjs",
"know-code": "node packages/cli/dist/index.js",
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,8 @@ describe("commands: config / init / quiz / doctor / reset / ship", () => {
// 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/);
});

it("validateQuiz happy and sad", () => {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
ref: \${{ github.event.pull_request.head.sha }}

- uses: ${ACTION_REF}
with:
Expand Down
19 changes: 10 additions & 9 deletions packages/cli/src/gate-drift.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,22 +167,22 @@ describe("gate survives commit when tree unchanged (range drift)", () => {
...DEFAULT_CONFIG,
rangeMode: "range",
});
assert.notEqual(afterCommit.diffHash, hash);
// Tree-canonical range hash: staged-at-pass === committed tip.
assert.equal(afterCommit.diffHash, hash);
assert.equal(materializedTreeOid(repoRoot), gatedTreeOid);

const drift = resolveEffectiveQuizState(repoRoot);
assert.equal(drift.commitDrift, true);
assert.equal(drift.effectiveHash, hash);
const after = resolveEffectiveQuizState(repoRoot);
assert.equal(after.commitDrift, false);
assert.equal(after.effectiveHash, hash);
assert.equal(readGate(repoRoot)!.gatedTreeOid, gatedTreeOid);
assert.equal(runCheck(repoRoot).allowed, true);

const cfg = { ...DEFAULT_CONFIG, level: "lite" as const, rangeMode: "range" as const };
assert.equal(
isSignedGateOpen(repoRoot, readGate(repoRoot), afterCommit.diffHash, "lite"),
false,
true,
);
assert.equal(
isSignedGateEffective(repoRoot, readGate(repoRoot), drift, "lite"),
isSignedGateEffective(repoRoot, readGate(repoRoot), after, "lite"),
true,
);
});
Expand Down Expand Up @@ -280,7 +280,8 @@ describe("gate survives commit when tree unchanged (range drift)", () => {

assert.equal(materializedTreeOid(repoRoot), gatedTreeOid);
const mid = resolveEffectiveQuizState(repoRoot);
assert.equal(mid.commitDrift, true);
// Index tree still equals gated tree → range hash unchanged (no drift).
assert.equal(mid.commitDrift, false);
assert.equal(mid.effectiveHash, hash);
assert.equal(runCheck(repoRoot).allowed, true);

Expand All @@ -295,7 +296,7 @@ describe("gate survives commit when tree unchanged (range drift)", () => {

assert.equal(materializedTreeOid(repoRoot), gatedTreeOid);
const done = resolveEffectiveQuizState(repoRoot);
assert.equal(done.commitDrift, true);
assert.equal(done.commitDrift, false);
assert.equal(runCheck(repoRoot).allowed, true);
});
});
15 changes: 14 additions & 1 deletion packages/cli/src/git-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {
} from "./git-env.js";
import { hasStagedChanges, knowCodeGitEnv } from "./git.js";
import { materializedTreeOid } from "./gate.js";
import { computeDiffContext, computeRangeDiffContext } from "./hash.js";
import { git, withTempRepo, writeFile } from "./test-helpers.js";
import { DEFAULT_CONFIG } from "./types.js";

describe("git-env", () => {
it("configValueBypassesHooks detects hooksPath and include.path", () => {
Expand Down Expand Up @@ -81,12 +83,23 @@ describe("git-env", () => {
assert.equal(materializedTreeOid(root), fullTree);
assert.equal(hasStagedChanges(root), true);

const cfg = { ...DEFAULT_CONFIG, level: "lite" as const };
const fromOid = git(root, ["rev-parse", "HEAD"]);
const rangeHash = computeRangeDiffContext(root, cfg, fromOid).diffHash;
const indexHash = computeDiffContext(root, cfg).diffHash;

// During the commit git also holds .git/index.lock — write-tree must
// still resolve the real tree (temp-copy fallback).
// still resolve the real tree (temp-copy fallback). Hashing must too:
// raw write-tree + EMPTY_TREE fallback would mint a wrong range/index hash.
const lockPath = join(root, ".git", "index.lock");
execFileSync("touch", [lockPath]);
try {
assert.equal(materializedTreeOid(root), fullTree);
assert.equal(
computeRangeDiffContext(root, cfg, fromOid).diffHash,
rangeHash,
);
assert.equal(computeDiffContext(root, cfg).diffHash, indexHash);
} finally {
execFileSync("rm", ["-f", lockPath]);
}
Expand Down
29 changes: 19 additions & 10 deletions packages/cli/src/hash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { readConfig } from "./config.js";
import {
currentHead,
git,
indexTreeOid,
mergeBase,
resolveBaseRef,
revListCount,
Expand Down Expand Up @@ -36,8 +37,7 @@ export function computeDiffContext(
rangeFrom = committed.trim() ? mb : EMPTY_TREE;
}

const indexTree =
git(["write-tree"], repoRoot, { allowFail: true }) || EMPTY_TREE;
const indexTree = indexTreeOid(repoRoot) || EMPTY_TREE;
const diff = git(["diff", EMPTY_TREE, indexTree], repoRoot, {
allowFail: true,
});
Expand All @@ -51,7 +51,14 @@ export function computeDiffContext(
};
}

/** Cumulative hash for fromOid..HEAD plus staged changes. */
/**
* Cumulative hash for fromOid → current index tree (HEAD + staged).
*
* Tree-canonical on purpose: the same resulting tree must hash the same whether
* the delta is still staged or already committed. Otherwise CI `verify` (no
* local range-seal / gate.json) cannot match a pass-time trailer after
* `know-code commit` lands the quiz tree — the classic dogfood gap.
*/
export function computeRangeDiffContext(
repoRoot: string,
config: Config,
Expand All @@ -62,20 +69,22 @@ export function computeRangeDiffContext(
const headLabel = headRef === EMPTY_TREE ? "HEAD" : headRef;
const commitCount = revListCount(repoRoot, fromOid, headLabel);

const rangeDiff = git(["diff", `${fromOid}...HEAD`], repoRoot, {
const fromTree =
fromOid === EMPTY_TREE
? EMPTY_TREE
: git(["rev-parse", `${fromOid}^{tree}`], repoRoot, { allowFail: true }) ||
EMPTY_TREE;
const indexTree = indexTreeOid(repoRoot) || EMPTY_TREE;
const diff = git(["diff", fromTree, indexTree], repoRoot, {
Comment thread
cursor[bot] marked this conversation as resolved.
allowFail: true,
});
const staged = git(["diff", "--cached"], repoRoot, { allowFail: true });
const material = staged.trim()
? `diff:${rangeDiff}\nstaged:${staged}`
: `diff:${rangeDiff}`;

return {
baseRef,
headRef: headRef === EMPTY_TREE ? EMPTY_TREE : headRef,
commitRange: `${fromOid}..${headLabel}`,
diff: staged.trim() ? `${rangeDiff}\n---staged---\n${staged}` : rangeDiff,
diffHash: sha256(material),
diff,
diffHash: sha256(`diff:${diff}`),
scope: "range",
rangeFromOid: fromOid,
commitCount,
Expand Down
73 changes: 68 additions & 5 deletions packages/cli/src/verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ describe("verify hash candidates", () => {
}
});

it("accepts commit-drift passHash only with grounded gate + gatedTreeOid", () => {
const repo = mkdtempSync(join(tmpdir(), "kc-verify-drift-"));
it("pass-time range trailer matches merge-base..HEAD without commit-drift", () => {
const repo = mkdtempSync(join(tmpdir(), "kc-verify-stable-pass-"));
try {
git(repo, ["init", "-b", "main", "--template="]);
git(repo, ["config", "user.email", "t@test"]);
Expand Down Expand Up @@ -108,9 +108,14 @@ describe("verify hash candidates", () => {
]);

const candidates = collectVerifyHashCandidates(repo, cfg);
const drift = candidates.find((c) => c.label === "commit-drift");
assert.ok(drift);
assert.equal(drift!.hash, passHash);
// Tree-canonical: no commit-drift needed — tip hash equals pass hash.
assert.equal(
candidates.find((c) => c.label === "commit-drift"),
undefined,
);
const range = candidates.find((c) => c.label === "merge-base..HEAD");
assert.ok(range);
assert.equal(range!.hash, passHash);
const match = matchHeadTrailer(repo, "HEAD", candidates);
assert.ok(match);
assert.equal(match!.hash, passHash);
Expand Down Expand Up @@ -345,4 +350,62 @@ describe("verify hash candidates", () => {
rmSync(repo, { recursive: true, force: true });
}
});

it("range hash is stable across staged → committed (CI dogfood)", () => {
const repo = mkdtempSync(join(tmpdir(), "kc-verify-stable-"));
try {
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 });
writeFileSync(
join(repo, ".know-code", "config.json"),
JSON.stringify({
...DEFAULT_CONFIG,
level: "lite",
rangeMode: "range",
}),
);
writeFileSync(join(repo, "f.txt"), "base\n");
git(repo, ["add", "f.txt"]);
git(repo, ["commit", "-m", "base"]);
const fromOid = git(repo, ["rev-parse", "HEAD"]);

writeFileSync(join(repo, "g.txt"), "feature\n");
git(repo, ["add", "g.txt"]);
const stagedHash = computeRangeDiffContext(
repo,
{ ...DEFAULT_CONFIG, level: "lite", rangeMode: "range" },
fromOid,
).diffHash;

git(repo, [
"commit",
"-m",
`feat\n\nKnow-Code-Verified: ${stagedHash}\n`,
]);

const committedHash = computeRangeDiffContext(
repo,
{ ...DEFAULT_CONFIG, level: "lite", rangeMode: "range" },
fromOid,
).diffHash;
assert.equal(committedHash, stagedHash);

// CI has no seal artifacts — only grounded candidates.
const candidates = collectVerifyHashCandidates(repo, {
...DEFAULT_CONFIG,
level: "lite",
});
assert.ok(
candidates.some((c) => c.hash === stagedHash),
"pass-time trailer must be among CI verify candidates",
);
const match = matchHeadTrailer(repo, "HEAD", candidates);
assert.ok(match);
assert.equal(match!.hash, stagedHash);
} finally {
rmSync(repo, { recursive: true, force: true });
}
});
});
Loading
Loading