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"] }