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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 215 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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"
29 changes: 23 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
{
"name": "gkit",
"version": "0.1.0",
"private": true,
"workspaces": {
"packages": [
Expand Down
6 changes: 1 addition & 5 deletions packages/gkit/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand All @@ -10,11 +10,7 @@
"bin",
"docs",
"generated",
"policy",
"scripts",
"sources",
"src",
"evals",
"!**/*.test.ts"
],
"type": "module",
Expand Down
62 changes: 33 additions & 29 deletions packages/gkit/scripts/generate-dataforseo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
Loading
Loading