diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e80ba46..61e2dc4 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -5,9 +5,6 @@ name: docs on: push: branches: [main] - paths: - - "website/**" - - ".github/workflows/docs.yml" workflow_dispatch: permissions: @@ -15,13 +12,54 @@ permissions: pages: write id-token: write -concurrency: - group: pages - cancel-in-progress: true - jobs: - build: + published-release: runs-on: ubuntu-latest + outputs: + ready: ${{ steps.release.outputs.ready }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - id: release + run: | + VERSION="$(node -p 'require("./website/versions.json")[0]')" + verify_provenance() { + npm view "@chtnnh/know-code@${VERSION}" dist.attestations --json >/tmp/know-code-attestations.json || return 1 + node -e 'const fs = require("node:fs"); const value = JSON.parse(fs.readFileSync(0, "utf8")); if (!value || typeof value !== "object" || !value.provenance || typeof value.provenance !== "object" || value.provenance.predicateType !== "https://slsa.dev/provenance/v1") process.exit(1)' < /tmp/know-code-attestations.json || return 1 + VERIFY_DIR="$(mktemp -d)" || return 1 + trap 'rm -rf "${VERIFY_DIR}"' RETURN + (cd "${VERIFY_DIR}" && npm init --yes >/dev/null) || return 1 + npm install --prefix "${VERIFY_DIR}" --ignore-scripts "@chtnnh/know-code@${VERSION}" || return 1 + npm audit signatures --prefix "${VERIFY_DIR}" --json || return 1 + } + if npm view "@chtnnh/know-code@${VERSION}" gitHead --json >/tmp/know-code-version.json 2>/tmp/know-code-version.err; then + PUBLISHED_GIT_HEAD="$(node -e 'const fs = require("node:fs"); const value = JSON.parse(fs.readFileSync(0, "utf8")); if (typeof value !== "string" || !/^[0-9a-f]{40}$/i.test(value)) process.exit(1); process.stdout.write(value)' < /tmp/know-code-version.json)" + verify_provenance + git fetch origin main --tags + TAG_GIT_HEAD="$(git rev-parse "v${VERSION}^{commit}")" + git merge-base --is-ancestor "${TAG_GIT_HEAD}" origin/main + [[ "${PUBLISHED_GIT_HEAD}" == "${TAG_GIT_HEAD}" ]] + node scripts/check-release-docs-provenance.mjs "v${VERSION}" "${GITHUB_SHA}" + echo "ready=true" >> "$GITHUB_OUTPUT" + elif grep -q "E404" /tmp/know-code-version.err; then + echo "ready=false" >> "$GITHUB_OUTPUT" + else + cat /tmp/know-code-version.err >&2 + exit 1 + fi + + deploy: + needs: published-release + if: needs.published-release.outputs.ready == 'true' + runs-on: ubuntu-latest + concurrency: + group: pages + cancel-in-progress: false + queue: max + environment: + name: github-pages steps: - uses: actions/checkout@v5 @@ -40,16 +78,17 @@ jobs: - name: Upload Pages artifact uses: actions/upload-pages-artifact@v5 with: + name: github-pages-${{ github.run_attempt }} path: website/build # v4+ excludes dotfiles by default; Docusaurus needs .nojekyll. include-hidden-files: true - deploy: - needs: build - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - id: deployment + - name: Confirm this is still main's latest commit + run: | + git fetch origin main + [[ "${GITHUB_SHA}" == "$(git rev-parse origin/main)" ]] + + - name: Deploy Pages uses: actions/deploy-pages@v5 + with: + artifact_name: github-pages-${{ github.run_attempt }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0406656..a9973dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,9 @@ name: release # Workflow filename: release.yml (filename only, not a path) # Environment: npm-release (must match job.environment) # No NPM_TOKEN / NODE_AUTH_TOKEN — those block OIDC. +# Protect v* tags from updates and deletion with a repository ruleset. The +# runtime checks below detect in-flight changes, but protection closes the +# unavoidable race after the final remote-tag fetch. on: push: tags: @@ -14,6 +17,7 @@ on: permissions: contents: write id-token: write # required for npm OIDC + pages: write jobs: publish: @@ -21,6 +25,8 @@ jobs: environment: npm-release steps: - uses: actions/checkout@v5 + with: + fetch-depth: 0 # Node 24 → npm ≥ 11.5.1 (required for trusted publishing). # Do NOT set registry-url: setup-node would inject a dummy NODE_AUTH_TOKEN @@ -35,20 +41,133 @@ jobs: - run: npm run build - run: npm test + - name: Verify release tag matches CLI package version + run: node scripts/check-release-tag.mjs + + - name: Verify main contains and declares this release + run: | + git fetch origin main + git merge-base --is-ancestor "${GITHUB_SHA}" origin/main + [[ "$(git show origin/main:website/versions.json | node -e 'let s=""; process.stdin.on("data", c => s += c).on("end", () => console.log(JSON.parse(s)[0]))')" == "${GITHUB_REF_NAME#v}" ]] + node scripts/check-release-docs-provenance.mjs "${GITHUB_SHA}" origin/main + + - name: Build frozen release docs + env: + UMAMI_WEBSITE_ID: ${{ vars.UMAMI_WEBSITE_ID }} + run: npm run build:docs + + - name: Reverify main immediately before publication + run: | + git fetch origin main + git merge-base --is-ancestor "${GITHUB_SHA}" origin/main + [[ "$(git show origin/main:website/versions.json | node -e 'let s=""; process.stdin.on("data", c => s += c).on("end", () => console.log(JSON.parse(s)[0]))')" == "${GITHUB_REF_NAME#v}" ]] + node scripts/check-release-docs-provenance.mjs "${GITHUB_SHA}" origin/main + git fetch origin "+refs/tags/${GITHUB_REF_NAME}:refs/know-code/release-tag" + [[ "$(git rev-parse refs/know-code/release-tag^{commit})" == "$(git rev-parse "${GITHUB_SHA}^{commit}")" ]] + - name: Publish CLI to npm (OIDC) working-directory: packages/cli run: | unset NODE_AUTH_TOKEN - npm publish --access public + PACKAGE_VERSION="$(npm pkg get version --workspaces=false | tr -d '"')" + verify_provenance() { + npm view "@chtnnh/know-code@${PACKAGE_VERSION}" dist.attestations --json >/tmp/know-code-attestations.json || return 1 + node -e 'const fs = require("node:fs"); const value = JSON.parse(fs.readFileSync(0, "utf8")); if (!value || typeof value !== "object" || !value.provenance || typeof value.provenance !== "object" || value.provenance.predicateType !== "https://slsa.dev/provenance/v1") process.exit(1)' < /tmp/know-code-attestations.json || return 1 + VERIFY_DIR="$(mktemp -d)" || return 1 + trap 'rm -rf "${VERIFY_DIR}"' RETURN + (cd "${VERIFY_DIR}" && npm init --yes >/dev/null) || return 1 + npm install --prefix "${VERIFY_DIR}" --ignore-scripts "@chtnnh/know-code@${PACKAGE_VERSION}" || return 1 + npm audit signatures --prefix "${VERIFY_DIR}" --json || return 1 + } + if npm view "@chtnnh/know-code@${PACKAGE_VERSION}" gitHead --json >/tmp/know-code-version.json 2>/tmp/know-code-version.err; then + PUBLISHED_GIT_HEAD="$(node -e 'const fs = require("node:fs"); const value = JSON.parse(fs.readFileSync(0, "utf8")); if (typeof value !== "string" || !/^[0-9a-f]{40}$/i.test(value)) process.exit(1); process.stdout.write(value)' < /tmp/know-code-version.json)" + EXPECTED_GIT_HEAD="$(git rev-parse "${GITHUB_SHA}^{commit}")" + if [[ "${PUBLISHED_GIT_HEAD}" != "${EXPECTED_GIT_HEAD}" ]]; then + echo "@chtnnh/know-code@${PACKAGE_VERSION} belongs to ${PUBLISHED_GIT_HEAD}, not ${EXPECTED_GIT_HEAD}." >&2 + exit 1 + fi + verify_provenance + echo "@chtnnh/know-code@${PACKAGE_VERSION} is already published from this tag; skipping npm publish." + elif grep -q "E404" /tmp/know-code-version.err; then + npm publish --provenance --access public + for attempt in {1..12}; do + if verify_provenance; then exit 0; fi + sleep 5 + done + echo "npm provenance did not become verifiable for @chtnnh/know-code@${PACKAGE_VERSION}." >&2 + exit 1 + else + cat /tmp/know-code-version.err >&2 + exit 1 + fi - name: Create GitHub Release env: GH_TOKEN: ${{ github.token }} run: | TAG="${GITHUB_REF_NAME}" + git fetch origin "+refs/tags/${TAG}:refs/know-code/release-tag" + [[ "$(git rev-parse refs/know-code/release-tag^{commit})" == "$(git rev-parse "${GITHUB_SHA}^{commit}")" ]] NOTES="Release ${TAG}. See CHANGELOG.md." if [[ -f CHANGELOG.md ]]; then NOTES="$(sed -n "/^## ${TAG#v}/,/^## /p" CHANGELOG.md | sed '$d' || true)" [[ -n "$NOTES" ]] || NOTES="Release ${TAG}. See CHANGELOG.md." fi - gh release create "$TAG" --title "$TAG" --notes "$NOTES" + if gh api --include "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" >/tmp/know-code-release.response 2>/tmp/know-code-release.err; then + gh release edit "$TAG" --title "$TAG" --notes "$NOTES" + elif head -n 1 /tmp/know-code-release.response | grep -Eq '^HTTP/[^ ]+ 404( |$)'; then + gh release create "$TAG" --verify-tag --title "$TAG" --notes "$NOTES" + else + cat /tmp/know-code-release.err >&2 + exit 1 + fi + + deploy-docs: + needs: publish + runs-on: ubuntu-latest + environment: + name: github-pages + concurrency: + group: pages + cancel-in-progress: false + queue: max + + steps: + - uses: actions/checkout@v5 + with: + ref: main + fetch-depth: 0 + + - uses: actions/setup-node@v5 + with: + node-version: "22" + cache: npm + + - name: Verify main still declares this release + run: | + [[ "$(node -p 'require("./website/versions.json")[0]')" == "${GITHUB_REF_NAME#v}" ]] + node scripts/check-release-docs-provenance.mjs "${GITHUB_REF_NAME}" HEAD + + - name: Build current docs + env: + UMAMI_WEBSITE_ID: ${{ vars.UMAMI_WEBSITE_ID }} + run: | + npm install + npm run build:docs + + - name: Upload current docs artifact + uses: actions/upload-pages-artifact@v5 + with: + name: github-pages-${{ github.run_attempt }} + path: website/build + include-hidden-files: true + + - name: Confirm main did not advance before deployment + run: | + git fetch origin main + [[ "$(git rev-parse HEAD)" == "$(git rev-parse origin/main)" ]] + + - name: Deploy frozen release docs + uses: actions/deploy-pages@v5 + with: + artifact_name: github-pages-${{ github.run_attempt }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 31da079..33013fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +## 0.3.1 + +### Fixes +- Fix the Umami proxy Worker’s script and event request forwarding, including a cache-versioned loader so updated upstream scripts do not depend on a manual Cloudflare cache purge. +- Make push verification walk stacked landed runs against their historical tree pairs, preserving grounded trailer verification after merge commits. +- Harden range-seal binding and status diagnostics so signed receipts stay tied to the correct head and explain stale state precisely. + +### Release safety +- Verify that the pushed `v…` tag exactly matches the CLI package version before npm publication or GitHub Release creation. + ### Umami proxy provision - **Deploy the Worker before binding `UMAMI_ORIGIN`.** wrangler-action’s `secrets:` input ran `secret bulk` first, which fails when the Worker does not exist yet — `wrangler.jsonc` still listed the `/s/*` route, so git looked provisioned while nothing was uploaded. CI now deploys, then `secret put`. - PRs that touch the proxy run unit tests + `wrangler deploy --dry-run`; only `main` / `workflow_dispatch` deploy. diff --git a/action/README.md b/action/README.md index 0bb584b..4860bb8 100644 --- a/action/README.md +++ b/action/README.md @@ -21,13 +21,13 @@ on: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha || github.sha }} -- uses: chtnnh/know-code/action@v0.3.0 +- uses: chtnnh/know-code/action@v0.3.1 with: base-branch: main from: ${{ github.event_name == 'push' && github.event.before || '' }} require-all: false require-range-trailers: false - version: "^0.3.0" + version: "^0.3.1" ``` All-zeros `github.event.before` (new branch) skips the walk. @@ -40,7 +40,7 @@ All-zeros `github.event.before` (new branch) skips the walk. | `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; PR path) | -| `version` | `^0.3.0` | npm version when not building from monorepo checkout | +| `version` | `^0.3.1` | npm version when not building from monorepo checkout | ## Quick add diff --git a/action/action.yml b/action/action.yml index 13e624b..dd7106b 100644 --- a/action/action.yml +++ b/action/action.yml @@ -32,7 +32,7 @@ inputs: version: description: npm version range for know-code when not building from this repo required: false - default: "^0.3.0" + default: "^0.3.1" runs: using: composite diff --git a/package-lock.json b/package-lock.json index 9bd76a5..c475c87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "know-code-monorepo", - "version": "0.3.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "know-code-monorepo", - "version": "0.3.0", + "version": "0.3.1", "license": "MIT", "workspaces": [ "packages/cli", @@ -20776,7 +20776,7 @@ }, "packages/cli": { "name": "@chtnnh/know-code", - "version": "0.3.0", + "version": "0.3.1", "license": "MIT", "bin": { "kc": "bin/know-code.js", diff --git a/package.json b/package.json index cb79208..34403b2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "know-code-monorepo", "private": true, - "version": "0.3.0", + "version": "0.3.1", "description": "k(no)w-code — agents don't push until you know exactly what's changed", "workspaces": [ "packages/cli", diff --git a/packages/cli/package.json b/packages/cli/package.json index 17f9e9a..f314903 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@chtnnh/know-code", - "version": "0.3.0", + "version": "0.3.1", "description": "Gate git push / PR creation until the human passes a comprehension quiz about the diff", "type": "module", "bin": { diff --git a/packages/cli/src/cli-surface.test.ts b/packages/cli/src/cli-surface.test.ts index e4ebd50..6a975c3 100644 --- a/packages/cli/src/cli-surface.test.ts +++ b/packages/cli/src/cli-surface.test.ts @@ -6,7 +6,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -36,6 +36,11 @@ import { } from "./test-helpers.js"; const CLI = join(dirname(fileURLToPath(import.meta.url)), "index.js"); +const CLI_VERSION = ( + JSON.parse( + readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../package.json"), "utf8"), + ) as { version: string } +).version; interface CliResult { status: number; @@ -72,13 +77,13 @@ function setupRepo(root: string, cfg = liteConfig()) { } describe("cli surface (spawned)", () => { - it("version matches the release", () => { + it("version matches the CLI package manifest", () => { const { root, cleanup } = withTempRepo("kc-cli-ver-"); try { setupRepo(root); const r = kc(root, ["version"]); assert.equal(r.status, 0); - assert.match(r.stdout, /0\.3\.0/); + assert.equal(r.stdout.trim(), CLI_VERSION); } finally { cleanup(); } diff --git a/packages/cli/src/commands-core.test.ts b/packages/cli/src/commands-core.test.ts index 3d2ad93..bfaf776 100644 --- a/packages/cli/src/commands-core.test.ts +++ b/packages/cli/src/commands-core.test.ts @@ -254,8 +254,14 @@ describe("commands: config / init / quiz / doctor / reset / ship", () => { it("consumerWorkflowYaml pins action, base branch, PR tip, and push walk", () => { const yml = consumerWorkflowYaml("develop"); + const packageVersion = JSON.parse( + readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../package.json"), "utf8"), + ) as { version: string }; assert.match(yml, /base-branch: develop/); - assert.match(yml, /chtnnh\/know-code\/action@v0\.3\.0/); + assert.match( + yml, + new RegExp(`chtnnh/know-code/action@v${packageVersion.version.replaceAll(".", "\\.")}`), + ); assert.match(yml, /actions\/checkout@v5/); assert.match(yml, /pull_request:/); assert.match(yml, /push:/); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 568f376..2013bf1 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -10,7 +10,7 @@ import { findGitRoot, knowCodeDir } from "../paths.js"; import { DEFAULT_CONFIG, isLevel, isRangeMode, isRangeSealMode, type Config } from "../types.js"; const DOCS = "https://kc.chtnnhfoundation.org"; -const ACTION_REF = "chtnnh/know-code/action@v0.3.0"; +const ACTION_REF = "chtnnh/know-code/action@v0.3.1"; export function consumerWorkflowYaml(baseBranch: string): string { // PR: checkout the tip (not pull/N/merge). Push: walk github.event.before..HEAD. diff --git a/packages/cli/src/release-contract.test.ts b/packages/cli/src/release-contract.test.ts new file mode 100644 index 0000000..848640a --- /dev/null +++ b/packages/cli/src/release-contract.test.ts @@ -0,0 +1,371 @@ +/** Release pins and publish preflight must agree on the shipped CLI version. */ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { describe, it } from "node:test"; +import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), "../../.."); + +function read(relativePath: string): string { + return readFileSync(join(root, relativePath), "utf8"); +} + +function runTagCheck(tag?: string) { + return spawnSync(process.execPath, ["scripts/check-release-tag.mjs"], { + cwd: root, + encoding: "utf8", + env: { ...process.env, GITHUB_REF_NAME: tag }, + }); +} + +function runDocsProvenanceCheck( + releaseRef: string, + deploymentRef: string, + cwd: string, + scriptPath = join(root, "scripts/check-release-docs-provenance.mjs"), +) { + return spawnSync( + process.execPath, + [scriptPath, releaseRef, deploymentRef], + { cwd, encoding: "utf8" }, + ); +} + +function git(cwd: string, ...args: 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", + }, + }); +} + +describe("release contract", () => { + it("keeps current release pins aligned and rejects a mismatched release tag", () => { + const rootPackage = JSON.parse(read("package.json")) as { version: string }; + const cliPackage = JSON.parse(read("packages/cli/package.json")) as { + version: string; + }; + const version = cliPackage.version; + + assert.match(version, /^\d+\.\d+\.\d+$/); + assert.equal(rootPackage.version, version); + const lockfile = JSON.parse(read("package-lock.json")) as { + version: string; + packages: Record; + }; + assert.equal(lockfile.version, version); + assert.equal(lockfile.packages[""].version, version); + assert.equal(lockfile.packages["packages/cli"].version, version); + assert.ok(read("action/action.yml").includes(`default: "^${version}"`)); + assert.ok(read("packages/cli/src/commands/init.ts").includes(`action@v${version}`)); + assert.ok(read("action/README.md").includes(`action@v${version}`)); + assert.ok(read("action/README.md").includes(`version: "^${version}"`)); + assert.ok(read("website/docs/ci.md").includes(`action@v${version}`)); + assert.ok(read("website/docs/ci.md").includes(`^${version}`)); + assert.ok(read(`website/versioned_docs/version-${version}/ci.md`).includes(`action@v${version}`)); + assert.equal(JSON.parse(read("website/versions.json"))[0], version); + assert.ok(read("CHANGELOG.md").includes(`## ${version}`)); + + assert.equal(runTagCheck(`v${version}`).status, 0); + const mismatch = runTagCheck("v0.0.0"); + assert.notEqual(mismatch.status, 0); + assert.match(mismatch.stderr, /does not match package version/); + + const workflow = read(".github/workflows/release.yml"); + assert.match(workflow, /Verify release tag matches CLI package version/); + assert.match(workflow, /node scripts\/check-release-tag\.mjs/); + assert.match(workflow, /Verify main contains and declares this release/); + assert.ok(workflow.indexOf("Verify main contains and declares this release") < workflow.indexOf("npm publish --provenance --access public")); + assert.match(workflow, /Reverify main immediately before publication/); + assert.ok(workflow.indexOf("Reverify main immediately before publication") < workflow.indexOf("npm publish --provenance --access public")); + assert.match(workflow, /git merge-base --is-ancestor "\$\{GITHUB_SHA\}" origin\/main/); + const frozenDocsGuard = `node scripts/check-release-docs-provenance.mjs "\${GITHUB_SHA}" origin/main`; + assert.equal(workflow.split(frozenDocsGuard).length - 1, 2); + assert.ok(workflow.indexOf("node scripts/check-release-tag.mjs") < workflow.indexOf("npm publish --provenance --access public")); + assert.match(workflow, /npm view "@chtnnh\/know-code@\$\{PACKAGE_VERSION\}" gitHead/); + assert.match(workflow, /EXPECTED_GIT_HEAD="\$\(git rev-parse "\$\{GITHUB_SHA\}\^\{commit\}"\)"/); + assert.match(workflow, /PUBLISHED_GIT_HEAD.*EXPECTED_GIT_HEAD/); + assert.match(workflow, /grep -q "E404" \/tmp\/know-code-version\.err/); + assert.match(workflow, /npm publish --provenance --access public/); + assert.match(workflow, /npm view "@chtnnh\/know-code@\$\{PACKAGE_VERSION\}" dist\.attestations --json/); + assert.match(workflow, /value\.provenance\.predicateType !== "https:\/\/slsa\.dev\/provenance\/v1"/); + assert.equal((workflow.match(/\|\| return 1/g) ?? []).length, 6); + assert.match(workflow, /\(cd "\$\{VERIFY_DIR\}" && npm init --yes/); + assert.match(workflow, /npm audit signatures --prefix "\$\{VERIFY_DIR\}" --json/); + assert.match(workflow, /for attempt in \{1\.\.12\}; do/); + assert.match(workflow, /gh api --include "repos\/\$\{GITHUB_REPOSITORY\}\/releases\/tags\/\$\{TAG\}"/); + assert.match(workflow, /head -n 1 \/tmp\/know-code-release\.response \| grep -Eq '\^HTTP\/\[\^ \]\+ 404\( \|\$\)'/); + assert.match(workflow, /gh release edit "\$TAG"/); + assert.match(workflow, /gh release create "\$TAG" --verify-tag/); + assert.equal( + workflow.split(`git fetch origin "+refs/tags/\${GITHUB_REF_NAME}:refs/know-code/release-tag"`).length - 1, + 1, + ); + assert.match(workflow, /git fetch origin "\+refs\/tags\/\$\{TAG\}:refs\/know-code\/release-tag"/); + const docsWorkflow = read(".github/workflows/docs.yml"); + assert.doesNotMatch(docsWorkflow, /^concurrency:/m); + assert.match(docsWorkflow, /npm view "@chtnnh\/know-code@\$\{VERSION\}" gitHead/); + assert.match(docsWorkflow, /npm view "@chtnnh\/know-code@\$\{VERSION\}" dist\.attestations --json/); + assert.match(docsWorkflow, /\(cd "\$\{VERIFY_DIR\}" && npm init --yes/); + assert.match(docsWorkflow, /npm audit signatures --prefix "\$\{VERIFY_DIR\}" --json/); + assert.match(docsWorkflow, /TAG_GIT_HEAD="\$\(git rev-parse "v\$\{VERSION\}\^\{commit\}"\)"/); + assert.match(docsWorkflow, /PUBLISHED_GIT_HEAD.*TAG_GIT_HEAD/); + assert.match(docsWorkflow, /deploy:[\s\S]*?concurrency:\n {6}group: pages\n {6}cancel-in-progress: false\n {6}queue: max/); + assert.match(workflow, /deploy-docs:[\s\S]*?concurrency:\n {6}group: pages\n {6}cancel-in-progress: false\n {6}queue: max/); + assert.doesNotMatch(workflow, /Upload frozen release docs artifact/); + assert.match(workflow, /name: github-pages-\$\{\{ github\.run_attempt \}\}/); + assert.match(workflow, /artifact_name: github-pages-\$\{\{ github\.run_attempt \}\}/); + assert.ok(workflow.indexOf("Upload current docs artifact") < workflow.indexOf("Confirm main did not advance before deployment")); + assert.ok(workflow.indexOf("Confirm main did not advance before deployment") < workflow.indexOf("Deploy frozen release docs")); + assert.doesNotMatch(docsWorkflow, /paths:/); + assert.match(docsWorkflow, /node scripts\/check-release-docs-provenance\.mjs "v\$\{VERSION\}" "\$\{GITHUB_SHA\}"/); + assert.match(workflow, /node scripts\/check-release-docs-provenance\.mjs "\$\{GITHUB_REF_NAME\}" HEAD/); + assert.match(workflow, /ref: main\n {10}fetch-depth: 0/); + }); + + it("rejects a later release that rewrites an older frozen docs snapshot", () => { + const fixture = mkdtempSync(join(tmpdir(), "kc-release-docs-")); + const scriptFixture = mkdtempSync(join(tmpdir(), "kc-release-provenance-")); + try { + git(fixture, "init", "-b", "main", "--template="); + git(fixture, "config", "user.email", "release-test@example.com"); + git(fixture, "config", "user.name", "Release Test"); + writeFileSync(join(fixture, "legacy.md"), "legacy docs before versioning\n"); + git(fixture, "add", "."); + git(fixture, "commit", "-m", "legacy release"); + git(fixture, "tag", "v0.3.0"); + mkdirSync(join(fixture, "packages/cli"), { recursive: true }); + mkdirSync(join(fixture, "website/versioned_docs/version-0.3.1"), { + recursive: true, + }); + mkdirSync(join(fixture, "website/versioned_docs/version-0.3.0"), { + recursive: true, + }); + mkdirSync(join(fixture, "website/versioned_docs/version-0.2.0"), { + recursive: true, + }); + mkdirSync(join(fixture, "website/versioned_sidebars"), { recursive: true }); + writeFileSync(join(fixture, "website/versioned_docs/version-0.3.0/old.md"), "old docs\n"); + writeFileSync(join(fixture, "website/versioned_docs/version-0.2.0/old.md"), "older docs\n"); + writeFileSync(join(fixture, "website/versioned_docs/version-0.3.1/new.md"), "new docs\n"); + writeFileSync(join(fixture, "website/versioned_sidebars/version-0.3.0-sidebars.json"), "{}\n"); + writeFileSync(join(fixture, "website/versioned_sidebars/version-0.2.0-sidebars.json"), "{}\n"); + writeFileSync(join(fixture, "website/versioned_sidebars/version-0.3.1-sidebars.json"), "{}\n"); + writeFileSync(join(fixture, "website/versions.json"), '["0.3.1", "0.3.0", "0.2.0"]\n'); + writeFileSync(join(fixture, "packages/cli/package.json"), '{"version":"0.3.1"}\n'); + git(fixture, "add", "."); + git(fixture, "commit", "-m", "first versioned release"); + git(fixture, "tag", "v0.3.1"); + const provenanceScript = join(scriptFixture, "check-release-docs-provenance.mjs"); + writeFileSync( + provenanceScript, + read("scripts/check-release-docs-provenance.mjs").replaceAll( + "ac656578a0667fc483d11847c1e6c97bf6c1aee6", + git(fixture, "rev-parse", "HEAD").trim(), + ), + ); + assert.equal(runDocsProvenanceCheck("v0.3.1", "HEAD", fixture, provenanceScript).status, 0); + + git(fixture, "checkout", "-b", "failed-release-tag"); + mkdirSync(join(fixture, "website/versioned_docs/version-0.9.0"), { + recursive: true, + }); + writeFileSync(join(fixture, "website/versioned_docs/version-0.9.0/new.md"), "failed docs\n"); + writeFileSync(join(fixture, "website/versioned_sidebars/version-0.9.0-sidebars.json"), "{}\n"); + writeFileSync(join(fixture, "website/versions.json"), '["0.9.0", "0.3.1", "0.3.0", "0.2.0"]\n'); + writeFileSync(join(fixture, "packages/cli/package.json"), '{"version":"0.9.0"}\n'); + git(fixture, "add", "."); + git(fixture, "commit", "-m", "failed release tag"); + git(fixture, "tag", "v0.9.0"); + git(fixture, "checkout", "main"); + const unrelatedTag = runDocsProvenanceCheck("v0.3.1", "HEAD", fixture, provenanceScript); + assert.equal(unrelatedTag.status, 0, unrelatedTag.stderr); + + git(fixture, "checkout", "-b", "malicious-manifest"); + mkdirSync(join(fixture, "website/versioned_docs/version-0.9.0"), { + recursive: true, + }); + writeFileSync(join(fixture, "website/versioned_docs/version-0.9.0/new.md"), "failed docs\n"); + writeFileSync(join(fixture, "website/versioned_sidebars/version-0.9.0-sidebars.json"), "{}\n"); + writeFileSync(join(fixture, "website/versions.json"), '["0.9.0", "0.3.1", "0.3.0", "0.2.0"]\n'); + writeFileSync(join(fixture, "packages/cli/package.json"), '{"version":"0.9.0"}\n'); + git(fixture, "add", "."); + git(fixture, "commit", "-m", "attempt to include failed release docs"); + const maliciousManifest = runDocsProvenanceCheck("HEAD", "HEAD", fixture, provenanceScript); + assert.notEqual(maliciousManifest.status, 0); + assert.match(maliciousManifest.stderr, /v0\.9\.0 is not an ancestor of release HEAD/); + git(fixture, "checkout", "main"); + + mkdirSync(join(fixture, "website/versioned_docs/version-0.3.2"), { + recursive: true, + }); + writeFileSync(join(fixture, "website/versioned_docs/version-0.3.2/new.md"), "newer docs\n"); + writeFileSync(join(fixture, "website/versioned_sidebars/version-0.3.2-sidebars.json"), "{}\n"); + rmSync(join(fixture, "website/versioned_docs/version-0.3.0"), { recursive: true }); + rmSync(join(fixture, "website/versioned_sidebars/version-0.3.0-sidebars.json")); + writeFileSync(join(fixture, "website/versions.json"), '["0.3.2", "0.3.1", "0.2.0"]\n'); + writeFileSync(join(fixture, "packages/cli/package.json"), '{"version":"0.3.2"}\n'); + git(fixture, "add", "."); + git(fixture, "commit", "-m", "later release dropping frozen docs"); + git(fixture, "tag", "v0.3.2"); + const deletion = runDocsProvenanceCheck("v0.3.2", "HEAD", fixture, provenanceScript); + assert.notEqual(deletion.status, 0); + assert.match(deletion.stderr, /omits legacy version 0\.3\.0/); + + git(fixture, "checkout", "-b", "rewrite-release", "v0.3.1"); + mkdirSync(join(fixture, "website/versioned_docs/version-0.3.0"), { + recursive: true, + }); + writeFileSync(join(fixture, "website/versioned_docs/version-0.3.0/old.md"), "changed old docs\n"); + mkdirSync(join(fixture, "website/versioned_docs/version-0.3.3"), { + recursive: true, + }); + writeFileSync(join(fixture, "website/versioned_docs/version-0.3.3/new.md"), "latest docs\n"); + writeFileSync(join(fixture, "website/versioned_sidebars/version-0.3.3-sidebars.json"), "{}\n"); + writeFileSync(join(fixture, "website/versioned_sidebars/version-0.3.0-sidebars.json"), "{}\n"); + writeFileSync(join(fixture, "website/versions.json"), '["0.3.3", "0.3.1", "0.3.0", "0.2.0"]\n'); + writeFileSync(join(fixture, "packages/cli/package.json"), '{"version":"0.3.3"}\n'); + git(fixture, "add", "."); + git(fixture, "commit", "-m", "later release with rewritten frozen docs"); + git(fixture, "tag", "v0.3.3"); + const mismatch = runDocsProvenanceCheck("v0.3.3", "HEAD", fixture, provenanceScript); + assert.notEqual(mismatch.status, 0); + assert.match(mismatch.stderr, /Frozen docs for 0\.3\.0 at HEAD do not match [0-9a-f]{40}/); + } finally { + rmSync(fixture, { recursive: true, force: true }); + rmSync(scriptFixture, { recursive: true, force: true }); + } + }); + + it("updates every current release pin in an isolated fixture", () => { + const fixture = mkdtempSync(join(tmpdir(), "kc-release-pins-")); + try { + for (const relativePath of [ + "scripts", + "packages/cli/src/commands", + "action", + "website/docs", + ]) { + mkdirSync(join(fixture, relativePath), { recursive: true }); + } + copyFileSync( + join(root, "scripts/bump-release-pins.mjs"), + join(fixture, "scripts/bump-release-pins.mjs"), + ); + const pins: Record = { + "packages/cli/src/commands/init.ts": "chtnnh/know-code/action@v0.3.0\n", + "action/action.yml": 'default: "^0.3.0"\n', + "action/README.md": "chtnnh/know-code/action@v0.3.0\n`^0.3.0`\nversion: \"^0.3.0\"\n", + "website/docs/ci.md": "chtnnh/know-code/action@v0.3.0\n`^0.3.0`\n", + "packages/cli/package.json": '{"version": "0.3.0"}\n', + "package.json": '{"version": "0.3.0"}\n', + "package-lock.json": [ + '{\n "name": "know-code-monorepo",\n "version": "0.3.0",', + '"": {\n "name": "know-code-monorepo",\n "version": "0.3.0",', + '"packages/cli": {\n "name": "@chtnnh/know-code",\n "version": "0.3.0",', + ].join("\n"), + }; + for (const [relativePath, content] of Object.entries(pins)) { + writeFileSync(join(fixture, relativePath), content); + } + + const result = spawnSync(process.execPath, ["scripts/bump-release-pins.mjs", "4.5.6"], { + cwd: fixture, + encoding: "utf8", + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Updated .*package-lock\.json/); + assert.equal( + readFileSync(join(fixture, "packages/cli/src/commands/init.ts"), "utf8").trim(), + "chtnnh/know-code/action@v4.5.6", + ); + assert.equal( + readFileSync(join(fixture, "action/action.yml"), "utf8").trim(), + 'default: "^4.5.6"', + ); + assert.equal( + readFileSync(join(fixture, "action/README.md"), "utf8"), + 'chtnnh/know-code/action@v4.5.6\n`^4.5.6`\nversion: "^4.5.6"\n', + ); + assert.equal( + readFileSync(join(fixture, "website/docs/ci.md"), "utf8"), + "chtnnh/know-code/action@v4.5.6\n`^4.5.6`\n", + ); + assert.equal(readFileSync(join(fixture, "packages/cli/package.json"), "utf8"), '{"version": "4.5.6"}\n'); + assert.equal(readFileSync(join(fixture, "package.json"), "utf8"), '{"version": "4.5.6"}\n'); + assert.doesNotMatch(readFileSync(join(fixture, "package-lock.json"), "utf8"), /0\.3\.0/); + + const second = spawnSync(process.execPath, ["scripts/bump-release-pins.mjs", "4.5.6"], { + cwd: fixture, + encoding: "utf8", + }); + assert.equal(second.status, 0, second.stderr); + + const beforeInvalid = readFileSync(join(fixture, "package.json"), "utf8"); + const invalid = spawnSync(process.execPath, ["scripts/bump-release-pins.mjs", "4.5.6-beta.1"], { + cwd: fixture, + encoding: "utf8", + }); + assert.notEqual(invalid.status, 0); + assert.match(invalid.stderr, /stable-semver/); + assert.equal(readFileSync(join(fixture, "package.json"), "utf8"), beforeInvalid); + + const lockfilePath = join(fixture, "package-lock.json"); + const validLockfile = readFileSync(lockfilePath, "utf8"); + writeFileSync(lockfilePath, "missing required lockfile pins\n"); + const beforeLateFailure = Object.fromEntries( + Object.keys(pins) + .filter((relativePath) => relativePath !== "package-lock.json") + .map((relativePath) => [ + relativePath, + readFileSync(join(fixture, relativePath), "utf8"), + ]), + ); + const lateFailure = spawnSync(process.execPath, ["scripts/bump-release-pins.mjs", "5.6.7"], { + cwd: fixture, + encoding: "utf8", + }); + assert.notEqual(lateFailure.status, 0); + for (const [relativePath, content] of Object.entries(beforeLateFailure)) { + assert.equal(readFileSync(join(fixture, relativePath), "utf8"), content); + } + + writeFileSync( + lockfilePath, + validLockfile.replaceAll("4.5.6", "5.6.6"), + ); + const beforeWriteFailure = Object.fromEntries( + Object.keys(pins).map((relativePath) => [ + relativePath, + readFileSync(join(fixture, relativePath), "utf8"), + ]), + ); + const writeFailure = spawnSync(process.execPath, ["scripts/bump-release-pins.mjs", "5.6.7"], { + cwd: fixture, + encoding: "utf8", + env: { ...process.env, KNOW_CODE_TEST_BUMP_FAIL_AFTER: "1" }, + }); + assert.notEqual(writeFailure.status, 0); + for (const [relativePath, content] of Object.entries(beforeWriteFailure)) { + assert.equal(readFileSync(join(fixture, relativePath), "utf8"), content); + } + } finally { + rmSync(fixture, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/bump-release-pins.mjs b/scripts/bump-release-pins.mjs index 7c7246c..5a6e6c1 100644 --- a/scripts/bump-release-pins.mjs +++ b/scripts/bump-release-pins.mjs @@ -1,14 +1,21 @@ #!/usr/bin/env node /** - * Bump version pins after release. Usage: node scripts/bump-release-pins.mjs 0.2.0 + * Bump all current release pins. Usage: node scripts/bump-release-pins.mjs 0.3.1 */ -import { readFileSync, writeFileSync } from "node:fs"; +import { + copyFileSync, + existsSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const version = process.argv[2]; -if (!version) { - console.error("Usage: node scripts/bump-release-pins.mjs "); +if (!version || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) { + console.error("Usage: node scripts/bump-release-pins.mjs "); process.exit(1); } @@ -16,27 +23,96 @@ const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const files = [ { path: join(root, "packages/cli/src/commands/init.ts"), - replace: [/chtnnh\/know-code\/action@v[\d.]+/g, `chtnnh/know-code/action@v${version}`], + replace: [[/chtnnh\/know-code\/action@v[\d.]+/g, `chtnnh/know-code/action@v${version}`]], }, { path: join(root, "action/action.yml"), - replace: [/default: "\^[\d.]+"/g, `default: "^${version}"`], + replace: [[/default: "\^[\d.]+"/g, `default: "^${version}"`]], + }, + { + path: join(root, "action/README.md"), + replace: [ + [/chtnnh\/know-code\/action@v[\d.]+/g, `chtnnh/know-code/action@v${version}`], + [/`\^[\d.]+`/g, `\`^${version}\``], + [/version: "\^[\d.]+"/g, `version: "^${version}"`], + ], + }, + { + path: join(root, "website/docs/ci.md"), + replace: [ + [/chtnnh\/know-code\/action@v[\d.]+/g, `chtnnh/know-code/action@v${version}`], + [/`\^[\d.]+`/g, `\`^${version}\``], + ], }, { path: join(root, "packages/cli/package.json"), - replace: [/"version": "[\d.]+"/, `"version": "${version}"`], + replace: [[/"version": "[\d.]+"/, `"version": "${version}"`]], }, { path: join(root, "package.json"), - replace: [/"version": "[\d.]+"/, `"version": "${version}"`], + replace: [[/"version": "[\d.]+"/, `"version": "${version}"`]], + }, + { + path: join(root, "package-lock.json"), + replace: [ + [/("name": "know-code-monorepo",\n {2}"version": ")[\d.]+"/, `$1${version}"`], + [/("": \{\n {6}"name": "know-code-monorepo",\n {6}"version": ")[\d.]+"/, `$1${version}"`], + [/("packages\/cli": \{\n {6}"name": "@chtnnh\/know-code",\n {6}"version": ")[\d.]+"/, `$1${version}"`], + ], }, ]; -for (const f of files) { +const updates = files.map((f, index) => { let content = readFileSync(f.path, "utf8"); for (const [re, sub] of f.replace) { + if (!re.test(content)) { + throw new Error(`Expected release pin was not found in ${f.path}: ${re}`); + } + re.lastIndex = 0; content = content.replace(re, sub); } - writeFileSync(f.path, content); - console.log(`Updated ${f.path}`); + return { + path: f.path, + content, + backupPath: `${f.path}.know-code-bump-${process.pid}-${index}.bak`, + tempPath: `${f.path}.know-code-bump-${process.pid}-${index}.tmp`, + }; +}); +const failAfter = Number(process.env.KNOW_CODE_TEST_BUMP_FAIL_AFTER); + +let rollbackFailed = false; +try { + for (const f of updates) { + writeFileSync(f.tempPath, f.content); + copyFileSync(f.path, f.backupPath); + } + for (const [index, f] of updates.entries()) { + if (index === failAfter) { + throw new Error("Injected release-pin write failure"); + } + renameSync(f.tempPath, f.path); + console.log(`Updated ${f.path}`); + } +} catch (error) { + const rollbackFailures = []; + for (const f of updates) { + try { + renameSync(f.backupPath, f.path); + } catch (rollbackError) { + // A backup is absent when staging failed before this file was copied. + if (existsSync(f.backupPath)) { + rollbackFailed = true; + rollbackFailures.push({ path: f.backupPath, error: rollbackError }); + } + } + } + if (rollbackFailures.length > 0) { + throw new AggregateError(rollbackFailures, "Release pin rollback failed; backups retained"); + } + throw error; +} finally { + for (const f of updates) { + rmSync(f.tempPath, { force: true }); + if (!rollbackFailed) rmSync(f.backupPath, { force: true }); + } } diff --git a/scripts/check-release-docs-provenance.mjs b/scripts/check-release-docs-provenance.mjs new file mode 100644 index 0000000..b241d3f --- /dev/null +++ b/scripts/check-release-docs-provenance.mjs @@ -0,0 +1,138 @@ +#!/usr/bin/env node +/** Refuse to deploy versioned docs that differ from the release tag. */ +import { spawnSync } from "node:child_process"; + +const [releaseRef, deploymentRef] = process.argv.slice(2); +// Versioned docs were introduced in 0.3.1. Older release tags have no +// versioned snapshot, so that first versioned release is their immutable base. +const legacyBaselines = { + "0.2.0": "ac656578a0667fc483d11847c1e6c97bf6c1aee6", + "0.3.0": "ac656578a0667fc483d11847c1e6c97bf6c1aee6", +}; + +if (!releaseRef || !deploymentRef) { + console.error("Usage: check-release-docs-provenance.mjs "); + process.exit(1); +} + +const releaseCommit = resolveCommit(releaseRef); +const deploymentCommit = resolveCommit(deploymentRef); +const manifestComparison = spawnSync( + "git", + ["diff", "--exit-code", releaseCommit, deploymentCommit, "--", "website/versions.json"], + { stdio: "inherit" }, +); +if (manifestComparison.status !== 0) { + console.error(`Version manifest at ${deploymentRef} does not match release ${releaseRef}.`); + process.exit(manifestComparison.status ?? 1); +} +const versions = JSON.parse(showFile(deploymentCommit, "website/versions.json")); + +if (!Array.isArray(versions) || versions.some((version) => typeof version !== "string")) { + console.error(`Invalid website/versions.json at ${deploymentRef}.`); + process.exit(1); +} +const versionSet = new Set(versions); + +for (const version of Object.keys(legacyBaselines)) { + if (!versionSet.has(version)) { + console.error(`Version manifest at ${deploymentRef} omits legacy version ${version}.`); + process.exit(1); + } +} + +const trustedReleaseTags = new Set(releaseTags(releaseCommit)); + +for (const tag of trustedReleaseTags) { + const version = tag.slice(1); + const packageText = tryShowFile(tag, "packages/cli/package.json"); + if (!packageText) continue; + const packageVersion = JSON.parse(packageText).version; + if (packageVersion !== version) continue; + const taggedVersionsText = tryShowFile(tag, "website/versions.json"); + if (!taggedVersionsText) continue; + const taggedVersions = JSON.parse(taggedVersionsText); + if (!Array.isArray(taggedVersions) || taggedVersions[0] !== version) continue; + if (!versionSet.has(version)) { + console.error(`Version manifest at ${deploymentRef} omits ${version} released by ${tag}.`); + process.exit(1); + } + if (!hasSuffix(versions, taggedVersions)) { + console.error(`Version history at ${deploymentRef} does not preserve ${tag}.`); + process.exit(1); + } +} + +for (const version of versions) { + const versionTag = `v${version}`; + const baselineRef = legacyBaselines[version] ?? versionTag; + const baselineCommit = resolveCommit(baselineRef); + if (!legacyBaselines[version]) { + if (!trustedReleaseTags.has(versionTag)) { + console.error(`${versionTag} is not an ancestor of release ${releaseRef}.`); + process.exit(1); + } + const tagVersions = JSON.parse(showFile(baselineCommit, "website/versions.json")); + if (tagVersions[0] !== version) { + console.error(`${versionTag} does not declare ${version} as its latest docs version.`); + process.exit(1); + } + } + const comparison = spawnSync( + "git", + [ + "diff", + "--exit-code", + baselineCommit, + deploymentCommit, + "--", + `website/versioned_docs/version-${version}`, + `website/versioned_sidebars/version-${version}-sidebars.json`, + ], + { stdio: "inherit" }, + ); + if (comparison.status !== 0) { + console.error(`Frozen docs for ${version} at ${deploymentRef} do not match ${baselineRef}.`); + process.exit(1); + } +} + +function resolveCommit(ref) { + const result = spawnSync("git", ["rev-parse", `${ref}^{commit}`], { + encoding: "utf8", + }); + if (result.status !== 0) { + process.stderr.write(result.stderr); + process.exit(result.status ?? 1); + } + return result.stdout.trim(); +} + +function showFile(ref, path) { + const result = spawnSync("git", ["show", `${ref}:${path}`], { encoding: "utf8" }); + if (result.status !== 0) { + process.stderr.write(result.stderr); + process.exit(result.status ?? 1); + } + return result.stdout; +} + +function tryShowFile(ref, path) { + const result = spawnSync("git", ["show", `${ref}:${path}`], { encoding: "utf8" }); + return result.status === 0 ? result.stdout : undefined; +} + +function releaseTags(commit) { + const result = spawnSync("git", ["tag", "--merged", commit, "--list", "v*"], { + encoding: "utf8", + }); + if (result.status !== 0) { + process.stderr.write(result.stderr); + process.exit(result.status ?? 1); + } + return result.stdout.split("\n").filter(Boolean); +} + +function hasSuffix(whole, suffix) { + return JSON.stringify(whole.slice(-suffix.length)) === JSON.stringify(suffix); +} diff --git a/scripts/check-release-tag.mjs b/scripts/check-release-tag.mjs new file mode 100644 index 0000000..9076384 --- /dev/null +++ b/scripts/check-release-tag.mjs @@ -0,0 +1,17 @@ +#!/usr/bin/env node +/** Refuse publication when the release tag and CLI package version diverge. */ +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const tag = process.env.GITHUB_REF_NAME; +const { version } = JSON.parse(readFileSync(join(root, "packages/cli/package.json"), "utf8")); +const expectedTag = `v${version}`; + +if (tag !== expectedTag) { + console.error(`Release tag ${tag ?? "(missing)"} does not match package version ${version}; expected ${expectedTag}.`); + process.exit(1); +} + +console.log(`Release tag ${tag} matches CLI package version ${version}.`); diff --git a/website/README.md b/website/README.md index 11137e8..01db479 100644 --- a/website/README.md +++ b/website/README.md @@ -14,7 +14,7 @@ npm run build:docs npm run start -w website ``` -Deployed by [`.github/workflows/docs.yml`](../.github/workflows/docs.yml) to GitHub Pages on pushes to `main` that touch `website/**` (and `workflow_dispatch`). Custom domain: `static/CNAME` → `kc.chtnnhfoundation.org`. +Deployed by [`.github/workflows/docs.yml`](../.github/workflows/docs.yml) to GitHub Pages on every push to `main` (and `workflow_dispatch`). Custom domain: `static/CNAME` → `kc.chtnnhfoundation.org`. ## Versions @@ -26,7 +26,7 @@ Deployed by [`.github/workflows/docs.yml`](../.github/workflows/docs.yml) to Git The navbar dropdown switches versions. HEAD is labeled and bannered as unreleased; it is not the default. -Cut a new docs version **when you tag a release whose docs actually changed** (skip patch-only releases): +Cut a new docs version for every npm release: ```bash npm run docs:version -w website -- 0.4.0 diff --git a/website/docs/ci.md b/website/docs/ci.md index f9ba605..e7651b1 100644 --- a/website/docs/ci.md +++ b/website/docs/ci.md @@ -31,7 +31,7 @@ jobs: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha || github.sha }} - - uses: chtnnh/know-code/action@v0.3.0 + - uses: chtnnh/know-code/action@v0.3.1 with: base-branch: main from: ${{ github.event_name == 'push' && github.event.before || '' }} @@ -49,7 +49,7 @@ The PR job checks out `head.sha` and runs `know-code verify` (merge-base ahead o | `from` | _(empty)_ | Previous tip SHA for push jobs (`github.event.before`). Empty on `pull_request`. All-zeros skips the walk. | | `require-all` | `false` | Stricter messaging when trailers missing | | `require-range-trailers` | `false` | Every commit ahead of base must share the same `Know-Code-Verified` hash (rewrite teams) | -| `version` | `^0.3.0` | npm pin for `@chtnnh/know-code` when not building from this monorepo | +| `version` | `^0.3.1` | npm pin for `@chtnnh/know-code` when not building from this monorepo | ## What verify checks diff --git a/website/versioned_docs/version-0.3.1/ci.md b/website/versioned_docs/version-0.3.1/ci.md new file mode 100644 index 0000000..e7651b1 --- /dev/null +++ b/website/versioned_docs/version-0.3.1/ci.md @@ -0,0 +1,95 @@ +--- +sidebar_position: 7 +title: CI & GitHub Action +--- + +# CI & GitHub Action + +## Quick add (recommended) + +```bash +know-code init --workflow +``` + +Writes `.github/workflows/know-code.yml`: + +```yaml +name: know-code + +on: + pull_request: + push: + branches: + - main + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - uses: chtnnh/know-code/action@v0.3.1 + with: + base-branch: main + from: ${{ github.event_name == 'push' && github.event.before || '' }} +``` + +:::note PR plus push +The PR job checks out `head.sha` and runs `know-code verify` (merge-base ahead of HEAD). The push job checks out the new base tip and passes `from` so verify walks `github.event.before..HEAD`. Without `--from`, HEAD *is* the base (`aheadCount` 0) and range verify cannot match. Direct pushes that skip the PR still get a trailer check. All-zeros `before` (new branch) skips the walk. +::: + +## Composite action inputs + +| Input | Default | Description | +|-------|---------|-------------| +| `base-branch` | `main` | Must match local `baseBranch` (`know-code config`) | +| `from` | _(empty)_ | Previous tip SHA for push jobs (`github.event.before`). Empty on `pull_request`. All-zeros skips the walk. | +| `require-all` | `false` | Stricter messaging when trailers missing | +| `require-range-trailers` | `false` | Every commit ahead of base must share the same `Know-Code-Verified` hash (rewrite teams) | +| `version` | `^0.3.1` | npm pin for `@chtnnh/know-code` when not building from this monorepo | + +## What verify checks + +Default `know-code verify` (one CI command for all merge styles). CI only sees **public git** — not gitignored `.know-code/` seals. + +1. **HEAD trailer** must match a **grounded** candidate: + - **merge-base..HEAD** — tree-canonical range hash (fromOid tree → `write-tree`) + - **index** — empty-tree → current tree (single-commit / hotfix) + - **uniform-trailers** — only when every commit’s trailer is already a grounded candidate +2. **Fallback:** any commit in `merge-base..HEAD` carries a matching trailer (Update branch, `pull/N/merge`, pre-squash PR branches). + +Do **not** rely on local `range-seal` / `commit-drift` for green CI. Checkout the PR tip SHA, not the ephemeral merge commit. + +**Push job:** `know-code verify --from` previous tip. Splits `before..HEAD` into trailer runs; each run’s historical tree-pair must match. A dirty index does not count. Full design: [Verification design](verify.md). + +### Merge methods (what CI actually sees) + +The **PR** job runs on the **PR tip**. The **push** job runs on the landing commit with `--from` previous tip. + +- **Update branch** (merge `main` into the PR): HEAD is a merge commit with no trailer. PR verify still passes: it scans `merge-base..HEAD` and the tree-canonical hash is unchanged if only unrelated `main` files were added. +- **Rebase and merge** / author `git rebase origin/main`: replayed commits keep the original `Know-Code-Verified` line. The hash against the **new** merge-base still matches. Push walk requires a trailer on every replayed non-merge commit (rewrite or each commit already trailered). +- **Squash and merge**: PR verify already passed on the multi-commit PR. Push verify checks the squash commit. GitHub’s default squash message often copies the trailer onto that landing; the `PR_TITLE` + `BLANK` preset does not — that fails the **push** job, not the PR job. +- **Create a merge commit**: PR job same as Update branch if HEAD has no trailer — range fallback. Push walk attaches the merge to the PR-side run but hashes the last non-merge (feature tip), so the landing still matches after `main` moved. Every non-merge in `before..HEAD` still needs a trailer. + +After any of those land on `main`, bare `know-code verify` cannot succeed (`on base tip`). The push job uses `--from` instead. Details: [Verification design](verify.md#github-merge-methods) and [Push walk](verify.md#push-walk). + +**Strict opt-in:** `--require-range-trailers` — every commit in the range must share the same trailer (rewrite teams only). + +Use `know-code commit -m "…"` locally so the trailer is attached automatically. + +## Branch protection + +After `ci` and `know-code` have run at least once: + +```bash +node scripts/setup-branch-protection.mjs chtnnh/your-repo main +``` + +Requires `gh` with admin access. Mark **know-code** (and preferably **ci**) as required checks. + +## Overrides + +`KNOW_CODE_OVERRIDE=1` is local-only, requires a prior `know-code override` on a TTY, is denied in agent hooks, and is logged to `.know-code/override.log`. It never satisfies CI. diff --git a/website/versioned_docs/version-0.3.1/cli.md b/website/versioned_docs/version-0.3.1/cli.md new file mode 100644 index 0000000..f734a39 --- /dev/null +++ b/website/versioned_docs/version-0.3.1/cli.md @@ -0,0 +1,104 @@ +--- +sidebar_position: 5 +title: CLI reference +--- + +# CLI reference + +Requires Node 20+. Install the scoped package: + +```bash +npm i -g @chtnnh/know-code +``` + +This installs two identical binaries: `know-code` and the short alias **`kc`** — every command below works as `kc status`, `kc commit -m "…"`, etc. (If your shell aliases `kc` to something else, your alias wins; use `know-code`.) + +## Commands + +| Command | Purpose | +|---------|---------| +| `know-code init` | Local config, git hooks; optional `--agents`, `--workflow` | +| `know-code config [--json]` · `config set ` | Effective settings + quiz scope | +| `know-code doctor [--json] [--strict]` | Health check: attest, hooks, pipeline, port; `--strict` fails on outdated git hooks, missing agent hooks, legacy gates | +| `know-code attest-init [--force]` | Human: create passphrase-encrypted Ed25519 key | +| `know-code range begin\|status\|seal\|abort\|continue` | One-quiz-per-range session | +| `know-code questions [--json] [--template]` | Quiz quota + template skeleton | +| `know-code quiz validate` | Lint `quiz.json` before `ask` | +| `know-code taught [--skip]` | **Seal** teach receipt (human) | +| `know-code ask` | Browser quiz → `answers.json` | +| `know-code grade propose [--json]` | Agent: rubric context for grading | +| `know-code grade --review\|--accept` | **Seal** grade after agent proposal | +| `know-code pass [--hash ]` | **Seal** gate receipt | +| `know-code status [--json]` | Gate, hash, `nextStep`, blockers | +| `know-code ship [--dry-run]` | Pre-push checklist (`doctor --strict` + `check --push`) | +| `know-code hash [--json]` | Current quiz hash | +| `know-code check [--push]` | Exit 0 allow / 2 block (hooks); `--push` requires trailer on HEAD only | +| `know-code commit -m "…"\|-F file` | `git commit` + trailer | +| `know-code amend` | Amend with gate check + trailer | +| `know-code reset` | Clear stale `.know-code` artifacts | +| `know-code override` | Human TTY: one-shot emergency allow | +| `know-code verify` | Trailer verification (`--from` for push walks; see [Verification design](verify.md)) | +| `know-code hooks install` | Install or refresh git pre-commit/pre-push hooks | +| `know-code hooks uninstall` | Remove git/agent hooks | +| `know-code skills [--global]` | Install Agent Skills | + +## Typical flow (range batch) + +Correct order — with roles: + +```bash +# YOU +know-code range begin + +# AGENT: know-code-teach +# YOU +know-code taught + +# AGENT +know-code questions --json +# agent writes .know-code/quiz.json +know-code quiz validate +know-code ask # YOU answer in browser + +# AGENT writes grade-proposal.json +# YOU +know-code grade --review +know-code pass + +# AGENT +know-code commit -m "feat: ship it" + +# YOU +know-code range seal +know-code doctor --strict # or: know-code ship +git push +``` + +**Commit messages:** quote `-m "…"` or use `-F file`. + +**Grading:** see [Grading](grading.md). Legacy `grade --score` requires `allowSelfScore: true`. + +**Agents (0.3.0+):** shell hooks deny raw `git add`, amend, merge/pull/rebase, pathspec commits, and hook bypasses. Humans stage outside the agent; agents use `know-code commit` / `amend` after pass. See [Hooks](hooks.md). + +**Before push:** `know-code doctor --strict` or `know-code ship`. + +## `verify` flags + +| Flag | Meaning | +|------|---------| +| `--from ` | 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 | +|----------|---------| +| `KNOW_CODE_LEVEL` | Override `level` | +| `KNOW_CODE_ATTEST_PASSPHRASE` | Non-interactive seal (never set by agents) | +| `KNOW_CODE_OVERRIDE=1` | Bypass after `know-code override` (denied in agent hooks) | + +See also: [Configuration](config.md) · [Quiz](quiz.md) · [Grading](grading.md) · [CI](ci.md) diff --git a/website/versioned_docs/version-0.3.1/config.md b/website/versioned_docs/version-0.3.1/config.md new file mode 100644 index 0000000..c55fadf --- /dev/null +++ b/website/versioned_docs/version-0.3.1/config.md @@ -0,0 +1,107 @@ +--- +sidebar_position: 3 +title: Configuration +--- + +# Configuration + +know-code merges settings from two JSON files (repo wins over home) and a few environment variables. + +```bash +know-code config # human-readable effective settings +know-code config --json # machine-readable (paths, quiz scope, attest status) +``` + +## Files + +| Path | Committed? | Purpose | +|------|------------|---------| +| `~/.know-code/config.json` | No (your machine) | Optional defaults for all repos | +| `.know-code/config.json` | **No** (gitignored) | Per-developer repo settings; created by `know-code init` | +| `~/.know-code/attest//` | No | Passphrase-encrypted Ed25519 keys + `meta.json` (never in repo config) | + +Each developer runs `know-code init` and `know-code attest-init` on their machine. Nothing under `.know-code/` except committed hook scripts should be shared via git. + +## Config fields + +| Field | Type | Default | Set by `init`? | Description | +|-------|------|---------|----------------|-------------| +| `level` | `lite` \| `standard` \| `deep` | `standard` | `--level` | Quiz difficulty and minimum question count | +| `baseBranch` | string | `main` | `--base-branch` | Branch used for merge-base and CI alignment | +| `requireTrailer` | boolean | `false` | `--require-trailer` | When true, local/CI verify expects `Know-Code-Verified` trailers | +| `rangeMode` | `auto` \| `index` \| `range` | `auto` | manual JSON | How quiz hash scope is chosen (see below) | +| `rangeSeal` | `receipt` \| `rewrite` | `receipt` | manual JSON | Default `range seal` behavior when `--rewrite` omitted | +| `requireAttest` | boolean | `true` | manual JSON | When true, `taught` / `grade` / `pass` / `range seal` need Ed25519 seals | +| `requireGradeProposal` | boolean | `true` | manual JSON | Require agent `grade-proposal.json` before human grade | +| `allowSelfScore` | boolean | `false` | manual JSON | Allow legacy `grade --score` without proposal | +| `enforcePipeline` | boolean | **`true`** (0.3.0) | manual JSON | Require sealed taught (+ quiz flow) before `ask` / pass | + +### `rangeMode` + +| Value | Quiz hash | +|-------|-----------| +| `auto` | **Range** cumulative hash when `know-code range begin` session is active; otherwise **index** hash (empty-tree → index tree) | +| `index` | Always index hash (single-commit / staged-only workflow) | +| `range` | Always range hash from merge-base (even without an active session) | + +### `rangeSeal` + +| Value | `know-code range seal` | +|-------|------------------------| +| `receipt` | Writes signed `.know-code/range-seal.json`; HEAD should carry trailer for CI | +| `rewrite` | Same receipt + rewrites commit messages in range with `Know-Code-Verified: ` (requires `git push --force-with-lease`) | + +CLI flag `range seal --rewrite` overrides config for that invocation. + +### `requireAttest` + +When `true` (default), agents cannot forge `taught.json`, `grade.json`, `gate.json`, or `range-seal.json` without your attest passphrase. Set `false` only for local experiments — not recommended for production dogfooding. + +## Environment overrides + +| Variable | Overrides | +|----------|-----------| +| `KNOW_CODE_LEVEL` | `level` | +| `KNOW_CODE_ATTEST_PASSPHRASE` | Non-interactive attest sealing (human terminal only; denied in agent hooks) | +| `KNOW_CODE_ATTEST_HOME` | Directory for `~/.know-code/attest` (default `~/.know-code/attest`) | +| `KNOW_CODE_HOME` | Directory for home config (default `~/.know-code`) | +| `KNOW_CODE_OVERRIDE=1` | Emergency bypass after `know-code override` on a TTY (denied in agent hooks and CI) | +| `KNOW_CODE_QUIZ_PORT` | Default port for `know-code ask` | +| `KNOW_CODE_QUIZ_TIMEOUT` | Seconds to wait for browser quiz (default `1800`) | + +## Example home config + +```json +{ + "level": "standard", + "rangeMode": "auto", + "rangeSeal": "receipt", + "requireAttest": true +} +``` + +## Example repo config (local, gitignored) + +```json +{ + "level": "standard", + "baseBranch": "main", + "requireTrailer": true, + "rangeMode": "auto", + "rangeSeal": "rewrite", + "requireAttest": true +} +``` + +## `init` flags + +```bash +know-code init \ + --level standard \ + --base-branch main \ + --require-trailer \ + --agents claude,cursor,codex \ + --workflow +``` + +`init` writes `.know-code/config.json`, updates `.gitignore` to ignore `.know-code/`, and installs git pre-commit/pre-push hooks. It does **not** set `rangeMode`, `rangeSeal`, or `requireAttest` — add those manually or via home config. diff --git a/website/versioned_docs/version-0.3.1/grading.md b/website/versioned_docs/version-0.3.1/grading.md new file mode 100644 index 0000000..9c63490 --- /dev/null +++ b/website/versioned_docs/version-0.3.1/grading.md @@ -0,0 +1,56 @@ +# Grading + +know-code uses **agent-proposed grading** with **human review and attest**. You never self-assign a passing score — the agent proposes, you review and seal. + +## Flow + +```mermaid +flowchart LR + A[Agent: ask] --> B[You: browser answers] + B --> C[Agent: grade-proposal.json] + C --> D[You: grade --review] + D --> E[You: pass] + E --> F[Gate opens] +``` + +| Step | Who | What | +|------|-----|------| +| 1 | **Agent** runs `ask` · **you** submit answers | `answers.json` | +| 2 | **Agent** | Reads answers + quiz + diff → writes `grade-proposal.json` | +| 3 | **You** | `know-code grade --review` (TUI: per-question scores + feedback) | +| 4 | **You** | Attest passphrase → sealed `grade.json` | +| 5 | **You** | `know-code pass` → sealed `gate.json` (gate opens) | + +```bash +# Agent (after you submitted the browser quiz) +know-code grade propose --json # optional rubric helper +# agent writes .know-code/grade-proposal.json + +# You +know-code grade --review # or --accept to skip score adjustment +know-code pass +``` + +## Pass bar + +Overall score ≥ **0.8**. Below the bar, `grade --review` exits 2 — re-teach and re-quiz. + +## `grade-proposal.json` + +Written by the agent (unsigned). Binds `diffHash` and `answersDigest`. See the skill reference `grading-rubric.md` in the repo. + +## Emergency self-score + +Disabled by default (`allowSelfScore: false`). Enable only for emergencies: + +```bash +know-code config set allowSelfScore true +know-code grade --score 0.85 +``` + +## Config + +| Field | Default | Meaning | +|-------|---------|---------| +| `requireGradeProposal` | `true` | Require agent proposal before human grade | +| `allowSelfScore` | `false` | Permit legacy `grade --score` | diff --git a/website/versioned_docs/version-0.3.1/hooks.md b/website/versioned_docs/version-0.3.1/hooks.md new file mode 100644 index 0000000..c5d9693 --- /dev/null +++ b/website/versioned_docs/version-0.3.1/hooks.md @@ -0,0 +1,92 @@ +# Hooks + +know-code gates shipping at two layers: **git hooks** (any terminal) and **agent shell hooks** (inside Cursor, Claude Code, Codex, …). + +## Why two layers? + +Git hooks catch every `git commit` and `git push`. Agent hooks go further: they stop the agent from **staging** (`git add`), **rewriting history** (`git commit --amend`), or **creating implicit commits** (`git merge`, `git pull`) that would bypass the quiz. You stage in your own terminal; the agent commits through `know-code commit` after you pass. + +```mermaid +flowchart LR + subgraph agentCtx["Inside agent (Cursor, Claude, Codex)"] + AH[Shell hook] + AH -->|deny| BAD["git add, amend,
merge, pull, bypasses"] + AH -->|check| OK["know-code commit,
git push"] + end + subgraph anyCtx["Any terminal"] + GH[Git pre-commit / pre-push] + GH --> CH[know-code check] + end + OK --> GH +``` + +## Git hooks + +Installed by `know-code init` (or refresh with `know-code hooks install`): + +- **pre-commit** — runs `know-code check` +- **pre-push** — runs `know-code check --push` + +Hooks unset `KNOW_CODE_COMMIT` so a leftover env var cannot skip trailer checks. During `know-code commit` / `amend`, Git writes the pending message (with grounded `Know-Code-Verified` trailer) to `COMMIT_EDITMSG` before `pre-commit`; `know-code check` accepts that pending trailer instead of requiring HEAD to already carry one. `pre-push` ignores `COMMIT_EDITMSG` and requires the trailer on HEAD. Shipping also requires `HEAD^{tree}` / index tree to match `gate.gatedTreeOid`. + +Refresh after upgrading the CLI (required after **0.3.0**): + +```bash +know-code hooks install +know-code init --agents cursor,claude,codex # refresh agent matchers +``` + +Uninstall (restores backup if present): + +```bash +know-code hooks uninstall +``` + +## Agent hooks (0.3.0 surface) + +Optional via `know-code init --agents claude,cursor,codex` — **strongly recommended**; `doctor --strict` and `ship` fail without them. + +| Agent | Config file | +|-------|-------------| +| Cursor | `.cursor/hooks.json` | +| Claude | `.claude/settings.json` | +| Codex | `.codex/hooks.json` | + +### Gated / denied commands + +The hook only inspects the **parsed command field** (never quiz text in JSON). + +**Always denied in agent hooks:** + +- `git add` (humans stage outside the agent) +- `git commit --amend`, `-a/--all`, `-u/--update`, `--only`/`-o`, pathspecs, `-C`/`-c`/`--reuse-message`/`--reedit-message`, `--fixup`/`--squash` +- Compound `git add && git commit` and `git commit && git push` +- `git push --no-verify` / `core.hooksPath` / `GIT_CONFIG_*` overrides +- `git merge`, `cherry-pick`, `revert`, `rebase`, `pull`, `am` +- `git stash apply|pop|branch` +- `git reset --hard` +- `KNOW_CODE_OVERRIDE=1` (human TTY only) + +**Still gated via `know-code check`:** + +- Plain `git commit -m "…"` — normal path after `pass` in range mode +- `git push` +- `gh pr create` / `glab mr create` + +Prefer `know-code commit` only when you want the trailer added immediately (index-only hotfixes). In range mode, use plain `git commit` and `range seal --rewrite`. + +Agents should not use raw `git commit --amend` — use `know-code amend` if you must rewrite the tip. + +Uninstall agent entries: + +```bash +know-code hooks uninstall --agents claude,cursor,codex +``` + +## Opting out + +1. `know-code hooks uninstall` +2. Remove agent hook entries manually +3. Human emergency: `know-code override` then `KNOW_CODE_OVERRIDE=1` in **your** terminal (not agent) + +See [troubleshooting](./troubleshooting.md). diff --git a/website/versioned_docs/version-0.3.1/how-it-works.md b/website/versioned_docs/version-0.3.1/how-it-works.md new file mode 100644 index 0000000..9c2d7e6 --- /dev/null +++ b/website/versioned_docs/version-0.3.1/how-it-works.md @@ -0,0 +1,156 @@ +--- +sidebar_position: 2 +title: How it works +--- + +# How it works + +## The problem + +Coding agents can produce large diffs fast. It's easy to approve a commit you don't actually understand. know-code adds a deliberate checkpoint: **you must demonstrate comprehension before anything ships**. + +## The checkpoint + +```mermaid +flowchart LR + A[Code change] --> B[Teach] + B --> C[Quiz in browser] + C --> D[Grade review] + D --> E[pass — gate opens] + E --> F[commit] + F --> G[push] + G --> H[CI verify] +``` + +Nothing in that chain is honor-system: + +- The **agent** writes the quiz and proposes your score — it cannot attest `pass` for you. +- **You** answer in the browser (not chat) and seal receipts with a passphrase only you hold. +- **Hooks** block commit/push when the gate is closed. +- **CI** rejects trailers that don't match a computed hash of the real diff. + +## Who does what + +| Phase | Agent | You (human) | +|-------|-------|-------------| +| Start batch | — | `range begin`, `attest-init` (once) | +| Teach | Explains via `know-code-teach` skill | `taught` (seal) | +| Quiz | `questions`, write `quiz.json`, `quiz validate`, `ask` | Answer in **browser** | +| Grade | Write `grade-proposal.json` | `grade --review`, `pass` (seal) | +| Stage | — (denied in agent hooks) | `git add` in your terminal | +| Commit | `git commit` after gate opens | Or you run it yourself | +| Finish batch | — | `range seal --rewrite`, `git push` | + +Commands that need your passphrase (`taught`, `grade`, `pass`, `range seal`) always run in **your** terminal — never from the agent. + +## One quiz per range + +`know-code range begin` pins a merge-base. **One quiz + one `pass`** covers every commit until `range seal` — you do not re-quiz per commit. + +```mermaid +flowchart LR + A[range begin] --> B[teach + quiz + pass] + B --> C[git add + git commit × N] + C --> D[range seal --rewrite] + D --> E[push] +``` + +Typical batch: + +1. Agent implements the feature (you may `git add` slices as you go). +2. You quiz **once** on the cumulative diff and `pass`. +3. Agent lands logical commits with **plain `git commit`** while the gate is open. +4. You `range seal --rewrite` to stamp `Know-Code-Verified` on every commit, then push. + +**Tree-stable range hash:** after `pass`, committing the same gated tree keeps the range hash identical (staged-at-pass === tip tree). The gate stays open while the tree matches `gatedTreeOid`. Legacy gates or tree edits may still surface as commit-drift locally — that is not the happy path for CI. + +Single-commit hotfix? Skip `range begin` — the hash covers the staged index only. See [Workflows](workflows.md) and [Verification design](verify.md). + +## What blocks commit and push + +```mermaid +flowchart TB + subgraph layers["Defense layers (in order)"] + direction TB + A["Agent shell hooks
deny git add, amend, merge, bypasses"] + G["Git pre-commit / pre-push
know-code check"] + C["CI know-code verify
grounded trailer hash"] + end + A --> G --> C +``` + +| Layer | When it runs | What it checks | +|-------|--------------|----------------| +| **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 | 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`). + +## Hash scope + +The quiz always binds to a **hash of the diff** you're about to ship. + +| Mode | When | Hash covers | +|------|------|-------------| +| **Index** | No active range session (or `rangeMode: index`) | Empty tree → current index (staged + HEAD tree) | +| **Range** | `range begin` active (or `rangeMode: range`) | Tree of range start → `write-tree` (HEAD + staged; same after commit) | + +```bash +know-code hash +know-code config --json # shows active scope +``` + +## passHash, tipHash, and trailers + +| Name | Meaning | +|------|---------| +| **passHash** | Diff hash stored in `gate.json` when you ran `pass` | +| **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 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 + subgraph pass["At pass"] + P[passHash + gatedTreeOid] + end + subgraph commits["After commits"] + C1[commit 1] + C2[commit 2] + C3[commit N] + end + subgraph seal["Range seal"] + S[tipHash on all commits] + end + P --> C1 --> C2 --> C3 --> S +``` + +**Range seal:** `range seal --rewrite` stamps the final **tipHash** on every commit in the batch so CI can verify the whole range. Use `verify --require-range-trailers` only when every commit must carry a trailer. + +## Attestation + +`attest-init` creates a passphrase-encrypted Ed25519 key under `~/.know-code/attest//`. + +Signed artifacts: `taught.json`, `grade.json`, `gate.json`, `range-seal.json`. Agents can read them but cannot forge signatures without your passphrase. + +## Question quota + +`know-code questions` sets the minimum quiz size from level, diff size, languages, and sensitive paths. The agent must meet that bar; `ask` rejects under-sized quizzes. + +## Configuration + +- `~/.know-code/config.json` — optional user defaults +- `.know-code/config.json` — per-repo settings (gitignored; from `init`) +- `know-code config` — effective merged settings + +Full reference: [Configuration](config.md). Notable default since 0.3.0: `enforcePipeline: true` (teach + quiz required before pass). + +## Hooks (summary) + +- **Git:** pre-commit / pre-push → `know-code check` +- **Agent:** deny `git add`, amend, merge/pull/rebase, hook bypasses; gate `know-code commit` and `git push` + +Details: [Hooks](hooks.md) diff --git a/website/versioned_docs/version-0.3.1/intro.md b/website/versioned_docs/version-0.3.1/intro.md new file mode 100644 index 0000000..9793509 --- /dev/null +++ b/website/versioned_docs/version-0.3.1/intro.md @@ -0,0 +1,73 @@ +--- +slug: / +sidebar_position: 1 +title: Getting started +--- + +# know-code + +**Your agents don't push until you know exactly what's changed.** + +know-code is a cross-harness [Agent Skill](https://agentskills.io) plus CLI. It blocks `git commit`, `git push`, and PR creation until **you** — the human — pass a comprehension quiz about the code that's about to ship. + +The agent does not grade itself. It teaches, writes the quiz, and proposes a score. **You** answer in the browser, review the grade, and seal the gate with your attest passphrase. + +## Who does what? + +| Role | Responsibility | +|------|----------------| +| **You (human)** | Install, `attest-init`, `range begin`, seal `taught` / `grade` / `pass`, answer the browser quiz, `range seal`, `git push`. Anything that needs your passphrase runs in **your** terminal — never inside the agent. | +| **Agent** | Explain the change (`know-code-teach`), write `.know-code/quiz.json`, run `know-code ask`, write `grade-proposal.json`, then **`git commit`** (plain git) for each slice after you pass. | +| **Git hooks** | Block commit/push when the gate is closed — for both you and the agent. | +| **CI** | `know-code verify` checks that shipped commits carry a grounded `Know-Code-Verified` trailer. | + +**Staging:** with agent hooks (recommended), **you** run `git add` in your own terminal. Agents cannot stage — that prevents sneaking edits in after the quiz. + +## Install (you, once per machine) + +```bash +npm i -g @chtnnh/know-code +know-code init --level standard --agents claude,cursor,codex --workflow +know-code attest-init # passphrase — only you know this +know-code skills # install teach + gate skills into your agent +``` + +`init --workflow` adds `.github/workflows/know-code.yml` and sets `requireTrailer` for CI. + +## The loop (range workflow) + +**Range mode = one quiz for the whole batch.** You pass once; the agent lands many commits with plain `git commit`; you finish with `range seal --rewrite` so every commit gets a `Know-Code-Verified` trailer before push. + +| Step | Who | What | +|------|-----|------| +| 1 | **You** | `know-code range begin` at the start of feature work | +| 2 | **Agent** | Implements + explains (`know-code-teach`) | +| 3 | **You** | `know-code taught` — seal that you were taught (passphrase) | +| 4 | **Agent** | `know-code questions` → writes `.know-code/quiz.json` → `quiz validate` | +| 5 | **Agent** | `know-code ask` — opens a **browser tab** for you | +| 6 | **You** | Answer questions in the browser (not in chat) | +| 7 | **Agent** | Writes `grade-proposal.json` from your answers | +| 8 | **You** | `know-code grade --review` then `know-code pass` (passphrase) — **gate opens** | +| 9 | **You** | `git add` each slice in your terminal | +| 10 | **Agent** | `git commit -m "…"` for each logical commit (gate stays open via range drift) | +| 11 | **You** | `know-code range seal --rewrite`, then `git push --force-with-lease` | + +```text +range begin → teach → taught → quiz → pass (once) + → [you: git add] → [agent: git commit] × N → range seal --rewrite → push +``` + +`know-code commit` is a convenience wrapper (adds the trailer for you). In range mode you usually use plain `git commit` and let **`range seal --rewrite`** stamp trailers on the whole batch. + +Single-commit hotfix? Skip `range begin` — see [Workflows](workflows.md). + +## When you're stuck + +```bash +know-code status --json # next step + blockers +know-code doctor --strict # hooks, attest, pipeline health +``` + +Walk through a full example: [Tutorial](tutorial.md) · Deeper mechanics: [How it works](how-it-works.md) + +Docs: [kc.chtnnhfoundation.org](https://kc.chtnnhfoundation.org) · Source: [github.com/chtnnh/know-code](https://github.com/chtnnh/know-code) diff --git a/website/versioned_docs/version-0.3.1/levels.md b/website/versioned_docs/version-0.3.1/levels.md new file mode 100644 index 0000000..b2edc51 --- /dev/null +++ b/website/versioned_docs/version-0.3.1/levels.md @@ -0,0 +1,22 @@ +--- +sidebar_position: 6 +title: Levels +--- + +# Levels + +Quiz depth is set at `init` or in config. The **agent** must write at least `minQuestions` for the active level (from `know-code questions`). + +| Level | Questions | Focus | +|-------|-----------|-------| +| `lite` | 2–3 | What changed | +| `standard` | 4–6 | Architecture + trade-offs (default) | +| `deep` | 7–10 | Failure modes, security, migrations | + +```bash +know-code init --level deep +# or +export KNOW_CODE_LEVEL=lite +``` + +Pass bar is **≥80%** solidly correct answers relative to the real diff. Vague hand-waving fails even if keywords match. diff --git a/website/versioned_docs/version-0.3.1/quiz.md b/website/versioned_docs/version-0.3.1/quiz.md new file mode 100644 index 0000000..0529b0b --- /dev/null +++ b/website/versioned_docs/version-0.3.1/quiz.md @@ -0,0 +1,63 @@ +# Quiz format (`quiz.json`) + +The **agent** writes `.know-code/quiz.json`. **You** answer in the browser when the agent runs `know-code ask`. You do not write quiz questions yourself unless you're working without an agent. + +## Who does what + +| Task | Who | +|------|-----| +| `know-code questions` (quota) | Agent | +| Write / edit `quiz.json` | Agent | +| `quiz validate` | Agent | +| `know-code ask` (opens browser) | Agent runs it · **you** submit answers | +| Read `answers.json` | Agent (for grade proposal) | + +## Schema + +```json +{ + "diffHash": "<64-hex from know-code hash>", + "level": "lite | standard | deep", + "title": "optional title", + "questions": [ + { + "id": "q1", + "prompt": "What does this change do and why?", + "expectedPoints": ["optional rubric hints for agent grading"], + "type": "text", + "choices": [] + } + ] +} +``` + +| Field | Required | Notes | +|-------|----------|-------| +| `diffHash` | yes | Must match current `know-code hash` | +| `level` | yes | Usually matches config level | +| `questions` | yes | At least `minQuestions` from `know-code questions` | +| `questions[].id` | yes | Unique stable ids (`q1`, `q2`, …) | +| `questions[].prompt` | yes | Shown in browser | +| `expectedPoints` | no | Helps agent scoring | +| `type` | no | `text` (default) or `mcq` | +| `choices` | mcq only | Array of strings | + +## Agent workflow + +```bash +know-code questions --json # agent: read minQuestions + context +know-code questions --template > .know-code/quiz.json # agent: starting skeleton +# agent edits prompts to match the real diff +know-code quiz validate +know-code ask # you: answer in browser tab +``` + +`ask` rejects quizzes that are too short, missing ids, or bound to a stale hash. + +## Validation errors + +- **Hash mismatch** — diff changed; agent re-runs `questions` and rewrites the quiz +- **Too few questions** — agent adds questions until `minQuestions` met +- **Missing ids** — every question needs `id` + `prompt` + +See also: [Grading](grading.md) · [Levels](levels.md) · [Tutorial](tutorial.md) diff --git a/website/versioned_docs/version-0.3.1/skills.md b/website/versioned_docs/version-0.3.1/skills.md new file mode 100644 index 0000000..39c4d14 --- /dev/null +++ b/website/versioned_docs/version-0.3.1/skills.md @@ -0,0 +1,66 @@ +--- +sidebar_position: 4 +title: Skills +--- + +# Skills + +Two skills ship in this repository. Install them into your agent harness so it knows the human/agent split. + +## know-code-teach + +**Agent** explains architecture, decisions, and trade-offs **before** edits and while coding. It never opens the gate. + +When to use: session start, before non-trivial work, after a gate deny (before the quiz), or when you ask the agent to catch you up. + +After the agent teaches, **you** seal receipt: + +```bash +know-code taught +``` + +Skip only if you explicitly say so ("skip teach", "just do it") — then you still run `taught --skip` yourself. + +## know-code + +**Agent** runs the gate workflow; **you** own attest seals and browser answers. + +| Step | Who | Action | +|------|-----|--------| +| 1 | **You** | `know-code range begin` (multi-commit batches) | +| 2 | **Agent** | Teach, implement, and explain the final staged diff (or you skipped) | +| 3 | **You** | `know-code taught` for that final staged hash | +| 4 | **Agent** | `questions` → write `quiz.json` → `quiz validate` | +| 5 | **Agent** | `know-code ask` → **you** answer in browser | +| 6 | **Agent** | Write `grade-proposal.json` | +| 7 | **You** | `grade --review` → `pass` | +| 8 | **You** | `git add` per slice | +| 9 | **Agent** | `git commit -m "…"` (plain git, gate open) | +| 10 | **You** | `range seal --rewrite` → `git push` | + +Hard rules for the agent: + +- Quiz answers happen in the **browser**, never in chat +- Never set `KNOW_CODE_OVERRIDE` or `KNOW_CODE_ATTEST_PASSPHRASE` +- Never forge signed artifacts (`taught`, `grade`, `pass`, `range-seal`) +- Never run `git add` (you stage in your terminal) + +## Install into your harness + +**Project** (committed with the repo): + +```bash +know-code skills +``` + +**Global** (every repo — Cursor, Claude Code, Codex, …): + +```bash +know-code skills --global +``` + +Global installs land under `~/.cursor/skills/`, `~/.claude/skills/`, `~/.codex/skills/`. List with `npx skills ls -g`. + +Optional: `know-code skills --agents claude,cursor --yes` + +This repo keeps committed copies under `.agents/skills/`. Local harness links: `npm run link-skills` (gitignored). diff --git a/website/versioned_docs/version-0.3.1/team.md b/website/versioned_docs/version-0.3.1/team.md new file mode 100644 index 0000000..78ae4a8 --- /dev/null +++ b/website/versioned_docs/version-0.3.1/team.md @@ -0,0 +1,31 @@ +# Team onboarding + +Each developer has **local** know-code state (`.know-code/` is gitignored). On a team, every person runs their own attest key and seals their own `taught` / `grade` / `pass` — the agent still writes quizzes and grade proposals; teammates answer and attest individually before pushing. + +## New developer checklist + +1. Clone repo +2. `npm i -g @chtnnh/know-code` (or use monorepo `npm run know-code --`) +3. `know-code init --agents cursor` (or your IDE) +4. `know-code attest-init` — **per machine** (passphrase seals) +5. `know-code skills` or `know-code skills --global` +6. Align CI: `know-code init --workflow` sets `requireTrailer: true` + +## Attest keys + +- Private key: `~/.know-code/attest//` (encrypted) +- Never commit attest material +- Rotating: `know-code attest-init --force` invalidates old seals + +## Branch protection + +Use `scripts/setup-branch-protection.mjs` (see [CI](./ci.md)) to require the know-code workflow on PRs. + +## CI vs local + +If CI fails but local push works, check `requireTrailer` — the GitHub Action defaults it to `true` when config is missing. + +```bash +know-code config set requireTrailer true +know-code doctor --strict +``` diff --git a/website/versioned_docs/version-0.3.1/troubleshooting.md b/website/versioned_docs/version-0.3.1/troubleshooting.md new file mode 100644 index 0000000..2e28f4e --- /dev/null +++ b/website/versioned_docs/version-0.3.1/troubleshooting.md @@ -0,0 +1,237 @@ +--- +sidebar_position: 8 +title: Troubleshooting +--- + +# Troubleshooting + +## commit/push blocked + +```text +know-code: commit/push blocked — diff changed since last quiz. +``` + +The diff changed since your last pass. Re-run the pipeline: + +| Step | Who | Command | +|------|-----|---------| +| Start range (if batch) | **You** | `know-code range begin` | +| Teach seal | **You** | `know-code taught` | +| Quiz | **Agent** | `questions` → write `quiz.json` → `ask` | +| Answer | **You** | Browser tab from `ask` | +| Grade + pass | **You** | `grade --review` → `pass` | +| Commit | **Agent** | `know-code commit -m "…"` | + +```bash +know-code status --json +know-code doctor +``` + +## Quiz hash mismatch + +```text +Quiz diffHash does not match current … hash +``` + +Re-run `know-code questions --template`, rewrite `.know-code/quiz.json` for the current hash (`know-code hash`). + +## Too few quiz questions + +```text +Quiz has N questions but need at least M +``` + +Run `know-code questions --json` and add questions until `minQuestions` is met. Validate with `know-code quiz validate`. + +## `range already active` + +Finish with `know-code range seal` or clear with `know-code range abort` (`--keep-seal` to retain range-seal.json). + +## Grade below 0.8 (exit 2) + +Re-teach weak areas, update quiz, re-run `ask`, agent re-proposes grade, human `grade --review` again. + +## `requireTrailer` local vs CI mismatch + +Local `init` defaults `requireTrailer: false`; the GitHub Action writes `true` if config is missing. Align: + +```bash +know-code config set requireTrailer true +``` + +Or use `know-code init --workflow` (sets `requireTrailer` when adding CI). + +## Port in use (`ask`) + +```text +Port 3847 in use — try: know-code ask --port +``` + +## Skills install failure + +If `npx skills add` fails, install manually from [skills.md](skills.md) or clone the repo skills into your agent skills directory. + +## Stale seals after rebase / pull + +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) + +Upgrade to ≥0.2.1 and re-run `know-code pass` once so `gate.json` includes `gatedTreeOid`. Seal honors commit drift (pass on staged batch → commits → seal on tip). + +## CI verify accepts wrong trailer (pre-0.2.1) + +Fixed in 0.2.1: verify only accepts grounded hashes. Do not rely on a hand-written `Know-Code-Verified` line that does not match `know-code hash` / merge-base..HEAD. + +## Pathspec / partial commits after pass + +`know-code commit -- path` can shrink the index tree and invalidate `gatedTreeOid`. Prefer staging the full batch (`git add -A`) before `pass`, then commit without pathspecs — or re-pass after intentional tree changes. + +## Corrupt `.know-code/*.json` + +`status` / `doctor` report a `corrupt` blocker. Fix or `know-code reset` and re-run the pipeline. + +## Wrong attest passphrase + +```text +wrong attest passphrase +``` + +Use the passphrase from `attest-init`. Rotate with `attest-init --force` (invalidates old seals). + +## `status --json` debugging + +```bash +know-code status --json | jq '.nextStep, .blockers' +``` + +## `know-code commit` says pass `-m "..."` + +The CLI must receive `-m` and the message as separate argv tokens. Always quote: + +```bash +know-code commit -m "fix(cli): thing" +# monorepo: npm run know-code -- commit -m "fix(cli): thing" +``` + +## Hash changed / scope confusion + +- **Index scope:** hash = empty-tree → index (staged + HEAD tree). Syncing `origin/main` without staging changes usually does not change it. +- **Range scope:** hash = tree of range start → `write-tree` while `range begin` is active. See `know-code config --json`. + +## After `range seal --rewrite` + +Trailers use the **range** hash from `range-seal.json`, not the post-seal index hash. Verify with: + +```bash +know-code verify --require-range-trailers +``` + +Push uses `check`, which reads the sealed range when trailers match. + +## Global `know-code` vs monorepo build + +Git hooks prefer `packages/cli/dist/index.js` when present. If push fails but `npm run know-code -- check` passes, reinstall hooks: `know-code init` or refresh `.git/hooks/pre-push`. + +## Cursor hook fired on a non-git command + +The shell hook only gates the **parsed** `command` field — not incidental text in JSON or heredocs. Re-run `know-code init --agents cursor` to refresh `.cursor/hooks.json`. + +## 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. See [Verification design](verify.md#github-merge-methods) for merge-button behavior. + +## Quiz timed out + +`know-code ask` waits 1800s by default. Raise with `--timeout` or `KNOW_CODE_QUIZ_TIMEOUT`. + +## Emergency bypass (human TTY only) + +```bash +know-code override +KNOW_CODE_OVERRIDE=1 git commit +``` + +Denied in agent hooks and CI. Logged under `.know-code/override.log`. + +## Upgrading to 0.3.0 + +1. Re-run **`know-code pass`** once so `gate.json` includes `gatedTreeOid` (legacy gates never open). +2. Refresh hooks: `know-code hooks install` and `know-code init --agents cursor,claude,codex`. +3. Agents can no longer run raw `git add`, `git commit --amend`, `git merge`, etc. — humans stage outside the agent; agents use `know-code commit` / `know-code amend`. +4. `enforcePipeline` defaults to **true** — teaching + quiz before pass. +5. Before shipping: `know-code doctor --strict` (also run by `know-code ship`). + +## Agent denied: git add / amend / merge + +Expected in 0.3.0. Stage and history rewrite belong to the human (or a non-agent shell). After the quiz: + +```bash +know-code commit -m "feat: …" +# or +know-code amend +``` + +## Gate closed: missing gatedTreeOid + +```text +gate.json missing gatedTreeOid (legacy) +``` + +Re-seal after upgrade: + +```bash +know-code pass +``` + +## pass refused: missing taught / answers / grade + +`know-code pass` needs matching **sealed** artifacts for the **current** hash. Re-run `taught`, complete `ask`, then `grade` before `pass` — in a human terminal with your attest passphrase. + +## attest not initialized + +```bash +know-code attest-init +``` + +Creates a passphrase-encrypted Ed25519 key under `~/.know-code/attest/` (public key in `meta.json`). Nothing attest-related is committed to git. diff --git a/website/versioned_docs/version-0.3.1/tutorial.md b/website/versioned_docs/version-0.3.1/tutorial.md new file mode 100644 index 0000000..677583f --- /dev/null +++ b/website/versioned_docs/version-0.3.1/tutorial.md @@ -0,0 +1,154 @@ +# 5-minute first gated commit + +This walkthrough assumes **you** and a **coding agent** (Cursor, Claude Code, Codex, …) working in the same repo. If you work solo without an agent, you run the agent steps yourself — but you still answer the quiz in the browser; you don't rubber-stamp your own score without `grade-proposal.json`. + +Prerequisites: a git repo with at least one commit on `main`, Node 20+. + +## At a glance + +| Step | Who | What | +|------|-----|------| +| [1. Install](#1-install-you-once) | You | CLI, hooks, attest key, skills | +| [2. Change + stage](#2-make-a-change-and-stage) | Agent edits · **you** `git add` | Code lands in the index | +| [3. Teach](#3-agent-teaches-you-seal) | Agent explains · **you** `taught` | Receipt that you were taught | +| [4. Quiz](#4-agent-writes-quiz-you-answer-in-browser) | Agent writes quiz · **you** answer in browser | Comprehension check | +| [5. Grade + pass](#5-agent-proposes-grade-you-seal) | Agent proposes · **you** `grade --review` + `pass` | Gate opens | +| [6. Commit](#6-agent-commits-plain-git) | You `git add` · Agent `git commit` | Plain git after pass | +| [7. Ship](#7-verify-and-push) | You | `range seal --rewrite`, push | + +```mermaid +flowchart TB + subgraph you["You"] + I[Install + attest-init] + T[taught] + Q[Answer in browser] + G[grade --review + pass] + SA[git add] + P[range seal --rewrite + push] + end + subgraph agent["Agent"] + E[Edit files] + W[Write quiz.json] + A[ask] + GP[grade-proposal.json] + C[git commit] + end + I --> E --> SA --> T + T --> W --> A --> Q + Q --> GP --> G --> C --> P +``` + +--- + +## 1. Install (you, once) + +Run in **your** terminal: + +```bash +npm i -g @chtnnh/know-code +know-code init --agents claude,cursor,codex # git hooks + agent shell hooks +know-code attest-init # passphrase — seals are yours alone +know-code skills # teach + gate skills for the agent +``` + +`attest-init` creates an Ed25519 key encrypted with your passphrase. The agent cannot forge `taught`, `grade`, or `pass` without it. + +--- + +## 2. Make a change and stage + +**Agent:** edit a file (e.g. add a line to `README.md`). + +**You:** stage the change in **your** terminal (not inside the agent — agent hooks deny `git add`): + +```bash +git add . +``` + +Why you stage: the quiz hashes what's in the index. Letting the agent stage would allow changing files after the quiz without you noticing. + +--- + +## 3. Agent teaches, you seal + +**Agent:** runs the **know-code-teach** skill — explains what changed, why, and trade-offs. (If you're solo, read the diff yourself or ask your agent to explain before continuing.) + +**You:** confirm you understood, then seal the teach receipt: + +```bash +know-code taught +``` + +Enter your attest passphrase. This writes a signed `taught.json` bound to the current diff hash. + +--- + +## 4. Agent writes quiz, you answer in browser + +**Agent** — not you — authors the quiz: + +```bash +know-code questions --json # see minimum question count +# agent writes .know-code/quiz.json from the diff (see quiz.md for schema) +know-code quiz validate +know-code ask +``` + +`know-code ask` opens a **browser tab**. Questions are about the real diff — the agent cannot answer for you in chat. + +**You:** complete the form in the browser. When you submit, `answers.json` is written locally. + +--- + +## 5. Agent proposes grade, you seal + +**Agent** reads your answers and writes `.know-code/grade-proposal.json` (proposed per-question scores and feedback). + +**You:** review and attest — the agent does not self-assign the final score: + +```bash +know-code grade --review # TUI: adjust scores if needed; needs ≥80% to pass +know-code pass # opens the gate for commit/push +``` + +Both commands need your attest passphrase. + +--- + +## 6. Agent commits (plain git) + +Gate open? **You** stage; **agent** commits with plain git: + +```bash +git add . # you, in your terminal +git commit -m "docs: my first gated commit" # agent +``` + +Hooks run `know-code check` on every `git commit` — no need for `know-code commit` per slice if you'll `range seal --rewrite` before push. `know-code commit` is a convenience that adds the trailer for you (handy for single-commit hotfixes). + +For a multi-commit batch: repeat `git add` / `git commit` for each slice, then seal. + +--- + +## 7. Verify and push + +**You:** + +```bash +know-code status +know-code range seal --rewrite # stamps Know-Code-Verified on every commit in range +know-code doctor --strict +git push --force-with-lease +``` + +For multi-commit batches, start with `know-code range begin` before step 3 — see [Workflows](workflows.md). + +--- + +## Stuck? + +```bash +know-code status --json +``` + +Common issues: [Troubleshooting](troubleshooting.md) · Quiz format: [Quiz](quiz.md) · Grading: [Grading](grading.md) diff --git a/website/versioned_docs/version-0.3.1/verify.md b/website/versioned_docs/version-0.3.1/verify.md new file mode 100644 index 0000000..def4da7 --- /dev/null +++ b/website/versioned_docs/version-0.3.1/verify.md @@ -0,0 +1,203 @@ +--- +sidebar_position: 12 +title: Verification design +--- + +# 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 +flowchart LR + subgraph local ["Local machine same UID"] + Teach[taught / quiz / grade] + Gate[gate.json seal] + Hooks[git + agent hooks] + end + subgraph publicGit ["Public git objects"] + Tip[PR tip commits] + Trailers[Know-Code-Verified trailers] + end + subgraph ci ["CI runner"] + Verify[know-code verify] + Tree[recomputed tree hashes] + end + Teach --> Gate + Gate --> Hooks + Hooks --> Tip + Tip --> Trailers + Trailers --> Verify + Tree --> Verify +``` + +| Artifact | Trusted in CI? | Why | +|----------|----------------|-----| +| 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 (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 + +### Index (hotfix / no active range) + +`sha256("diff:" + git diff empty-tree write-tree)` + +Covers **HEAD + staged** as one tree. Used when `rangeMode` is off or no range session is active. + +### Range (active `range begin` or `rangeMode: range`) + +```text +sha256("diff:" + git diff FROM_TREE INDEX_TREE) +``` + +`FROM_TREE` is the tree of the range start commit. `INDEX_TREE` is `git write-tree` (HEAD plus staged). + +**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”): + +1. **index** — empty-tree → current index tree +2. **merge-base..HEAD** — when ahead of base: range formula from merge-base +3. **uniform-trailers** — only if every commit shares a hash that is already a grounded candidate +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` (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 | 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: + push: + branches: [main] + +jobs: + verify: + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + 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: | + if [ "${{ github.event_name }}" = "push" ]; then + know-code verify --from "${{ github.event.before }}" + else + know-code verify + fi +``` + +`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 + +```bash +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, then `know-code verify --from HEAD^`. A forged trailer must fail. + +## See also + +- [CI & GitHub Action](ci.md) — install / branch protection +- [How it works](how-it-works.md) — local gate + layers +- [Workflows](workflows.md) — receipt vs rewrite +- [Troubleshooting](troubleshooting.md) — CI trailer failures diff --git a/website/versioned_docs/version-0.3.1/workflows.md b/website/versioned_docs/version-0.3.1/workflows.md new file mode 100644 index 0000000..0b91ee6 --- /dev/null +++ b/website/versioned_docs/version-0.3.1/workflows.md @@ -0,0 +1,96 @@ +# Workflows + +All workflows share the same **teach → quiz → pass** pipeline. What changes is how many commits one quiz covers. + +## Range (default — one quiz per feature batch) + +Use when the agent will make **2+ commits** before push. + +**The point of range mode:** quiz and `pass` **once**, commit freely with plain `git`, then `range seal --rewrite` before push. + +```mermaid +flowchart LR + A[range begin] --> B[teach + quiz + pass] + B --> C["git add (you)"] + C --> D["git commit × N (agent)"] + D --> E[range seal --rewrite] + E --> F[push] +``` + +| Step | Who | Command | +|------|-----|---------| +| Start session | **You** | `know-code range begin` | +| Implement | **Agent** | edits + `know-code-teach` | +| Seal teach | **You** | `know-code taught` | +| Write quiz | **Agent** | `questions` → `.know-code/quiz.json` → `quiz validate` | +| Quiz | **Agent** runs `ask` · **you** answer in browser | `know-code ask` | +| Grade + pass | **Agent** writes `grade-proposal.json` · **you** seal | `grade --review` → `pass` | +| Stage each slice | **You** | `git add` | +| Commit each slice | **Agent** | `git commit -m "…"` (plain git, gate open) | +| Seal + ship | **You** | `range seal --rewrite` → `git push --force-with-lease` | + +```bash +know-code range begin +# agent: implement + teach → you: taught +# agent: quiz + ask → you: browser → agent: grade-proposal → you: grade --review + pass + +git add -A # you: stage slice 1 +git commit -m "feat(cli): kernel" # agent +git add -A # you: stage slice 2 +git commit -m "fix(hooks): surface" # agent +# … repeat … + +know-code range seal --rewrite +git push --force-with-lease +``` + +`know-code commit` adds a trailer automatically — useful for index-only hotfixes. In range mode, **`range seal --rewrite`** is how trailers land on every commit. + +Next batch: `know-code range continue --yes` + +## Index-only (single-commit hotfix) + +No `range begin`. Quiz hash = staged index diff. One commit, then push. + +```mermaid +flowchart LR + A[git add] --> B[teach + quiz + pass] + B --> C[git commit or know-code commit] + C --> D[git push] +``` + +| Step | Who | +|------|-----| +| Stage | **You** — `git add` | +| Pipeline | Same teach → quiz → grade → pass | +| Commit | **Agent** — `git commit` or `know-code commit` | +| Push | **You** — `git push` | + +```bash +git add -p +# agent: teach → you: taught → agent: quiz + ask → you: browser +# agent: grade-proposal → you: grade --review + pass +git commit -m "hotfix: …" +git push +``` + +Set `rangeMode: "index"` in config to always use index scope. + +## PR-first + +Hooks gate `gh pr create` and `glab mr create`. Complete the quiz pipeline **before** opening the PR. + +## Receipt vs rewrite + +| Mode | When | What happens | +|------|------|--------------| +| **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** 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` + +- Wrong merge-base / started range by mistake +- `range abort` clears the session (`--keep-seal` retains `range-seal.json`) +- Does not undo commits — only local session state diff --git a/website/versioned_sidebars/version-0.3.1-sidebars.json b/website/versioned_sidebars/version-0.3.1-sidebars.json new file mode 100644 index 0000000..19ae395 --- /dev/null +++ b/website/versioned_sidebars/version-0.3.1-sidebars.json @@ -0,0 +1,19 @@ +{ + "docsSidebar": [ + "intro", + "tutorial", + "how-it-works", + "workflows", + "config", + "quiz", + "grading", + "skills", + "cli", + "levels", + "hooks", + "ci", + "verify", + "team", + "troubleshooting" + ] +} diff --git a/website/versions.json b/website/versions.json index eff911b..8c70846 100644 --- a/website/versions.json +++ b/website/versions.json @@ -1,4 +1,5 @@ [ + "0.3.1", "0.3.0", "0.2.0" ]