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
27 changes: 27 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Line-ending rules.
#
# Scoped deliberately to the file types this change introduces, rather than a
# blanket `* text=auto eol=lf`. Nine of the 33 tracked files (LICENSE, several
# .go files, both existing workflows) currently carry CRLF; a blanket rule would
# renormalise all of them the next time anyone touched them, producing a
# whole-file diff at a moment unrelated to whatever they were actually changing.
# That mixed state is worth cleaning up, but as its own commit, not as a side
# effect of this one.

# Shell scripts must be LF. CRLF gives `bad interpreter: /usr/bin/env bash^M`,
# which is a genuinely confusing way to fail.
*.sh text eol=lf

# Generated compliance artifacts are written with LF. Without this, a
# contributor with core.autocrlf=true checks them out as CRLF and the freshness
# check reports them stale with a diff that looks empty.
NOTICE text eol=lf
THIRD-PARTY-*.md text eol=lf
*.css text eol=lf

# Vendored assets — never normalise. These are embedded into the binary
# verbatim and any rewriting corrupts them.
*.woff2 binary
*.woff binary
*.png binary
ai-studio-cli/internal/benchui/ui/vendor/js/*.js -text
13 changes: 12 additions & 1 deletion .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,18 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
# Full history, not a shallow clone.
#
# With fetch-depth: 1 the runner has only the PR head commit and no
# base branch, so the action's own `git fetch origin master --depth=1`
# had nothing to graft onto and failed with
# "Command failed: git fetch origin master --depth=1"
# before the review ever started. A review action has to diff against
# the base branch, which means the base branch has to be present.
#
# 0 = full history. On a repo this size the extra clone cost is
# negligible compared to a job that cannot run at all.
fetch-depth: 0

- name: Run Claude Code Review
id: claude-review
Expand Down
13 changes: 12 additions & 1 deletion .github/workflows/claude.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,18 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
# Full history, not a shallow clone.
#
# With fetch-depth: 1 the runner has only the PR head commit and no
# base branch, so the action's own `git fetch origin master --depth=1`
# had nothing to graft onto and failed with
# "Command failed: git fetch origin master --depth=1"
# before the review ever started. A review action has to diff against
# the base branch, which means the base branch has to be present.
#
# 0 = full history. On a repo this size the extra clone cost is
# negligible compared to a job that cannot run at all.
fetch-depth: 0

- name: Run Claude Code
id: claude
Expand Down
166 changes: 166 additions & 0 deletions .github/workflows/compliance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
name: Licence compliance

# Makes the licence position self-enforcing.
#
# The distribution model here is the strictest of the three AI Studio repos.
# aistudio-server conveys container images; aistudio-app serves a JS bundle;
# this ships a compiled Go binary that STATICALLY LINKS every dependency. There
# is no node_modules or site-packages beside the artifact for notices to live
# in — the binary is the whole distribution, so the notices have to be inside
# it. These checks confirm they are.

on:
push:
branches: [master, main]
pull_request:
schedule:
# Weekly. Upstream modules change licences occasionally and silently.
- cron: "0 6 * * 1"
workflow_dispatch:

permissions:
contents: read

env:
GO_VERSION: "1.25"

jobs:
files:
name: Licence files and bench UI assets
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Static compliance checks
# Same target as `make compliance` locally, so the feedback loop does
# not require a push. Covers LICENSE completeness and filled-in
# copyright, required files, no CDN references in the bench UI, and that
# the vendored fonts and Chart.js are present with their licences.
#
# Needs no Go toolchain and no network, deliberately — these are the
# checks anyone should be able to run on any checkout. The placeholder
# state is reported here, not failed on; that is the `build` job's job.
run: make compliance

- name: Vendored UI assets match their declared versions
run: |
# These binaries are committed (go:embed needs them at compile time
# and `go build` cannot run npm), so nothing stops someone editing
# them by hand. Regenerate into a scratch dir and compare.
VENDOR_DEST=/tmp/vendor-check ./scripts/vendor-ui-assets.sh
if ! diff -r /tmp/vendor-check ai-studio-cli/internal/benchui/ui/vendor; then
echo "::error::vendored UI assets differ from a clean regeneration."
echo "Run ./scripts/vendor-ui-assets.sh and commit the result."
exit 1
fi
echo "Vendored assets reproduce exactly."

- name: Every font referenced by fonts.css exists
run: |
# Guards against a weight being added to the script's list and
# shipping as a CSS rule with no file behind it — which go:embed
# would happily compile into the binary.
cd ai-studio-cli/internal/benchui/ui/vendor
missing=0
for url in $(grep -oE 'url\("\./[^"]+"\)' fonts.css | sed -E 's|url\("\./||; s|"\)||'); do
[ -f "$url" ] || { echo "::error::fonts.css references missing $url"; missing=1; }
done
exit $missing

build:
name: Build with generated notices and verify the binary carries them
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: ai-studio-cli/go.sum

- name: Generate notices and build
# Generation happens HERE rather than being compared against a committed
# copy. An earlier version of this workflow diffed the generated output
# against a file in the repo, which meant a reviewable branch could not
# exist without a Go toolchain and network access, and every dependency
# bump carried a regenerate-and-commit step whose only failure mode was
# a red build with a misleading message.
#
# `go list -deps` answers the only question that matters: which modules
# are actually linked into this binary — not everything in go.sum, which
# also lists test and tooling modules that never reach a user.
run: make build-release

- name: "`licenses` prints real notices"
# The check that actually matters. Everything else verifies files exist
# in a repository; this verifies the notices are inside the artifact a
# user receives, which is what the obligation is.
run: |
out=$(./bin/ai-studio-cli licenses 2>&1) || {
echo "::error::\`bin/ai-studio-cli licenses\` failed:"
echo "$out"
exit 1
}

if grep -q "NOTICES-NOT-GENERATED" <<<"$out"; then
echo "::error::the binary embeds the placeholder — make build-release did not generate them."
exit 1
fi

copyrights=$(grep -ci "copyright" <<<"$out" || true)
if [ "$copyrights" -lt 10 ]; then
echo "::error::only $copyrights copyright notices in the binary output — expected 10+."
exit 1
fi
echo "$copyrights copyright notices embedded."

# The go:embed'd UI assets are not Go modules, so the module walk
# cannot see them. generate-notices.sh appends them; confirm it did.
grep -qi "SIL Open Font License" <<<"$out" || {
echo "::error::OFL-1.1 text missing, but Inter is embedded (OFL s2)."
exit 1
}
grep -qi "chart.js" <<<"$out" || {
echo "::error::Chart.js attribution missing, but it is embedded (MIT)."
exit 1
}
echo "Embedded UI asset notices present."

- name: Bench UI serves no external assets
run: |
# Belt and braces against the Makefile check: assert the reference is
# absent from the compiled binary, not just from the source tree.
#
# Matches a LOADING position (`="https://host`), not any mention of a
# hostname. index.html carries a comment explaining why the Google
# Fonts links were removed, and that comment names the hostname — it is
# embedded verbatim by go:embed, so a bare hostname grep matches our own
# documentation and fails the build. It did exactly that on the first
# run of this check.
#
# The `="` prefix is what separates markup from prose: a real
# regression looks like `<link href="https://fonts.googleapis.com/...`.
if strings ./bin/ai-studio-cli \
| grep -nE '=["'"'"']https?://(fonts\.(googleapis|gstatic)\.com|cdn\.|unpkg\.com|cdnjs\.|.*jsdelivr)'; then
echo "::error::the compiled binary loads an asset from a third-party origin (above)."
exit 1
fi
echo "No third-party asset loads in the binary."

- uses: actions/upload-artifact@v4
if: always()
with:
name: third-party-notices
path: ai-studio-cli/internal/notices/THIRD-PARTY-NOTICES.txt
if-no-files-found: warn

vet:
name: go vet
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: ai-studio-cli/go.sum
- run: make vet
113 changes: 113 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
name: Release

# Publishes a release the installer can actually verify.
#
# The previous flow produced a tarball with no checksum, and install.sh
# downloaded whatever "latest" resolved to and `sudo mv`d it into
# /usr/local/bin unverified. This publishes SHA256SUMS alongside the binary,
# and attaches the third-party notices as their own asset so they can be read
# without running the binary.

on:
push:
tags:
- "ai-studio-cli-*-opensource" # e.g. ai-studio-cli-1.2.0-1-opensource
workflow_dispatch:
inputs:
tag:
description: "Annotated tag to build and release (must already exist)"
required: true

permissions:
contents: write

env:
GO_VERSION: "1.25"
BINARY: ai-studio-cli

jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.tag || github.ref }}
# Annotated tag metadata is needed to verify the tag is not
# lightweight — see the check below.
fetch-depth: 0

- uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: ai-studio-cli/go.sum

- name: Tag must be annotated
run: |
TAG="${{ github.event.inputs.tag }}"
TAG="${TAG:-$GITHUB_REF_NAME}"
type=$(git cat-file -t "$TAG" 2>/dev/null || echo missing)
if [ "$type" != "tag" ]; then
echo "::error::'$TAG' is a lightweight tag (type=$type)."
echo "Lightweight tags carry no author, date or signature, and can be"
echo "silently repointed. Re-cut it with: git tag -a -s $TAG -m ..."
exit 1
fi
echo "$TAG is annotated."

- name: Compliance gate
run: make compliance

- name: Build with generated notices
# This is where the placeholder is a hard stop rather than a note.
# `build-release` generates the notices, builds, and fails if the
# resulting binary cannot print them. A released binary must never ship
# without its attribution.
run: make build-release

- name: Verify the binary carries its notices
run: |
out=$(./bin/${BINARY} licenses)
# if/then, not `grep -q ... && { exit 1; }`. The AND-list returns
# grep's status, so in the good case (marker absent) it evaluates to 1
# — and if it were ever the last command in the step, `bash -e` would
# fail the step precisely when nothing was wrong.
if grep -q "NOTICES-NOT-GENERATED" <<<"$out"; then
echo "::error::refusing to release a binary embedding the placeholder."
exit 1
fi
echo "Notices embedded: $(grep -ci copyright <<<"$out") copyright lines."

- name: Package
run: |
TAG="${{ github.event.inputs.tag }}"
TAG="${TAG:-$GITHUB_REF_NAME}"
mkdir -p dist
# The tarball carries the notices and licence beside the binary, so
# they are present even for someone who never runs `licenses`.
cp LICENSE NOTICE ai-studio-cli/internal/notices/THIRD-PARTY-NOTICES.txt dist/
cp "bin/${BINARY}" dist/
tar -czf "${TAG}.tar.gz" -C dist .
cp ai-studio-cli/internal/notices/THIRD-PARTY-NOTICES.txt .

- name: Checksums
run: |
TAG="${{ github.event.inputs.tag }}"
TAG="${TAG:-$GITHUB_REF_NAME}"
sha256sum "${TAG}.tar.gz" THIRD-PARTY-NOTICES.txt > SHA256SUMS
cat SHA256SUMS

- name: Publish
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${{ github.event.inputs.tag }}"
TAG="${TAG:-$GITHUB_REF_NAME}"
gh release create "$TAG" \
--title "ai-studio-cli $TAG" \
--generate-notes \
--verify-tag \
"${TAG}.tar.gz" \
THIRD-PARTY-NOTICES.txt \
SHA256SUMS \
|| gh release upload "$TAG" \
"${TAG}.tar.gz" THIRD-PARTY-NOTICES.txt SHA256SUMS --clobber
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@

# Build output — the binary is written to bin/, deliberately not the repo
# root, where its name would collide with the ai-studio-cli/ module directory.
bin/
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.

Copyright [yyyy] [name of copyright owner]
Copyright 2026 CoreSpan AI

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down
Loading
Loading