From 250b2366c2080d1432adebedbdc5023dad80b3e5 Mon Sep 17 00:00:00 2001 From: Deniffer Date: Mon, 3 Aug 2026 16:35:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20publish=20gkit=20through=20immutable=20?= =?UTF-8?q?GitHub=20Release=20tarballs=20=E2=80=94=20make=20public=20Bun?= =?UTF-8?q?=20installation=20the=20supported=20distribution=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release v0.1.1 from the package version on main, keep npm publishing disabled, and verify both local artifacts and tokenless public installs. Raise the existing DataForSEO generator test budget to prevent the known GitHub runner timeout from blocking the release gate. --- .github/workflows/release.yml | 215 ++++++++++++++++ README.md | 29 ++- package.json | 1 - packages/gkit/package.json | 6 +- .../gkit/scripts/generate-dataforseo.test.ts | 62 ++--- .../gkit/scripts/release-preflight.test.ts | 62 +++++ packages/gkit/scripts/release-preflight.ts | 241 ++++++++++++++++++ .../gkit/scripts/verify-package-artifact.ts | 150 +++++++++-- packages/gkit/tsconfig.json | 2 +- 9 files changed, 704 insertions(+), 64 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 packages/gkit/scripts/release-preflight.test.ts create mode 100644 packages/gkit/scripts/release-preflight.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..98665d3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,215 @@ +name: Release gkit + +on: + push: + branches: + - main + paths: + - packages/gkit/package.json + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: release-gkit + cancel-in-progress: false + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout triggering commit and tags + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.10 + + - name: Validate release trigger and version + id: preflight + run: | + args=( + --event-name "${{ github.event_name }}" + --target-sha "${{ github.sha }}" + --ref "${{ github.ref }}" + --output "$GITHUB_OUTPUT" + ) + if [[ "${{ github.event_name }}" == "push" ]]; then + args+=(--before-sha "${{ github.event.before }}") + fi + bun run ./packages/gkit/scripts/release-preflight.ts "${args[@]}" + + - name: Install frozen dependencies + if: steps.preflight.outputs.release == 'true' + run: bun install --frozen-lockfile + + - name: Check types + if: steps.preflight.outputs.release == 'true' + run: bun run check-types + + - name: Test + if: steps.preflight.outputs.release == 'true' + run: bun run test + + - name: Evaluate agent contract + if: steps.preflight.outputs.release == 'true' + run: bun run eval + + - name: Build and verify release assets + if: steps.preflight.outputs.release == 'true' + env: + RELEASE_DIR: ${{ runner.temp }}/gkit-release + run: bun run verify:package -- --output-dir "$RELEASE_DIR" + + - name: Create immutable release tag + if: steps.preflight.outputs.release == 'true' + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.preflight.outputs.tag }} + TARGET_SHA: ${{ github.sha }} + TAG_EXISTS: ${{ steps.preflight.outputs.tag_exists }} + run: | + if [[ "$TAG_EXISTS" == "true" ]]; then + [[ "$(git rev-list -n 1 "$TAG")" == "$TARGET_SHA" ]] + exit 0 + fi + gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ + -f ref="refs/tags/$TAG" \ + -f sha="$TARGET_SHA" >/dev/null + + - name: Create, resume, or verify draft GitHub Release + if: steps.preflight.outputs.release == 'true' + id: release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.preflight.outputs.tag }} + PRERELEASE: ${{ steps.preflight.outputs.prerelease }} + run: | + releases=$(gh api --paginate --slurp \ + "repos/$GITHUB_REPOSITORY/releases?per_page=100" \ + | jq --arg tag "$TAG" '[.[][] | select(.tag_name == $tag)]') + release_count=$(jq length <<<"$releases") + [[ "$release_count" -le 1 ]] + make_latest=true + if [[ "$PRERELEASE" == "true" ]]; then + make_latest=false + fi + if [[ "$release_count" == "0" ]]; then + release_json=$(gh api --method POST "repos/$GITHUB_REPOSITORY/releases" \ + -f tag_name="$TAG" \ + -f target_commitish="$GITHUB_SHA" \ + -F draft=true \ + -F prerelease="$PRERELEASE" \ + -F generate_release_notes=true \ + -f make_latest="$make_latest") + else + release_json=$(jq '.[0]' <<<"$releases") + fi + [[ "$(jq -r .tag_name <<<"$release_json")" == "$TAG" ]] + [[ "$(jq -r .prerelease <<<"$release_json")" == "$PRERELEASE" ]] + echo "release_id=$(jq -r .id <<<"$release_json")" >>"$GITHUB_OUTPUT" + echo "draft=$(jq -r .draft <<<"$release_json")" >>"$GITHUB_OUTPUT" + + - name: Upload or verify release assets without overwrite + if: steps.preflight.outputs.release == 'true' + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.preflight.outputs.version }} + RELEASE_DIR: ${{ runner.temp }}/gkit-release + RELEASE_ID: ${{ steps.release.outputs.release_id }} + run: | + for name in "gkit-$VERSION.tgz" gkit.tgz SHA256SUMS; do + path="$RELEASE_DIR/$name" + local_digest="sha256:$(sha256sum "$path" | cut -d ' ' -f 1)" + asset_json=$(gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \ + --jq ".assets | map(select(.name == \"$name\"))") + asset_count=$(jq length <<<"$asset_json") + if [[ "$asset_count" == "0" ]]; then + encoded_name=$(jq -rn --arg name "$name" '$name | @uri') + gh api --method POST \ + -H "Content-Type: application/octet-stream" \ + "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=$encoded_name" \ + --input "$path" >/dev/null + continue + fi + [[ "$asset_count" == "1" ]] + remote_digest=$(jq -r '.[0].digest // ""' <<<"$asset_json") + if [[ -z "$remote_digest" ]]; then + asset_id=$(jq -r '.[0].id' <<<"$asset_json") + downloaded_asset=$(mktemp) + gh api -H "Accept: application/octet-stream" \ + "repos/$GITHUB_REPOSITORY/releases/assets/$asset_id" >"$downloaded_asset" + remote_digest="sha256:$(sha256sum "$downloaded_asset" | cut -d ' ' -f 1)" + rm "$downloaded_asset" + fi + if [[ "$remote_digest" != "$local_digest" ]]; then + echo "Release asset $name already exists with different bytes." >&2 + exit 1 + fi + done + + - name: Publish verified draft Release + if: steps.preflight.outputs.release == 'true' && steps.release.outputs.draft == 'true' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_ID: ${{ steps.release.outputs.release_id }} + PRERELEASE: ${{ steps.preflight.outputs.prerelease }} + run: | + make_latest=true + if [[ "$PRERELEASE" == "true" ]]; then + make_latest=false + fi + gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \ + -F draft=false \ + -F prerelease="$PRERELEASE" \ + -f make_latest="$make_latest" >/dev/null + + - name: Verify latest release semantics + if: steps.preflight.outputs.release == 'true' + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.preflight.outputs.tag }} + PRERELEASE: ${{ steps.preflight.outputs.prerelease }} + run: | + latest_tag=$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name 2>/dev/null || true) + if [[ "$PRERELEASE" == "true" ]]; then + [[ "$latest_tag" != "$TAG" ]] + else + [[ "$latest_tag" == "$TAG" ]] + fi + + - name: Smoke public tokenless global install + if: steps.preflight.outputs.release == 'true' + env: + TAG: ${{ steps.preflight.outputs.tag }} + run: | + consumer=$(mktemp -d) + trap 'rm -rf "$consumer"' EXIT + export BUN_INSTALL_GLOBAL_DIR="$consumer/global" + export BUN_INSTALL_BIN="$consumer/bin" + export BUN_INSTALL_CACHE_DIR="$consumer/cache" + url="https://github.com/$GITHUB_REPOSITORY/releases/download/$TAG/gkit.tgz" + installed=false + for _ in 1 2 3 4 5 6; do + if env -u GH_TOKEN -u GITHUB_TOKEN bun add --global "gkit@$url"; then + installed=true + break + fi + sleep 10 + done + [[ "$installed" == "true" ]] + "$BUN_INSTALL_BIN/gkit" --schema gsc >/dev/null + "$BUN_INSTALL_BIN/gkit" describe --id gsc.properties.list \ + | jq -e '.id == "gsc.properties.list"' >/dev/null + docs_dir=$("$BUN_INSTALL_BIN/gkit" docs --provider gsc) + grep -q 'gsc.properties.list' "$docs_dir/capabilities.md" diff --git a/README.md b/README.md index 3a01595..a5e8914 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,40 @@ # gkit -Private, profile-bound CLI for agent-first access to growth providers. This -repository has one CLI and one workspace package: `gkit`. +Profile-bound CLI for agent-first access to growth providers. This repository +has one CLI and one workspace package: `gkit`. The reviewed provider surface includes DataForSEO, PostHog, Google Ads, Google Search Console, and Bing Webmaster. ## Install -gkit is private and is not published to npm. Install it once per machine from -the repository checkout: +gkit requires [Bun](https://bun.sh/) and is distributed only as a public npm +tarball attached to GitHub Releases. It is not published to an npm registry. + +Install the latest stable release globally: ```bash -bun install -bun link --cwd packages/gkit +bun add --global "gkit@https://github.com/celados/gkit/releases/latest/download/gkit.tgz" +gkit --schema +``` +Install an exact version instead: + +```bash +bun add --global "gkit@https://github.com/celados/gkit/releases/download/v0.1.1/gkit-0.1.1.tgz" gkit --schema ``` +Prereleases are available only through their exact version URLs and never +replace the stable `latest` download. + +Upgrade to the newest stable release by running the latest install command +again. To uninstall: + +```bash +bun remove --global gkit +``` + ## Discover capabilities Discovery commands are offline and do not load a profile or resolve secrets: diff --git a/package.json b/package.json index bf5c67a..8c49054 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,5 @@ { "name": "gkit", - "version": "0.1.0", "private": true, "workspaces": { "packages": [ diff --git a/packages/gkit/package.json b/packages/gkit/package.json index d3b7bc8..ec589f6 100644 --- a/packages/gkit/package.json +++ b/packages/gkit/package.json @@ -1,6 +1,6 @@ { "name": "gkit", - "version": "0.1.0", + "version": "0.1.1", "private": true, "description": "Profile-bound, agent-first growth provider CLI.", "bin": { @@ -10,11 +10,7 @@ "bin", "docs", "generated", - "policy", - "scripts", - "sources", "src", - "evals", "!**/*.test.ts" ], "type": "module", diff --git a/packages/gkit/scripts/generate-dataforseo.test.ts b/packages/gkit/scripts/generate-dataforseo.test.ts index 2dd3b17..a5dcc81 100644 --- a/packages/gkit/scripts/generate-dataforseo.test.ts +++ b/packages/gkit/scripts/generate-dataforseo.test.ts @@ -5,36 +5,40 @@ import { generateDataForSeoArtifacts } from "./generate-dataforseo"; const packageRoot = new URL("..", import.meta.url).pathname; describe("DataForSEO artifact generator", () => { - it("projects one pinned source and reviewed policy into executable and inventory surfaces", async () => { - const first = await generateDataForSeoArtifacts(packageRoot); - const second = await generateDataForSeoArtifacts(packageRoot); + it( + "projects one pinned source and reviewed policy into executable and inventory surfaces", + async () => { + const first = await generateDataForSeoArtifacts(packageRoot); + const second = await generateDataForSeoArtifacts(packageRoot); - expect(second).toEqual(first); + expect(second).toEqual(first); - const manifest = JSON.parse(first.manifest) as { - capabilities: Array<{ id: string }>; - }; - const inventory = JSON.parse(first.inventory) as { - operations: Array<{ - operationId: string; - exposure: "executable" | "inventory"; - reason: string; - }>; - }; + const manifest = JSON.parse(first.manifest) as { + capabilities: Array<{ id: string }>; + }; + const inventory = JSON.parse(first.inventory) as { + operations: Array<{ + operationId: string; + exposure: "executable" | "inventory"; + reason: string; + }>; + }; - expect(manifest.capabilities.map((record) => record.id)).toEqual([ - "dataforseo.ai_optimization.llm_mentions.search.live", - "dataforseo.backlinks.bulk_ranks.live", - "dataforseo.backlinks.referring_domains.live", - "dataforseo.backlinks.summary.live", - "dataforseo.serp.google.organic.live.advanced", - ]); - expect(inventory.operations).toContainEqual( - expect.objectContaining({ - operationId: "LlmMentionsSearchLive", - exposure: "executable", - capabilityId: "dataforseo.ai_optimization.llm_mentions.search.live", - }), - ); - }); + expect(manifest.capabilities.map((record) => record.id)).toEqual([ + "dataforseo.ai_optimization.llm_mentions.search.live", + "dataforseo.backlinks.bulk_ranks.live", + "dataforseo.backlinks.referring_domains.live", + "dataforseo.backlinks.summary.live", + "dataforseo.serp.google.organic.live.advanced", + ]); + expect(inventory.operations).toContainEqual( + expect.objectContaining({ + operationId: "LlmMentionsSearchLive", + exposure: "executable", + capabilityId: "dataforseo.ai_optimization.llm_mentions.search.live", + }), + ); + }, + 15_000, + ); }); diff --git a/packages/gkit/scripts/release-preflight.test.ts b/packages/gkit/scripts/release-preflight.test.ts new file mode 100644 index 0000000..9a26b26 --- /dev/null +++ b/packages/gkit/scripts/release-preflight.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { compareSemVer, decideRelease, parseSemVer } from "./release-preflight"; + +describe("release preflight", () => { + it("implements SemVer precedence including prereleases", () => { + const ordered = [ + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-alpha.beta", + "1.0.0-beta", + "1.0.0-beta.2", + "1.0.0-beta.11", + "1.0.0-rc.1", + "1.0.0", + ].map(parseSemVer); + for (let index = 1; index < ordered.length; index += 1) { + expect(compareSemVer(ordered[index - 1]!, ordered[index]!)).toBe(-1); + } + expect( + compareSemVer( + parseSemVer("999999999999999999999999.0.0"), + parseSemVer("1000000000000000000000000.0.0"), + ), + ).toBe(-1); + }); + + it("rejects invalid SemVer values", () => { + for (const version of ["v1.0.0", "1.0", "01.0.0", "1.0.0-01", "1.0.0-"]) { + expect(() => parseSemVer(version)).toThrow("Invalid SemVer"); + } + }); + + it("allows a new version only when it is greater than every release tag", () => { + expect(decideRelease("1.2.0", ["not-a-release", "v1.1.9"], "abc", new Map())).toEqual({ + version: "1.2.0", + tag: "v1.2.0", + prerelease: false, + tagExists: false, + }); + expect(() => decideRelease("1.1.8", ["v1.1.9"], "abc", new Map())).toThrow( + "must be greater", + ); + }); + + it("permits an idempotent rerun only when the tag points to the same commit", () => { + expect(decideRelease("1.2.0-beta.1", ["v1.2.0-beta.1"], "abc", new Map([ + ["v1.2.0-beta.1", "abc"], + ]))).toMatchObject({ prerelease: true, tagExists: true }); + expect(() => + decideRelease("1.2.0-beta.1", ["v1.2.0-beta.1"], "abc", new Map([ + ["v1.2.0-beta.1", "different"], + ])), + ).toThrow("not abc"); + }); + + it("does not let build metadata bypass equal precedence", () => { + expect(() => decideRelease("1.2.0+new", ["v1.2.0+old"], "abc", new Map())).toThrow( + "must be greater", + ); + }); +}); diff --git a/packages/gkit/scripts/release-preflight.ts b/packages/gkit/scripts/release-preflight.ts new file mode 100644 index 0000000..e104bf3 --- /dev/null +++ b/packages/gkit/scripts/release-preflight.ts @@ -0,0 +1,241 @@ +import { appendFile, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +type SemVer = Readonly<{ + raw: string; + major: bigint; + minor: bigint; + patch: bigint; + prerelease: readonly string[]; +}>; + +type ReleaseDecision = Readonly<{ + version: string; + tag: string; + prerelease: boolean; + tagExists: boolean; +}>; + +type CommandResult = Readonly<{ + stdout: string; + stderr: string; +}>; + +const packagePath = "packages/gkit/package.json"; +const semVerPattern = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; + +export function parseSemVer(value: string): SemVer { + const match = semVerPattern.exec(value); + if (!match) { + throw new Error(`Invalid SemVer version: ${value}`); + } + return Object.freeze({ + raw: value, + major: BigInt(match[1]), + minor: BigInt(match[2]), + patch: BigInt(match[3]), + prerelease: Object.freeze(match[4]?.split(".") ?? []), + }); +} + +export function compareSemVer(left: SemVer, right: SemVer): number { + for (const key of ["major", "minor", "patch"] as const) { + if (left[key] !== right[key]) return left[key] < right[key] ? -1 : 1; + } + if (left.prerelease.length === 0 || right.prerelease.length === 0) { + if (left.prerelease.length === right.prerelease.length) return 0; + return left.prerelease.length === 0 ? 1 : -1; + } + const length = Math.max(left.prerelease.length, right.prerelease.length); + for (let index = 0; index < length; index += 1) { + const leftIdentifier = left.prerelease[index]; + const rightIdentifier = right.prerelease[index]; + if (leftIdentifier === rightIdentifier) continue; + if (leftIdentifier === undefined) return -1; + if (rightIdentifier === undefined) return 1; + const leftNumeric = /^\d+$/.test(leftIdentifier); + const rightNumeric = /^\d+$/.test(rightIdentifier); + if (leftNumeric && rightNumeric) { + if (leftIdentifier.length !== rightIdentifier.length) { + return leftIdentifier.length < rightIdentifier.length ? -1 : 1; + } + return leftIdentifier < rightIdentifier ? -1 : 1; + } + if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1; + return leftIdentifier < rightIdentifier ? -1 : 1; + } + return 0; +} + +export function decideRelease( + versionValue: string, + tags: readonly string[], + targetSha: string, + tagTargets: ReadonlyMap, +): ReleaseDecision { + const version = parseSemVer(versionValue); + const tag = `v${version.raw}`; + const releaseTags = tags.flatMap((candidate) => { + if (!candidate.startsWith("v")) return []; + try { + return [{ tag: candidate, version: parseSemVer(candidate.slice(1)) }]; + } catch { + return []; + } + }); + const existingTarget = tagTargets.get(tag); + if (existingTarget !== undefined && existingTarget !== targetSha) { + throw new Error(`Existing tag ${tag} points to ${existingTarget}, not ${targetSha}.`); + } + for (const existing of releaseTags) { + if (existing.tag === tag) continue; + if (compareSemVer(version, existing.version) <= 0) { + throw new Error( + `Version ${version.raw} must be greater than existing release tag ${existing.tag}.`, + ); + } + } + return Object.freeze({ + version: version.raw, + tag, + prerelease: version.prerelease.length > 0, + tagExists: existingTarget !== undefined, + }); +} + +if (import.meta.main) await main(); + +async function main(): Promise { + const options = parseOptions(Bun.argv.slice(2)); + if (options.ref !== "refs/heads/main") { + throw new Error(`Releases must run from refs/heads/main, received ${options.ref}.`); + } + const packageJson = await readPackageJson(resolve(packagePath)); + const version = requireVersion(packageJson); + if (options.eventName === "push" && options.beforeSha !== undefined) { + const previousPackageJson = await readPackageJsonAtCommit(options.beforeSha); + if (previousPackageJson !== undefined && requireVersion(previousPackageJson) === version) { + await writeOutputs(options.output, { release: "false" }); + process.stdout.write("Package version did not change; release skipped.\n"); + return; + } + } + const tags = (await runCommand(["git", "tag", "--list"], process.cwd())).stdout + .split("\n") + .filter(Boolean); + const tagTargets = new Map(); + for (const tag of tags) { + const target = ( + await runCommand(["git", "rev-list", "-n", "1", tag], process.cwd()) + ).stdout.trim(); + tagTargets.set(tag, target); + } + const decision = decideRelease(version, tags, options.targetSha, tagTargets); + await writeOutputs(options.output, { + release: "true", + version: decision.version, + tag: decision.tag, + prerelease: String(decision.prerelease), + tag_exists: String(decision.tagExists), + }); + process.stdout.write( + `Release preflight passed for ${decision.tag} at ${options.targetSha}.\n`, + ); +} + +function parseOptions(arguments_: readonly string[]): { + eventName: string; + beforeSha?: string; + targetSha: string; + ref: string; + output: string; +} { + const values = new Map(); + for (let index = 0; index < arguments_.length; index += 2) { + const key = arguments_[index]; + const value = arguments_[index + 1]; + if (!key?.startsWith("--") || value === undefined) { + throw new Error("Release preflight arguments must be --key value pairs."); + } + values.set(key.slice(2), value); + } + const eventName = requireOption(values, "event-name"); + const targetSha = requireOption(values, "target-sha"); + const ref = requireOption(values, "ref"); + const output = requireOption(values, "output"); + const beforeSha = values.get("before-sha"); + return { eventName, targetSha, ref, output, ...(beforeSha ? { beforeSha } : {}) }; +} + +function requireOption(values: ReadonlyMap, name: string): string { + const value = values.get(name); + if (!value) throw new Error(`Missing required option --${name}.`); + return value; +} + +async function readPackageJson(path: string): Promise { + return JSON.parse(await readFile(path, "utf8")) as unknown; +} + +async function readPackageJsonAtCommit(commit: string): Promise { + if (/^0+$/.test(commit)) return undefined; + const result = await runCommand( + ["git", "show", `${commit}:${packagePath}`], + process.cwd(), + true, + ); + if (!result) return undefined; + return JSON.parse(result.stdout) as unknown; +} + +function requireVersion(value: unknown): string { + if (!value || typeof value !== "object" || !("version" in value)) { + throw new Error(`${packagePath} must contain a version.`); + } + const version = (value as { version?: unknown }).version; + if (typeof version !== "string") throw new Error("Package version must be a string."); + parseSemVer(version); + return version; +} + +async function writeOutputs( + outputPath: string, + values: Readonly>, +): Promise { + const content = Object.entries(values) + .map(([key, value]) => `${key}=${value}\n`) + .join(""); + await appendFile(outputPath, content, "utf8"); +} + +function runCommand(command: readonly string[], cwd: string): Promise; +function runCommand( + command: readonly string[], + cwd: string, + allowFailure: true, +): Promise; +async function runCommand( + command: readonly string[], + cwd: string, + allowFailure = false, +): Promise { + const child = Bun.spawn([...command], { + cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + if (exitCode !== 0) { + if (allowFailure) return undefined; + throw new Error( + `Command failed (${exitCode}): ${command.join(" ")}\n${stdout}${stderr}`.trimEnd(), + ); + } + return { stdout, stderr }; +} diff --git a/packages/gkit/scripts/verify-package-artifact.ts b/packages/gkit/scripts/verify-package-artifact.ts index 43a33eb..6c82c1c 100644 --- a/packages/gkit/scripts/verify-package-artifact.ts +++ b/packages/gkit/scripts/verify-package-artifact.ts @@ -1,12 +1,38 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { constants } from "node:fs"; +import { + copyFile, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { basename, join, resolve } from "node:path"; -type PackResult = { +type PackResult = Readonly<{ filename: string; -}; + files?: readonly Readonly<{ path: string }>[]; +}>; + +type PackageJson = Readonly<{ + name?: unknown; + version?: unknown; + private?: unknown; +}>; + +const allowedTopLevelEntries = new Set(["bin", "docs", "generated", "package.json", "src"]); +const forbiddenPathPattern = + /(^|\/)(?:\.env(?:\..*)?|\.npmrc|bunfig\.toml|[^/]*\.test\.[^/]+|__tests__|evals?|scripts?|sources?|policy)(?:\/|$)/i; +const secretMaterialPatterns = [ + /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/, + /\bgh(?:p|o|u|s|r)_[A-Za-z0-9]{20,}\b/, + /\bgithub_pat_[A-Za-z0-9_]{20,}\b/, +] as const; const packageRoot = resolve(import.meta.dir, ".."); +const outputDirectory = parseOutputDirectory(Bun.argv.slice(2)); const temporaryRoot = await mkdtemp(join(tmpdir(), "gkit-package-artifact-")); const packDirectory = join(temporaryRoot, "pack"); const consumerDirectory = join(temporaryRoot, "consumer"); @@ -16,31 +42,49 @@ try { mkdir(packDirectory, { recursive: true }), mkdir(consumerDirectory, { recursive: true }), ]); + const packageJson = JSON.parse( + await readFile(join(packageRoot, "package.json"), "utf8"), + ) as PackageJson; + if (packageJson.name !== "gkit" || typeof packageJson.version !== "string") { + throw new Error("The package must have name gkit and a string version."); + } + if (packageJson.private !== true) { + throw new Error("The gkit package must remain private to prevent registry publication."); + } + const packed = await runCommand( ["npm", "pack", "--json", "--pack-destination", packDirectory, packageRoot], temporaryRoot, ); const packResults = JSON.parse(packed.stdout) as PackResult[]; - const filename = packResults[0]?.filename; - if (!filename) throw new Error("npm pack did not report a package filename."); + const packResult = packResults[0]; + if (!packResult?.filename || !packResult.files) { + throw new Error("npm pack did not report the package filename and contents."); + } + await verifyPackageContents(packResult.files.map((file) => file.path)); - const tarballPath = join(packDirectory, filename); - await writeFile( - join(consumerDirectory, "package.json"), - `${JSON.stringify({ name: "gkit-package-consumer", private: true }, null, 2)}\n`, - "utf8", + const tarballPath = join(packDirectory, packResult.filename); + const globalDirectory = join(consumerDirectory, "global"); + const globalBinDirectory = join(consumerDirectory, "bin"); + await runCommand( + ["bun", "add", "--global", `gkit@${tarballPath}`], + consumerDirectory, + { + BUN_INSTALL_GLOBAL_DIR: globalDirectory, + BUN_INSTALL_BIN: globalBinDirectory, + BUN_INSTALL_CACHE_DIR: join(consumerDirectory, "cache"), + }, ); - await runCommand(["bun", "add", tarballPath], consumerDirectory); const installedPackageJson = await readFile( - join(consumerDirectory, "node_modules", "gkit", "package.json"), + join(globalDirectory, "node_modules", "gkit", "package.json"), "utf8", ); if (installedPackageJson.includes('"catalog:"')) { throw new Error("The installed gkit artifact still contains workspace-only catalog ranges."); } - const gkit = join(consumerDirectory, "node_modules", ".bin", "gkit"); + const gkit = join(globalBinDirectory, "gkit"); await runCommand([gkit, "--schema", "gsc"], consumerDirectory); const described = await runCommand( [gkit, "describe", "--id", "gsc.properties.list"], @@ -50,31 +94,93 @@ try { if (capability.id !== "gsc.properties.list") { throw new Error("The installed gkit artifact returned an unexpected capability."); } + const docs = await runCommand([gkit, "docs", "--provider", "gsc"], consumerDirectory); + const capabilities = await readFile(join(docs.stdout.trim(), "capabilities.md"), "utf8"); + if (!capabilities.includes("gsc.properties.list")) { + throw new Error("The installed gkit artifact could not read its provider documentation."); + } - process.stdout.write(`Verified installable gkit artifact: ${filename}\n`); + if (outputDirectory) { + await writeReleaseAssets(tarballPath, packageJson.version, outputDirectory); + } + process.stdout.write(`Verified installable gkit artifact: ${packResult.filename}\n`); } finally { await rm(temporaryRoot, { recursive: true, force: true }); } +function parseOutputDirectory(arguments_: readonly string[]): string | undefined { + if (arguments_.length === 0) return undefined; + if (arguments_.length !== 2 || arguments_[0] !== "--output-dir" || !arguments_[1]) { + throw new Error("Usage: verify-package-artifact.ts [--output-dir ]"); + } + return resolve(arguments_[1]); +} + +async function verifyPackageContents(paths: readonly string[]): Promise { + if (!paths.includes("bin/gkit.js") || !paths.includes("package.json")) { + throw new Error("The package is missing its binary or package manifest."); + } + if (!paths.some((path) => path.startsWith("generated/"))) { + throw new Error("The package is missing generated provider manifests."); + } + if (!paths.some((path) => path.startsWith("docs/"))) { + throw new Error("The package is missing provider documentation."); + } + for (const path of paths) { + const topLevelEntry = path.split("/", 1)[0]; + if (!topLevelEntry || !allowedTopLevelEntries.has(topLevelEntry)) { + throw new Error(`Unexpected package entry: ${path}`); + } + if (forbiddenPathPattern.test(path)) { + throw new Error(`Forbidden package entry: ${path}`); + } + const content = await readFile(join(packageRoot, path), "utf8"); + if (secretMaterialPatterns.some((pattern) => pattern.test(content))) { + throw new Error(`Potential secret material found in package entry: ${path}`); + } + } +} + +async function writeReleaseAssets( + tarballPath: string, + version: string, + destination: string, +): Promise { + await mkdir(destination, { recursive: true }); + const exactName = `gkit-${version}.tgz`; + const stableName = "gkit.tgz"; + const exactPath = join(destination, exactName); + const stablePath = join(destination, stableName); + await copyFile(tarballPath, exactPath, constants.COPYFILE_EXCL); + await copyFile(tarballPath, stablePath, constants.COPYFILE_EXCL); + const digest = createHash("sha256").update(await readFile(tarballPath)).digest("hex"); + await writeFile( + join(destination, "SHA256SUMS"), + `${digest} ${exactName}\n${digest} ${stableName}\n`, + { encoding: "utf8", flag: "wx" }, + ); +} + async function runCommand( - command: string[], + command: readonly string[], cwd: string, + additionalEnvironment: Readonly> = {}, ): Promise<{ stdout: string; stderr: string }> { - const process = Bun.spawn(command, { + const child = Bun.spawn([...command], { cwd, - env: { ...Bun.env, CI: "1" }, + env: { ...Bun.env, ...additionalEnvironment, CI: "1" }, stdin: "ignore", stdout: "pipe", stderr: "pipe", }); const [exitCode, stdout, stderr] = await Promise.all([ - process.exited, - new Response(process.stdout).text(), - new Response(process.stderr).text(), + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), ]); if (exitCode !== 0) { throw new Error( - `Command failed (${exitCode}): ${command.join(" ")}\n${stdout}${stderr}`.trimEnd(), + `Command failed (${exitCode}): ${command.map((value) => basename(value)).join(" ")}\n${stdout}${stderr}`.trimEnd(), ); } return { stdout, stderr }; diff --git a/packages/gkit/tsconfig.json b/packages/gkit/tsconfig.json index b70125b..924f7bc 100644 --- a/packages/gkit/tsconfig.json +++ b/packages/gkit/tsconfig.json @@ -9,5 +9,5 @@ "skipLibCheck": true, "types": ["node", "bun-types"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "scripts/**/*.ts"] }