feat: V1.0 (#734)

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Wendy Tang <wendytang@squareup.com>
Co-authored-by: Jarrod Sibbison <72240382+jsibbison-square@users.noreply.github.com>
Co-authored-by: Alex Hancock <alex.hancock@example.com>
Co-authored-by: Alex Hancock <alexhancock@block.xyz>
Co-authored-by: Lifei Zhou <lifei@squareup.com>
Co-authored-by: Wes <141185334+wesrblock@users.noreply.github.com>
Co-authored-by: Max Novich <maksymstepanenko1990@gmail.com>
Co-authored-by: Zaki Ali <zaki@squareup.com>
Co-authored-by: Salman Mohammed <smohammed@squareup.com>
Co-authored-by: Kalvin C <kalvinnchau@users.noreply.github.com>
Co-authored-by: Alec Thomas <alec@swapoff.org>
Co-authored-by: lily-de <119957291+lily-de@users.noreply.github.com>
Co-authored-by: kalvinnchau <kalvin@block.xyz>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Rizel Scarlett <rizel@squareup.com>
Co-authored-by: bwrage <bwrage@squareup.com>
Co-authored-by: Kalvin Chau <kalvin@squareup.com>
Co-authored-by: Alice Hau <110418948+ahau-square@users.noreply.github.com>
Co-authored-by: Alistair Gray <ajgray@stripe.com>
Co-authored-by: Nahiyan Khan <nahiyan.khan@gmail.com>
Co-authored-by: Alex Hancock <alexhancock@squareup.com>
Co-authored-by: Nahiyan Khan <nahiyan@squareup.com>
Co-authored-by: marcelle <1852848+laanak08@users.noreply.github.com>
Co-authored-by: Yingjie He <yingjiehe@block.xyz>
Co-authored-by: Yingjie He <yingjiehe@squareup.com>
Co-authored-by: Lily Delalande <ldelalande@block.xyz>
Co-authored-by: Adewale Abati <acekyd01@gmail.com>
Co-authored-by: Ebony Louis <ebony774@gmail.com>
Co-authored-by: Angie Jones <jones.angie@gmail.com>
Co-authored-by: Ebony Louis <55366651+EbonyLouis@users.noreply.github.com>
This commit is contained in:
Bradley Axen
2025-01-24 13:04:43 -08:00
committed by GitHub
parent eccb1b2261
commit 1c9a7c0b05
688 changed files with 71147 additions and 19132 deletions
+69
View File
@@ -0,0 +1,69 @@
# This is a **reuseable** workflow that bundles the Desktop App for macOS.
# It doesn't get triggered on its own. It gets used in multiple workflows:
# - release.yml
# - canary.yml
on:
workflow_call:
inputs:
# Let's allow overriding the OSes and architectures in JSON array form:
# e.g. '["ubuntu-latest","macos-latest"]'
# If no input is provided, these defaults apply.
operating-systems:
type: string
required: false
default: '["ubuntu-latest","macos-latest"]'
architectures:
type: string
required: false
default: '["x86_64","aarch64"]'
name: "Reusable workflow to build CLI"
jobs:
build-cli:
name: Build CLI
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: ${{ fromJson(inputs.operating-systems) }}
architecture: ${{ fromJson(inputs.architectures) }}
include:
- os: ubuntu-latest
target-suffix: unknown-linux-gnu
- os: macos-latest
target-suffix: apple-darwin
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
target: ${{ matrix.architecture }}-${{ matrix.target-suffix }}
- name: Install cross
run: cargo install cross --git https://github.com/cross-rs/cross
- name: Build CLI
env:
CROSS_NO_WARNINGS: 0
run: |
export TARGET="${{ matrix.architecture }}-${{ matrix.target-suffix }}"
rustup target add "${TARGET}"
# 'cross' is used to cross-compile for different architectures (see Cross.toml)
cross build --release --target ${TARGET} -p goose-cli
# tar the goose binary as goose-<TARGET>.tar.bz2
cd target/${TARGET}/release
tar -cjf goose-${TARGET}.tar.bz2 goose
echo "ARTIFACT=target/${TARGET}/release/goose-${TARGET}.tar.bz2" >> $GITHUB_ENV
- name: Upload CLI artifact
uses: actions/upload-artifact@v4
with:
name: goose-${{ matrix.architecture }}-${{ matrix.target-suffix }}
path: ${{ env.ARTIFACT }}
+198
View File
@@ -0,0 +1,198 @@
# This is a **reuseable** workflow that bundles the Desktop App for macOS.
# It doesn't get triggered on its own. It gets used in multiple workflows:
# - release.yml
# - canary.yml
# - pr-comment-bundle-desktop.yml
on:
workflow_call:
inputs:
signing:
description: 'Whether to perform signing and notarization'
required: false
default: false
type: boolean
secrets:
CERTIFICATE_OSX_APPLICATION:
description: 'Certificate for macOS application signing'
required: false
CERTIFICATE_PASSWORD:
description: 'Password for the macOS certificate'
required: false
APPLE_ID:
description: 'Apple ID for notarization'
required: false
APPLE_ID_PASSWORD:
description: 'Password for the Apple ID'
required: false
APPLE_TEAM_ID:
description: 'Apple Team ID'
required: false
name: Reusable workflow to bundle desktop app
jobs:
bundle-desktop:
runs-on: macos-latest
name: Bundle Desktop App on macOS
steps:
# Validate Signing Secrets if signing is enabled
- name: Validate Signing Secrets
if: ${{ inputs.signing }}
run: |
if [[ -z "${{ secrets.CERTIFICATE_OSX_APPLICATION }}" ]]; then
echo "Error: CERTIFICATE_OSX_APPLICATION secret is required for signing."
exit 1
fi
if [[ -z "${{ secrets.CERTIFICATE_PASSWORD }}" ]]; then
echo "Error: CERTIFICATE_PASSWORD secret is required for signing."
exit 1
fi
if [[ -z "${{ secrets.APPLE_ID }}" ]]; then
echo "Error: APPLE_ID secret is required for signing."
exit 1
fi
if [[ -z "${{ secrets.APPLE_ID_PASSWORD }}" ]]; then
echo "Error: APPLE_ID_PASSWORD secret is required for signing."
exit 1
fi
if [[ -z "${{ secrets.APPLE_TEAM_ID }}" ]]; then
echo "Error: APPLE_TEAM_ID secret is required for signing."
exit 1
fi
echo "All required signing secrets are present."
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
- name: Cache Cargo registry
uses: actions/cache@v3
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-registry-
- name: Cache Cargo index
uses: actions/cache@v3
with:
path: ~/.cargo/index
key: ${{ runner.os }}-cargo-index
restore-keys: |
${{ runner.os }}-cargo-index
- name: Cache Cargo build
uses: actions/cache@v3
with:
path: target
key: ${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-build-
- name: Build goosed
run: cargo build --release -p goose-server
- name: Copy binary into Electron folder
run: cp target/release/goosed ui/desktop/src/bin/goosed
# Conditional Signing Step - we skip this for faster builds
- name: Add MacOS certs for signing and notarization
if: ${{ inputs.signing }}
run: ./add-macos-cert.sh
working-directory: ui/desktop
env:
CERTIFICATE_OSX_APPLICATION: ${{ secrets.CERTIFICATE_OSX_APPLICATION }}
CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }}
- name: Set up Node.js
uses: actions/setup-node@v2
with:
node-version: 'lts/*'
- name: Install dependencies
run: npm ci
working-directory: ui/desktop
- name: Make Unsigned App
if: ${{ !inputs.signing }}
run: |
attempt=0
max_attempts=2
until [ $attempt -ge $max_attempts ]; do
npm run bundle:default && break
attempt=$((attempt + 1))
echo "Attempt $attempt failed. Retrying..."
sleep 5
done
if [ $attempt -ge $max_attempts ]; then
echo "Action failed after $max_attempts attempts."
exit 1
fi
working-directory: ui/desktop
- name: Make Signed App
if: ${{ inputs.signing }}
run: |
attempt=0
max_attempts=2
until [ $attempt -ge $max_attempts ]; do
npm run bundle:default && break
attempt=$((attempt + 1))
echo "Attempt $attempt failed. Retrying..."
sleep 5
done
if [ $attempt -ge $max_attempts ]; then
echo "Action failed after $max_attempts attempts."
exit 1
fi
working-directory: ui/desktop
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Upload Desktop artifact
uses: actions/upload-artifact@v4
with:
name: Goose-darwin-arm64
path: ui/desktop/out/Goose-darwin-arm64/Goose.zip
- name: Quick launch test (macOS)
run: |
# Ensure no quarantine attributes (if needed)
xattr -cr "ui/desktop/out/Goose-darwin-arm64/Goose.app"
echo "Opening Goose.app..."
open -g "ui/desktop/out/Goose-darwin-arm64/Goose.app"
# Give the app a few seconds to start and write logs
sleep 5
# Check if it's running
if pgrep -f "Goose.app/Contents/MacOS/Goose" > /dev/null; then
echo "App appears to be running."
else
echo "App did not stay open. Possible crash or startup error."
exit 1
fi
LOGFILE="$HOME/Library/Application Support/Goose/logs/main.log"
# Print the log and verify "ChatWindow loaded" is in the logs
if [ -f "$LOGFILE" ]; then
echo "===== Log file contents ====="
cat "$LOGFILE"
echo "============================="
if grep -F "ChatWindow loaded" "$LOGFILE"; then
echo "Confirmed: 'ChatWindow loaded' found in logs!"
else
echo "Did not find 'ChatWindow loaded' in logs. Failing..."
exit 1
fi
else
echo "No log file found at $LOGFILE. Exiting with failure."
exit 1
fi
# Kill the app to clean up
pkill -f "Goose.app/Contents/MacOS/Goose"
+82
View File
@@ -0,0 +1,82 @@
# This workflow is for canary releases, automatically triggered by push to v1.0 branch.
# This workflow is identical to "release.yml" with these exceptions:
# - Triggered by push to v1.0 branch
# - Github Release tagged as "canary"
on:
push:
paths-ignore:
- 'docs/**'
branches:
- main
name: Canary
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# ------------------------------------
# 1) Build CLI for multiple OS/Arch
# ------------------------------------
build-cli:
uses: ./.github/workflows/build-cli.yml
# ------------------------------------
# 2) Upload Install CLI Script (we only need to do this once)
# ------------------------------------
install-script:
name: Upload Install Script
runs-on: ubuntu-latest
needs: [ build-cli ]
steps:
- uses: actions/checkout@v4
- uses: actions/upload-artifact@v4
with:
name: download_cli.sh
path: download_cli.sh
# ------------------------------------------------------------
# 3) Bundle Desktop App (macOS only) - builds goosed and Electron app
# ------------------------------------------------------------
bundle-desktop:
uses: ./.github/workflows/bundle-desktop.yml
with:
signing: true
secrets:
CERTIFICATE_OSX_APPLICATION: ${{ secrets.CERTIFICATE_OSX_APPLICATION }}
CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# ------------------------------------
# 4) Create/Update GitHub Release
# ------------------------------------
release:
name: Release
runs-on: ubuntu-latest
needs: [ build-cli, install-script, bundle-desktop ]
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
merge-multiple: true
# Create/update the canary release
- name: Release canary
uses: ncipollo/release-action@v1
with:
tag: canary
name: Canary
token: ${{ secrets.GITHUB_TOKEN }}
artifacts: |
goose-*.tar.bz2
Goose*.zip
download_cli.sh
allowUpdates: true
omitBody: true
omitPrereleaseDuringUpdate: true
-111
View File
@@ -1,111 +0,0 @@
name: CI
on:
pull_request:
branches:
- main # Trigger CI on PRs to main
push:
branches:
- main # Trigger CI on pushes to main
jobs:
exchange:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
version: "0.5.4"
- name: Source Cargo Environment
run: source $HOME/.cargo/env
- name: Ruff
run: |
uvx ruff check packages/exchange
uvx ruff format packages/exchange --check
- name: Run tests
working-directory: ./packages/exchange
run: |
uv run pytest tests -m 'not integration'
goose:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
version: "0.5.4"
- name: Source Cargo Environment
run: source $HOME/.cargo/env
- name: Ruff
run: |
uvx ruff check src tests
uvx ruff format src tests --check
- name: Run tests
run: |
uv run pytest tests -m 'not integration'
# This runs integration tests of the OpenAI API, using Ollama to host models.
# This lets us test PRs from forks which can't access secrets like API keys.
ollama:
runs-on: ubuntu-latest
strategy:
matrix:
python-version:
# Only test the lastest python version.
- "3.12"
ollama-model:
# For quicker CI, use a smaller, tool-capable model than the default.
- "qwen2.5:0.5b"
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
version: "0.5.4"
- name: Source Cargo Environment
run: source $HOME/.cargo/env
- name: Set up Python
run: uv python install ${{ matrix.python-version }}
- name: Install Ollama
run: curl -fsSL https://ollama.com/install.sh | sh
- name: Start Ollama
run: |
# Run the background, in a way that survives to the next step
nohup ollama serve > ollama.log 2>&1 &
# Block using the ready endpoint
time curl --retry 5 --retry-connrefused --retry-delay 1 -sf http://localhost:11434
# Tests use OpenAI which does not have a mechanism to pull models. Run a
# simple prompt to (pull and) test the model first.
- name: Test Ollama model
run: ollama run $OLLAMA_MODEL hello || cat ollama.log
env:
OLLAMA_MODEL: ${{ matrix.ollama-model }}
- name: Run Ollama tests
run: uv run pytest tests -m integration -k ollama
working-directory: ./packages/exchange
env:
OLLAMA_MODEL: ${{ matrix.ollama-model }}
+107
View File
@@ -0,0 +1,107 @@
on:
push:
paths-ignore:
- 'docs/**'
branches:
- main
pull_request:
paths-ignore:
- 'docs/**'
branches:
- main
workflow_dispatch:
name: CI
jobs:
rust-format:
name: Check Rust Code Format
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
- name: Run cargo fmt
run: cargo fmt --check
rust-build-and-test:
name: Build and Test Rust Project
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Install Dependencies
run: |
sudo apt update -y
sudo apt install -y libdbus-1-dev gnome-keyring libxcb1-dev
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
- name: Cache Cargo Registry
uses: actions/cache@v3
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-registry-
- name: Cache Cargo Index
uses: actions/cache@v3
with:
path: ~/.cargo/index
key: ${{ runner.os }}-cargo-index
restore-keys: |
${{ runner.os }}-cargo-index
- name: Cache Cargo Build
uses: actions/cache@v3
with:
path: target
key: ${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-build-
- name: Build and Test
run: |
gnome-keyring-daemon --components=secrets --daemonize --unlock <<< 'foobar'
cargo test
working-directory: crates
- name: Lint
run: cargo clippy -- -D warnings
desktop-lint:
name: Lint Electron Desktop App
runs-on: macos-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v2
with:
node-version: 'lts/*'
- name: Install Dependencies
run: npm ci
working-directory: ui/desktop
- name: Run Lint
run: npm run lint:check
working-directory: ui/desktop
# Faster Desktop App build for PRs only
bundle-desktop-unsigned:
uses: ./.github/workflows/bundle-desktop.yml
if: github.event_name == 'pull_request'
with:
signing: false
@@ -0,0 +1,47 @@
name: Deploy v1 Docs & Extensions # (/documentation and /extensions-site)
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout the branch
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 20
- name: Install dependencies and build docs
working-directory: ./documentation
run: |
npm install
npm run build
- name: Install dependencies and build extensions-site
working-directory: ./extensions-site
env:
VITE_BASENAME: "/goose/v1/extensions/" # Set the base URL here for the extensions site
run: |
npm install
npm run build
- name: Combine builds into one directory
run: |
mkdir combined-build
cp -r documentation/build/* combined-build/
mkdir -p combined-build/extensions
cp -r extensions-site/build/client/* combined-build/extensions/
- name: Deploy to gh-pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: combined-build
destination_dir: v1 # Deploy the site to the 'v1' subfolder
-40
View File
@@ -1,40 +0,0 @@
name: Deploy MkDocs
on:
push:
branches:
- main # Trigger deployment on pushes to main
paths:
- 'docs/**'
- 'mkdocs.yml'
- '.github/workflows/deploy_docs.yaml'
pull_request:
branches:
- main
paths:
- 'docs/**'
- 'mkdocs.yml'
- '.github/workflows/deploy_docs.yaml'
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install UV
uses: astral-sh/setup-uv@v3
- name: Create UV virtual environment
run: uv venv
- name: Install dependencies
run: uv pip install "mkdocs-material[imaging]" Pillow cairosvg
- name: Build the documentation
run: uv run mkdocs gh-deploy --force
-53
View File
@@ -1,53 +0,0 @@
---
name: License Check
on:
pull_request: # Trigger license check on any PRs
paths:
- '**/pyproject.toml'
- '.github/workflows/license-check.yml'
- '.github/workflows/scripts/check_licenses.py'
push: # Trigger license check on pushes to main
branches:
- main
paths: # TODO: can't DRY unless https://github.com/actions/runner/issues/1182
- '**/pyproject.toml'
- '.github/workflows/license-check.yml'
- '.github/workflows/scripts/check_licenses.py'
jobs:
check-licenses:
name: Check Package Licenses
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install tomli requests urllib3
- name: Check licenses
run: |
python .github/workflows/scripts/check_licenses.py \
pyproject.toml || exit_code=$?
if [ "${exit_code:-0}" -ne 0 ]; then
echo "::error::Found packages with disallowed licenses"
exit 1
fi
- name: Check Exchange licenses
run: |
python .github/workflows/scripts/check_licenses.py \
packages/exchange/pyproject.toml || exit_code=$?
if [ "${exit_code:-0}" -ne 0 ]; then
echo "::error::Found packages with disallowed licenses in exchange"
exit 1
fi
@@ -0,0 +1,76 @@
# This workflow is triggered by a comment on an issue or PR with the text ".bundle"
# It bundles the Desktop App, then creates a PR comment with a link to download the app.
# IMPORTANT: issue_comment workflows only use files found on "main" branch to run.
# Do NOT allow on: pull_request since that allows a user to alter a file in a PR and exfil secrets for example.
# Only using issue_comment is the suggested workflow type. Comments on pull requests, and issues will trigger the issue_comment workflow event.
on:
issue_comment:
types: [created]
# permissions needed for reacting to IssueOps commands on PRs
permissions:
pull-requests: write
checks: read
# issues: write
name: Workflow to Bundle Desktop App
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
trigger-on-command:
name: Trigger on ".bundle" PR comment
runs-on: ubuntu-latest
steps:
- uses: github/command@v1.3.0
id: command
with:
command: ".bundle"
reaction: "eyes"
allowed_contexts: pull_request
bundle-desktop:
# Only run this if ".bundle" command is detected.
if: ${{ steps.command.outputs.continue == 'true' }}
uses: ./.github/workflows/bundle-desktop.yml
with:
signing: true
secrets:
CERTIFICATE_OSX_APPLICATION: ${{ secrets.CERTIFICATE_OSX_APPLICATION }}
CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
pr-comment:
name: PR Comment with Desktop App
runs-on: ubuntu-latest
needs: [ bundle-desktop ]
permissions:
pull-requests: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
merge-multiple: true
- name: Comment on PR with download link
uses: peter-evans/create-or-update-comment@v3
with:
comment-id: ${{ steps.command.outputs.comment_id }}
issue-number: ${{ github.event.pull_request.number }}
body: |
### Desktop App for this PR
The following build is available for testing:
- [📱 macOS Desktop App (arm64, signed)](https://nightly.link/${{ github.repository }}/actions/runs/${{ github.run_id }}/Goose-darwin-arm64.zip)
After downloading, unzip the file and drag the Goose.app to your Applications folder. The app is signed and notarized for macOS.
This link is provided by nightly.link and will work even if you're not logged into GitHub.
edit-mode: replace
-50
View File
@@ -1,50 +0,0 @@
name: Publish
# A release on goose will also publish exchange, if it has updated
# This means in some cases we may need to make a bump in goose without other changes to release exchange
on:
release:
types: [published]
jobs:
publish:
permissions:
id-token: write
contents: read
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Get current version from pyproject.toml
id: get_version
run: |
echo "VERSION=$(grep -m 1 'version =' "pyproject.toml" | awk -F'"' '{print $2}')" >> $GITHUB_ENV
- name: Extract tag version
id: extract_tag
run: |
TAG_VERSION=$(echo "${{ github.event.release.tag_name }}" | sed -E 's/v(.*)/\1/')
echo "TAG_VERSION=$TAG_VERSION" >> $GITHUB_ENV
- name: Check if tag matches version from pyproject.toml
id: check_tag
run: |
if [ "${{ env.TAG_VERSION }}" != "${{ env.VERSION }}" ]; then
echo "::error::Tag version (${{ env.TAG_VERSION }}) does not match version in pyproject.toml (${{ env.VERSION }})."
exit 1
fi
- name: Install the latest version of uv
uses: astral-sh/setup-uv@v1
with:
version: "latest"
- name: Build Package
run: |
uv build -o dist --package goose-ai
uv build -o dist --package ai-exchange
- name: Publish package to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
skip-existing: true
-48
View File
@@ -1,48 +0,0 @@
name: 'Lint PR'
on:
pull_request_target:
types:
- opened
- edited
- synchronize
- reopened
permissions:
pull-requests: write
jobs:
main:
name: Validate PR title
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
requireScope: false
- uses: marocchino/sticky-pull-request-comment@v2
# When the previous steps fails, the workflow would stop. By adding this
# condition you can continue the execution with the populated error message.
if: always() && (steps.lint_pr_title.outputs.error_message != null)
with:
header: pr-title-lint-error
message: |
Hey there and thank you for opening this pull request! 👋🏼
We require pull request titles to follow the [Conventional Commits specification](https://gist.github.com/Zekfad/f51cb06ac76e2457f11c80ed705c95a3#file-conventional-commits-md) and it looks like your proposed title needs to be adjusted.
Details:
```
${{ steps.lint_pr_title.outputs.error_message }}
```
# Delete a previous comment when the issue has been resolved
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@v2
with:
header: pr-title-lint-error
delete: true
-41
View File
@@ -1,41 +0,0 @@
name: Release Monitor
on:
release:
types: [published]
workflow_dispatch: # Add this line to enable manual triggering
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pipx
pipx install goose-ai
- name: Check Goose AI Version
run: goose version
- name: Create Issue on Failure
if: failure()
uses: actions/github-script@v3
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { owner, repo } = context.repo;
await github.issues.create({
owner: owner,
repo: repo,
title: 'Release Build Failed',
body: `The release for version ${{ github.event.release.tag_name }} failed to run. Please investigate the issue.`
});
+91
View File
@@ -0,0 +1,91 @@
# This workflow is main release, needs to be manually tagged & pushed.
on:
push:
tags:
- "v1.*"
name: Release
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# ------------------------------------
# 1) Build CLI for multiple OS/Arch
# ------------------------------------
build-cli:
uses: ./.github/workflows/build-cli.yml
# ------------------------------------
# 2) Upload Install CLI Script (we only need to do this once)
# ------------------------------------
install-script:
name: Upload Install Script
runs-on: ubuntu-latest
needs: [ build-cli ]
steps:
- uses: actions/checkout@v4
- uses: actions/upload-artifact@v4
with:
name: download_cli.sh
path: download_cli.sh
# ------------------------------------------------------------
# 3) Bundle Desktop App (macOS only) - builds goosed and Electron app
# ------------------------------------------------------------
bundle-desktop:
uses: ./.github/workflows/bundle-desktop.yml
with:
signing: true
secrets:
CERTIFICATE_OSX_APPLICATION: ${{ secrets.CERTIFICATE_OSX_APPLICATION }}
CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
# ------------------------------------
# 4) Create/Update GitHub Release
# ------------------------------------
release:
name: Release
runs-on: ubuntu-latest
needs: [ build-cli, install-script, bundle-desktop ]
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
merge-multiple: true
# Create/update the versioned release
- name: Release versioned
uses: ncipollo/release-action@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
# This pattern will match both goose tar.bz2 artifacts and the Goose.zip
artifacts: |
goose-*.tar.bz2
Goose*.zip
download_cli.sh
allowUpdates: true
omitBody: true
omitPrereleaseDuringUpdate: true
# Create/update the stable release
- name: Release stable
uses: ncipollo/release-action@v1
with:
tag: stable
name: Stable
token: ${{ secrets.GITHUB_TOKEN }}
artifacts: |
goose-*.tar.bz2
Goose*.zip
download_cli.sh
allowUpdates: true
omitBody: true
omitPrereleaseDuringUpdate: true
-320
View File
@@ -1,320 +0,0 @@
#!/usr/bin/env python3
import argparse
import os
import sys
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
import requests
import tomli
import urllib3
class Color(str, Enum):
"""ANSI color codes with fallback for non-color terminals"""
@staticmethod
def supports_color() -> bool:
"""Check if the terminal supports color output."""
if not hasattr(sys.stdout, "isatty"):
return False
if not sys.stdout.isatty():
return False
if "NO_COLOR" in os.environ:
return False
term = os.environ.get("TERM", "")
if term == "dumb":
return False
return True
has_color = supports_color()
RED = "\033[91m" if has_color else ""
GREEN = "\033[92m" if has_color else ""
RESET = "\033[0m" if has_color else ""
BOLD = "\033[1m" if has_color else ""
@dataclass(frozen=True)
class LicenseConfig:
allowed_licenses: frozenset[str] = frozenset(
{
"MIT",
"BSD-3-Clause",
"Apache-2.0",
"Apache License 2",
"Apache Software License",
"Python Software Foundation License",
"BSD License",
"ISC",
}
)
exceptions: frozenset[str] = frozenset(
{
"ai-exchange",
"tiktoken",
}
)
@dataclass(frozen=True)
class LicenseInfo:
license: str | None
allowed: bool = False
def __str__(self) -> str:
status = "" if self.allowed else ""
color = Color.GREEN if self.allowed else Color.RED
return f"{color}{status}{Color.RESET} {self.license}"
class LicenseChecker:
def __init__(self, config: LicenseConfig = LicenseConfig()) -> None:
self.config = config
self.session = self._setup_session()
def _setup_session(self) -> requests.Session:
session = requests.Session()
session.verify = True
max_retries = urllib3.util.Retry(
total=3,
backoff_factor=0.5,
status_forcelist=[
500,
502,
503,
504,
],
)
adapter = requests.adapters.HTTPAdapter(max_retries=max_retries)
session.mount("https://", adapter)
return session
def normalize_license(self, license_str: str | None) -> str | None:
"""
Normalize license string for comparison.
This method takes a license string and normalizes it by:
1. Converting to uppercase
2. Removing 'LICENSE' or 'LICENCE' suffixes
3. Stripping whitespace
4. Replacing common variations with standardized forms
Args:
license_str (str | None): The original license string to normalize.
Returns:
str | None: The normalized license string, or None if the input was None.
"""
if not license_str:
return None
# fmt: off
normalized = (
license_str.upper()
.replace(" LICENSE", "")
.replace(" LICENCE", "")
.strip()
)
# fmt: on
replacements = {
"APACHE 2.0": "APACHE-2.0",
"APACHE SOFTWARE LICENSE": "APACHE-2.0",
"BSD": "BSD-3-CLAUSE",
"MIT LICENSE": "MIT",
"PYTHON SOFTWARE FOUNDATION": "PSF",
}
return replacements.get(normalized, normalized)
def get_package_license(self, package_name: str) -> str | None:
"""Fetch license information from PyPI.
Args:
package_name (str): The name of the package to fetch the license for.
Returns:
str | None: The license of the package, or None if not found.
"""
try:
response = self.session.get(
f"https://pypi.org/pypi/{package_name}/json",
timeout=10,
)
response.raise_for_status()
data = response.json()
# fmt: off
license_info = (
data["info"].get("license") or
data["info"].get("classifiers", [])
)
# fmt: on
if isinstance(license_info, list):
for classifier in license_info:
if classifier.startswith("License :: "):
parts = classifier.split(" :: ")
return parts[-1]
return license_info if isinstance(license_info, str) else None
except requests.exceptions.SSLError as e:
print(f"SSL Error fetching license for {package_name}: {e}", file=sys.stderr)
except Exception as e:
print(f"Warning: Could not fetch license for {package_name}: {e}", file=sys.stderr)
return None
def extract_dependencies(self, toml_file: Path) -> list[str]:
"""Extract all dependencies from a TOML file."""
with open(toml_file, "rb") as f:
data = tomli.load(f)
dependencies = []
# Get direct dependencies
project_deps = data.get("project", {}).get("dependencies", [])
dependencies.extend(self._parse_dependency_strings(project_deps))
# Get dev dependencies
tool_deps = data.get("tool", {}).get("uv", {}).get("dev-dependencies", [])
dependencies.extend(self._parse_dependency_strings(tool_deps))
return list(set(dependencies))
def _parse_dependency_strings(self, deps: list[str]) -> list[str]:
"""
Parse dependency strings to extract package names.
Args:
deps (list[str]): A list of dependency strings to parse.
Returns:
list[str]: A list of extracted package names.
"""
packages = []
for dep in deps:
if "workspace = true" in dep:
continue
# fmt: off
# Handle basic package specifiers
package = (
dep.split(">=")[0]
.split("==")[0]
.split("<")[0]
.split(">")[0]
.strip()
)
package = package.split("{")[0].strip()
# fmt: on
if package:
packages.append(package)
return packages
def check_licenses(self, toml_file: Path) -> dict[str, LicenseInfo]:
"""
Check licenses for all dependencies in the TOML file.
Args:
toml_file (Path): The path to the TOML file containing the dependencies.
Returns:
dict[str, LicenseInfo]: A dictionary where the keys are package names and the values are LicenseInfo objects
containing the license information and whether it's allowed."""
dependencies = self.extract_dependencies(toml_file)
results: dict[str, LicenseInfo] = {}
checked: set[str] = set()
for package in dependencies:
if package in checked:
continue
checked.add(package)
results[package] = self._check_package(package)
return results
def _check_package(self, package: str) -> LicenseInfo:
"""
Check license for a single package.
Args:
package (str): The name of the package to check.
Returns:
LicenseInfo: A LicenseInfo object containing the license
information and whether it's allowed.
"""
if package in self.config.exceptions:
return LicenseInfo("Approved Exception", True)
license_info = self.get_package_license(package)
normalized_license = self.normalize_license(license_info)
allowed = False
# fmt: off
if normalized_license:
allowed = normalized_license in {
self.normalize_license(x)
for x in self.config.allowed_licenses
}
# fmt: on
return LicenseInfo(license_info, allowed)
def main() -> None:
parser = argparse.ArgumentParser(description="Check package licenses in TOML files")
parser.add_argument("toml_files", type=Path, nargs="*", help="Paths to TOML files")
parser.add_argument("--supported-licenses", action="store_true", help="Print supported licenses")
checker = LicenseChecker()
all_results: dict[str, LicenseInfo] = {}
args = parser.parse_args()
if args.supported_licenses:
for license in sorted(checker.config.allowed_licenses, key=str.casefold):
print(f" - {license}")
sys.exit(0)
if not args.toml_files:
print("Error: No TOML files specified", file=sys.stderr)
parser.print_help()
sys.exit(1)
for toml_file in args.toml_files:
results = checker.check_licenses(toml_file)
for package, info in results.items():
if package in all_results and all_results[package] != info:
print(f"Warning: Package {package} has conflicting license info:", file=sys.stderr)
print(f" {toml_file}: {info}", file=sys.stderr)
print(f" Previous: {all_results[package]}", file=sys.stderr)
all_results[package] = info
max_package_length = max(len(package) for package in all_results.keys())
any_disallowed = False
for package, info in sorted(all_results.items()):
if Color.has_color:
package_name = f"{Color.BOLD}{package}{Color.RESET}"
padding = len(Color.BOLD) + len(Color.RESET)
else:
package_name = package
padding = 0
print(f"{package_name:<{max_package_length + padding}} {info}")
if not info.allowed:
any_disallowed = True
sys.exit(1 if any_disallowed else 0)
if __name__ == "__main__":
main()
@@ -1,248 +0,0 @@
from pathlib import Path
from typing import Optional
from unittest.mock import Mock, patch
import pytest
import tomli
from check_licenses import Color, LicenseChecker, LicenseConfig, LicenseInfo, main
@pytest.fixture
def checker() -> LicenseChecker:
return LicenseChecker()
@pytest.fixture
def mock_pypi_response() -> Mock:
response = Mock()
response.status_code = 200
response.raise_for_status = Mock()
response.ok = True
response.json.return_value = {
"info": {
"license": "Apache-2.0",
}
}
return response
@pytest.fixture
def mock_toml_content() -> str:
return """
[project]
dependencies = [
"requests>=2.28.0",
"tomli==2.0.1",
"urllib3<2.0.0",
"package-with-workspace{workspace = true}",
]
[tool.uv]
dev-dependencies = [
"pytest>=7.0.0",
"black==23.3.0",
]
"""
@pytest.fixture
def mock_toml_files(tmp_path: Path) -> list[Path]:
"""Create mock TOML files with different dependencies."""
file1 = tmp_path / "pyproject1.toml"
file1.write_text("""
[project]
dependencies = [
"requests>=2.28.0",
"tomli==2.0.1",
]
""")
file2 = tmp_path / "pyproject2.toml"
file2.write_text("""
[project]
dependencies = [
"urllib3<2.0.0",
"requests>=2.27.0", # Different version but same package
]
""")
return [file1, file2]
def test_normalize_license_variations(checker: LicenseChecker) -> None:
assert checker.normalize_license("MIT License") == "MIT"
assert checker.normalize_license("Apache 2.0") == "APACHE-2.0"
assert checker.normalize_license("BSD") == "BSD-3-CLAUSE"
assert checker.normalize_license(None) is None
assert checker.normalize_license("") is None
assert checker.normalize_license("MIT License") == "MIT"
assert checker.normalize_license("Apache 2.0") == "APACHE-2.0"
assert checker.normalize_license("BSD") == "BSD-3-CLAUSE"
assert checker.normalize_license(None) is None
assert checker.normalize_license("") is None
@patch.object(LicenseChecker, "get_package_license")
def test_package_license_verification(mock_get_license: Mock, checker: LicenseChecker) -> None:
def check_package_license(
package: str, license: Optional[str], expected_allowed: bool, expected_license: str
) -> None:
if license:
mock_get_license.return_value = license
result = checker._check_package(package=package)
assert result.allowed is expected_allowed
assert result.license == expected_license
check_package_license(
package="tiktoken",
license=None,
expected_allowed=True,
expected_license="Approved Exception",
)
check_package_license(
package="requests",
license="Apache-2.0",
expected_allowed=True,
expected_license="Apache-2.0",
)
check_package_license(
package="gpl-package",
license="GPL",
expected_allowed=False,
expected_license="GPL",
)
def test_color_support_with_environment_variables(monkeypatch: pytest.MonkeyPatch) -> None:
def verify_color_support_disabled(*, env_var: str, value: str) -> None:
monkeypatch.setenv(env_var, value)
assert not Color.supports_color()
monkeypatch.undo()
verify_color_support_disabled(env_var="NO_COLOR", value="1")
verify_color_support_disabled(env_var="TERM", value="dumb")
@patch("tomli.load")
@patch("builtins.open")
def test_extract_dependencies(
mock_open: Mock, mock_tomli_load: Mock, checker: LicenseChecker, mock_toml_content: str
) -> None:
mock_tomli_load.return_value = tomli.loads(mock_toml_content)
mock_file = Mock()
mock_open.return_value.__enter__.return_value = mock_file
dependencies = checker.extract_dependencies(Path("mock_pyproject.toml"))
expected = ["requests", "tomli", "urllib3", "pytest", "black"]
assert sorted(dependencies) == sorted(expected)
@patch("requests.Session")
def test_get_package_license(mock_session: Mock, checker: LicenseChecker, mock_pypi_response: Mock) -> None:
mock_session.return_value.get.return_value = mock_pypi_response
assert checker.get_package_license("requests") == "Apache-2.0"
# test exception handling
mock_session.return_value.get.side_effect = Exception("error")
assert checker.get_package_license("nonexistent-package") is None
def test_license_info_string_representation() -> None:
def assert_license_info_str(info: LicenseInfo, no_color_expected: str, color_expected: str) -> None:
expected = color_expected if Color.has_color else no_color_expected
assert str(info) == expected
assert_license_info_str(LicenseInfo("MIT", True), "✓ MIT", f"{Color.GREEN}{Color.RESET} MIT")
assert_license_info_str(LicenseInfo("GPL", False), "✗ GPL", f"{Color.RED}{Color.RESET} GPL")
def test_custom_license_config() -> None:
custom_config = LicenseConfig(
allowed_licenses=frozenset({"MIT", "Apache-2.0"}), exceptions=frozenset({"special-package"})
)
checker = LicenseChecker(config=custom_config)
assert "special-package" in checker.config.exceptions
assert len(checker.config.allowed_licenses) == 2
def test_dependency_parsing_scenarios() -> None:
"""Test various dependency parsing scenarios."""
def parse_dependencies(toml_string: str) -> list[str]:
checker = LicenseChecker()
return checker._parse_dependency_strings(tomli.loads(toml_string)["project"]["dependencies"])
# basic version specifiers
parsed = parse_dependencies("""
[project]
dependencies = [
"requests>=2.28.0",
"tomli==2.0.1",
"urllib3<2.0.0",
]
""")
assert sorted(parsed) == sorted(["requests", "tomli", "urllib3"])
# workspace dependencies
parsed = parse_dependencies("""
[project]
dependencies = [
"package-with-workspace{workspace = true}",
]
""")
assert parsed == []
# mixed dependencies
parsed = parse_dependencies("""
[project]
dependencies = [
"requests>=2.28.0",
"package-with-workspace{workspace = true}",
"urllib3<2.0.0",
]
""")
assert sorted(parsed) == sorted(["requests", "urllib3"])
# multiple version constraints
parsed = parse_dependencies("""
[project]
dependencies = [
"urllib3>=1.25.4,<2.0.0",
"requests>=2.28.0,<3.0.0",
]
""")
assert sorted(parsed) == sorted(["urllib3", "requests"])
# empty dependencies
parsed = parse_dependencies("""
[project]
dependencies = []
""")
assert parsed == []
@patch.object(LicenseChecker, "get_package_license")
def test_multiple_toml_files(
mock_get_license: Mock,
mock_toml_files: list[Path],
capsys: pytest.CaptureFixture,
) -> None:
def get_license(package: str) -> Optional[str]:
licenses = {"requests": "Apache-2.0", "tomli": "MIT", "urllib3": "MIT"}
return licenses.get(package)
mock_get_license.side_effect = get_license
with patch("sys.argv", ["check_licenses.py"] + [str(f) for f in mock_toml_files]):
try:
main()
except SystemExit as e:
assert e.code == 0
captured = capsys.readouterr()
assert "requests" in captured.out
assert "tomli" in captured.out
assert "urllib3" in captured.out
assert "" not in captured.out
@@ -1,12 +0,0 @@
{
"pull_request": {
"head": {
"ref": "test-branch"
},
"base": {
"ref": "main"
},
"number": 123,
"title": "test: Update dependency licenses"
}
}