Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,14 @@ poetry.lock -text
*.cmd text eol=crlf
*.bat text eol=crlf

# Changelogs are append-mostly; auto-merge by unioning both sides.
packages/*/CHANGELOG.md merge=union

# CHANGELOGs are deliberately NOT given `merge=union`. Union never conflicts --
# it concatenates both sides of an overlapping hunk. Release commits insert
# `## [X.Y.Z]` directly beneath the surviving `## [Unreleased]` heading, so a
# branch that added entries under Unreleased overlaps exactly that region and
# union silently files those entries inside a version that already shipped.
# GitHub also ignores the driver when computing mergeability, so it reported
# phantom conflicts on every PR that touched a CHANGELOG. A real conflict that
# an author resolves is strictly better than a silent misfile.

# Byte-exact render-golden fixtures must never be normalized. Mark them
# -diff too so `git diff --check` does not flag trailing whitespace on raw
Expand Down
10 changes: 10 additions & 0 deletions .github/workflows/public-site-sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.3"
# The docs index is generated and untracked, and this job deliberately
# skips `bun install` (it only reads repo metadata), so the root `prepare`
# hook never fires here. Build it explicitly, which also keeps the
# staleness assertion in check:public-sync meaningful rather than vacuous.
- name: Build the generated docs index
run: bun --cwd=packages/coding-agent run generate-docs-index
- name: Check local public docs/site/version metadata
run: bun run check:public-sync

Expand All @@ -39,5 +45,9 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.3"
# `--live` runs the local surface check too, so it needs the generated
# docs index for the same reason as local-public-sync above.
- name: Build the generated docs index
run: bun --cwd=packages/coding-agent run generate-docs-index
- name: Exercise production remote final-evidence and deployed release-state validation
run: bun scripts/check-public-version-sync.ts --live
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ bun run check

Use focused tests first for code changes, then broader checks when the change affects shared behavior or release-critical paths.

## Rebasing onto `dev`

`dev` moves often, so expect to rebase. Two files behave in ways worth knowing about up front.

**`packages/*/CHANGELOG.md` conflicts are normal.** These files have no custom merge driver: if your branch and `dev` both added entries under `## [Unreleased]`, git reports a real conflict. Resolve it by keeping **both** entries under `## [Unreleased]`. Never move an entry into a released `## [X.Y.Z]` section, and never edit a released section — that version already shipped and its notes are historical record.

**`packages/coding-agent/src/internal-urls/docs-index.generated.ts` is generated and untracked.** `bun install` rebuilds it through the root `prepare` hook, and `bun run generate-docs-index` rebuilds it on demand. Do not commit it. If you see it in `git status`, something forced it back into the index — `git rm --cached` it. A tracked copy inlines every doc onto a single line, which git cannot three-way merge, so it conflicts on every rebase.

## PR checklist

- Target branch is `dev`, not `main`.
Expand Down
127 changes: 0 additions & 127 deletions packages/coding-agent/src/internal-urls/docs-index.generated.ts

This file was deleted.

