From 87f700b9ae4b1ab125f0faff30224566c73bd8cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Fri, 27 Feb 2026 09:28:19 +0100 Subject: [PATCH 01/10] feat(docs): add What's New blog section with v1.0.0 post - Add docs/blog/index.md as release listing page - Add docs/blog/release-v1-0-0.md with v1 highlights - Add /blog/ nav item and sidebar to VitePress config Part of #60, closes #56 --- docs/.vitepress/config.mts | 14 +++++++ docs/blog/index.md | 20 ++++++++++ docs/blog/release-v1-0-0.md | 73 +++++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 docs/blog/index.md create mode 100644 docs/blog/release-v1-0-0.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 3876ce9..ca80fbb 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -68,6 +68,11 @@ export default defineConfig({ activeMatch: "^/getting-started/", }, { text: "Usage", link: "/usage/search-syntax", activeMatch: "^/usage/" }, + { + text: "What's New", + link: "/blog/", + activeMatch: "^/blog/", + }, { text: "Reference", link: "/reference/cli-options", @@ -146,6 +151,15 @@ export default defineConfig({ ], }, ], + "/blog/": [ + { + text: "What's New", + items: [ + { text: "All releases", link: "/blog/" }, + { text: "v1.0.0", link: "/blog/release-v1-0-0" }, + ], + }, + ], "/architecture/": [ { text: "Architecture", diff --git a/docs/blog/index.md b/docs/blog/index.md new file mode 100644 index 0000000..c97ed03 --- /dev/null +++ b/docs/blog/index.md @@ -0,0 +1,20 @@ +# What's New + +Release highlights for every version of `github-code-search`. +Each post covers the main features, fixes, and upgrade notes for that release. +Full release notes and changelogs are always available on +[GitHub Releases](https://github.com/fulll/github-code-search/releases). + +--- + +## v1 series + +| Release | Highlights | +| -------------------------- | -------------------------------------------------------------------------------------- | +| [v1.0.0](./release-v1-0-0) | Initial public release — interactive TUI, per-repo aggregation, markdown / JSON output | + +--- + +::: tip Upgrading? +Run `github-code-search upgrade` to update to the latest version in one command. +::: diff --git a/docs/blog/release-v1-0-0.md b/docs/blog/release-v1-0-0.md new file mode 100644 index 0000000..0a915ab --- /dev/null +++ b/docs/blog/release-v1-0-0.md @@ -0,0 +1,73 @@ +--- +title: "What's new in v1.0.0" +description: "Initial public release of github-code-search — interactive TUI, per-repo aggregation, markdown/JSON output." +date: 2025-01-01 +--- + +# What's new in github-code-search v1.0.0 + +> Full release notes: + +## The beginning + +`github-code-search` is an interactive CLI for searching GitHub code across an entire organization. +It wraps the [GitHub Code Search API](https://docs.github.com/en/rest/search/search#search-code) +and adds a keyboard-driven TUI on top, so you can browse results, fold/unfold extracts, and select +the ones you care about before printing structured output. + +## Highlights + +### Keyboard-driven TUI + +Search results are loaded and displayed in an interactive terminal UI. +Navigate with / , fold/unfold extracts with / , +select results with Space, and confirm with Enter. +Press ? to show the full keyboard shortcut reference. + +→ [Interactive mode guide](/usage/interactive-mode) + +### Per-repository aggregation + +All code matches for the same repository are grouped together, with per-file headings. +No more scanning through a flat list of individual matches. + +### Markdown and JSON output + +Use `--format markdown` (default) for human-readable output, or `--format json` for +machine-readable output suitable for further processing. + +Use `--output-type repo-only` to print only repository names (no code extracts). + +→ [Output formats guide](/usage/output-formats) + +### Filtering + +Exclude noisy repositories with `--exclude-repositories` or specific extracts with +`--exclude-extracts`. Both flags accept short (`repoName`) and long (`org/repoName`) forms. + +→ [Filtering guide](/usage/filtering) + +### Team grouping + +Group results by team prefix using `--group-by-team-prefix`. Results are arranged in sections +per team, making it easy to see which squads own the code that matches your search. + +→ [Team grouping guide](/usage/team-grouping) + +### Non-interactive / CI mode + +Use `--no-interactive` (or set `CI=true`) to pipe output directly to downstream tools. +Syntax highlighting and TUI chrome are stripped so the output is clean. + +→ [Non-interactive mode guide](/usage/non-interactive-mode) + +### Built-in upgrade command + +```bash +github-code-search upgrade +``` + +Checks for a newer binary on GitHub Releases and replaces the running binary in-place. +No package manager required. + +→ [Upgrade guide](/usage/upgrade) From 99febf9f8284e044ed247df168867dde44a107fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Fri, 27 Feb 2026 09:31:03 +0100 Subject: [PATCH 02/10] feat(ci): auto-generate blog stub on major release tags - Add 'Generate blog post stub for new major version' step in the snapshot CI job: creates docs/blog/release-vX-0-0.md idempotently - Extend git-auto-commit step to include docs/blog/ alongside versions.json - Make blog sidebar dynamically generated from docs/blog/*.md files in config.mts so new stubs appear in the nav without a manual config PR Part of #60, closes #57 --- .github/workflows/docs.yml | 48 +++++++++++++++++++++++++++++++++++--- docs/.vitepress/config.mts | 26 ++++++++++++++++++++- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index dc33499..e560365 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -194,11 +194,53 @@ jobs: docs/public/versions.json > /tmp/versions_new.json mv /tmp/versions_new.json docs/public/versions.json - - name: Commit versions.json to main + - name: Generate blog post stub for new major version + run: | + set -euo pipefail + # Convert vX.0.0 → v-0-0 for the file name (e.g. v3.0.0 → v3-0-0) + TAG="$GITHUB_REF_NAME" + SLUG="${TAG//./-}" # v3.0.0 → v3-0-0 + BLOG_FILE="docs/blog/release-${SLUG}.md" + RELEASE_DATE="$(date -u +%Y-%m-%d)" + # Idempotent — skip creation if the file already exists (manually authored). + if [ -f "$BLOG_FILE" ]; then + echo "Blog post $BLOG_FILE already exists — skipping stub generation." + else + cat > "$BLOG_FILE" < Full release notes: + + + EOF + echo "Created blog stub: $BLOG_FILE" + fi + + - name: Update blog/index.md table with new major version + run: | + set -euo pipefail + TAG="$GITHUB_REF_NAME" + SLUG="${TAG//./-}" + # Add a row to the blog index table only if the version isn't already listed. + if grep -qF "release-${SLUG}" docs/blog/index.md; then + echo "Blog index already contains ${TAG} — skipping." + else + # Insert a new row after the last existing table row (line ending with |). + sed -i "s|^\(| \[v1\.0\.0\].*\)$|\1\n| [${TAG}](./release-${SLUG}) | |" \ + docs/blog/index.md || true + fi + + - name: Commit blog stub and versions.json to main uses: stefanzweifel/git-auto-commit-action@v5.0.1 with: # No [skip ci] — the push to main matches paths: docs/** and re-triggers # the deploy job, which merges the new snapshot into the Pages artifact. - commit_message: "docs: add ${{ steps.ver.outputs.major }} to versions.json" - file_pattern: docs/public/versions.json + commit_message: "docs: add ${{ steps.ver.outputs.major }} to versions.json and blog" + file_pattern: docs/public/versions.json docs/blog/ branch: main diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index ca80fbb..d31b0ca 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -1,6 +1,30 @@ import { defineConfig } from "vitepress"; +import { readdirSync } from "node:fs"; +import { resolve } from "node:path"; import versionsData from "../public/versions.json"; +// ── Blog sidebar — built dynamically from docs/blog/*.md files ──────────────── +// Files are sorted newest-first (reverse lexicographic on the slug). +// The index.md is excluded from the per-post list since it is the section root. +function buildBlogSidebarItems(): { text: string; link: string }[] { + const blogDir = resolve(__dirname, "../blog"); + let files: string[] = []; + try { + files = readdirSync(blogDir) + .filter((f) => f.endsWith(".md") && f !== "index.md") + .sort() + .reverse(); + } catch { + // blog dir may not exist during the very first build + } + return files.map((f) => { + const slug = f.replace(/\.md$/, ""); + // slug: release-v1-0-0 → display: v1.0.0 + const label = slug.replace(/^release-/, "").replace(/-/g, "."); + return { text: label, link: `/blog/${slug}` }; + }); +} + // ── Versioning convention ──────────────────────────────────────────────────── // • main branch → always publishes the "latest" docs at /github-code-search/ // • Major release tag (vX.0.0) → CI takes a snapshot: @@ -156,7 +180,7 @@ export default defineConfig({ text: "What's New", items: [ { text: "All releases", link: "/blog/" }, - { text: "v1.0.0", link: "/blog/release-v1-0-0" }, + ...buildBlogSidebarItems(), ], }, ], From 35ec1cccefda65d58067b340b2c744991d919f62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Fri, 27 Feb 2026 09:33:39 +0100 Subject: [PATCH 03/10] feat(upgrade): enrich upgrade output with release notes and doc links - Add html_url to GithubRelease interface - Add blogPostUrl() helper to derive doc blog URL from release tag - Replace 'Already up to date' with friendlier 'Congrats!' message - After successful upgrade print: Welcome message, What's new blog link, GitHub release notes link, commit comparison URL, bug-report link - Add checkForUpdate() pure helper: returns latest tag or null (swallows errors) - Update tests: add html_url to all fixtures, cover new messages, add full checkForUpdate test suite (36 tests pass) Part of #60, closes #55, closes #51 --- src/upgrade.test.ts | 90 +++++++++++++++++++++++++++++++++++++++++---- src/upgrade.ts | 57 +++++++++++++++++++++++++++- 2 files changed, 137 insertions(+), 10 deletions(-) diff --git a/src/upgrade.test.ts b/src/upgrade.test.ts index ecf80a9..3650413 100644 --- a/src/upgrade.test.ts +++ b/src/upgrade.test.ts @@ -1,7 +1,28 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { fetchLatestRelease, isNewerVersion, performUpgrade, selectAsset } from "./upgrade.ts"; +import { + blogPostUrl, + checkForUpdate, + fetchLatestRelease, + isNewerVersion, + performUpgrade, + selectAsset, +} from "./upgrade.ts"; import type { ReleaseAsset } from "./upgrade.ts"; +// ─── blogPostUrl ───────────────────────────────────────────────────────────────── +describe("blogPostUrl", () => { + it("converts a semver tag to the expected blog URL", () => { + expect(blogPostUrl("v1.2.3")).toBe( + "https://fulll.github.io/github-code-search/blog/release-v1-2-3", + ); + }); + + it("handles a major-only release tag", () => { + expect(blogPostUrl("v2.0.0")).toBe( + "https://fulll.github.io/github-code-search/blog/release-v2-0-0", + ); + }); +}); // ─── isNewerVersion ─────────────────────────────────────────────────────────── describe("isNewerVersion", () => { @@ -140,18 +161,19 @@ describe("fetchLatestRelease", () => { it("returns release data from the GitHub API", async () => { globalThis.fetch = (async () => - new Response(JSON.stringify({ tag_name: "v1.2.0", assets: [] }), { + new Response(JSON.stringify({ tag_name: "v1.2.0", html_url: "https://github.com/fulll/github-code-search/releases/tag/v1.2.0", assets: [] }), { status: 200, headers: { "content-type": "application/json" }, })) as typeof fetch; const release = await fetchLatestRelease("faketoken"); expect(release.tag_name).toBe("v1.2.0"); + expect(release.html_url).toBe("https://github.com/fulll/github-code-search/releases/tag/v1.2.0"); expect(release.assets).toHaveLength(0); }); it("works without a token", async () => { globalThis.fetch = (async () => - new Response(JSON.stringify({ tag_name: "v2.0.0", assets: [] }), { + new Response(JSON.stringify({ tag_name: "v2.0.0", html_url: "https://github.com/fulll/github-code-search/releases/tag/v2.0.0", assets: [] }), { status: 200, headers: { "content-type": "application/json" }, })) as typeof fetch; @@ -188,7 +210,7 @@ describe("performUpgrade", () => { it("prints 'up to date' when no newer version", async () => { globalThis.fetch = (async () => - new Response(JSON.stringify({ tag_name: "v1.0.0", assets: [] }), { + new Response(JSON.stringify({ tag_name: "v1.0.0", html_url: "https://github.com/fulll/github-code-search/releases/tag/v1.0.0", assets: [] }), { status: 200, headers: { "content-type": "application/json" }, })) as typeof fetch; @@ -203,12 +225,15 @@ describe("performUpgrade", () => { await performUpgrade("1.0.0", "/tmp/test-binary-uptodate"); process.stdout.write = origWrite; - expect(writes.some((s) => s.includes("up to date"))).toBe(true); + const output = writes.join(""); + expect(output).toInclude("Congrats"); + expect(output).toInclude("latest version"); + expect(output).toInclude("v1.0.0"); }); it("throws when no matching binary asset found in the release", async () => { globalThis.fetch = (async () => - new Response(JSON.stringify({ tag_name: "v9.9.9", assets: [] }), { + new Response(JSON.stringify({ tag_name: "v9.9.9", html_url: "https://github.com/fulll/github-code-search/releases/tag/v9.9.9", assets: [] }), { status: 200, headers: { "content-type": "application/json" }, })) as typeof fetch; @@ -219,6 +244,51 @@ describe("performUpgrade", () => { }); }); +// ─── checkForUpdate ─────────────────────────────────────────────────────────────── + +describe("checkForUpdate", () => { + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("returns the latest version tag when a newer version exists", async () => { + globalThis.fetch = (async () => + new Response( + JSON.stringify({ tag_name: "v2.0.0", html_url: "https://github.com/fulll/github-code-search/releases/tag/v2.0.0", assets: [] }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as typeof fetch; + const result = await checkForUpdate("1.0.0"); + expect(result).toBe("v2.0.0"); + }); + + it("returns null when already on the latest version", async () => { + globalThis.fetch = (async () => + new Response( + JSON.stringify({ tag_name: "v1.0.0", html_url: "https://github.com/fulll/github-code-search/releases/tag/v1.0.0", assets: [] }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as typeof fetch; + const result = await checkForUpdate("1.0.0"); + expect(result).toBeNull(); + }); + + it("returns null for dev version", async () => { + const result = await checkForUpdate("dev"); + expect(result).toBeNull(); + }); + + it("returns null silently on network error", async () => { + globalThis.fetch = (() => Promise.reject(new Error("network failure"))) as typeof fetch; + const result = await checkForUpdate("1.0.0"); + expect(result).toBeNull(); + }); + + it("returns null on non-OK API response", async () => { + globalThis.fetch = (async () => new Response("Not Found", { status: 404 })) as typeof fetch; + const result = await checkForUpdate("1.0.0"); + expect(result).toBeNull(); + }); +}); + // ─── performUpgrade — download path (covers downloadBinary) ────────────────── describe("performUpgrade — download path", () => { @@ -246,7 +316,7 @@ describe("performUpgrade — download path", () => { callCount++; if (callCount === 1) { return new Response( - JSON.stringify({ tag_name: "v9.9.9", assets: [makeAsset(assetName)] }), + JSON.stringify({ tag_name: "v9.9.9", html_url: "https://github.com/fulll/github-code-search/releases/tag/v9.9.9", assets: [makeAsset(assetName)] }), { status: 200, headers: { "content-type": "application/json" }, @@ -288,6 +358,10 @@ describe("performUpgrade — download path", () => { expect(stdoutWrites.some((s) => s.includes("Upgrading"))).toBe(true); expect(stdoutWrites.some((s) => s.includes("Replacing"))).toBe(true); - expect(stdoutWrites.some((s) => s.includes("Successfully upgraded"))).toBe(true); + expect(stdoutWrites.some((s) => s.includes("Welcome to github-code-search"))).toBe(true); + expect(stdoutWrites.some((s) => s.includes("What's new"))).toBe(true); + expect(stdoutWrites.some((s) => s.includes("blog/release-v9-9-9"))).toBe(true); + expect(stdoutWrites.some((s) => s.includes("Commit log"))).toBe(true); + expect(stdoutWrites.some((s) => s.includes("Report a bug"))).toBe(true); }); }); diff --git a/src/upgrade.ts b/src/upgrade.ts index 2678c73..2fb572a 100644 --- a/src/upgrade.ts +++ b/src/upgrade.ts @@ -7,6 +7,7 @@ export interface ReleaseAsset { interface GithubRelease { tag_name: string; + html_url: string; assets: ReleaseAsset[]; } @@ -67,6 +68,17 @@ export function selectAsset( return assets.find((a) => a.name === name) ?? assets.find((a) => a.name === legacyName) ?? null; } +// ─── Blog URL helper ─────────────────────────────────────────────────────────── + +/** + * Derives the VitePress blog post URL for a given release tag. + * Convention: vX.Y.Z → https://fulll.github.io/github-code-search/blog/release-vX-Y-Z + */ +export function blogPostUrl(tag: string): string { + const slug = tag.replace(/^v/, "v").replace(/\./g, "-"); // v1.2.3 → v1-2-3 + return `https://fulll.github.io/github-code-search/blog/release-${slug}`; +} + // ─── GitHub API ─────────────────────────────────────────────────────────────── export async function fetchLatestRelease(token?: string): Promise { @@ -171,7 +183,9 @@ export async function performUpgrade( const latestVersion = release.tag_name; if (!isNewerVersion(currentVersion, latestVersion)) { - process.stdout.write(`Already up to date (${currentVersion}).\n`); + process.stdout.write( + `Congrats! You're already on the latest version of github-code-search (${latestVersion}).\n`, + ); return; } @@ -190,5 +204,44 @@ export async function performUpgrade( process.stdout.write(`Upgrading ${currentVersion} → ${latestVersion}…\n`); await downloadBinary(asset.browser_download_url, execPath, debug); - process.stdout.write(`Successfully upgraded to ${latestVersion}.\n`); + process.stdout.write( + [ + ``, + `Welcome to github-code-search ${latestVersion}!`, + ``, + `What's new in ${latestVersion}:`, + ` ${blogPostUrl(latestVersion)}`, + ``, + `Release notes:`, + ` ${release.html_url}`, + ``, + `Commit log:`, + ` https://github.com/fulll/github-code-search/compare/${currentVersion.startsWith("v") ? currentVersion : `v${currentVersion}`}...${latestVersion}`, + ``, + `Report a bug:`, + ` https://github.com/fulll/github-code-search/issues/new`, + ``, + `Run \`github-code-search --help\` to explore all options.`, + ``, + ].join("\n"), + ); +} + +// ─── Silent update check ────────────────────────────────────────────────────── + +/** + * Returns the latest version tag if it is strictly newer than `currentVersion`, or null. + * Silently swallows any network / API error so it never breaks the main search flow. + */ +export async function checkForUpdate( + currentVersion: string, + token?: string, +): Promise { + if (currentVersion === "dev") return null; + try { + const release = await fetchLatestRelease(token); + return isNewerVersion(currentVersion, release.tag_name) ? release.tag_name : null; + } catch { + return null; + } } From bae7b8da29505c108524988a11caa5dec8dc8d1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Fri, 27 Feb 2026 09:36:50 +0100 Subject: [PATCH 04/10] feat: add deep doc links to --help and update-available notice [#58] Deep documentation links in CLI help: - program --help shows 'Documentation: https://fulll.github.io/github-code-search/' - upgrade --help points to /usage/upgrade - query --help points to /usage/search-syntax - --exclude-repositories/--exclude-extracts mention /usage/filtering - --format/--output-type mention /usage/output-formats - --group-by-team-prefix mentions /usage/team-grouping [#59] Update-available notice after non-interactive search: - import checkForUpdate() from upgrade.ts - After CI/non-interactive output, race a 2s timeout against checkForUpdate - If a newer version exists, print a yellow framed notice to stderr (never pollutes piped stdout, never shown in interactive TUI mode) Also fix toSorted/toReversed lint warnings in config.mts Part of #60, closes #58, closes #59 --- docs/.vitepress/config.mts | 9 ++--- github-code-search.ts | 54 +++++++++++++++++++++++-- src/upgrade.test.ts | 82 ++++++++++++++++++++++++++++---------- 3 files changed, 115 insertions(+), 30 deletions(-) diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index d31b0ca..e3d7c78 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -12,8 +12,8 @@ function buildBlogSidebarItems(): { text: string; link: string }[] { try { files = readdirSync(blogDir) .filter((f) => f.endsWith(".md") && f !== "index.md") - .sort() - .reverse(); + .toSorted() + .toReversed(); } catch { // blog dir may not exist during the very first build } @@ -178,10 +178,7 @@ export default defineConfig({ "/blog/": [ { text: "What's New", - items: [ - { text: "All releases", link: "/blog/" }, - ...buildBlogSidebarItems(), - ], + items: [{ text: "All releases", link: "/blog/" }, ...buildBlogSidebarItems()], }, ], "/architecture/": [ diff --git a/github-code-search.ts b/github-code-search.ts index 2721138..0987549 100644 --- a/github-code-search.ts +++ b/github-code-search.ts @@ -20,6 +20,7 @@ import { aggregate, normaliseExtractRef, normaliseRepo } from "./src/aggregate.t import { fetchAllResults, fetchRepoTeams } from "./src/api.ts"; import { buildOutput } from "./src/output.ts"; import { groupByTeamPrefix, flattenTeamSections } from "./src/group.ts"; +import { checkForUpdate } from "./src/upgrade.ts"; import { runInteractive } from "./src/tui.ts"; import type { OutputFormat, OutputType } from "./src/types.ts"; @@ -54,6 +55,7 @@ function addSearchOptions(cmd: Command): Command { "Comma-separated list of repositories to exclude.", "Short form (without org prefix) or full form accepted:", " repoA,repoB OR myorg/repoA,myorg/repoB", + "Docs: https://fulll.github.io/github-code-search/usage/filtering", ].join("\n"), "", ) @@ -64,6 +66,7 @@ function addSearchOptions(cmd: Command): Command { "Format (shortest): repoName:path:matchIndex", " e.g. repoA:src/foo.ts:0,repoB:lib/core.ts:2", "Full form also accepted: myorg/repoA:src/foo.ts:0", + "Docs: https://fulll.github.io/github-code-search/usage/filtering", ].join("\n"), "", ) @@ -71,10 +74,20 @@ function addSearchOptions(cmd: Command): Command { "--no-interactive", "Disable interactive mode (non-interactive). Also triggered by CI=true env var.", ) - .option("--format ", "Output format: markdown (default) or json", "markdown") + .option( + "--format ", + [ + "Output format: markdown (default) or json.", + "Docs: https://fulll.github.io/github-code-search/usage/output-formats", + ].join("\n"), + "markdown", + ) .option( "--output-type ", - "Output type: repo-and-matches (default) or repo-only", + [ + "Output type: repo-and-matches (default) or repo-only.", + "Docs: https://fulll.github.io/github-code-search/usage/output-formats", + ].join("\n"), "repo-and-matches", ) .option( @@ -89,6 +102,7 @@ function addSearchOptions(cmd: Command): Command { "Example: squad-,chapter-", "Repos are first grouped by the first prefix (single-team, then multi-team),", "then by the next prefix, and so on. Repos matching no prefix go into 'other'.", + "Docs: https://fulll.github.io/github-code-search/usage/team-grouping", ].join("\n"), "", ) @@ -166,6 +180,28 @@ async function searchAction( groupByTeamPrefix: opts.groupByTeamPrefix, }), ); + // Check for a newer version and notify on stderr so it never pollutes piped output. + // Race against a 2 s timeout so slow networks never delay the exit. + const latestTag = await Promise.race([ + checkForUpdate(VERSION, GITHUB_TOKEN), + new Promise((res) => setTimeout(() => res(null), 2000)), + ]); + if (latestTag) { + const w = 55; + const bar = "─".repeat(w); + const pad = (s: string) => s + " ".repeat(Math.max(0, w - s.length)); + process.stderr.write( + pc.yellow( + [ + `╭─ Update available ${"─".repeat(w - 18)}╮`, + `│ ${pad(`github-code-search ${VERSION} → ${latestTag}`)} │`, + `│ ${pad("Run: github-code-search upgrade")} │`, + `╰${bar}╯`, + "", + ].join("\n"), + ), + ); + } } else { await runInteractive( groups, @@ -186,12 +222,17 @@ async function searchAction( program .name("github-code-search") .version(VERSION_FULL, "-V, --version", "Output version, commit, OS and architecture") - .description("Interactive GitHub code search with per-repo aggregation"); + .description("Interactive GitHub code search with per-repo aggregation") + .addHelpText("after", "\nDocumentation:\n https://fulll.github.io/github-code-search/"); // `upgrade` subcommand — does NOT require GITHUB_TOKEN (uses it only if set) program .command("upgrade") .description("Check for a new release and auto-upgrade the binary") + .addHelpText( + "after", + "\nDocumentation:\n https://fulll.github.io/github-code-search/usage/upgrade", + ) .option("--debug", "Print debug information for troubleshooting") .action(async (opts: { debug?: boolean }) => { const { performUpgrade } = await import("./src/upgrade.ts"); @@ -226,7 +267,12 @@ program // `query` subcommand — the default (backward-compat: `gcs --org `) const queryCmd = addSearchOptions( - new Command("query").description("Search GitHub code (default command when no subcommand given)"), + new Command("query") + .description("Search GitHub code (default command when no subcommand given)") + .addHelpText( + "after", + "\nDocumentation:\n https://fulll.github.io/github-code-search/usage/search-syntax", + ), ).action(async (query: string, opts) => { await searchAction(query, opts); }); diff --git a/src/upgrade.test.ts b/src/upgrade.test.ts index 3650413..78c486a 100644 --- a/src/upgrade.test.ts +++ b/src/upgrade.test.ts @@ -161,22 +161,38 @@ describe("fetchLatestRelease", () => { it("returns release data from the GitHub API", async () => { globalThis.fetch = (async () => - new Response(JSON.stringify({ tag_name: "v1.2.0", html_url: "https://github.com/fulll/github-code-search/releases/tag/v1.2.0", assets: [] }), { - status: 200, - headers: { "content-type": "application/json" }, - })) as typeof fetch; + new Response( + JSON.stringify({ + tag_name: "v1.2.0", + html_url: "https://github.com/fulll/github-code-search/releases/tag/v1.2.0", + assets: [], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + )) as typeof fetch; const release = await fetchLatestRelease("faketoken"); expect(release.tag_name).toBe("v1.2.0"); - expect(release.html_url).toBe("https://github.com/fulll/github-code-search/releases/tag/v1.2.0"); + expect(release.html_url).toBe( + "https://github.com/fulll/github-code-search/releases/tag/v1.2.0", + ); expect(release.assets).toHaveLength(0); }); it("works without a token", async () => { globalThis.fetch = (async () => - new Response(JSON.stringify({ tag_name: "v2.0.0", html_url: "https://github.com/fulll/github-code-search/releases/tag/v2.0.0", assets: [] }), { - status: 200, - headers: { "content-type": "application/json" }, - })) as typeof fetch; + new Response( + JSON.stringify({ + tag_name: "v2.0.0", + html_url: "https://github.com/fulll/github-code-search/releases/tag/v2.0.0", + assets: [], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + )) as typeof fetch; const release = await fetchLatestRelease(); expect(release.tag_name).toBe("v2.0.0"); }); @@ -210,10 +226,17 @@ describe("performUpgrade", () => { it("prints 'up to date' when no newer version", async () => { globalThis.fetch = (async () => - new Response(JSON.stringify({ tag_name: "v1.0.0", html_url: "https://github.com/fulll/github-code-search/releases/tag/v1.0.0", assets: [] }), { - status: 200, - headers: { "content-type": "application/json" }, - })) as typeof fetch; + new Response( + JSON.stringify({ + tag_name: "v1.0.0", + html_url: "https://github.com/fulll/github-code-search/releases/tag/v1.0.0", + assets: [], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + )) as typeof fetch; const writes: string[] = []; const origWrite = process.stdout.write.bind(process.stdout); @@ -233,10 +256,17 @@ describe("performUpgrade", () => { it("throws when no matching binary asset found in the release", async () => { globalThis.fetch = (async () => - new Response(JSON.stringify({ tag_name: "v9.9.9", html_url: "https://github.com/fulll/github-code-search/releases/tag/v9.9.9", assets: [] }), { - status: 200, - headers: { "content-type": "application/json" }, - })) as typeof fetch; + new Response( + JSON.stringify({ + tag_name: "v9.9.9", + html_url: "https://github.com/fulll/github-code-search/releases/tag/v9.9.9", + assets: [], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + )) as typeof fetch; await expect(performUpgrade("1.0.0", "/tmp/test-binary-noasset")).rejects.toThrow( "No binary found", @@ -254,7 +284,11 @@ describe("checkForUpdate", () => { it("returns the latest version tag when a newer version exists", async () => { globalThis.fetch = (async () => new Response( - JSON.stringify({ tag_name: "v2.0.0", html_url: "https://github.com/fulll/github-code-search/releases/tag/v2.0.0", assets: [] }), + JSON.stringify({ + tag_name: "v2.0.0", + html_url: "https://github.com/fulll/github-code-search/releases/tag/v2.0.0", + assets: [], + }), { status: 200, headers: { "content-type": "application/json" } }, )) as typeof fetch; const result = await checkForUpdate("1.0.0"); @@ -264,7 +298,11 @@ describe("checkForUpdate", () => { it("returns null when already on the latest version", async () => { globalThis.fetch = (async () => new Response( - JSON.stringify({ tag_name: "v1.0.0", html_url: "https://github.com/fulll/github-code-search/releases/tag/v1.0.0", assets: [] }), + JSON.stringify({ + tag_name: "v1.0.0", + html_url: "https://github.com/fulll/github-code-search/releases/tag/v1.0.0", + assets: [], + }), { status: 200, headers: { "content-type": "application/json" } }, )) as typeof fetch; const result = await checkForUpdate("1.0.0"); @@ -316,7 +354,11 @@ describe("performUpgrade — download path", () => { callCount++; if (callCount === 1) { return new Response( - JSON.stringify({ tag_name: "v9.9.9", html_url: "https://github.com/fulll/github-code-search/releases/tag/v9.9.9", assets: [makeAsset(assetName)] }), + JSON.stringify({ + tag_name: "v9.9.9", + html_url: "https://github.com/fulll/github-code-search/releases/tag/v9.9.9", + assets: [makeAsset(assetName)], + }), { status: 200, headers: { "content-type": "application/json" }, From 1574244ce2f34d15249f504e33ee4a11d4575a85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Fri, 27 Feb 2026 09:42:46 +0100 Subject: [PATCH 05/10] feat(cli): colorize --help output with Commander style hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add HAS_COLOR guard (process.stdout.isTTY) — pipes and CI stay plain-text - Add helpFormatConfig with Commander v14 style hooks: styleTitle → bold yellow (Usage:, Options:, Commands: …) styleCommandText → bold (github-code-search name) styleSubcommandText / styleSubcommandTerm → cyan (upgrade, query) styleArgumentText / styleArgumentTerm → yellow () styleOptionText / styleOptionTerm → green (--org, --format …) styleOptionDescription + siblings → colorDesc(): • 'Docs: ' → dim label + cyan underlined URL • 'Example: ...' → dim label + italic value • Code-example lines (e.g. / repoA / myorg) → dim - Add helpLink() and helpSection() helpers; update all addHelpText('after',…) calls to use them so 'Documentation:' labels are bold and URLs are cyan+underline - Apply configureHelp(helpFormatConfig) to program, upgrade and query commands Part of #60 --- github-code-search.ts | 84 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 80 insertions(+), 4 deletions(-) diff --git a/github-code-search.ts b/github-code-search.ts index 0987549..81b5608 100644 --- a/github-code-search.ts +++ b/github-code-search.ts @@ -42,7 +42,74 @@ const TARGET_ARCH = /** Full version string shown by `--version`. */ const VERSION_FULL = `${VERSION} (${COMMIT} · ${TARGET_OS}/${TARGET_ARCH})`; -// ─── Helpers ───────────────────────────────────────────────────────────────── +// ─── Help colorization ─────────────────────────────────────────────────────── +// Only apply colours when stdout is connected to a real terminal. +// All pipes, CI redirects, and `--no-color` environments stay plain-text. +const HAS_COLOR = Boolean(process.stdout.isTTY); + +/** + * Walk through a multi-line option/argument description and: + * • "Docs: " → dim label + cyan underlined URL + * • "Example: ..." → dim label + italic value + * • indent lines that look like code examples (e.g. / repoA / myorg) → dim + */ +function colorDesc(s: string): string { + if (!HAS_COLOR) return s; + return s + .split("\n") + .map((line) => { + const docsMatch = line.match(/^(\s*Docs:\s*)(https?:\/\/\S+)$/); + if (docsMatch) return pc.dim(docsMatch[1]) + pc.cyan(pc.underline(docsMatch[2])); + const exampleMatch = line.match(/^(\s*Example:\s*)(.+)$/); + if (exampleMatch) return pc.dim(exampleMatch[1]) + pc.italic(exampleMatch[2]); + if (/^\s+(e\.g\.|repoA|myorg\/|squad-|chapter-)/.test(line)) return pc.dim(line); + return line; + }) + .join("\n"); +} + +/** Colored hyperlink (cyan + underline), falls back to plain when not a TTY. */ +function helpLink(url: string): string { + return HAS_COLOR ? pc.cyan(pc.underline(url)) : url; +} + +/** + * Builds the `addHelpText("after", ...)` footer block with a labelled link. + * The label is bold when color is supported. + */ +function helpSection(label: string, url: string): string { + const t = HAS_COLOR ? pc.bold(label) : label; + return `\n${t}\n ${helpLink(url)}`; +} + +/** + * Commander configureHelp options shared by all commands. + * Each style hook only applies colour when HAS_COLOR is true. + */ +const helpFormatConfig = { + // Section headings: "Usage:", "Options:", "Commands:" … + styleTitle: (s: string) => (HAS_COLOR ? pc.bold(pc.yellow(s)) : s), + // Command name in the usage line + styleCommandText: (s: string) => (HAS_COLOR ? pc.bold(s) : s), + // Subcommand names in the command listing + styleSubcommandText: (s: string) => (HAS_COLOR ? pc.cyan(s) : s), + // Argument placeholders () + styleArgumentText: (s: string) => (HAS_COLOR ? pc.yellow(s) : s), + // Option flags in the usage line (--org, --format …) + styleOptionText: (s: string) => (HAS_COLOR ? pc.green(s) : s), + // Option terms in the options table + styleOptionTerm: (s: string) => (HAS_COLOR ? pc.green(s) : s), + // Subcommand terms in the commands table + styleSubcommandTerm: (s: string) => (HAS_COLOR ? pc.cyan(s) : s), + // Argument terms in the arguments table + styleArgumentTerm: (s: string) => (HAS_COLOR ? pc.yellow(s) : s), + // Descriptions — color "Docs:", "Example:" and code-example lines + styleOptionDescription: colorDesc, + styleSubcommandDescription: colorDesc, + styleArgumentDescription: colorDesc, + styleCommandDescription: colorDesc, + styleDescriptionText: colorDesc, +}; /** Add the shared search options to a command. */ function addSearchOptions(cmd: Command): Command { @@ -223,15 +290,20 @@ program .name("github-code-search") .version(VERSION_FULL, "-V, --version", "Output version, commit, OS and architecture") .description("Interactive GitHub code search with per-repo aggregation") - .addHelpText("after", "\nDocumentation:\n https://fulll.github.io/github-code-search/"); + .configureHelp(helpFormatConfig) + .addHelpText( + "after", + helpSection("Documentation:", "https://fulll.github.io/github-code-search/"), + ); // `upgrade` subcommand — does NOT require GITHUB_TOKEN (uses it only if set) program .command("upgrade") .description("Check for a new release and auto-upgrade the binary") + .configureHelp(helpFormatConfig) .addHelpText( "after", - "\nDocumentation:\n https://fulll.github.io/github-code-search/usage/upgrade", + helpSection("Documentation:", "https://fulll.github.io/github-code-search/usage/upgrade"), ) .option("--debug", "Print debug information for troubleshooting") .action(async (opts: { debug?: boolean }) => { @@ -269,9 +341,13 @@ program const queryCmd = addSearchOptions( new Command("query") .description("Search GitHub code (default command when no subcommand given)") + .configureHelp(helpFormatConfig) .addHelpText( "after", - "\nDocumentation:\n https://fulll.github.io/github-code-search/usage/search-syntax", + helpSection( + "Documentation:", + "https://fulll.github.io/github-code-search/usage/search-syntax", + ), ), ).action(async (query: string, opts) => { await searchAction(query, opts); From bf0fd09f28534ae6a8fcb41ae5897cfa4a8e0050 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Fri, 27 Feb 2026 09:54:24 +0100 Subject: [PATCH 06/10] Address PR #61 review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix blogPostUrl() no-op replace: normalize tag to always have v-prefix before slug-ifying (replace(/^v/, 'v') → \`v${tag.replace(/^v/, '')}\`) - Add AbortController to checkForUpdate timeout race so the in-flight fetch is actually cancelled when the 2s deadline fires (github-code-search.ts) - Pass AbortSignal through checkForUpdate → fetchLatestRelease - Fix ESM __dirname usage in docs/.vitepress/config.mts: use fileURLToPath(new URL('../blog', import.meta.url)) instead of resolve(__dirname, '../blog') which may be undefined in ESM - Add semantic version sort for blog sidebar: parseBlogVersion() + compareVersionArrays() helpers replace lexicographic toSorted().toReversed() so multi-digit versions (v1.10.0) sort correctly before v1.9.0 - Replace malformed sed command in docs.yml blog index update with a Python heredoc script that reliably inserts the new table row - Pin stefanzweifel/git-auto-commit-action to commit SHA 8621497c (v5.0.1) to prevent supply-chain attacks flagged by GH Advanced Security --- .github/workflows/docs.yml | 25 +++++++++++++++++++++---- docs/.vitepress/config.mts | 29 ++++++++++++++++++++++++----- github-code-search.ts | 13 ++++++++++--- src/upgrade.test.ts | 7 +++++++ src/upgrade.ts | 13 ++++++++++--- 5 files changed, 72 insertions(+), 15 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e560365..2a69fa0 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -231,13 +231,30 @@ jobs: if grep -qF "release-${SLUG}" docs/blog/index.md; then echo "Blog index already contains ${TAG} — skipping." else - # Insert a new row after the last existing table row (line ending with |). - sed -i "s|^\(| \[v1\.0\.0\].*\)$|\1\n| [${TAG}](./release-${SLUG}) | |" \ - docs/blog/index.md || true + python3 - < |" + # Insert the new row after the last markdown table row (line ending with |). + updated = re.sub( + r"(\|[^\n]+\|)(\s*\Z)", + lambda m: m.group(1) + "\n" + new_row + m.group(2), + content, + count=1, + flags=re.DOTALL, + ) + path.write_text(updated) + print(f"Inserted row for {tag} into blog/index.md") + PY fi - name: Commit blog stub and versions.json to main - uses: stefanzweifel/git-auto-commit-action@v5.0.1 + # Pin to exact commit SHA to prevent supply-chain attacks. + uses: stefanzweifel/git-auto-commit-action@8621497c8c39c72f3e2a999a26b4ca1b5058a842 # v5.0.1 with: # No [skip ci] — the push to main matches paths: docs/** and re-triggers # the deploy job, which merges the new snapshot into the Pages artifact. diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index e3d7c78..f8e07fe 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -1,19 +1,38 @@ import { defineConfig } from "vitepress"; import { readdirSync } from "node:fs"; -import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import versionsData from "../public/versions.json"; +// ─── Semantic version helpers for blog sidebar sort ─────────────────────────── +function parseBlogVersion(filename: string): number[] { + // "release-v1-2-3.md" → [1, 2, 3] + return filename + .replace(/^release-v/, "") + .replace(/\.md$/, "") + .split("-") + .map(Number); +} + +function compareVersionArrays(a: number[], b: number[]): number { + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const diff = (b[i] ?? 0) - (a[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; +} + // ── Blog sidebar — built dynamically from docs/blog/*.md files ──────────────── -// Files are sorted newest-first (reverse lexicographic on the slug). +// Files are sorted newest-first using semantic version comparison so that +// multi-digit components (e.g. v1.10.0) sort correctly before v1.9.0. // The index.md is excluded from the per-post list since it is the section root. function buildBlogSidebarItems(): { text: string; link: string }[] { - const blogDir = resolve(__dirname, "../blog"); + // Fix: use import.meta.url instead of __dirname which may be undefined in ESM + const blogDir = fileURLToPath(new URL("../blog", import.meta.url)); let files: string[] = []; try { files = readdirSync(blogDir) .filter((f) => f.endsWith(".md") && f !== "index.md") - .toSorted() - .toReversed(); + .toSorted((a, b) => compareVersionArrays(parseBlogVersion(a), parseBlogVersion(b))); } catch { // blog dir may not exist during the very first build } diff --git a/github-code-search.ts b/github-code-search.ts index 81b5608..5b628a0 100644 --- a/github-code-search.ts +++ b/github-code-search.ts @@ -249,10 +249,17 @@ async function searchAction( ); // Check for a newer version and notify on stderr so it never pollutes piped output. // Race against a 2 s timeout so slow networks never delay the exit. + // Fix: use AbortController so the in-flight fetch is actually cancelled on timeout. + const updateAbortController = new AbortController(); const latestTag = await Promise.race([ - checkForUpdate(VERSION, GITHUB_TOKEN), - new Promise((res) => setTimeout(() => res(null), 2000)), - ]); + checkForUpdate(VERSION, GITHUB_TOKEN, updateAbortController.signal), + new Promise((res) => + setTimeout(() => { + updateAbortController.abort(); + res(null); + }, 2000), + ), + ]).catch(() => null); if (latestTag) { const w = 55; const bar = "─".repeat(w); diff --git a/src/upgrade.test.ts b/src/upgrade.test.ts index 78c486a..1378713 100644 --- a/src/upgrade.test.ts +++ b/src/upgrade.test.ts @@ -22,6 +22,13 @@ describe("blogPostUrl", () => { "https://fulll.github.io/github-code-search/blog/release-v2-0-0", ); }); + + it("normalizes a tag without v-prefix", () => { + // Fix: previously `replace(/^v/, "v")` was a no-op for tags like "1.2.3" + expect(blogPostUrl("1.2.3")).toBe( + "https://fulll.github.io/github-code-search/blog/release-v1-2-3", + ); + }); }); // ─── isNewerVersion ─────────────────────────────────────────────────────────── diff --git a/src/upgrade.ts b/src/upgrade.ts index 2fb572a..5f7cd2d 100644 --- a/src/upgrade.ts +++ b/src/upgrade.ts @@ -75,13 +75,18 @@ export function selectAsset( * Convention: vX.Y.Z → https://fulll.github.io/github-code-search/blog/release-vX-Y-Z */ export function blogPostUrl(tag: string): string { - const slug = tag.replace(/^v/, "v").replace(/\./g, "-"); // v1.2.3 → v1-2-3 + // Fix: normalize to always have a v-prefix (handles both "v1.2.3" and "1.2.3") + const normalized = `v${tag.replace(/^v/, "")}`; + const slug = normalized.replace(/\./g, "-"); // v1.2.3 → v1-2-3 return `https://fulll.github.io/github-code-search/blog/release-${slug}`; } // ─── GitHub API ─────────────────────────────────────────────────────────────── -export async function fetchLatestRelease(token?: string): Promise { +export async function fetchLatestRelease( + token?: string, + signal?: AbortSignal, +): Promise { const headers: Record = { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", @@ -90,6 +95,7 @@ export async function fetchLatestRelease(token?: string): Promise const res = await fetch("https://api.github.com/repos/fulll/github-code-search/releases/latest", { headers, + signal, }); if (!res.ok) { const body = await res.text(); @@ -236,10 +242,11 @@ export async function performUpgrade( export async function checkForUpdate( currentVersion: string, token?: string, + signal?: AbortSignal, ): Promise { if (currentVersion === "dev") return null; try { - const release = await fetchLatestRelease(token); + const release = await fetchLatestRelease(token, signal); return isNewerVersion(currentVersion, release.tag_name) ? release.tag_name : null; } catch { return null; From 74ac2d08c2014daff64e8f0dee33e938325cf958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Fri, 27 Feb 2026 10:13:25 +0100 Subject: [PATCH 07/10] docs(agents): document release process in AGENTS.md and instruction files - Add '## Release process' section to AGENTS.md covering: - semver decision table (patch / minor / major) - step-by-step release commands (bun pm version, tag, push) - what cd.yaml and docs.yml do automatically on each tag type - blog post convention (required for minor/major, optional for patch) - Add step 8 to bug-fixing.instructions.md: patch release after merge - Add step 8 to implement-feature.instructions.md: minor/major release after merge, including blog post reminder --- .../instructions/bug-fixing.instructions.md | 16 +++++ .../implement-feature.instructions.md | 25 +++++++ AGENTS.md | 67 +++++++++++++++++++ 3 files changed, 108 insertions(+) diff --git a/.github/instructions/bug-fixing.instructions.md b/.github/instructions/bug-fixing.instructions.md index 8dec3d6..4a9411a 100644 --- a/.github/instructions/bug-fixing.instructions.md +++ b/.github/instructions/bug-fixing.instructions.md @@ -82,3 +82,19 @@ Add a one-line comment above the fix if the root cause is non-obvious: - Steps to reproduce (before the fix). - Steps to verify (after the fix). - Reference to the issue number. + +## 8. Release after merge + +Once the PR is merged into `main`, publish a **patch** release: + +```bash +bun pm version patch # bumps package.json: 1.2.4 → 1.2.5 +git checkout -b release/$(jq -r .version package.json) +git add package.json +git commit -S -m "v$(jq -r .version package.json)" +git tag v$(jq -r .version package.json) +git push origin release/$(jq -r .version package.json) --tags +``` + +The tag push triggers `cd.yaml` which builds all-platform binaries and creates the GitHub Release automatically. +See the full release guide in `AGENTS.md § Release process`. diff --git a/.github/instructions/implement-feature.instructions.md b/.github/instructions/implement-feature.instructions.md index c4104ee..82d392c 100644 --- a/.github/instructions/implement-feature.instructions.md +++ b/.github/instructions/implement-feature.instructions.md @@ -69,3 +69,28 @@ bun run build.ts # binary compiles without errors - **All commits must be signed** (GPG or SSH). Configure once with `git config --global commit.gpgsign true`. Commits pushed via the GitHub API (Copilot Coding Agent, MCP tools) are automatically Verified by GitHub. - PR description: motivation, what changed, how to test manually. + +## 8. Release after merge + +Once the PR is merged into `main`, publish a **minor** (new feature) or **major** (breaking change) release: + +```bash +bun pm version minor # new feature: 1.2.4 → 1.3.0 +# or +bun pm version major # breaking change: 1.2.4 → 2.0.0 + +git checkout -b release/$(jq -r .version package.json) +git add package.json +git commit -S -m "v$(jq -r .version package.json)" +git tag v$(jq -r .version package.json) +git push origin release/$(jq -r .version package.json) --tags +``` + +The tag push triggers `cd.yaml` which builds all-platform binaries and creates the GitHub Release automatically. + +For **minor and major releases**, also write the blog post **before pushing the tag**: + +- Create `docs/blog/release-v.md` with feature highlights +- Add a row in `docs/blog/index.md` + +See the full release guide in `AGENTS.md § Release process`. diff --git a/AGENTS.md b/AGENTS.md index cdb522b..0adc930 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,6 +155,73 @@ For epics spanning multiple PRs, create a long-lived **feature branch** (`feat/< --- +## Release process + +### Deciding the version bump + +This project follows [Semantic Versioning](https://semver.org/): + +| Change type | Bump | Example | +| ------------------------------------------ | ------- | ------------- | +| Bug fix (no new behaviour, no API change) | `patch` | 1.2.4 → 1.2.5 | +| New feature, backward-compatible | `minor` | 1.2.4 → 1.3.0 | +| Breaking change (CLI flag removed/renamed) | `major` | 1.2.4 → 2.0.0 | + +### Step-by-step + +```bash +# 1. Bump the version in package.json (pick one) +bun pm version patch # bug fix +bun pm version minor # new feature +bun pm version major # breaking change + +# 2. Create the release branch and commit +git checkout -b release/$(jq -r .version package.json) +git add package.json +git commit -S -m "v$(jq -r .version package.json)" + +# 3. Write (or update) the blog post for the release +# • Required for minor and major releases. +# • Patch releases: optional — a brief note in the GitHub Release is sufficient. +# File: docs/blog/release-v.md (e.g. docs/blog/release-v1-3-0.md) +# Update docs/blog/index.md table too. + +# 4. Tag and push — this triggers the CD pipeline +git tag v$(jq -r .version package.json) +git push origin release/$(jq -r .version package.json) --tags +``` + +### What the CI does automatically + +Pushing a tag `vX.Y.Z` triggers **`cd.yaml`**: + +1. Compiles the binary for all six targets (linux-x64, linux-arm64, linux-x64-baseline, darwin-x64, darwin-arm64, windows-x64). +2. Creates a **GitHub Release** with all binaries attached. + `generate_release_notes: true` — GitHub auto-populates the release body from merged PR titles and commit messages since the previous tag. +3. Legacy platform aliases are also published for backward-compat with pre-v1.2.1 binaries. + +Pushing a **major** tag (`vX.0.0`) additionally triggers **`docs.yml` → snapshot job**: + +1. Builds a versioned docs snapshot at `/github-code-search/vX/`. +2. Auto-generates `docs/blog/release-vX-0-0.md` stub if it does not exist yet. +3. Prepends the new entry to `docs/public/versions.json` and commits back to `main`. + +### Blog post requirement + +| Release type | Blog post | Location | +| ------------ | ----------------------------------------------------------- | --------------------------------- | +| **Major** | Required (written by hand — CI stub automates the skeleton) | `docs/blog/release-vX-0-0.md` | +| **Minor** | Required | `docs/blog/release-vX-Y-0.md` | +| **Patch** | Optional | GitHub Release body is sufficient | + +For minor/major releases update `docs/blog/index.md` to add a row in the version table: + +```markdown +| [vX.Y.Z](./release-vX-Y-Z) | One-line highlights | +``` + +--- + ## Development notes - **TypeScript throughout** — no `.js` files in `src/`. From 0d5c9cdaa5c1712145636102bdef46d630440021 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Fri, 27 Feb 2026 10:14:07 +0100 Subject: [PATCH 08/10] v1.3.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6a70bef..7e6506b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "github-code-search", - "version": "1.2.4", + "version": "1.3.0", "description": "Interactive GitHub code search with per-repo aggregation", "license": "MIT", "author": "fulll", From 5ec4bb0ad6f3c61afdf928149b187544f1eec544 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Fri, 27 Feb 2026 10:24:04 +0100 Subject: [PATCH 09/10] v1.3.0 New features in this release: - Enriched upgrade output: blog post URL, release notes link, commit log - Update-available notice printed on stderr after CI/non-interactive searches - Colorized --help output (Commander style hooks, picocolors) - Deep documentation links in every --help text - What's New blog section in VitePress docs with per-release posts - CI: auto-generate blog stub for major release tags --- docs/blog/index.md | 7 ++-- docs/blog/release-v1-3-0.md | 84 +++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 docs/blog/release-v1-3-0.md diff --git a/docs/blog/index.md b/docs/blog/index.md index c97ed03..8f49440 100644 --- a/docs/blog/index.md +++ b/docs/blog/index.md @@ -9,9 +9,10 @@ Full release notes and changelogs are always available on ## v1 series -| Release | Highlights | -| -------------------------- | -------------------------------------------------------------------------------------- | -| [v1.0.0](./release-v1-0-0) | Initial public release — interactive TUI, per-repo aggregation, markdown / JSON output | +| Release | Highlights | +| -------------------------- | --------------------------------------------------------------------------------------------------- | +| [v1.3.0](./release-v1-3-0) | Richer upgrade output, update-available notice, colorized `--help`, deep doc links, What's New blog | +| [v1.0.0](./release-v1-0-0) | Initial public release — interactive TUI, per-repo aggregation, markdown / JSON output | --- diff --git a/docs/blog/release-v1-3-0.md b/docs/blog/release-v1-3-0.md new file mode 100644 index 0000000..7e35bd4 --- /dev/null +++ b/docs/blog/release-v1-3-0.md @@ -0,0 +1,84 @@ +--- +title: "What's new in v1.3.0" +description: "Richer upgrade experience, colorized help, deep doc links, update-available notices, and a new What's New blog section." +date: 2026-02-27 +--- + +# What's new in github-code-search v1.3.0 + +> Full release notes: + +## Highlights + +### Richer `upgrade` output + +The `upgrade` subcommand now prints a detailed post-upgrade summary so you immediately know +what landed in the new version: + +``` +Welcome to github-code-search v1.3.0! + +What's new in v1.3.0: + https://fulll.github.io/github-code-search/blog/release-v1-3-0 + +Release notes: + https://github.com/fulll/github-code-search/releases/tag/v1.3.0 + +Commit log: + https://github.com/fulll/github-code-search/compare/v1.2.4...v1.3.0 + +Report a bug: + https://github.com/fulll/github-code-search/issues/new + +Run `github-code-search --help` to explore all options. +``` + +It mirrors the experience you get with `bun upgrade` — you always know where to look for details. + +→ [Upgrade guide](/usage/upgrade) + +### Update-available notice after searches + +When a newer release exists, a notice is printed on `stderr` after non-interactive (`--no-interactive` / CI) searches, so it never pollutes piped output: + +``` +╭─ Update available ──────────────────────────────────────╮ +│ github-code-search v1.2.4 → v1.3.0 │ +│ Run: github-code-search upgrade │ +╰─────────────────────────────────────────────────────────╯ +``` + +The check races against a 2-second timeout so slow networks never delay your pipeline. + +### Colorized `--help` output + +When the terminal supports color, the `--help` output is now styled for readability: + +- Section titles are **bold yellow** +- Flags and options are **green** +- Subcommands are **cyan** +- Arguments are **yellow** +- Documentation links are _italic_ and **cyan underlined** + +Color is automatically disabled when output is piped or the terminal doesn't support it. + +### Deep doc links in `--help` + +Every subcommand's help text now includes direct links to the relevant documentation page, +so you can jump straight from the CLI to the full guide: + +``` +$ github-code-search query --help +... +Docs: https://fulll.github.io/github-code-search/usage/interactive-mode + https://fulll.github.io/github-code-search/reference/cli-options +``` + +### What's New blog section in the docs + +The documentation now has a [What's New](/blog/) section where each release gets a dedicated +page with feature highlights, usage examples, and links to release notes. + +For major releases, a blog stub is auto-generated by the CI pipeline when the tag is pushed. + +→ [What's New](/blog/) From f366ee39d99fdac0c411d8b93e89de71657f805d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20HOUZ=C3=89?= Date: Fri, 27 Feb 2026 10:36:44 +0100 Subject: [PATCH 10/10] Address remaining PR #61 review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix update notice box: corners and side bars were different widths (header used w-18 constant; corrected using totalWidth formula so all four sides are exactly w+4 characters wide) - Fix docs.yml heredoc: blog stub was written with leading whitespace on every line (breaking YAML frontmatter); replaced with Python that writes the file programmatically, indented correctly for the YAML block - Add test for already-aborted AbortSignal in checkForUpdate: verifies the function silently returns null when the signal is pre-aborted - Fix 'organization' → 'organisation' in release-v1-0-0.md (British spelling consistent with the rest of the documentation) --- .github/workflows/docs.yml | 32 ++++++++++++++++++-------------- docs/blog/release-v1-0-0.md | 2 +- github-code-search.ts | 14 +++++++++++--- src/upgrade.test.ts | 12 ++++++++++++ 4 files changed, 42 insertions(+), 18 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 2a69fa0..70d69fa 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -206,20 +206,24 @@ jobs: if [ -f "$BLOG_FILE" ]; then echo "Blog post $BLOG_FILE already exists — skipping stub generation." else - cat > "$BLOG_FILE" < Full release notes: - - - EOF - echo "Created blog stub: $BLOG_FILE" + # Fix: use Python to write the file so heredoc indentation never + # leaks into the generated Markdown (which would break frontmatter). + python3 - < Full release notes: \n\n' + '\n' + ) + pathlib.Path(blog_file).write_text(content, encoding="utf-8") + print(f"Created blog stub: {blog_file}") + PY fi - name: Update blog/index.md table with new major version diff --git a/docs/blog/release-v1-0-0.md b/docs/blog/release-v1-0-0.md index 0a915ab..df3fb9b 100644 --- a/docs/blog/release-v1-0-0.md +++ b/docs/blog/release-v1-0-0.md @@ -10,7 +10,7 @@ date: 2025-01-01 ## The beginning -`github-code-search` is an interactive CLI for searching GitHub code across an entire organization. +`github-code-search` is an interactive CLI for searching GitHub code across an entire organisation. It wraps the [GitHub Code Search API](https://docs.github.com/en/rest/search/search#search-code) and adds a keyboard-driven TUI on top, so you can browse results, fold/unfold extracts, and select the ones you care about before printing structured output. diff --git a/github-code-search.ts b/github-code-search.ts index 5b628a0..76ffba8 100644 --- a/github-code-search.ts +++ b/github-code-search.ts @@ -262,15 +262,23 @@ async function searchAction( ]).catch(() => null); if (latestTag) { const w = 55; - const bar = "─".repeat(w); + // Fix: compute all widths from totalWidth so corners always align. + // totalWidth = w + 4 ("│ " + w content chars + " │") + const totalWidth = w + 4; + const headerPrefix = "╭─"; + const headerLabel = " Update available "; + const headerDashes = "─".repeat( + Math.max(0, totalWidth - headerPrefix.length - headerLabel.length - 1), + ); + const bottomBar = "─".repeat(totalWidth - 2); const pad = (s: string) => s + " ".repeat(Math.max(0, w - s.length)); process.stderr.write( pc.yellow( [ - `╭─ Update available ${"─".repeat(w - 18)}╮`, + `${headerPrefix}${headerLabel}${headerDashes}╮`, `│ ${pad(`github-code-search ${VERSION} → ${latestTag}`)} │`, `│ ${pad("Run: github-code-search upgrade")} │`, - `╰${bar}╯`, + `╰${bottomBar}╯`, "", ].join("\n"), ), diff --git a/src/upgrade.test.ts b/src/upgrade.test.ts index 1378713..5a8ce0c 100644 --- a/src/upgrade.test.ts +++ b/src/upgrade.test.ts @@ -332,6 +332,18 @@ describe("checkForUpdate", () => { const result = await checkForUpdate("1.0.0"); expect(result).toBeNull(); }); + + it("returns null when given an already-aborted signal", async () => { + const controller = new AbortController(); + controller.abort(); + // fetch will throw an AbortError; checkForUpdate must catch it and return null. + globalThis.fetch = (async (_url: string, opts?: RequestInit) => { + if (opts?.signal?.aborted) throw new DOMException("Aborted", "AbortError"); + throw new Error("fetch should not succeed with an aborted signal"); + }) as typeof fetch; + const result = await checkForUpdate("1.0.0", undefined, controller.signal); + expect(result).toBeNull(); + }); }); // ─── performUpgrade — download path (covers downloadBinary) ──────────────────