From c11e8456037ff41816f3cafd7ced363f1b78f140 Mon Sep 17 00:00:00 2001 From: konojunya Date: Sun, 6 Sep 2026 01:24:45 +0900 Subject: [PATCH 1/3] Detect CLI release drift and synchronize localized version guidance --- .github/workflows/ci.yaml | 5 ++ .github/workflows/release-freshness.yaml | 24 +++++++ docs/guide/getting-started.md | 2 +- docs/ja/guide/getting-started.md | 2 +- docs/ko/guide/getting-started.md | 2 +- docs/zh/guide/getting-started.md | 2 +- package.json | 4 +- scripts/cli-release.mjs | 91 ++++++++++++++++++++++++ scripts/cli-release.test.mjs | 30 ++++++++ scripts/docs-validation.config.mjs | 4 +- 10 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/release-freshness.yaml create mode 100644 scripts/cli-release.mjs create mode 100644 scripts/cli-release.test.mjs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e54c52b..acbff41 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -68,6 +68,11 @@ jobs: - name: Run tests run: npm test + - name: Check latest published CLI release + env: + GH_TOKEN: ${{ github.token }} + run: npm run docs:release:check + - name: Verify pinned example snapshots env: STACK_SPECIFICATION_ROOT: ${{ github.workspace }}/.stack-specification diff --git a/.github/workflows/release-freshness.yaml b/.github/workflows/release-freshness.yaml new file mode 100644 index 0000000..3247662 --- /dev/null +++ b/.github/workflows/release-freshness.yaml @@ -0,0 +1,24 @@ +name: Release freshness + +on: + schedule: + - cron: "23 2 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + freshness: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: 22.14.0 + - name: Compare documentation with the latest published CLI + env: + GH_TOKEN: ${{ github.token }} + run: node scripts/cli-release.mjs --live diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index bc70dc6..ac81f4b 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -4,7 +4,7 @@ The fastest way to use Stack is the browser [Playground](https://stack-diagram.c ## Install the native CLI -For terminal workflows and local automation, install the owner-maintained Homebrew formula with `brew install stack-sh/tap/stack`. It uses the canonical Stack CLI 0.3.0 release archive and supports Apple Silicon macOS plus glibc-based Linux on arm64 and x86_64 when the host meets Homebrew's current tier-1 requirements. Homebrew owns upgrades through `brew upgrade stack-sh/tap/stack`; uninstalling the formula leaves your Stack configuration and icon store in place. See the [CLI distribution contract](https://github.com/stack-sh/cli/blob/main/docs/distribution.md#homebrew-installation) for the exact platform matrix, direct-install alternative, and recovery policy. +For terminal workflows and local automation, install the owner-maintained Homebrew formula with `brew install stack-sh/tap/stack`. It uses the canonical Stack CLI 0.4.0 release archive and supports Apple Silicon macOS plus glibc-based Linux on arm64 and x86_64 when the host meets Homebrew's current tier-1 requirements. Homebrew owns upgrades through `brew upgrade stack-sh/tap/stack`; uninstalling the formula leaves your Stack configuration and icon store in place. See the [CLI distribution contract](https://github.com/stack-sh/cli/blob/main/docs/distribution.md#homebrew-installation) for the exact platform matrix, direct-install alternative, and recovery policy. ## Write your first document diff --git a/docs/ja/guide/getting-started.md b/docs/ja/guide/getting-started.md index 18d56db..9a8d5dd 100644 --- a/docs/ja/guide/getting-started.md +++ b/docs/ja/guide/getting-started.md @@ -4,7 +4,7 @@ ## Native CLIをinstallする -Terminal workflowとlocal automationでは、owner管理のHomebrew formulaを`brew install stack-sh/tap/stack`でinstallします。CanonicalなStack CLI 0.3.0 release archiveを使用し、Homebrewの現行Tier 1要件を満たすApple Silicon macOSと、arm64 / x86_64のglibc Linuxをsupportします。UpgradeはHomebrewが`brew upgrade stack-sh/tap/stack`で管理し、formulaをuninstallしてもStackのconfigとicon storeは保持されます。正確なplatform matrix、direct install、recovery policyは[CLI distribution contract](https://github.com/stack-sh/cli/blob/main/docs/distribution.md#homebrew-installation)を参照してください。 +Terminal workflowとlocal automationでは、owner管理のHomebrew formulaを`brew install stack-sh/tap/stack`でinstallします。CanonicalなStack CLI 0.4.0 release archiveを使用し、Homebrewの現行Tier 1要件を満たすApple Silicon macOSと、arm64 / x86_64のglibc Linuxをsupportします。UpgradeはHomebrewが`brew upgrade stack-sh/tap/stack`で管理し、formulaをuninstallしてもStackのconfigとicon storeは保持されます。正確なplatform matrix、direct install、recovery policyは[CLI distribution contract](https://github.com/stack-sh/cli/blob/main/docs/distribution.md#homebrew-installation)を参照してください。 ## 最初のdocumentを書く diff --git a/docs/ko/guide/getting-started.md b/docs/ko/guide/getting-started.md index 3117623..210357f 100644 --- a/docs/ko/guide/getting-started.md +++ b/docs/ko/guide/getting-started.md @@ -4,7 +4,7 @@ Stack을 가장 빠르게 사용하는 방법은 브라우저 [Playground](https ## 네이티브 CLI 설치 -터미널 워크플로와 로컬 자동화에서는 `brew install stack-sh/tap/stack`으로 Stack이 관리하는 Homebrew formula를 설치합니다. 이 formula는 표준 Stack CLI 0.3.0 릴리스 아카이브를 사용하며, Homebrew의 현재 Tier 1 요구 사항을 충족하는 Apple Silicon macOS와 arm64 / x86_64 glibc Linux를 지원합니다. 업그레이드는 Homebrew가 `brew upgrade stack-sh/tap/stack`으로 관리하며, formula를 제거해도 Stack 설정과 아이콘 저장소는 유지됩니다. 정확한 플랫폼 매트릭스, 직접 설치 방법 및 복구 정책은 [CLI 배포 계약](https://github.com/stack-sh/cli/blob/main/docs/distribution.md#homebrew-installation)을 참고하세요. +터미널 워크플로와 로컬 자동화에서는 `brew install stack-sh/tap/stack`으로 Stack이 관리하는 Homebrew formula를 설치합니다. 이 formula는 표준 Stack CLI 0.4.0 릴리스 아카이브를 사용하며, Homebrew의 현재 Tier 1 요구 사항을 충족하는 Apple Silicon macOS와 arm64 / x86_64 glibc Linux를 지원합니다. 업그레이드는 Homebrew가 `brew upgrade stack-sh/tap/stack`으로 관리하며, formula를 제거해도 Stack 설정과 아이콘 저장소는 유지됩니다. 정확한 플랫폼 매트릭스, 직접 설치 방법 및 복구 정책은 [CLI 배포 계약](https://github.com/stack-sh/cli/blob/main/docs/distribution.md#homebrew-installation)을 참고하세요. ## 첫 문서 작성 diff --git a/docs/zh/guide/getting-started.md b/docs/zh/guide/getting-started.md index 41f5c80..a072d72 100644 --- a/docs/zh/guide/getting-started.md +++ b/docs/zh/guide/getting-started.md @@ -4,7 +4,7 @@ ## 安装原生 CLI -对于终端工作流和本地自动化,请使用 `brew install stack-sh/tap/stack` 安装由 Stack 维护的 Homebrew formula。它使用规范的 Stack CLI 0.3.0 发布归档,并支持符合 Homebrew 当前 Tier 1 要求的 Apple Silicon macOS,以及 arm64 / x86_64 的 glibc Linux。Homebrew 通过 `brew upgrade stack-sh/tap/stack` 管理升级;卸载 formula 不会删除 Stack 配置和图标存储。有关准确的平台矩阵、直接安装方式和恢复策略,请参阅 [CLI 分发约定](https://github.com/stack-sh/cli/blob/main/docs/distribution.md#homebrew-installation)。 +对于终端工作流和本地自动化,请使用 `brew install stack-sh/tap/stack` 安装由 Stack 维护的 Homebrew formula。它使用规范的 Stack CLI 0.4.0 发布归档,并支持符合 Homebrew 当前 Tier 1 要求的 Apple Silicon macOS,以及 arm64 / x86_64 的 glibc Linux。Homebrew 通过 `brew upgrade stack-sh/tap/stack` 管理升级;卸载 formula 不会删除 Stack 配置和图标存储。有关准确的平台矩阵、直接安装方式和恢复策略,请参阅 [CLI 分发约定](https://github.com/stack-sh/cli/blob/main/docs/distribution.md#homebrew-installation)。 ## 编写第一份文档 diff --git a/package.json b/package.json index 6dd9f80..aaf4b70 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "deploy": "npm run build && wrangler deploy", "dev": "vite", "docs:build": "npm run docs:check && vitepress build docs && node scripts/validate-docs-output.mjs", - "docs:check": "node scripts/validate-docs.mjs && node scripts/validate-docs-examples.mjs && npm run examples:check", + "docs:check": "node scripts/cli-release.mjs && node scripts/validate-docs.mjs && node scripts/validate-docs-examples.mjs && npm run examples:check", + "docs:release:check": "node scripts/cli-release.mjs --live", + "docs:release:sync": "node scripts/cli-release.mjs --sync", "docs:smoke": "node scripts/validate-docs-cli.mjs", "docs:test": "node --test scripts/*.test.mjs", "docs:dev": "vitepress dev docs", diff --git a/scripts/cli-release.mjs b/scripts/cli-release.mjs new file mode 100644 index 0000000..273e1ef --- /dev/null +++ b/scripts/cli-release.mjs @@ -0,0 +1,91 @@ +import { readFile, writeFile } from "node:fs/promises" +import { fileURLToPath } from "node:url" +import path from "node:path" + +export function validateRelease(lock, release, revision) { + const errors = [] + if (release.draft || release.prerelease || !/^v\d+\.\d+\.\d+$/.test(release.tag_name)) { + errors.push("Expected a published stable CLI release") + } + if (release.tag_name !== `v${lock.version}`) { + errors.push( + `CLI release drift: docs ${lock.version}, latest ${release.tag_name}; run npm run docs:release:sync`, + ) + } + if (revision !== lock.revision) + errors.push("CLI release tag commit differs from the documentation pin") + return errors +} + +export function synchronizeVersion(source, version) { + if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error("Invalid CLI version") + if (!/Stack CLI \d+\.\d+\.\d+/.test(source)) throw new Error("Missing CLI version declaration") + return source.replace(/Stack CLI \d+\.\d+\.\d+/g, `Stack CLI ${version}`) +} + +async function github(endpoint) { + const headers = { Accept: "application/vnd.github+json" } + if (process.env.GH_TOKEN) headers.Authorization = `Bearer ${process.env.GH_TOKEN}` + const response = await fetch(`https://api.github.com/repos/stack-sh/cli/${endpoint}`, { + headers, + signal: AbortSignal.timeout(15000), + }) + if (!response.ok) throw new Error(`GitHub ${endpoint}: HTTP ${response.status}`) + return response.json() +} + +export async function latestRelease() { + const release = await github("releases/latest") + if (release.draft || release.prerelease || !/^v\d+\.\d+\.\d+$/.test(release.tag_name)) { + throw new Error("Expected a published stable CLI release") + } + let { object } = await github(`git/ref/tags/${encodeURIComponent(release.tag_name)}`) + for (let depth = 0; object.type === "tag" && depth < 4; depth += 1) { + ;({ object } = await github(`git/tags/${object.sha}`)) + } + if (object.type !== "commit" || !/^[a-f0-9]{40}$/.test(object.sha)) { + throw new Error("CLI release tag does not resolve to a commit") + } + return { release, revision: object.sha } +} + +async function main() { + const { documentationContract } = await import("./docs-validation.config.mjs") + const configPath = new URL("./docs-validation.config.mjs", import.meta.url) + const { cli } = documentationContract + const sync = process.argv.includes("--sync") + let version = cli.version + if (sync || process.argv.includes("--live")) { + const { release, revision } = await latestRelease() + if (sync) { + version = release.tag_name.slice(1) + const config = await readFile(configPath, "utf8") + await writeFile( + configPath, + config + .replace(cli.revision, revision) + .replace(`version: "${cli.version}"`, `version: "${version}"`), + ) + } else { + const errors = validateRelease(cli, release, revision) + if (errors.length) throw new Error(errors.join("\n")) + } + } + for (const locale of ["", ...documentationContract.locales]) { + const page = new URL( + `../docs/${locale ? `${locale}/` : ""}guide/getting-started.md`, + import.meta.url, + ) + const source = await readFile(page, "utf8") + const updated = synchronizeVersion(source, version) + if (sync) await writeFile(page, updated) + else if (source !== updated) throw new Error(`Stale CLI version in ${fileURLToPath(page)}`) + } + console.log( + `Validated CLI ${version} documentation${sync ? " and synchronized release pin" : ""}.`, + ) +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await main() +} diff --git a/scripts/cli-release.test.mjs b/scripts/cli-release.test.mjs new file mode 100644 index 0000000..8bfc523 --- /dev/null +++ b/scripts/cli-release.test.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { synchronizeVersion, validateRelease } from "./cli-release.mjs" + +const lock = { version: "0.4.0", revision: "a".repeat(40) } +const release = { tag_name: "v0.4.0", draft: false, prerelease: false } + +test("matches a published release and its exact commit", () => { + assert.deepEqual(validateRelease(lock, release, lock.revision), []) +}) +test("rejects a new release omitted from docs", () => { + assert.match( + validateRelease(lock, { ...release, tag_name: "v0.5.0" }, lock.revision).join(), + /release drift/, + ) +}) +test("rejects draft, prerelease, and retargeted tags", () => { + for (const field of ["draft", "prerelease"]) { + assert.ok(validateRelease(lock, { ...release, [field]: true }, lock.revision).length) + } + assert.ok(validateRelease(lock, release, "b".repeat(40)).length) +}) +test("updates all declarations without changing unrelated prose or versions", () => { + assert.equal( + synchronizeVersion("Stack CLI 0.3.0, Engine 0.7.0, Stack CLI 0.3.0", "0.4.0"), + "Stack CLI 0.4.0, Engine 0.7.0, Stack CLI 0.4.0", + ) + assert.throws(() => synchronizeVersion("No declaration", "0.4.0")) + assert.throws(() => synchronizeVersion("Stack CLI 0.3.0", "main")) +}) diff --git a/scripts/docs-validation.config.mjs b/scripts/docs-validation.config.mjs index d688e97..902713c 100644 --- a/scripts/docs-validation.config.mjs +++ b/scripts/docs-validation.config.mjs @@ -32,8 +32,8 @@ export const documentationContract = { ], cli: { repository: "stack-sh/cli", - revision: "ca4a3c8f3eba3bac374f120e05151ac516de0faa", - version: "0.3.0", + revision: "7f4066884f7902d4c582d45184f3a9019fe59bf7", + version: "0.4.0", executionExceptions: [ { prefix: "stack icons import ", From 5d6122b34e47b87ea37a8b9746e35d1ee2844d4e Mon Sep 17 00:00:00 2001 From: konojunya Date: Sun, 6 Sep 2026 01:27:38 +0900 Subject: [PATCH 2/3] Exercise released CLI artifacts and reject invalid documentation commands --- .github/workflows/ci.yaml | 12 +++++++++ README.md | 8 +++++- package.json | 2 +- scripts/validate-docs-cli-negative.mjs | 34 ++++++++++++++++++++++++++ scripts/validate-docs-cli.mjs | 2 +- 5 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 scripts/validate-docs-cli-negative.mjs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index acbff41..9fbae57 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -83,6 +83,18 @@ jobs: STACK_CLI_BIN: ${{ github.workspace }}/.stack-cli/target/debug/stack run: npm run docs:smoke + - name: Verify and smoke test the published CLI archive + env: + GH_TOKEN: ${{ github.token }} + run: | + version=$(node --input-type=module -e 'import { documentationContract as c } from "./scripts/docs-validation.config.mjs"; console.log(c.cli.version)') + archive="stack-v${version}-x86_64-unknown-linux-gnu.tar.gz" + mkdir .stack-release + gh release download "v${version}" --repo stack-sh/cli --pattern "$archive" --dir .stack-release + gh attestation verify ".stack-release/$archive" --repo stack-sh/cli + tar -xzf ".stack-release/$archive" -C .stack-release + STACK_CLI_BIN="$PWD/.stack-release/stack-v${version}-x86_64-unknown-linux-gnu/stack" npm run docs:smoke + - name: Build playground run: npm run build diff --git a/README.md b/README.md index f27a022..143562f 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,13 @@ The Playground publishes canonical, Open Graph, Twitter Card, and `WebApplicatio [`public/robots.txt`](./public/robots.txt) permits public search crawling and advertises both sitemaps. [`public/llms.txt`](./public/llms.txt) and [`docs/public/llms.txt`](./docs/public/llms.txt) provide curated agent entry points. The VitePress build also emits clean Markdown alternatives for every documentation page and generates `/llms-full.txt` from the complete English documentation, so the agent-facing content stays synchronized with its public source. -## Scope +## Keeping CLI documentation current + +`scripts/docs-validation.config.mjs` pins the published CLI version and its exact release commit. Run `npm run docs:release:sync` after a stable CLI release to update that pin and all four Getting Started version declarations together. Review any changed command behavior, then run `STACK_CLI_BIN=/absolute/path/to/the/verified/release/stack npm run docs:smoke` and the normal build checks before opening a PR. The sync command does not install a CLI, accept provider terms, or publish changes. + +The required CI baseline compares the pin against GitHub's latest stable release and resolved tag commit, executes documentation commands using both the pinned source and the attested published Linux archive, and verifies that an unsupported flag fails the documentation smoke. Offline documentation builds check locale version consistency without contacting GitHub. The read-only `Release freshness` workflow checks daily and on manual dispatch, so a CLI-only release cannot remain silently stale until someone edits this repository. A failed run requires a synchronization PR and a verified Web deployment; this workflow does not auto-merge or auto-publish. GitHub scheduled runs may be delayed, and GitHub Actions notification preferences govern failure notifications. + +## Playground scope The initial playground includes: diff --git a/package.json b/package.json index aaf4b70..e830b30 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "docs:check": "node scripts/cli-release.mjs && node scripts/validate-docs.mjs && node scripts/validate-docs-examples.mjs && npm run examples:check", "docs:release:check": "node scripts/cli-release.mjs --live", "docs:release:sync": "node scripts/cli-release.mjs --sync", - "docs:smoke": "node scripts/validate-docs-cli.mjs", + "docs:smoke": "node scripts/validate-docs-cli.mjs && node scripts/validate-docs-cli-negative.mjs", "docs:test": "node --test scripts/*.test.mjs", "docs:dev": "vitepress dev docs", "docs:preview": "vitepress preview docs", diff --git a/scripts/validate-docs-cli-negative.mjs b/scripts/validate-docs-cli-negative.mjs new file mode 100644 index 0000000..c3e6e5a --- /dev/null +++ b/scripts/validate-docs-cli-negative.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +if (!process.env.STACK_CLI_BIN) throw new Error("STACK_CLI_BIN is required") +const temporary = await mkdtemp(path.join(os.tmpdir(), "stack-docs-negative-")) +try { + const docsRoot = path.join(temporary, "docs") + await cp("docs", docsRoot, { recursive: true, filter: (entry) => !entry.includes(".vitepress") }) + for (const locale of ["", "ja", "zh", "ko"]) { + const page = path.join(docsRoot, locale, "guide/getting-started.md") + await writeFile( + page, + `${await readFile(page, "utf8")}\n\`\`\`sh\nstack check architecture.stack --not-a-real-option\n\`\`\`\n`, + ) + } + assert.throws( + () => + execFileSync(process.execPath, ["scripts/validate-docs-cli.mjs"], { + env: { ...process.env, STACK_DOCS_ROOT: docsRoot }, + stdio: "pipe", + }), + (error) => { + assert.notEqual(error.status, 0) + assert.match(error.stderr.toString(), /unexpected argument.*--not-a-real-option/) + return true + }, + ) + console.log("Documentation smoke rejects unsupported CLI flags.") +} finally { + await rm(temporary, { recursive: true, force: true }) +} diff --git a/scripts/validate-docs-cli.mjs b/scripts/validate-docs-cli.mjs index 37d3c7d..4e31b60 100644 --- a/scripts/validate-docs-cli.mjs +++ b/scripts/validate-docs-cli.mjs @@ -12,7 +12,7 @@ const execute = promisify(execFile) const cli = process.env.STACK_CLI_BIN if (!cli) throw new Error("STACK_CLI_BIN must point to the pinned Stack CLI binary") -const docsRoot = path.resolve("docs") +const docsRoot = path.resolve(process.env.STACK_DOCS_ROOT ?? "docs") const { documents, cliExamples } = await validateDocumentationContract({ docsRoot, locales: documentationContract.locales, From b9d1b6079f924b3acc7b87508f100f28a39bc99b Mon Sep 17 00:00:00 2001 From: konojunya Date: Sun, 6 Sep 2026 01:28:23 +0900 Subject: [PATCH 3/3] Keep invalid-flag fixture within the shell documentation contract --- scripts/validate-docs-cli-negative.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/validate-docs-cli-negative.mjs b/scripts/validate-docs-cli-negative.mjs index c3e6e5a..eceb63e 100644 --- a/scripts/validate-docs-cli-negative.mjs +++ b/scripts/validate-docs-cli-negative.mjs @@ -13,7 +13,7 @@ try { const page = path.join(docsRoot, locale, "guide/getting-started.md") await writeFile( page, - `${await readFile(page, "utf8")}\n\`\`\`sh\nstack check architecture.stack --not-a-real-option\n\`\`\`\n`, + `${await readFile(page, "utf8")}\n\`\`\`sh\n$ stack check architecture.stack --not-a-real-option\n\`\`\`\n`, ) } assert.throws(