Skip to content

feat(desktop): launch team skills from pull request review #231475

feat(desktop): launch team skills from pull request review

feat(desktop): launch team skills from pull request review #231475

Workflow file for this run

name: Agent Skills
# No paths filter on master pushes (matching the other ci-* workflows): the skills build
# imports product code whose import graph any Python change can affect, so a filtered
# master trigger lets unrelated commits break the build invisibly until the next
# skills-path commit takes the blame. PR runs stay filtered via the changes job below.
on:
pull_request:
push:
branches:
- master
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.sha || github.head_ref || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
changes:
permissions:
contents: read
pull-requests: read
runs-on: ubuntu-24.04
timeout-minutes: 5
name: Determine need to run skill checks
outputs:
skills: ${{ steps.filter.outputs.skills || 'true' }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: .github/actions/paths-filter
sparse-checkout-cone-mode: false
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
id: app-token
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
with:
client-id: ${{ vars.GH_APP_POSTHOG_PATHS_FILTER_APP_ID }}
private-key: ${{ secrets.GH_APP_POSTHOG_PATHS_FILTER_PRIVATE_KEY }}
- uses: ./.github/actions/paths-filter
id: filter
if: github.event_name != 'push'
with:
token: ${{ steps.app-token.outputs.token || github.token }}
filters: |
skills:
- 'products/*/skills/**'
- 'products/*/backend/max_tools.py'
- 'products/posthog_ai/**'
- '.github/workflows/ci-agent-skills.yml'
- '.github/actions/setup-uv/**'
- 'services/mcp/schema/**'
# check-skills zips products/*/skills/* via `hogli build:skills`
# (see build.py's affected-path map), so posthog_ai's frontend
# can't affect it. Scoped to posthog_ai, not
# 'products/*/frontend/**', so a future deliberate frontend
# include here isn't silently vetoed.
- '!products/posthog_ai/frontend/**'
check-skills:
name: Check agent skills
runs-on: ubuntu-24.04
timeout-minutes: 30
needs: changes
if: needs.changes.outputs.skills == 'true'
permissions:
contents: read
env:
DOCKERHUB_USERNAME: ${{ vars.DOCKERHUB_USER }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Deep enough to reach the PR's merge base, which keys the schema cache below.
fetch-depth: 1000
filter: blob:none
- name: Fetch base branch for merge-base computation
if: github.event_name == 'pull_request'
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
# Scoped, blob-less, no-tags: without an explicit refspec, git fetch
# falls back to remote.origin.fetch and pulls every branch.
run: git fetch --no-tags --depth=1000 --filter=blob:none origin "$BASE_REF:refs/remotes/origin/$BASE_REF"
- name: Compute schema cache keys
id: schema-key
# Restores the schema dump ci-backend saves on master pushes, so the
# migrate step below only tops up what is newer instead of replaying the
# full migration history. On a PR the migration-set key is the merge-base's;
# on a master push it is HEAD's. A cache miss falls through to a full
# migrate, so an absent or wrong key is never a correctness risk.
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
IS_PUSH: ${{ github.event_name == 'push' }}
# Manual cache-bust knob. Keep in sync with SCHEMA_CACHE_EPOCH in
# ci-backend.yml, which saves these caches on master pushes.
SCHEMA_CACHE_EPOCH: v2
run: |
if [ "$IS_PUSH" = "true" ]; then
# Pushed commit is checked out directly: no merge commit, no base ref.
# The per-SHA key would point at this same push's cache (saved
# concurrently by ci-backend, so racy); rely on the stable
# migration-set key below.
REF=HEAD
else
# HEAD is the synthetic merge commit; HEAD^2 is the PR branch tip.
MERGE_BASE=$(git merge-base HEAD^2 "origin/${BASE_REF}" 2>/dev/null || echo "")
if [ -z "$MERGE_BASE" ]; then
echo "key=" >> $GITHUB_OUTPUT
echo "migrations_key=" >> $GITHUB_OUTPUT
echo "::notice::merge-base not found (branch too stale?), so the schema cache is skipped"
exit 0
fi
# Routing decides which app labels reach the dump, so an edited routing
# config matches no master dump: drop both keys and migrate from scratch.
DB_ROUTING_BASE=$(git ls-tree -r --format='%(objectname) %(path)' "$MERGE_BASE" -- products/db_routing.yaml posthog/product_db_config.py posthog/product_db_router.py)
DB_ROUTING_HEAD=$(git ls-tree -r --format='%(objectname) %(path)' HEAD -- products/db_routing.yaml posthog/product_db_config.py posthog/product_db_router.py)
if [ "$DB_ROUTING_BASE" != "$DB_ROUTING_HEAD" ]; then
echo "key=" >> $GITHUB_OUTPUT
echo "migrations_key=" >> $GITHUB_OUTPUT
echo "::notice::db routing config or router differs from the merge base, so the schema cache is skipped"
exit 0
fi
echo "key=posthog-schema-master-${MERGE_BASE}" >> $GITHUB_OUTPUT
REF="$MERGE_BASE"
fi
# Last-resort restore prefix: matches the newest saved dump when the exact
# keys miss. Gated on checkout age because a re-run of an old commit could
# restore a dump newer than its code, which forward-only migrate cannot repair.
HEAD_AGE_SECONDS=$(( $(date +%s) - $(git show -s --format=%ct HEAD) ))
if [ "$HEAD_AGE_SECONDS" -lt 86400 ]; then
echo "prefix_key=posthog-schema-mig-${SCHEMA_CACHE_EPOCH}-" >> $GITHUB_OUTPUT
fi
# Must match ci-backend.yml's save-side computation byte for byte or the key never hits.
MIG_FILES=$(git ls-tree -r --format='%(objectname) %(path)' "$REF" \
| grep -E '/migrations/[^/]+\.py$|^[0-9a-f]+ uv\.lock$' \
| grep -vE '/(clickhouse|async_migrations)/migrations/' \
| LC_ALL=C sort) || true
# Routing decides which app labels are in the dump, so these inputs belong in the key.
DB_ROUTING=$(git ls-tree -r --format='%(objectname) %(path)' "$REF" -- products/db_routing.yaml posthog/product_db_config.py posthog/product_db_router.py)
PG_IMAGE=$(git show "${REF}:docker-compose.base.yml" 2>/dev/null \
| grep -m1 -E '^[[:space:]]*image:[[:space:]]*postgres:' | tr -d '[:space:]')
if [ -z "$MIG_FILES" ]; then
echo "migrations_key=" >> $GITHUB_OUTPUT
echo "::notice::no migration files matched, so restore falls back to the SHA schema key"
else
MIG_HASH=$(printf '%s\n%s\n%s' "$MIG_FILES" "$PG_IMAGE" "$DB_ROUTING" | sha256sum | cut -c1-40)
echo "migrations_key=posthog-schema-mig-${SCHEMA_CACHE_EPOCH}-${MIG_HASH}" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub
continue-on-error: true
if: ${{ env.DOCKERHUB_USERNAME != '' && env.DOCKERHUB_TOKEN != '' }}
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ vars.DOCKERHUB_USER }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Start Docker services
env:
COMPOSE_FILE: docker-compose.dev.yml:docker-compose.profiles.yml
run: |
bin/ci-wait-for-docker launch --down
- name: Mint setup-action GitHub token
id: setup-gh-token
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ vars.GH_APP_POSTHOG_SETUP_ACTIONS_APP_ID }}
private-key: ${{ secrets.GH_APP_POSTHOG_SETUP_ACTIONS_PRIVATE_KEY }}
skip-token-revoke: true
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version-file: 'pyproject.toml'
token: ${{ steps.setup-gh-token.outputs.token || github.token }}
- name: Install uv
uses: ./.github/actions/setup-uv
- name: Install python dependencies
run: UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync --frozen --dev
- name: Install Rust
uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9
with:
toolchain: stable
components: cargo
- name: Install sqlx-cli
uses: ./.github/actions/setup-sqlx-cli
- name: Lint skills
run: ./bin/hogli lint:skills
- name: Wait for Docker services
env:
COMPOSE_FILE: docker-compose.dev.yml:docker-compose.profiles.yml
run: bin/ci-wait-for-docker wait
- name: Restore schema cache from master
if: steps.schema-key.outputs.migrations_key != '' || steps.schema-key.outputs.key != ''
uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: schema.sql.gz
# Prefer the shared content key; fall back to the exact merge-base SHA
# key, then to the newest dump of any migration set.
key: ${{ steps.schema-key.outputs.migrations_key || steps.schema-key.outputs.key }}
restore-keys: |
${{ steps.schema-key.outputs.key }}
${{ steps.schema-key.outputs.prefix_key }}
- name: Prime posthog from cached schema
# db:restore-schema-fresh restores the dump and seeds the RunPython data a
# schema-only dump lacks; the migrate step below layers anything newer on
# top. On failure it leaves a fresh empty db, so migrate runs the full history.
env:
SECRET_KEY: '6b01eee4f945ca25045b5aab440b953461faf08693a9abbf1166dc7c6b9772da'
DATABASE_URL: 'postgres://posthog:posthog@localhost:5432/posthog'
REDIS_URL: 'redis://localhost'
OBJECT_STORAGE_ENABLED: 'False'
TEST: 1
DEBUG: 1
TARGET_DB: posthog
run: |
if [ ! -f schema.sql.gz ]; then
echo "::notice::Schema cache miss, so the migrate step below runs the full history"
exit 0
fi
mkdir -p .postgres-backups
mv schema.sql.gz .postgres-backups/schema-latest.sql.gz
if ! ./bin/hogli db:restore-schema-fresh; then
echo "::warning::Schema restore failed (stale or incompatible cached dump?), so the migrate step below runs the full history"
fi
- name: Run migrations and create test data
env:
SECRET_KEY: '6b01eee4f945ca25045b5aab440b953461faf08693a9abbf1166dc7c6b9772da'
DATABASE_URL: 'postgres://posthog:posthog@localhost:5432/posthog'
REDIS_URL: 'redis://localhost'
OBJECT_STORAGE_ENABLED: 'False'
TEST: 1
DEBUG: 1
run: |
DATABASE_URL="postgres://posthog:posthog@localhost:5432/posthog_persons" \
sqlx database create
DATABASE_URL="postgres://posthog:posthog@localhost:5432/posthog_persons" \
sqlx migrate run --source rust/persons_migrations/
python manage.py migrate --noinput
python manage.py setup_dev --no-data
- name: Build skills
env:
SECRET_KEY: '6b01eee4f945ca25045b5aab440b953461faf08693a9abbf1166dc7c6b9772da'
DATABASE_URL: 'postgres://posthog:posthog@localhost:5432/posthog'
REDIS_URL: 'redis://localhost'
OBJECT_STORAGE_ENABLED: 'False'
TEST: 1
DEBUG: 1
run: ./bin/hogli build:skills
- name: Upload skills ZIP for the release job
# release-skills publishes this exact artifact, so it does not repeat the
# Postgres, Rust, and sqlx setup that produces it.
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: agent-skills-zip
path: products/posthog_ai/dist/skills.zip
if-no-files-found: error
retention-days: 7
current-master:
name: Check current master
runs-on: ubuntu-24.04
timeout-minutes: 5
needs: check-skills
if: github.repository_owner == 'PostHog' && github.ref == 'refs/heads/master'
outputs:
is-current: ${{ steps.current-master.outputs.is_current }}
permissions:
contents: read
steps:
- name: Check current master
id: current-master
env:
GIT_SHA: ${{ github.sha }}
REPOSITORY: ${{ github.repository }}
run: |
REMOTE_MASTER=$(git ls-remote "https://github.com/${REPOSITORY}.git" refs/heads/master || true)
CURRENT_MASTER=$(printf '%s\n' "$REMOTE_MASTER" | awk '{print $1}')
if [ "$CURRENT_MASTER" = "$GIT_SHA" ]; then
echo "is_current=true" >> "$GITHUB_OUTPUT"
else
echo "is_current=false" >> "$GITHUB_OUTPUT"
echo "Skipping agent skills release for stale master SHA $GIT_SHA"
fi
release-skills:
name: Release agent skills
runs-on: ubuntu-24.04
timeout-minutes: 10
needs: [check-skills, current-master]
if: github.repository_owner == 'PostHog' && github.ref == 'refs/heads/master' && needs.current-master.outputs.is-current == 'true'
permissions:
contents: write
steps:
# The version step below only needs the origin remote for `git ls-remote`, so
# nothing of the tree has to be materialized.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: .github/workflows/ci-agent-skills.yml
sparse-checkout-cone-mode: false
filter: blob:none
- name: Download skills ZIP built by check-skills
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: agent-skills-zip
path: products/posthog_ai/dist
- name: Determine next version
id: version
run: |
REMOTE_TAGS=$(git ls-remote --tags --refs origin 'refs/tags/agent-skills-v*')
LATEST_TAG=$(printf '%s\n' "$REMOTE_TAGS" | awk '{print $2}' | sed 's#refs/tags/##' | grep -E '^agent-skills-v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1)
if [ -z "$LATEST_TAG" ]; then LATEST_TAG="agent-skills-v0.0.0"; fi
MINOR=$(echo "${LATEST_TAG#agent-skills-v}" | cut -d. -f2)
echo "tag=agent-skills-v0.$((MINOR + 1)).0" >> "$GITHUB_OUTPUT"
- name: Create versioned release
# The job's own contents:write GITHUB_TOKEN, like build-phrocs.yml and
# build-livestream-tui.yml. No workflow triggers off these releases, so the
# app token bought nothing and only added a permission that can drift away.
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ steps.version.outputs.tag }}
run: |
gh release create "$RELEASE_TAG" \
products/posthog_ai/dist/skills.zip \
--title "Agent skills $RELEASE_TAG" \
--notes "Build from ${{ github.sha }}" \
--target "${{ github.sha }}" \
--latest=false
- name: Update latest release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ steps.version.outputs.tag }}
run: |
if gh release view agent-skills-latest &>/dev/null; then
gh release upload agent-skills-latest \
products/posthog_ai/dist/skills.zip --clobber
gh release edit agent-skills-latest \
--title "Agent skills (Latest)" \
--notes "Latest build — same as $RELEASE_TAG"
else
gh release create agent-skills-latest \
products/posthog_ai/dist/skills.zip \
--title "Agent skills (Latest)" \
--notes "Latest build — same as $RELEASE_TAG" \
--target "${{ github.sha }}" \
--latest=false
fi