38 changes: 18 additions & 20 deletions packages/coding-agent/test/docs-index-lazy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,14 @@ async function scanDocsCorpus(): Promise<string[]> {
const REPO_ROOT = path.join(import.meta.dir, "../../..");
const GENERATED_INDEX = "packages/coding-agent/src/internal-urls/docs-index.generated.ts";

/** `git diff --quiet HEAD -- <paths>`. Exit 0 means the worktree matches the commit. */
function matchesHead(...paths: string[]): boolean {
/** `git ls-files --error-unmatch <path>`. Exit 0 means git tracks the path. */
function isTracked(relativePath: string): boolean {
const result = Bun.spawnSync({
cmd: ["git", "diff", "--quiet", "HEAD", "--", ...paths],
cmd: ["git", "ls-files", "--error-unmatch", "--", relativePath],
cwd: REPO_ROOT,
stdout: "pipe",
stderr: "pipe",
});
// 0 = no diff, 1 = diff. Anything else (no git, not a repo) is a real error.
expect([0, 1], result.stderr.toString() || `git diff exited ${result.exitCode}`).toContain(result.exitCode);
return result.exitCode === 0;
}

Expand Down Expand Up @@ -103,23 +101,23 @@ describe("internal-urls docs index loading", () => {
});

/**
* The two assertions above compare the worktree index to the worktree docs, and
* the root `prepare` hook regenerates the index on every `bun install` — which CI
* runs before any test. So a *committed* index that is stale gets silently repaired
* in the worktree and both assertions pass. The invariant they cannot see is
* `committed index == committed docs`.
* The two assertions above compare the worktree index to the worktree docs,
* which is the whole contract now that the index is generated rather than
* committed: the root `prepare` hook rebuilds it on every `bun install`, so
* the worktree copy is authoritative and a stale *committed* copy cannot
* exist to drift from it.
*
* This closes that: if `docs/` matches HEAD but the index does not, the only thing
* that could have rewritten the index is the generator, which means the commit
* shipped a stale one. When `docs/` is itself dirty the developer is mid-edit and
* there is nothing to conclude, so the check yields rather than false-failing.
* Keeping it untracked is what makes that true. The generator emits one line
* per doc — each holding an entire document as a single JSON string — so two
* branches editing the same doc produce a whole-line conflict that git cannot
* three-way merge. Tracking it reintroduces a conflict on every rebase, and
* because `.gitignore` only governs untracked paths, the ignore rule goes
* inert the moment something forces it back into the index.
*/
it("commits an index that matches the committed docs", () => {
if (!matchesHead("docs")) return;

it("keeps the generated index untracked so it cannot conflict on rebase", () => {
expect(
matchesHead(GENERATED_INDEX),
`committed docs index is stale relative to committed docs/; ${REGENERATE_HINT}`,
).toBe(true);
isTracked(GENERATED_INDEX),
`${GENERATED_INDEX} is committed; it is generated and gitignored. Run: git rm --cached ${GENERATED_INDEX}`,
).toBe(false);
});
});
45 changes: 45 additions & 0 deletions scripts/check-public-version-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { $ } from "bun";
import { buildDocsIndexOutput, checkLivePublicVersionSync, checkPublicVersionSync } from "./check-public-version-sync";
import { canonicalJsonBytes, createExpectedEvidence, createFinalEvidence, expectedEvidenceSha256, PUBLIC_PACKAGE_DEFINITIONS } from "./release-evidence";

Expand Down Expand Up @@ -210,6 +211,50 @@ describe("public docs/site/version sync guard", () => {
expect(violations.some(violation => violation.path === "README.md" && violation.message.includes("Visible marketing version 1.2.2"))).toBe(true);
expect(violations.some(violation => violation.path.includes("docs-index.generated.ts") && violation.message.includes("stale"))).toBe(true);
});
test("fails when the generated docs index is committed, and passes once it is untracked", async () => {
// The file is gitignored, but git only consults .gitignore for untracked
// paths -- so an accidental `git add` makes the ignore rule inert and
// restores the unmergeable one-line-per-doc artifact to the index.
const root = await createRepo({
"package.json": rootPackage(),
"packages/coding-agent/package.json": packageJson("@gajae-code/coding-agent"),
"packages/gajae-code/package.json": packageJson("gajae-code"),
"README.md": "# Gajae-Code\n",
"docs/sdk.md": "# SDK\n\nCurrent docs.\n",
".gitignore": "packages/coding-agent/src/internal-urls/docs-index.generated.ts\n",
});
await addGeneratedDocsIndex(root);
await $`git -C ${root} init -q`.quiet();
await $`git -C ${root} add -A --force`.quiet();

const committed = await checkPublicVersionSync(root);
expect(
committed.some(
violation =>
violation.path.includes("docs-index.generated.ts") && violation.message.includes("git rm --cached"),
),
).toBe(true);

await $`git -C ${root} rm --cached -q -- packages/coding-agent/src/internal-urls/docs-index.generated.ts`.quiet();

// Untracked but still on disk and still current: nothing left to report.
await expect(checkPublicVersionSync(root)).resolves.toEqual([]);
});

test("does not report tracking outside a git work tree", async () => {
// An extracted release tarball has no .git; the guard must stay silent
// rather than fail on a missing git or a non-repo directory.
const root = await createRepo({
"package.json": rootPackage(),
"packages/coding-agent/package.json": packageJson("@gajae-code/coding-agent"),
"packages/gajae-code/package.json": packageJson("gajae-code"),
"README.md": "# Gajae-Code\n",
"docs/sdk.md": "# SDK\n\nCurrent docs.\n",
});
await addGeneratedDocsIndex(root);

await expect(checkPublicVersionSync(root)).resolves.toEqual([]);
});

test("live check executes the production final-evidence validator against canonical deployed release state", async () => {
const responses = liveResponses(stableRelease(), releaseState());
Expand Down
32 changes: 31 additions & 1 deletion scripts/check-public-version-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import * as fs from "node:fs/promises";
import * as path from "node:path";
import { Glob } from "bun";
import { $, Glob } from "bun";
import {
canonicalJsonBytes,
PUBLIC_PACKAGE_DEFINITIONS,
Expand Down Expand Up @@ -110,6 +110,23 @@ async function pathExists(candidate: string): Promise<boolean> {
}
}

/**
* Whether git tracks `relativePath` inside `repoRoot`.
*
* A generated artifact that is both gitignored and tracked is the worst of both
* worlds: the ignore rule is inert (git only consults it for untracked paths),
* and every regeneration shows up as a spurious working-tree change. Returns
* false when `repoRoot` is not a git work tree, so the unit-test temp dirs and
* an extracted release tarball are both treated as "nothing to report" rather
* than failing on an absent git.
*/
export async function isTrackedByGit(repoRoot: string, relativePath: string): Promise<boolean> {
const insideWorkTree = await $`git -C ${repoRoot} rev-parse --is-inside-work-tree`.quiet().nothrow();
if (insideWorkTree.exitCode !== 0 || insideWorkTree.text().trim() !== "true") return false;
const tracked = await $`git -C ${repoRoot} ls-files --error-unmatch -- ${relativePath}`.quiet().nothrow();
return tracked.exitCode === 0;
}

async function readJson<T>(filePath: string): Promise<T> {
return (await Bun.file(filePath).json()) as T;
}
Expand Down Expand Up @@ -253,6 +270,19 @@ export async function checkPublicVersionSync(repoRoot = path.join(import.meta.di
}
}

if (await isTrackedByGit(repoRoot, GENERATED_DOCS_INDEX)) {
violations.push({
path: GENERATED_DOCS_INDEX,
message:
"Generated docs index is committed. It is listed in .gitignore and rebuilt by the root `prepare` hook, " +
"so a tracked copy only re-creates the merge conflict it was ignored to avoid: the generator emits one " +
"line per doc, and git cannot three-way merge two edits to the same multi-thousand-character line. " +
"Run `git rm --cached " +
GENERATED_DOCS_INDEX +
"`.",
});
}

return violations;
}

Expand Down
Loading