chore: import upstream snapshot with attribution
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:40:13 +08:00
commit e30e75b5d4
3893 changed files with 533074 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
name: autofix.ci
on: [pull_request]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
jobs:
autofix:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout code repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7
with:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # ratchet:pnpm/action-setup@v6.0.9
- name: Setup node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # ratchet:actions/setup-node@v6
with:
node-version: 24
cache: "pnpm"
- name: Install dependencies
run: pnpm install --ignore-scripts
- name: Lint fix
run: pnpm lint:fix
- name: Update API surface snapshots
run: pnpm api-surface
- uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4
@@ -0,0 +1,302 @@
name: Changeset Semver Check
on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened, labeled]
permissions:
contents: read
pull-requests: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
analyze:
name: Semver Check
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 2
steps:
- name: Checkout PR head
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- name: Check changeset bump types against package versions
id: check
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
with:
script: |
const fs = require("fs");
const path = require("path");
const { execFileSync } = require("child_process");
// --- Helpers ---
function bumpVersion(version, bumpType) {
const [major, minor, patch] = version.split(".").map(Number);
if (bumpType === "major") return `${major + 1}.0.0`;
if (bumpType === "minor") return `${major}.${minor + 1}.0`;
return `${major}.${minor}.${patch + 1}`;
}
function isOutsideCaretRange(rangeVersion, newVersion) {
const [rMajor, rMinor, rPatch] = rangeVersion.split(".").map(Number);
const [nMajor, nMinor, nPatch] = newVersion.split(".").map(Number);
// ^0.0.Z — exact match only
if (rMajor === 0 && rMinor === 0) {
return nMajor !== 0 || nMinor !== 0 || nPatch !== rPatch;
}
// ^0.X.Z (X > 0) — same major and minor
if (rMajor === 0) {
return nMajor !== 0 || nMinor !== rMinor;
}
// ^X.Y.Z (X > 0) — same major
return nMajor !== rMajor;
}
function computeCascade(bumps, pkgMap, revDeps) {
const cascade = [];
const visited = new Set(bumps.map((v) => v.pkg));
const queue = bumps.map((v) => ({
pkg: v.pkg,
newVersion: bumpVersion(v.version, v.bumpType),
}));
let qi = 0;
while (qi < queue.length) {
const { pkg, newVersion } = queue[qi++];
const dependents = revDeps.get(pkg) || [];
for (const dep of dependents) {
if (visited.has(dep.name)) continue;
// Extract version from the ^ range (e.g., "^0.12.15" → "0.12.15")
const rangeVersion = dep.range.replace(/^\^/, "");
if (!isOutsideCaretRange(rangeVersion, newVersion)) continue;
visited.add(dep.name);
// This dependent gets a cascade patch
const depInfo = pkgMap.get(dep.name);
const depVersion = depInfo?.version ?? dep.version;
const cascadeNewVersion = bumpVersion(depVersion, "patch");
const isBreaking = isOutsideCaretRange(depVersion, cascadeNewVersion);
cascade.push({
name: dep.name,
version: depVersion,
newVersion: cascadeNewVersion,
isBreaking,
});
// If the cascade patch is itself range-breaking (0.0.x),
// recurse to check further downstream
if (isBreaking) {
queue.push({ pkg: dep.name, newVersion: cascadeNewVersion });
}
}
}
return cascade;
}
// --- Scope to PR-changed changeset files ---
const { BASE_SHA, HEAD_SHA } = process.env;
let prChangedFiles;
try {
const diff = execFileSync(
"git",
["diff", "--name-only", "--diff-filter=ACM", BASE_SHA, HEAD_SHA, "--", ".changeset/*.md"],
{ encoding: "utf8" },
).trim();
prChangedFiles = new Set(
diff ? diff.split("\n").map((f) => path.basename(f)) : [],
);
} catch {
// If git diff fails (e.g., shallow clone), fall back to all files
core.warning("Could not diff against base — checking all changeset files");
prChangedFiles = null;
}
const changesetDir = ".changeset";
const allFiles = fs.readdirSync(changesetDir).filter(
(f) => f.endsWith(".md") && f !== "README.md",
);
const files = prChangedFiles
? allFiles.filter((f) => prChangedFiles.has(f))
: allFiles;
if (files.length === 0) {
core.info("No changeset files changed in this PR.");
return;
}
// --- Build package map and reverse dependency graph ---
const pkgMap = new Map();
const revDeps = new Map();
const packageManifests = [];
const packagesDir = "packages";
for (const entry of fs.readdirSync(packagesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const dir = entry.name;
const pkgJsonPath = path.join(packagesDir, dir, "package.json");
let pkgJson;
try {
pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"));
} catch {
continue;
}
if (pkgJson.private) continue;
packageManifests.push(pkgJson);
const major = parseInt(pkgJson.version.split(".")[0], 10);
pkgMap.set(pkgJson.name, { major, version: pkgJson.version });
}
for (const pkgJson of packageManifests) {
const allDeps = {
...pkgJson.dependencies,
...pkgJson.peerDependencies,
};
for (const [depName, range] of Object.entries(allDeps)) {
if (typeof range !== "string" || !range.startsWith("^")) continue;
if (!pkgMap.has(depName)) continue;
if (!revDeps.has(depName)) revDeps.set(depName, []);
revDeps.get(depName).push({
name: pkgJson.name,
version: pkgJson.version,
range,
});
}
}
// --- Parse changeset files ---
const allBumps = [];
const violations = [];
for (const file of files) {
const content = fs.readFileSync(
path.join(changesetDir, file),
"utf8",
);
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) continue;
const frontmatter = match[1];
for (const line of frontmatter.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const bumpMatch = trimmed.match(
/^"([^"]+)":\s*(patch|minor|major)$/,
);
if (!bumpMatch) continue;
const [, pkg, bumpType] = bumpMatch;
const pkgInfo = pkgMap.get(pkg);
// Skip unknown or private packages (not in pkgMap)
if (!pkgInfo) continue;
const { major, version } = pkgInfo;
allBumps.push({ file, pkg, bumpType, version });
// Rules:
// 0.0.x → patch technically breaks ^0.0.x (exact match), but not
// flagged because these packages are pre-stable with no
// internal dependents. Only minor/major are flagged.
// 0.x → only patch is safe (minor/major break ^)
// 1.x+ → patch and minor are safe (only major breaks ^)
const needsConfirmation =
(major === 0 && bumpType !== "patch") ||
(major >= 1 && bumpType === "major");
if (needsConfirmation) {
const reason =
major === 0
? `0.x package — ${bumpType} bump breaks \`^\` caret range`
: `major bump breaks \`^\` caret range`;
violations.push({ file, pkg, bumpType, version, reason });
}
}
}
if (allBumps.length === 0) {
core.info("No package bumps found in changesets.");
return;
}
// --- Compute cascade impact for ALL bumps ---
const cascade = computeCascade(allBumps, pkgMap, revDeps);
const breakingCascade = cascade.filter((c) => c.isBreaking);
// --- Build cascade table (shared between both summary paths) ---
function renderCascadeSection() {
if (cascade.length === 0) return "";
let section = `### Cascade Impact (${cascade.length} packages)\n\n`;
section += "| Package | Version | Cascade Bump | Breaking |\n";
section += "| --- | --- | --- | --- |\n";
for (const c of cascade) {
const indicator = c.isBreaking
? `⛔ YES — \`^${c.version}\` breaks`
: "✅ no";
section += `| \`${c.name}\` | ${c.version} → ${c.newVersion} | patch | ${indicator} |\n`;
}
section += "\n";
if (breakingCascade.length > 0) {
section += `> **${breakingCascade.length} downstream package(s)** will also break their consumers' \`^\` ranges.\n\n`;
}
return section;
}
// --- Build summary ---
let summary;
if (violations.length === 0) {
// Informational summary for safe PRs
summary = "## Changeset Impact Summary\n\n";
summary += "| File | Package | Version | Bump |\n";
summary += "| --- | --- | --- | --- |\n";
for (const b of allBumps) {
summary += `| \`${b.file}\` | \`${b.pkg}\` | ${b.version} | ${b.bumpType} |\n`;
}
summary += "\n";
summary += renderCascadeSection();
await core.summary.addRaw(summary).write();
core.info("All changeset bump types are safe for current package versions. ✓");
} else {
// Violation summary
summary = "## ⚠️ Semver-Breaking Changeset Detected\n\n";
summary += "| File | Package | Version | Bump | Why |\n";
summary += "| --- | --- | --- | --- | --- |\n";
for (const v of violations) {
summary += `| \`${v.file}\` | \`${v.pkg}\` | ${v.version} | **${v.bumpType}** | ${v.reason} |\n`;
}
summary += "\n";
summary += renderCascadeSection();
summary += "### What this means\n\n";
summary += "- **0.x packages**: `^0.12.15` only matches `>=0.12.15 <0.13.0` — ";
summary += "a minor bump is effectively a breaking change for all consumers.\n";
summary += "- **1.x+ packages**: `^1.3.12` matches `>=1.3.12 <2.0.0` — ";
summary += "minor/patch are safe, but a major bump breaks all consumers.\n\n";
summary += "This check is informational — the PR can still be merged if this is intentional.\n";
await core.summary.addRaw(summary).write();
// Emit warnings for breaking cascade entries (visible in PR checks tab)
for (const c of breakingCascade) {
core.warning(
`Cascade breaks ${c.name} (${c.version} → ${c.newVersion}, ^${c.version} consumers affected)`,
);
}
core.setFailed(`Semver-breaking bumps detected: ${violations.map((v) => `${v.pkg}@${v.bumpType}`).join(", ")}`);
}
+139
View File
@@ -0,0 +1,139 @@
name: Changesets
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
env:
CI: true
jobs:
version:
name: Create Version PR
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout code repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Setup node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Create version PR
id: changesets
uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0
with:
commit: "chore: update versions"
title: "chore: update versions"
version: pnpm ci:version
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Detect release packages
id: release_plan
if: steps.changesets.outputs.hasChangesets == 'false'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { execFileSync } = require("node:child_process");
const { existsSync, readFileSync } = require("node:fs");
const before = context.payload.before;
const after = context.sha;
const isZeroBefore = typeof before === "string" && /^0+$/.test(before);
if (isZeroBefore) {
core.warning(
"Skipping release dispatch on zero before SHA (no baseline to diff)",
);
core.setOutput("hasReleasePackages", "false");
return;
}
const runGit = (args) =>
execFileSync("git", args, { encoding: "utf8" }).trim();
try {
runGit(["cat-file", "-e", `${before}^{commit}`]);
} catch {
core.warning(
`Skipping release dispatch because before SHA is missing: ${before}`,
);
core.setOutput("hasReleasePackages", "false");
return;
}
const changedFilesOutput = runGit([
"diff",
"--name-only",
before,
after,
"--",
"packages/*/package.json",
]);
const changedFiles = changedFilesOutput
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
let hasReleasePackages = false;
for (const filePath of changedFiles) {
if (!existsSync(filePath)) continue;
const current = JSON.parse(readFileSync(filePath, "utf8"));
if (current.private === true) continue;
let previousVersion = null;
try {
const previousRaw = runGit(["show", `${before}:${filePath}`]);
previousVersion = JSON.parse(previousRaw).version ?? null;
} catch {
previousVersion = null;
}
if (previousVersion !== current.version) {
hasReleasePackages = true;
core.info(`Release candidate: ${current.name}@${current.version}`);
}
}
if (!hasReleasePackages) {
core.info("Release candidates: none");
}
core.setOutput("hasReleasePackages", hasReleasePackages ? "true" : "false");
- name: Trigger npm publish
if: steps.changesets.outputs.hasChangesets == 'false' && steps.release_plan.outputs.hasReleasePackages == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
await github.rest.repos.createDispatchEvent({
owner: context.repo.owner,
repo: context.repo.repo,
event_type: 'npm-publish',
client_payload: {
releaseSha: context.sha,
},
});
+159
View File
@@ -0,0 +1,159 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
concurrency:
group: claude-code-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
env:
# Shared action configuration
TRACK_PROGRESS: 'true'
USE_STICKY_COMMENT: 'true'
ADDITIONAL_PERMISSIONS: |
actions: read
REVIEW_PROMPT: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}
TITLE: ${{ github.event.pull_request.title }}
BODY: ${{ github.event.pull_request.body }}
AUTHOR: ${{ github.event.pull_request.user.login }}
COMMIT: ${{ github.event.pull_request.head.sha }}
Note: The PR branch is already checked out in the current working directory.
FIRST: Immediately launch a simplification subagent in the background using the Agent tool (with run_in_background: true) with this prompt:
"Read the file .github/prompts/simplify-review.md for guidance on what to look for. The PR number is ${{ github.event.pull_request.number }}. Wherever the file says <PR_NUMBER>, use ${{ github.event.pull_request.number }}. The repository is checked out in the current working directory. Return your findings as a structured summary — do NOT post any GitHub comments yourself."
THEN: While the simplify agent runs in the background, proceed with the review:
Gather Context (complete these steps silently - don't report on this process):
1. Check CI status: Use mcp__github_ci__get_ci_status to see if tests passed/failed
2. Get previous reviews: Run gh pr view ${{ github.event.pull_request.number }} --comments
3. Identify previous bot reviews and track which issues were raised before
4. If CI failed: Use mcp__github_ci__get_workflow_run_details for failure details
Review Approach:
- If first review: Comprehensive analysis of all changes
- If previous reviews exist: Focus on changes since last review, track issue status, understand how changes impact pr
- Incorporate CI/test failure context into your feedback if relevant
- Use inline comments (mcp__github_inline_comment__create_inline_comment) for specific code issues
Do not include in your review:
- Task completion statements ("I completed the review", "Review finished")
- Process descriptions ("First I checked X, then I analyzed Y")
- Generic meta-commentary about reviewing
- Progress tracking
Review this pull request by individually analysing each of these thoroughly:
- Code quality and best practices
- Potential bugs or issues
- Security implications
- Performance considerations
- Test coverage
- assistant-ui specific requirements:
- The PR has a changeset if updating source code of published packages (packages/*)
- The PR includes documentation if API surface changes
Provide a comprehensive review build on previous reviews including:
- Summary of changes since last review
- Critical issues found (be thorough)
- Suggested improvements (be thorough)
- Good practices observed (be concise - list only the most notable items without elaboration)
- Leverage collapsible <details> sections where appropriate for lengthy explanations or code snippets to enhance human readability
When reviewing subsequent commits:
- Track status of previously identified issues
- Identify new problems introduced since last review
- Note if fixes introduced new issues
IMPORTANT: Be comprehensive about issues and improvements. For good practices, be brief - just note what was done well without explaining why or praising excessively. NO meta-commentary about the review process itself.
Use `mcp__github_inline_comment__create_inline_comment` only for critical code issues or relevant suggestions. Check existing inline comments first to avoid duplicates. Do NOT use inline comments for good practices or positive observations. Each inline comment must be well thought out, precise, and actionable.
You have been provided with the mcp__github_comment__update_claude_comment tool to update your comment. This tool automatically handles PR comments. Only the body parameter is required - the tool automatically knows which comment to update.
FINALLY: Once your review is complete, wait for the background simplify agent to finish. Collect its structured findings, then:
1. For each simplify finding that you agree is worth flagging, post an inline comment using mcp__github_inline_comment__create_inline_comment. Skip findings that overlap with issues you already commented on during your review.
2. Submit one final comment using mcp__github_comment__update_claude_comment that includes your full review followed by a `## Simplify` section with the agent's summary. If no simplification issues were found, note that the code looks clean.
# Allowed tools:
# - mcp__github_inline_comment__create_inline_comment: Create inline code comments
# - Bash(gh ...): Read-only GitHub CLI commands for PR/issue info
# Note: gh pr comment is NOT allowed - Claude must use update_claude_comment only
# Temporarily on Opus 4.8 while Fable usage credits are exhausted; swap back to
# `--model fable` when access returns. The Fable-specific machinery below
# (Opus-fallback flagging) stays in place for that switch-back.
CLAUDE_ARGS: '--model claude-opus-4-8 --effort high --allowedTools "Agent,mcp__github_inline_comment__create_inline_comment,Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
jobs:
claude-review:
if: github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 15
permissions:
contents: read
pull-requests: write
issues: read
id-token: write
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7
with:
ref: refs/pull/${{ github.event.pull_request.number }}/head
fetch-depth: 1
- name: Review
id: review
uses: anthropics/claude-code-action@536f2c32a39763739000b0e1ac69ca2647d97ce9 # ratchet:anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: ${{ env.REVIEW_PROMPT }}
use_sticky_comment: ${{ fromJSON(env.USE_STICKY_COMMENT) }}
track_progress: ${{ fromJSON(env.TRACK_PROGRESS) }}
claude_args: ${{ env.CLAUDE_ARGS }}
additional_permissions: ${{ env.ADDITIONAL_PERMISSIONS }}
# The action reports success even when the Claude execution errors out
# (e.g. expired OAuth token or exhausted usage limit), leaving PRs with a
# green check and no review. Fail loudly and surface the error text,
# which the action redacts from its own logs.
- name: Fail on Claude execution error
if: always()
run: |
FILE='${{ steps.review.outputs.execution_file }}'
[ -f "$FILE" ] || exit 0
jq -e 'last(.[] | select(.type == "result")) | .is_error == true' "$FILE" >/dev/null || exit 0
echo "::error::Claude review failed: $(jq -r 'last(.[] | select(.type == "result")) | .result // "no error text"' "$FILE" | tr '\n' ' ' | head -c 500)"
exit 1
# Fable's cybersecurity classifier can silently swap the session to Opus
# mid-run; the model's own context still claims Fable, so the transcript
# is the only reliable signal of the downgrade.
- name: Flag Opus fallback
if: always()
env:
GH_TOKEN: ${{ github.token }}
run: |
FILE='${{ steps.review.outputs.execution_file }}'
[ -f "$FILE" ] && grep -q '"model_refusal_fallback"' "$FILE" || exit 0
echo "::warning::Fable safeguard triggered — review served by Opus fallback"
COMMENT=$(gh api --paginate "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \
--jq '.[] | select(.user.login == "claude[bot]")' | jq -s 'last')
[ -n "$COMMENT" ] && [ "$COMMENT" != "null" ] || exit 0
gh api --method PATCH "repos/${{ github.repository }}/issues/comments/$(jq -r .id <<<"$COMMENT")" \
-f body="> [!WARNING]
> **Fable's cybersecurity safeguard triggered — this review was served by Opus 4.8.** Ignore it and re-review with Fable locally.
$(jq -r .body <<<"$COMMENT")"
+182
View File
@@ -0,0 +1,182 @@
name: Code Quality
on:
push:
branches: [main]
paths:
- "packages/**"
- "api-surface/**"
- "apps/**"
- "examples/**"
- "templates/**"
- "scripts/generate-api-surface.mjs"
- "scripts/check-api-surface.mjs"
- "scripts/lib/script-options.mjs"
- "scripts/sync-templates.sh"
- "turbo.json"
- "package.json"
- "pnpm-lock.yaml"
- ".github/workflows/code-quality.yaml"
pull_request:
branches: [main]
paths:
- "packages/**"
- "api-surface/**"
- "apps/**"
- "examples/**"
- "templates/**"
- "scripts/generate-api-surface.mjs"
- "scripts/check-api-surface.mjs"
- "scripts/lib/script-options.mjs"
- "scripts/sync-templates.sh"
- "turbo.json"
- "package.json"
- "pnpm-lock.yaml"
- ".github/workflows/code-quality.yaml"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Oxlint + Oxfmt
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 5
steps:
- name: Checkout code repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7
with:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # ratchet:pnpm/action-setup@v6.0.9
- name: Setup node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # ratchet:actions/setup-node@v6
with:
node-version: 24
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run oxlint + oxfmt
run: pnpm lint
- name: Check resource memoization (React Compiler)
run: pnpm check:resource-memo
template-sync:
name: Template Sync
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 2
steps:
- name: Checkout code repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7
with:
fetch-depth: 1
- name: Verify templates match packages/ui source
run: bash scripts/sync-templates.sh
build:
name: Build Changed Packages
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 10
steps:
- name: Checkout code repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7
with:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # ratchet:pnpm/action-setup@v6.0.9
- name: Setup node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # ratchet:actions/setup-node@v6
with:
node-version: 24
cache: "pnpm"
- name: Cache turbo build setup
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # ratchet:actions/cache@v5
with:
path: .turbo
key: ${{ runner.os }}-turbo-build-${{ github.ref }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-build-${{ github.ref }}-
${{ runner.os }}-turbo-build-
${{ runner.os }}-turbo-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build packages
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
# PR: Build packages changed compared to base branch
pnpm turbo build --filter="...[origin/${{ github.base_ref }}]"
else
# Push to main: Build packages changed in last commit
pnpm turbo build --filter="...[HEAD^1]"
fi
- name: Check API surface
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
# PR: Check API surface for packages changed compared to base branch
pnpm run api-surface:check -- --skip-build --filter="...[origin/${{ github.base_ref }}]"
else
# Push to main: Check API surface for packages changed in last commit
pnpm run api-surface:check -- --skip-build --filter="...[HEAD^1]"
fi
- name: Test API surface generator
run: pnpm run api-surface:test
test:
name: Test Changed Packages
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 5
# Note: No dependency on 'build' job - Turbo handles test->build dependencies internally via dependsOn
steps:
- name: Checkout code repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7
with:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # ratchet:pnpm/action-setup@v6.0.9
- name: Setup node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # ratchet:actions/setup-node@v6
with:
node-version: 24
cache: "pnpm"
- name: Cache turbo build setup
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # ratchet:actions/cache@v5
with:
path: .turbo
key: ${{ runner.os }}-turbo-test-${{ github.ref }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-test-${{ github.ref }}-
${{ runner.os }}-turbo-test-
${{ runner.os }}-turbo-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run tests
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
# PR: Test packages changed compared to base branch
pnpm turbo test --filter="...[origin/${{ github.base_ref }}]"
else
# Push to main: Test packages changed in last commit
pnpm turbo test --filter="...[HEAD^1]"
fi
+70
View File
@@ -0,0 +1,70 @@
name: Deploy Expo Example
on:
push:
branches:
- main
paths:
- examples/with-expo/**
- packages/react-native/**
- packages/react-ai-sdk/**
- packages/core/**
- .github/workflows/expo.yaml
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_EXPO_PROJECT_ID }}
EXPO_PUBLIC_CHAT_ENDPOINT_URL: https://www.assistant-ui.com/api/chat
jobs:
deploy-production:
name: Deploy Production
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout code repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7
with:
fetch-depth: 1
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # ratchet:pnpm/action-setup@v6.0.9
- name: Setup node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # ratchet:actions/setup-node@v6
with:
node-version: 24
cache: "pnpm"
- name: Cache turbo build setup
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # ratchet:actions/cache@v5
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.ref }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-${{ github.ref }}-
${{ runner.os }}-turbo-
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Pull Vercel Environment Information
working-directory: examples/with-expo
run: vercel pull --yes --prod --token=${{ secrets.VERCEL_TOKEN }}
- name: Build Project Artifacts
working-directory: examples/with-expo
run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
- name: Deploy Project Artifacts to Vercel
working-directory: examples/with-expo
run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}
+70
View File
@@ -0,0 +1,70 @@
name: Deploy Ink Example
on:
push:
branches:
- main
paths:
- examples/with-react-ink-web/**
- packages/react-ink/**
- packages/react-ink-markdown/**
- packages/react-ai-sdk/**
- packages/core/**
- .github/workflows/ink.yaml
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_INK_PROJECT_ID }}
jobs:
deploy-production:
name: Deploy Production
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout code repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7
with:
fetch-depth: 1
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # ratchet:pnpm/action-setup@v6.0.9
- name: Setup node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # ratchet:actions/setup-node@v6
with:
node-version: 24
cache: "pnpm"
- name: Cache turbo build setup
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # ratchet:actions/cache@v5
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.ref }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-${{ github.ref }}-
${{ runner.os }}-turbo-
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Pull Vercel Environment Information
working-directory: examples/with-react-ink-web
run: vercel pull --yes --prod --token=${{ secrets.VERCEL_TOKEN }}
- name: Build Project Artifacts
working-directory: examples/with-react-ink-web
run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
- name: Deploy Project Artifacts to Vercel
working-directory: examples/with-react-ink-web
run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}
+816
View File
@@ -0,0 +1,816 @@
name: npm Publish
on:
repository_dispatch:
types: [npm-publish]
workflow_dispatch:
inputs:
releaseSha:
description: "Release commit SHA to publish (defaults to latest default-branch commit)"
required: false
type: string
permissions:
contents: read
env:
CI: true
jobs:
approve:
name: Prepare npm publish
runs-on: ubuntu-24.04
concurrency:
group: npm-publish-approval
cancel-in-progress: true
outputs:
release_sha: ${{ steps.release-sha.outputs.value }}
default_branch: ${{ steps.release-sha.outputs.default_branch }}
steps:
- name: Resolve release commit SHA
id: release-sha
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const dispatchReleaseShaRaw = context.payload?.client_payload?.releaseSha;
const manualReleaseShaRaw = context.payload?.inputs?.releaseSha;
const payloadDefaultBranchRaw =
context.payload?.repository?.default_branch;
const dispatchReleaseSha =
typeof dispatchReleaseShaRaw === "string"
? dispatchReleaseShaRaw.trim()
: "";
const manualReleaseSha =
typeof manualReleaseShaRaw === "string"
? manualReleaseShaRaw.trim()
: "";
const payloadDefaultBranch =
typeof payloadDefaultBranchRaw === "string"
? payloadDefaultBranchRaw.trim()
: "";
let releaseSha = dispatchReleaseSha || manualReleaseSha;
let defaultBranch = payloadDefaultBranch;
if (!defaultBranch) {
const repoInfo = await github.rest.repos.get(context.repo);
defaultBranch = repoInfo.data.default_branch;
}
if (!defaultBranch) {
core.setFailed("Unable to determine repository default branch");
return;
}
if (!releaseSha) {
if (context.eventName === "repository_dispatch") {
core.setFailed("repository_dispatch payload is missing releaseSha");
return;
}
const ref = await github.rest.git.getRef({
...context.repo,
ref: `heads/${defaultBranch}`,
});
releaseSha = ref.data.object.sha;
core.info(
`No release SHA provided; using ${defaultBranch} HEAD ${releaseSha}`,
);
}
if (!/^[0-9a-f]{40}$/i.test(releaseSha)) {
core.setFailed(
`releaseSha must be a full 40-character commit SHA: ${releaseSha}`,
);
return;
}
core.setOutput("value", releaseSha);
core.setOutput("default_branch", defaultBranch);
cancel_pending:
name: Cancel stale pending publishes
needs: approve
runs-on: ubuntu-24.04
permissions:
actions: write
contents: read
env:
DEFAULT_BRANCH: ${{ needs.approve.outputs.default_branch }}
steps:
- name: Cancel older queued/waiting runs
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const currentRunId = context.runId;
const currentRunNumber = context.runNumber;
const defaultBranch = (process.env.DEFAULT_BRANCH || "").trim();
const workflowRef = process.env.GITHUB_WORKFLOW_REF || "";
const workflowPathMatch = workflowRef.match(
/^[^/]+\/[^/]+\/(.+)@/,
);
const workflowPath = workflowPathMatch?.[1] ?? "";
const workflowId = workflowPath.split("/").pop() || "";
let runs = [];
if (workflowId) {
runs = await github.paginate(github.rest.actions.listWorkflowRuns, {
owner,
repo,
workflow_id: workflowId,
per_page: 100,
});
} else {
core.warning(
"Unable to resolve workflow filename; using repository-wide workflow run list",
);
runs = await github.paginate(
github.rest.actions.listWorkflowRunsForRepo,
{
owner,
repo,
per_page: 100,
},
);
}
const candidates = runs.filter((run) => {
if (run.id === currentRunId) return false;
if (run.run_number >= currentRunNumber) return false;
if (run.status === "completed") return false;
if (workflowPath && run.path !== workflowPath) return false;
if (defaultBranch && run.head_branch && run.head_branch !== defaultBranch) {
return false;
}
if (
run.event !== "repository_dispatch" &&
run.event !== "workflow_dispatch"
) {
return false;
}
return true;
});
let canceledCount = 0;
for (const run of candidates) {
const jobs = await github.paginate(
github.rest.actions.listJobsForWorkflowRun,
{
owner,
repo,
run_id: run.id,
per_page: 100,
},
);
const publishJob = jobs.find((job) => job.name === "Publish to npm");
if (publishJob?.status === "in_progress") {
core.info(
`Keeping run ${run.id} because Publish to npm is in progress`,
);
continue;
}
try {
await github.rest.actions.cancelWorkflowRun({
owner,
repo,
run_id: run.id,
});
canceledCount += 1;
core.info(`Canceled stale pending run ${run.id}`);
} catch (error) {
core.warning(
`Unable to cancel run ${run.id}: ${error.message}`,
);
}
}
core.info(`Canceled ${canceledCount} stale pending run(s)`);
summary:
name: Release summary
needs:
- approve
runs-on: ubuntu-24.04
outputs:
target_count: ${{ steps.targets.outputs.targetCount }}
targets_json: ${{ steps.targets.outputs.targetsJson }}
env:
RELEASE_SHA: ${{ needs.approve.outputs.release_sha }}
DEFAULT_BRANCH: ${{ needs.approve.outputs.default_branch }}
steps:
- name: Checkout code repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
ref: ${{ env.RELEASE_SHA }}
- name: Validate release commit is on default branch
run: |
if [ -z "$RELEASE_SHA" ]; then
echo "Missing releaseSha" >&2
exit 1
fi
if [ -z "$DEFAULT_BRANCH" ]; then
echo "Missing default branch" >&2
exit 1
fi
head_sha=$(git rev-parse HEAD)
if [ "$head_sha" != "$RELEASE_SHA" ]; then
echo "Checked out commit does not match releaseSha payload" >&2
echo "HEAD=$head_sha" >&2
echo "releaseSha=$RELEASE_SHA" >&2
exit 1
fi
git fetch origin "$DEFAULT_BRANCH"
origin_default_sha=$(git rev-parse "origin/$DEFAULT_BRANCH")
if ! git merge-base --is-ancestor "$RELEASE_SHA" "origin/$DEFAULT_BRANCH"; then
echo "releaseSha is not an ancestor of origin/$DEFAULT_BRANCH; refusing to publish" >&2
echo "releaseSha=$RELEASE_SHA" >&2
echo "origin/$DEFAULT_BRANCH SHA=$origin_default_sha" >&2
exit 1
fi
- name: Validate no pending changesets
run: |
pending=$(find .changeset -name '*.md' ! -name 'README.md' | sort)
if [ -n "$pending" ]; then
echo "::error::Release SHA has pending changesets, so changesets/action would update the version PR instead of publishing."
echo "::error::Merge the version PR first, then dispatch with its merge commit as releaseSha."
echo "Pending changesets:"
echo "$pending"
exit 1
fi
- name: Resolve publish targets
id: targets
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const fs = require("node:fs");
const path = require("node:path");
const fetchNpmMetadata = async (packageName) => {
const encodedName = encodeURIComponent(packageName);
const url = `https://registry.npmjs.org/${encodedName}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
try {
const response = await fetch(url, {
signal: controller.signal,
});
if (response.ok) {
return await response.json();
}
if (response.status === 404) {
return null;
}
throw new Error(`HTTP ${response.status}`);
} finally {
clearTimeout(timeout);
}
};
const parseStableSemver = (value) => {
if (typeof value !== "string") return null;
const match = value.match(/^(\d+)\.(\d+)\.(\d+)$/);
if (!match) return null;
return match.slice(1).map((part) => Number(part));
};
const compareStableSemver = (a, b) => {
const parsedA = parseStableSemver(a);
const parsedB = parseStableSemver(b);
if (!parsedA || !parsedB) return null;
for (let index = 0; index < 3; index += 1) {
if (parsedA[index] > parsedB[index]) return 1;
if (parsedA[index] < parsedB[index]) return -1;
}
return 0;
};
const packagesDir = path.join(process.cwd(), "packages");
const entries = fs.readdirSync(packagesDir, { withFileTypes: true });
const prereleasePackages = [];
const downgradePackages = [];
const targets = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const manifestPath = path.join(packagesDir, entry.name, "package.json");
if (!fs.existsSync(manifestPath)) continue;
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
if (manifest.private === true) continue;
if (typeof manifest.version !== "string" || manifest.version.length === 0) {
core.setFailed(`Package ${manifest.name} is missing a version`);
return;
}
if (manifest.version.includes("-")) {
prereleasePackages.push(`${manifest.name}@${manifest.version}`);
continue;
}
const pkgName = manifest.name;
const pkgVersion = manifest.version;
let metadata = null;
try {
metadata = await fetchNpmMetadata(pkgName);
} catch (error) {
core.setFailed(
`Failed to query npm metadata for ${pkgName}: ${error.message}`,
);
return;
}
const latestTag = metadata?.["dist-tags"]?.latest ?? null;
const hasVersion = Boolean(metadata?.versions?.[pkgVersion]);
if (latestTag !== null) {
const comparison = compareStableSemver(pkgVersion, latestTag);
if (comparison === null) {
core.setFailed(
`Unsupported semver for ${pkgName}: local=${pkgVersion} latest=${latestTag}`,
);
return;
}
if (comparison < 0) {
downgradePackages.push(
`${pkgName} local=${pkgVersion} latest=${latestTag}`,
);
continue;
}
}
if (hasVersion) {
if (latestTag !== pkgVersion) {
core.warning(
`${pkgName}@${pkgVersion} is on npm but latest is ${latestTag ?? "unset"}; run: npm dist-tag add ${pkgName}@${pkgVersion} latest --registry https://registry.npmjs.org`,
);
}
} else {
targets.push({
name: pkgName,
version: pkgVersion,
latestVersion: latestTag,
dir: entry.name,
});
}
}
if (prereleasePackages.length > 0) {
core.setFailed(
`Prerelease versions are not allowed on default-branch releases: ${prereleasePackages.join(", ")}`,
);
return;
}
if (downgradePackages.length > 0) {
core.setFailed(
`Refusing to retag older versions as latest: ${downgradePackages.join(", ")}`,
);
return;
}
targets.sort((a, b) => a.name.localeCompare(b.name));
core.info(
`Targets: ${targets.length === 0 ? "none" : targets.map((target) => `${target.name}@${target.version}`).join(", ")}`,
);
core.setOutput("targetCount", String(targets.length));
core.setOutput("targetsJson", JSON.stringify(targets));
- name: Write release summary
if: steps.targets.outputs.targetCount != '0'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
TARGETS_JSON: ${{ steps.targets.outputs.targetsJson }}
with:
script: |
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const targets = JSON.parse(process.env.TARGETS_JSON || "[]");
const releaseSha = process.env.RELEASE_SHA;
const runGit = (args) => {
try {
return execFileSync("git", args, { encoding: "utf8" }).trim();
} catch {
return "";
}
};
const readJsonFromGit = (gitRef, filePath) => {
try {
return JSON.parse(
execFileSync(
"git",
["show", `${gitRef}:${filePath}`],
{ encoding: "utf8" },
),
);
} catch {
return null;
}
};
function classifyVersionBump(previousVersion, newVersion) {
const [pMajor, pMinor, pPatch] = previousVersion.split(".").map(Number);
const [nMajor, nMinor, nPatch] = newVersion.split(".").map(Number);
if (nMajor !== pMajor) return "major";
if (nMinor !== pMinor) return "minor";
if (nPatch !== pPatch) return "patch";
return "none";
}
function isOutsideCaretRange(rangeVersion, newVersion) {
const [rMajor, rMinor, rPatch] = rangeVersion.split(".").map(Number);
const [nMajor, nMinor, nPatch] = newVersion.split(".").map(Number);
if (rMajor === 0 && rMinor === 0) {
return nMajor !== 0 || nMinor !== 0 || nPatch !== rPatch;
}
if (rMajor === 0) return nMajor !== 0 || nMinor !== rMinor;
return nMajor !== rMajor;
}
// --- 1. Version changes table ---
let summary = "## Release Summary\n\n";
summary += `**Release SHA:** \`${releaseSha.slice(0, 12)}\`\n`;
summary += `**Packages:** ${targets.length}\n\n`;
summary += "### Version Changes\n\n";
summary += "| Package | Previous | New |\n";
summary += "| --- | --- | --- |\n";
for (const t of targets) {
const prev = t.latestVersion ?? "_new_";
summary += `| \`${t.name}\` | ${prev} | **${t.version}** |\n`;
}
summary += "\n";
const breakingTargets = targets
.filter((target) => target.latestVersion)
.map((target) => ({
...target,
previousVersion: target.latestVersion,
bumpType: classifyVersionBump(target.latestVersion, target.version),
}))
.filter((target) => isOutsideCaretRange(target.previousVersion, target.version))
.sort((a, b) => a.name.localeCompare(b.name));
summary += "### Breaking Version Changes\n\n";
if (breakingTargets.length > 0) {
summary += "These published versions move outside the previous `^` range and would be breaking for caret consumers if this release is published:\n\n";
summary += "| Package | Previous | New | Bump |\n";
summary += "| --- | --- | --- | --- |\n";
for (const target of breakingTargets) {
summary += `| \`${target.name}\` | ${target.previousVersion} | **${target.version}** | ${target.bumpType} |\n`;
}
} else {
summary += "No published package versions move outside their previous `^` range.\n";
}
summary += "\n";
// --- 2. Cascade impact ---
const packagesDir = path.join(process.cwd(), "packages");
const currentPackages = new Map();
const previousPackages = new Map();
for (const entry of fs.readdirSync(packagesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const dir = entry.name;
const pkgJsonPath = path.join(packagesDir, dir, "package.json");
let pkgJson;
try {
pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"));
} catch {
continue;
}
if (pkgJson.private) continue;
currentPackages.set(pkgJson.name, {
dir,
...pkgJson,
});
const previousPkgJson = readJsonFromGit(
`${releaseSha}^`,
path.join("packages", dir, "package.json"),
);
if (!previousPkgJson || previousPkgJson.private) continue;
previousPackages.set(previousPkgJson.name, {
dir,
...previousPkgJson,
});
}
const targetVersions = new Map(
targets.map((target) => [target.name, target.version]),
);
const cascadeByConsumer = new Map();
for (const previousPkg of previousPackages.values()) {
const allDeps = {
...previousPkg.dependencies,
...previousPkg.peerDependencies,
};
for (const [depName, range] of Object.entries(allDeps)) {
if (typeof range !== "string" || !range.startsWith("^")) continue;
const targetVersion = targetVersions.get(depName);
if (!targetVersion) continue;
const rangeVersion = range.replace(/^\^/, "");
if (!isOutsideCaretRange(rangeVersion, targetVersion)) continue;
const currentPkg = currentPackages.get(previousPkg.name);
const existing = cascadeByConsumer.get(previousPkg.name);
if (existing) {
existing.triggers.push({
name: depName,
range,
version: targetVersion,
});
continue;
}
cascadeByConsumer.set(previousPkg.name, {
name: previousPkg.name,
previousVersion: previousPkg.version,
newVersion: currentPkg?.version ?? previousPkg.version,
triggers: [
{
name: depName,
range,
version: targetVersion,
},
],
});
}
}
const cascadeEntries = Array.from(cascadeByConsumer.values()).sort(
(a, b) => a.name.localeCompare(b.name),
);
if (cascadeEntries.length > 0) {
summary += "### Downstream Impact\n\n";
summary += `${cascadeEntries.length} internal consumer(s) have \`^\` ranges that will be broken by these version changes:\n\n`;
summary += "| Consumer | Version | Broken Dependencies |\n";
summary += "| --- | --- | --- |\n";
for (const c of cascadeEntries) {
const brokenDeps = c.triggers
.map((trigger) => `\`${trigger.name}\` (${trigger.range} -> ${trigger.version})`)
.join(", ");
summary += `| \`${c.name}\` | ${c.previousVersion} -> ${c.newVersion} | ${brokenDeps} |\n`;
}
summary += "\n";
}
// --- 3. Changelog entries ---
summary += "### Changelog\n\n";
for (const t of targets) {
const changelogPath = path.join(packagesDir, t.dir, "CHANGELOG.md");
let changelogEntry = "";
try {
const content = fs.readFileSync(changelogPath, "utf8");
const versionEscaped = t.version.replace(/\./g, "\\.");
const headingRe = new RegExp("^## " + versionEscaped + "\\b", "m");
const headingMatch = content.match(headingRe);
if (headingMatch) {
const headingEnd = content.indexOf("\n", headingMatch.index);
const afterHeading = headingEnd === -1 ? content.length : headingEnd + 1;
const rest = content.slice(afterHeading);
const nextIdx = rest.search(/^## /m);
changelogEntry = (nextIdx === -1 ? rest : rest.slice(0, nextIdx)).trim();
}
} catch {
// No changelog file
}
if (changelogEntry) {
summary += `<details><summary><code>${t.name}@${t.version}</code></summary>\n\n`;
summary += changelogEntry + "\n";
summary += "</details>\n\n";
} else {
summary += `- \`${t.name}@${t.version}\` — _no changelog entry_\n`;
}
}
summary += "\n";
// --- 4. Commit history ---
const prevVersionCommit = runGit([
"log", "--format=%H", "--fixed-strings", "--grep=chore: update versions",
`${releaseSha}^`, "--max-count=1",
]);
if (prevVersionCommit) {
const commitLog = runGit([
"log", "--oneline", "--no-merges",
`${prevVersionCommit}..${releaseSha}`,
]);
if (commitLog) {
summary += "### Commits in This Release\n\n";
summary += "```\n" + commitLog + "\n```\n\n";
}
}
await core.summary.addRaw(summary).write();
core.info("Release summary written to job summary.");
- name: No targets
if: steps.targets.outputs.targetCount == '0'
run: echo "No packages require publishing for this release SHA."
publish:
name: Publish to npm
needs:
- approve
- cancel_pending
- summary
if: needs.summary.outputs.target_count != '0'
environment: npm Publish
runs-on: ubuntu-24.04
permissions:
contents: write
# Required for npm trusted publishing (OIDC).
id-token: write
concurrency:
group: npm-publish-exec
cancel-in-progress: false
env:
RELEASE_SHA: ${{ needs.approve.outputs.release_sha }}
steps:
- name: Checkout code repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
ref: ${{ env.RELEASE_SHA }}
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Setup node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
# No cache - hardened against cache poisoning for sensitive publish operations
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Verify npm trusted publishing
# Temporarily disabled: npm appears to reject the subsequent CLI OIDC
# exchange after this pre-flight call within the same job, causing
# "OIDC publish authorize: Invalid token" on every package.
if: false
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
TARGETS_JSON: ${{ needs.summary.outputs.targets_json }}
with:
script: |
const targets = JSON.parse(process.env.TARGETS_JSON || "[]");
const oidcRequestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
const oidcRequestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
if (!oidcRequestUrl || !oidcRequestToken) {
core.setFailed("OIDC token request context is unavailable");
return;
}
const audience = "npm:registry.npmjs.org";
const oidcUrl = `${oidcRequestUrl}${oidcRequestUrl.includes("?") ? "&" : "?"}audience=${encodeURIComponent(audience)}`;
const fetchOidcToken = async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
try {
const response = await fetch(oidcUrl, {
headers: { Authorization: `Bearer ${oidcRequestToken}` },
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const payload = await response.json();
if (!payload?.value) {
throw new Error("OIDC response is missing token value");
}
return payload.value;
} finally {
clearTimeout(timeout);
}
};
const failures = [];
for (const target of targets) {
let githubOidcToken;
try {
githubOidcToken = await fetchOidcToken();
} catch (error) {
core.setFailed(
`Failed to fetch GitHub OIDC token: ${error.message}`,
);
return;
}
const encodedName = encodeURIComponent(target.name);
const exchangeUrl =
`https://registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/${encodedName}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
let response;
try {
response = await fetch(exchangeUrl, {
method: "POST",
headers: { Authorization: `Bearer ${githubOidcToken}` },
signal: controller.signal,
});
} catch (error) {
failures.push(`${target.name}: network error (${error.message})`);
continue;
} finally {
clearTimeout(timeout);
}
if (!response.ok) {
const hint =
response.status === 404
? "not configured for npm trusted publishing"
: `HTTP ${response.status}`;
failures.push(`${target.name}: ${hint}`);
}
}
if (failures.length > 0) {
core.setFailed(
[
`Pre-flight OIDC verification failed for ${failures.length} package(s):`,
...failures.map((f) => ` - ${f}`),
"",
"Configure trusted publishing for each failed package at:",
" https://docs.npmjs.com/trusted-publishers",
].join("\n"),
);
return;
}
core.info(
`All ${targets.length} package(s) verified for npm trusted publishing.`,
);
- name: Publish to npm and create GitHub releases
id: changesets-publish
uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0
with:
publish: pnpm ci:publish
createGithubReleases: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# The action's fallback version path commits without ci:version's lockfile refresh, so the pre-commit hook's pnpm run fails on the stale lockfile.
HUSKY: 0
- name: Recovery guidance
if: failure()
run: |
echo "::error::Publish failed for release SHA ${RELEASE_SHA}."
echo "::error::Rerun this workflow run, or run workflow_dispatch with releaseSha=${RELEASE_SHA}."
+309
View File
@@ -0,0 +1,309 @@
name: PyPI Publish
on:
repository_dispatch:
types: [pypi-publish]
workflow_dispatch:
inputs:
releaseSha:
description: "Release commit SHA to publish (defaults to latest default-branch commit)"
required: false
type: string
permissions:
contents: read
env:
CI: true
PYTHON_PACKAGE_DIR: python/assistant-stream
PYTHON_PACKAGE_NAME: assistant-stream
jobs:
approve:
name: Resolve release SHA
runs-on: ubuntu-24.04
concurrency:
group: pypi-publish-approval
cancel-in-progress: true
outputs:
release_sha: ${{ steps.release-sha.outputs.value }}
default_branch: ${{ steps.release-sha.outputs.default_branch }}
steps:
- name: Resolve release commit SHA
id: release-sha
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const dispatchReleaseShaRaw = context.payload?.client_payload?.releaseSha;
const manualReleaseShaRaw = context.payload?.inputs?.releaseSha;
const payloadDefaultBranchRaw =
context.payload?.repository?.default_branch;
const dispatchReleaseSha =
typeof dispatchReleaseShaRaw === "string"
? dispatchReleaseShaRaw.trim()
: "";
const manualReleaseSha =
typeof manualReleaseShaRaw === "string"
? manualReleaseShaRaw.trim()
: "";
const payloadDefaultBranch =
typeof payloadDefaultBranchRaw === "string"
? payloadDefaultBranchRaw.trim()
: "";
let releaseSha = dispatchReleaseSha || manualReleaseSha;
let defaultBranch = payloadDefaultBranch;
if (!defaultBranch) {
const repoInfo = await github.rest.repos.get(context.repo);
defaultBranch = repoInfo.data.default_branch;
}
if (!defaultBranch) {
core.setFailed("Unable to determine repository default branch");
return;
}
if (!releaseSha) {
if (context.eventName === "repository_dispatch") {
core.setFailed("repository_dispatch payload is missing releaseSha");
return;
}
const ref = await github.rest.git.getRef({
...context.repo,
ref: `heads/${defaultBranch}`,
});
releaseSha = ref.data.object.sha;
core.info(
`No release SHA provided; using ${defaultBranch} HEAD ${releaseSha}`,
);
}
if (!/^[0-9a-f]{40}$/i.test(releaseSha)) {
core.setFailed(
`releaseSha must be a full 40-character commit SHA: ${releaseSha}`,
);
return;
}
core.setOutput("value", releaseSha);
core.setOutput("default_branch", defaultBranch);
summary:
name: Resolve publish target
needs: approve
runs-on: ubuntu-24.04
outputs:
should_publish: ${{ steps.target.outputs.shouldPublish }}
version: ${{ steps.target.outputs.version }}
env:
RELEASE_SHA: ${{ needs.approve.outputs.release_sha }}
DEFAULT_BRANCH: ${{ needs.approve.outputs.default_branch }}
steps:
- name: Checkout code repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
ref: ${{ env.RELEASE_SHA }}
- name: Validate release commit is on default branch
run: |
if [ -z "$RELEASE_SHA" ]; then
echo "Missing releaseSha" >&2
exit 1
fi
if [ -z "$DEFAULT_BRANCH" ]; then
echo "Missing default branch" >&2
exit 1
fi
head_sha=$(git rev-parse HEAD)
if [ "$head_sha" != "$RELEASE_SHA" ]; then
echo "Checked out commit does not match releaseSha payload" >&2
echo "HEAD=$head_sha" >&2
echo "releaseSha=$RELEASE_SHA" >&2
exit 1
fi
git fetch origin "$DEFAULT_BRANCH"
origin_default_sha=$(git rev-parse "origin/$DEFAULT_BRANCH")
if ! git merge-base --is-ancestor "$RELEASE_SHA" "origin/$DEFAULT_BRANCH"; then
echo "releaseSha is not an ancestor of origin/$DEFAULT_BRANCH; refusing to publish" >&2
echo "releaseSha=$RELEASE_SHA" >&2
echo "origin/$DEFAULT_BRANCH SHA=$origin_default_sha" >&2
exit 1
fi
- name: Resolve target version + PyPI status
id: target
run: |
set -euo pipefail
version=$(python3 - <<'PY' "$PYTHON_PACKAGE_DIR/pyproject.toml"
import sys, tomllib
with open(sys.argv[1], "rb") as f:
data = tomllib.load(f)
v = data.get("project", {}).get("version")
if not v:
sys.exit("[project].version missing from pyproject.toml")
print(v)
PY
)
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Refusing to publish non-stable version: $version" >&2
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
pypi_body=$(mktemp)
trap 'rm -f "$pypi_body"' EXIT
status=$(curl -s --max-time 30 -o "$pypi_body" -w "%{http_code}" \
"https://pypi.org/pypi/$PYTHON_PACKAGE_NAME/json")
case "$status" in
200)
latest=$(python3 -c 'import sys, json; print(json.load(sys.stdin).get("info", {}).get("version") or "")' < "$pypi_body")
exists=$(python3 -c 'import sys, json; d=json.load(sys.stdin); print("true" if sys.argv[1] in d.get("releases", {}) else "false")' "$version" < "$pypi_body")
;;
404)
latest=""
exists="false"
;;
*)
echo "Unexpected PyPI status $status for $PYTHON_PACKAGE_NAME" >&2
exit 1
;;
esac
if [ -n "$latest" ] && [ "$version" != "$latest" ]; then
highest=$(printf '%s\n%s\n' "$latest" "$version" | sort -V | tail -n1)
if [ "$highest" != "$version" ]; then
echo "Refusing to publish $PYTHON_PACKAGE_NAME@$version: lower than current PyPI latest ($latest)" >&2
exit 1
fi
fi
if [ "$exists" = "true" ]; then
echo "$PYTHON_PACKAGE_NAME@$version already on PyPI; skipping publish."
echo "shouldPublish=false" >> "$GITHUB_OUTPUT"
else
echo "$PYTHON_PACKAGE_NAME@$version not yet on PyPI; will publish."
echo "shouldPublish=true" >> "$GITHUB_OUTPUT"
fi
- name: Write release summary
if: steps.target.outputs.shouldPublish == 'true'
run: |
{
echo "## PyPI Release Summary"
echo
echo "**Release SHA:** \`${RELEASE_SHA:0:12}\`"
echo "**Package:** \`$PYTHON_PACKAGE_NAME\`"
echo "**Version:** **${{ steps.target.outputs.version }}**"
} >> "$GITHUB_STEP_SUMMARY"
- name: No targets
if: steps.target.outputs.shouldPublish == 'false'
run: echo "$PYTHON_PACKAGE_NAME@${{ steps.target.outputs.version }} is already published; nothing to do."
publish:
name: Publish to PyPI
needs:
- approve
- summary
if: needs.summary.outputs.should_publish == 'true'
environment: pypi publish
runs-on: ubuntu-24.04
permissions:
# Required to create GitHub releases.
contents: write
# Required for PyPI trusted publishing (OIDC).
id-token: write
concurrency:
group: pypi-publish-exec
cancel-in-progress: false
env:
RELEASE_SHA: ${{ needs.approve.outputs.release_sha }}
VERSION: ${{ needs.summary.outputs.version }}
steps:
- name: Checkout code repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 1
fetch-tags: true
ref: ${{ env.RELEASE_SHA }}
- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
# No cache - hardened against cache poisoning for sensitive publish operations
enable-cache: false
- name: Build sdist + wheel
working-directory: ${{ env.PYTHON_PACKAGE_DIR }}
run: |
set -euo pipefail
rm -rf dist
uv build
ls -la dist
- name: Verify built version matches target
working-directory: ${{ env.PYTHON_PACKAGE_DIR }}
run: |
set -euo pipefail
dist_name="${PYTHON_PACKAGE_NAME//-/_}-${VERSION}"
shopt -s nullglob
wheels=( "dist/${dist_name}"-*.whl )
if [ "${#wheels[@]}" -eq 0 ] || [ ! -f "dist/${dist_name}.tar.gz" ]; then
echo "Expected build artifacts for ${dist_name} are missing." >&2
ls dist >&2 || true
exit 1
fi
# PyPI trusted publishing requires this workflow filename and the
# `pypi publish` environment to match the project's trusted-publisher config.
- name: Publish to PyPI via OIDC
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
packages-dir: ${{ env.PYTHON_PACKAGE_DIR }}/dist
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
tag="pypi/${PYTHON_PACKAGE_NAME}@${VERSION}"
if gh release view "$tag" >/dev/null 2>&1; then
echo "Release $tag already exists; skipping."
exit 0
fi
prev=$(git tag --list "pypi/${PYTHON_PACKAGE_NAME}@*" \
| sort -V \
| awk -v t="$tag" '$0 != t' \
| tail -n1)
if [ -n "$prev" ]; then
gh release create "$tag" \
--target "$RELEASE_SHA" \
--title "$tag" \
--notes-start-tag "$prev" \
--generate-notes
else
gh release create "$tag" \
--target "$RELEASE_SHA" \
--title "$tag" \
--notes "First $PYTHON_PACKAGE_NAME release published via this workflow."
fi
- name: Recovery guidance
if: failure()
run: |
echo "::error::Publish failed for $PYTHON_PACKAGE_NAME@${VERSION} on release SHA ${RELEASE_SHA}."
echo "::error::Rerun this workflow run, or workflow_dispatch with releaseSha=${RELEASE_SHA}."
+51
View File
@@ -0,0 +1,51 @@
name: Python Tests
on:
push:
branches: [main]
paths:
- "python/**"
- ".github/workflows/python-tests.yaml"
pull_request:
branches: [main]
paths:
- "python/**"
- ".github/workflows/python-tests.yaml"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: pytest (${{ matrix.package }}, ${{ matrix.python-version }})
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
package:
- assistant-stream
- assistant-ui-sync-server-api
python-version: ["3.10", "3.12"]
defaults:
run:
working-directory: python/${{ matrix.package }}
steps:
- name: Checkout code repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
python-version: ${{ matrix.python-version }}
enable-cache: true
- name: Install dependencies
run: uv sync --all-extras
- name: Run tests
run: uv run pytest
+67
View File
@@ -0,0 +1,67 @@
name: Deploy Shadcn Registry
on:
push:
branches:
- main
paths:
- apps/registry/**
- packages/ui/**
- .github/workflows/registry.yaml
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_REGISTRY_PROJECT_ID }}
jobs:
deploy-production:
name: Deploy Production
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout code repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7
with:
fetch-depth: 1
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # ratchet:pnpm/action-setup@v6.0.9
- name: Setup node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # ratchet:actions/setup-node@v6
with:
node-version: 24
cache: "pnpm"
- name: Cache turbo build setup
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # ratchet:actions/cache@v5
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.ref }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-${{ github.ref }}-
${{ runner.os }}-turbo-
- name: Install Vercel CLI
run: npm install --global vercel@latest
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Pull Vercel Environment Information
working-directory: apps/registry
run: vercel pull --yes --prod --token=${{ secrets.VERCEL_TOKEN }}
- name: Build Project Artifacts
working-directory: apps/registry
run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
- name: Deploy Project Artifacts to Vercel
working-directory: apps/registry
run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}
+157
View File
@@ -0,0 +1,157 @@
name: Template Metrics
on:
push:
branches: [main]
paths:
- "templates/**"
- "packages/ui/**"
pull_request:
branches: [main]
paths:
- "templates/**"
- "packages/ui/**"
permissions:
contents: read
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
# Templates to build for bundle-size measurement. LOC is measured for all
# templates regardless; only these are built (next build is the expensive part).
BUNDLE_TEMPLATES: "default minimal"
# Per-template regression limits. A template that already exists on the main
# baseline fails the gate if it grows past either limit.
MAX_LOC_INCREASE: "50"
MAX_BUNDLE_INCREASE_KB: "10"
jobs:
metrics:
name: LOC + Bundle Size
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 20
steps:
- name: Checkout code repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # ratchet:actions/checkout@v6
with:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # ratchet:pnpm/action-setup@v4
- name: Setup node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # ratchet:actions/setup-node@v6
with:
node-version: 24
cache: "pnpm"
- name: Cache turbo build setup
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # ratchet:actions/cache@v4
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.ref }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-${{ github.ref }}-
${{ runner.os }}-turbo-
# Baseline is main's metrics, cached by the last run on main. PRs restore
# the newest one via the restore-keys prefix to compute deltas against main.
- name: Restore main baseline
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # ratchet:actions/cache/restore@v4
with:
path: install-footprint/branch/main/data.json
key: install-footprint-main-${{ github.sha }}
restore-keys: install-footprint-main-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build templates (bundle subset)
env:
# next build needs these public vars defined; values are placeholders.
NEXT_PUBLIC_ASSISTANT_BASE_URL: "https://example.com"
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: "pk_test_placeholder"
NEXT_TELEMETRY_DISABLED: "1"
run: |
filters=""
for t in $BUNDLE_TEMPLATES; do
filters="$filters --filter=./templates/$t"
done
# turbo pulls in the @assistant-ui/* workspace deps each template needs.
pnpm turbo build $filters
- name: Measure metrics
run: node scripts/template-metrics.mjs measure . metrics-head.json
- name: Build report
run: |
node scripts/template-metrics.mjs report install-footprint/branch/main/data.json metrics-head.json metrics-report.md metrics-gate.txt metrics-comment.txt
cat metrics-report.md
# continue-on-error: a fork PR's read-only token makes commenting 403; that
# must not fail the check or skip the gate below.
- name: Post / update PR comment
if: github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # ratchet:actions/github-script@v7
with:
script: |
const fs = require('fs');
const body = fs.readFileSync('metrics-report.md', 'utf8');
const marker = '<!-- template-metrics -->';
// paginate: a long PR thread can push the sticky comment past the
// first 30, and missing it would post a duplicate on every re-run.
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find((c) => c.body?.includes(marker));
const shouldCreate = fs.readFileSync('metrics-comment.txt', 'utf8').trim() === 'post';
if (!existing && !shouldCreate) {
console.log('No LOC changes; skipping template metrics comment.');
return;
}
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
# always(): run even if the comment step failed (e.g. fork 403), so the
# gate verdict is never lost. Only blocks PRs; pushes refresh the baseline.
- name: Enforce regression gate
if: always() && github.event_name == 'pull_request'
run: |
if [ "$(cat metrics-gate.txt 2>/dev/null)" = "fail" ]; then
echo "::error::Template metrics regressed past the configured limits. See the PR comment."
exit 1
fi
echo "Template metrics within limits."
- name: Persist baseline (main only)
if: github.event_name == 'push'
run: |
mkdir -p install-footprint/branch/main
cp metrics-head.json install-footprint/branch/main/data.json
- name: Save baseline cache (main only)
if: github.event_name == 'push'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # ratchet:actions/cache/save@v4
with:
path: install-footprint/branch/main/data.json
key: install-footprint-main-${{ github.sha }}
+399
View File
@@ -0,0 +1,399 @@
name: Update README Traction Chart
on:
schedule:
- cron: "0 14 * * 1" # Monday 8 AM Central
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
generate-chart:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
- name: Install dependencies
run: |
mkdir -p /tmp/sharp-deps
cd /tmp/sharp-deps
npm init -y > /dev/null
npm install sharp
- name: Generate chart
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_PATH: /tmp/sharp-deps/node_modules
run: |
node << 'SCRIPT'
const sharp = require('sharp');
const fs = require('fs');
const PACKAGE_NAME = '@assistant-ui/react';
const GITHUB_REPO = 'assistant-ui/assistant-ui';
const OUTPUT_PATH = '.github/assets/traction.png';
const COLORS = { primary: '#ffffff', text: '#ffffff', textMuted: '#888888', grid: '#333333' };
const ASSISTANT_UI_LOGO = `<path d="M14 9a2 2 0 0 1-2 2H6l-4 4V4c0-1.1.9-2 2-2h8a2 2 0 0 1 2 2z" fill="none" stroke="${COLORS.text}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1" fill="none" stroke="${COLORS.text}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`;
const GITHUB_LOGO = `<path d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.463-1.11-1.463-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z" fill="${COLORS.textMuted}"/>`;
const NPM_LOGO = `<path d="M0,0 H24 V24 H0 Z M4.5,4.5 V19.5 H12 V7.5 H16.5 V19.5 H19.5 V4.5 Z" fill="${COLORS.textMuted}" fill-rule="evenodd"/>`;
function getStartDate() {
const d = new Date();
d.setMonth(d.getMonth() - 18);
return d.toISOString().split('T')[0];
}
const START_DATE = getStartDate();
async function fetchNpmStats(packageName) {
const end = new Date().toISOString().split('T')[0];
const url = `https://npm-stat.com/api/download-counts?package=${encodeURIComponent(packageName)}&from=${START_DATE}&until=${end}`;
const response = await fetch(url);
if (!response.ok) throw new Error(`npm stats fetch failed: ${response.status}`);
const data = await response.json();
return data[packageName] || {};
}
async function fetchCurrentStars(repo) {
const [owner, name] = repo.split('/');
const headers = { 'Accept': 'application/vnd.github.v3+json' };
if (process.env.GITHUB_TOKEN) {
headers['Authorization'] = `token ${process.env.GITHUB_TOKEN}`;
}
const response = await fetch(`https://api.github.com/repos/${owner}/${name}`, { headers });
if (!response.ok) throw new Error(`GitHub repo fetch failed: ${response.status}`);
const data = await response.json();
return data.stargazers_count;
}
async function fetchStarHistory(repo, currentStars) {
const [owner, name] = repo.split('/');
const stars = [];
let page = 1;
const headers = { 'Accept': 'application/vnd.github.star+json' };
if (process.env.GITHUB_TOKEN) {
headers['Authorization'] = `token ${process.env.GITHUB_TOKEN}`;
}
// Paginate through all stars (no hard limit) with delays to avoid rate limits
while (true) {
if (page > 1) await new Promise(r => setTimeout(r, 100));
let response = await fetch(
`https://api.github.com/repos/${owner}/${name}/stargazers?per_page=100&page=${page}`,
{ headers }
);
if (response.status === 403 || response.status === 429) {
console.warn(`Rate limited on page ${page}, waiting 3 minutes before retry...`);
await new Promise(r => setTimeout(r, 180000));
response = await fetch(
`https://api.github.com/repos/${owner}/${name}/stargazers?per_page=100&page=${page}`,
{ headers }
);
}
if (!response.ok) {
console.warn(`Stopped at page ${page}: HTTP ${response.status} (fetched ${stars.length} stars)`);
break;
}
const data = await response.json();
if (data.length === 0) break;
stars.push(...data.map(s => s.starred_at));
console.log(`Fetched page ${page}, ${stars.length} stars total`);
if (data.length < 100) break;
page++;
}
const startDate = new Date(START_DATE);
const starsBeforeStart = stars.filter(d => new Date(d) < startDate).length;
const weeks = {};
for (const date of stars) {
const d = new Date(date);
if (d < startDate) continue;
const weekStart = getWeekStart(d.toISOString().split('T')[0]);
weeks[weekStart] = (weeks[weekStart] || 0) + 1;
}
const result = [];
const now = new Date();
const currentWeekStart = getWeekStart(now.toISOString().split('T')[0]);
let d = new Date(START_DATE);
const dayOfWeek = d.getDay();
const daysToMonday = dayOfWeek === 0 ? 1 : (dayOfWeek === 1 ? 0 : 8 - dayOfWeek);
d.setDate(d.getDate() + daysToMonday);
let cumulative = starsBeforeStart;
while (d <= now) {
const key = d.toISOString().split('T')[0];
if (key >= currentWeekStart) break;
cumulative += (weeks[key] || 0);
result.push({ date: key, value: cumulative });
d.setDate(d.getDate() + 7);
}
result.push({ date: now.toISOString().split('T')[0], value: currentStars });
return result;
}
function getWeekStart(date) {
const d = new Date(date);
const day = d.getDay();
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
d.setDate(diff);
return d.toISOString().split('T')[0];
}
function aggregateByWeek(dailyData) {
const sorted = Object.entries(dailyData)
.filter(([, count]) => count > 0)
.sort(([a], [b]) => a.localeCompare(b));
if (sorted.length < 7) return sorted.map(([date, value]) => ({ date, value }));
const result = [];
for (let i = 3; i < sorted.length - 4; i += 3) {
let sum = 0;
for (let j = i - 3; j <= i + 3; j++) {
sum += sorted[j][1];
}
result.push({ date: sorted[i][0], value: sum });
}
return result;
}
function smoothData(data) {
if (data.length < 3) return data;
const avg = [];
for (let i = 0; i < data.length; i++) {
const s = Math.max(0, i - 2), e = Math.min(data.length, i + 3);
const w = data.slice(s, e).map(d => d.value);
avg.push({ date: data[i].date, value: w.reduce((a, b) => a + b, 0) / w.length });
}
const out = [avg[0]];
let h = avg[0].value;
for (let i = 1; i < avg.length; i++) {
const p = out[i - 1].value, c = avg[i].value;
h = c > h ? c : h * 0.99;
const v = c > p ? p * 0.3 + c * 0.7 : c < p * 0.8 ? p * 0.85 + h * 0.1 + c * 0.05 : p * 0.95 + c * 0.05;
out.push({ date: avg[i].date, value: Math.round(v) });
}
return out;
}
function getMonthlyRate(dailyData) {
const entries = Object.entries(dailyData).sort(([a], [b]) => a.localeCompare(b));
const recent = entries.slice(-60);
if (recent.length < 30) return Math.round(recent.reduce((s, [, v]) => s + v, 0) / recent.length * 30);
let max = 0;
for (let i = 0; i <= recent.length - 30; i++) {
const sum = recent.slice(i, i + 30).reduce((s, [, v]) => s + v, 0);
if (sum > max) max = sum;
}
return max;
}
function formatNumber(num) {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
return num.toString();
}
function smoothPath(pts) {
if (pts.length < 2) return '';
if (pts.length === 2) return `M ${pts[0].x.toFixed(1)} ${pts[0].y.toFixed(1)} L ${pts[1].x.toFixed(1)} ${pts[1].y.toFixed(1)}`;
let path = `M ${pts[0].x.toFixed(1)} ${pts[0].y.toFixed(1)}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[Math.max(0, i - 1)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(pts.length - 1, i + 2)];
const cp1x = p1.x + (p2.x - p0.x) / 6;
const cp1y = p1.y + (p2.y - p0.y) / 6;
const cp2x = p2.x - (p3.x - p1.x) / 6;
const cp2y = p2.y - (p3.y - p1.y) / 6;
path += ` C ${cp1x.toFixed(1)} ${cp1y.toFixed(1)}, ${cp2x.toFixed(1)} ${cp2y.toFixed(1)}, ${p2.x.toFixed(1)} ${p2.y.toFixed(1)}`;
}
return path;
}
function generateDualSVG(starsData, downloadsData, monthlyDownloads, currentStars, totalDownloads) {
const width = 800;
const height = 380;
const margin = 20;
const gap = 24;
const cardWidth = (width - margin * 2 - gap) / 2;
const cardHeight = height - margin * 2;
const chartPadding = { top: 50, right: 20, bottom: 35, left: 45 };
const innerW = cardWidth - chartPadding.left - chartPadding.right;
const innerH = cardHeight - chartPadding.top - chartPadding.bottom;
function chartCard(data, cardX, cardY, packageName, metricName, subtitle, gradId, clipId, logoSvg, subtitle2 = null, yAxisLabel = null) {
if (!data.length) return '';
const maxVal = Math.max(...data.map(d => d.value));
const chartLeft = cardX + chartPadding.left;
const chartRight = cardX + cardWidth - chartPadding.right;
const chartTop = cardY + chartPadding.top;
const chartBottom = cardY + chartPadding.top + innerH;
const niceStep = (max) => {
const rough = max / 6;
const magnitude = Math.pow(10, Math.floor(Math.log10(rough)));
const normalized = rough / magnitude;
let nice;
if (normalized <= 1) nice = 1;
else if (normalized <= 2) nice = 2;
else if (normalized <= 5) nice = 5;
else nice = 10;
return nice * magnitude;
};
const step = niceStep(maxVal);
const niceMax = Math.ceil(maxVal / step) * step;
const yVals = [];
for (let v = 0; v <= niceMax; v += step) {
yVals.push(v);
}
const pts = data.map((d, i) => ({
x: chartLeft + (data.length > 1 ? (i / (data.length - 1)) * innerW : innerW / 2),
y: chartTop + innerH - (d.value / niceMax) * innerH
}));
const line = smoothPath(pts);
const areaPath = line + ` L ${pts[pts.length - 1].x.toFixed(1)} ${chartBottom} L ${chartLeft} ${chartBottom} Z`;
const xLabels = [];
const numLabels = 8;
const labelStep = Math.max(1, Math.floor(data.length / (numLabels - 1)));
let lastYear = null;
for (let labelNum = 0; labelNum < numLabels - 1; labelNum++) {
const i = Math.min(labelNum * labelStep, data.length - 2);
const x = pts[i].x;
const d = new Date(data[i].date);
const year = d.getFullYear();
const month = d.toLocaleDateString('en-US', { month: 'short' });
let label;
if (year !== lastYear && lastYear !== null) {
label = String(year);
} else {
label = month;
}
lastYear = year;
xLabels.push({ x, label });
}
xLabels.push({ x: pts[data.length - 1].x, label: 'Today' });
return `
<clipPath id="${clipId}">
<rect x="${chartLeft}" y="${chartTop}" width="${innerW}" height="${innerH + 1}"/>
</clipPath>
<rect x="${cardX}" y="${cardY}" width="${cardWidth}" height="${cardHeight}" fill="#111111" rx="12"/>
<g transform="translate(${cardX + 12}, ${cardY + 10}) scale(0.6)">
${ASSISTANT_UI_LOGO}
</g>
<text x="${cardX + 38}" y="${cardY + 24}" fill="${COLORS.text}" font-family="system-ui,-apple-system,sans-serif" font-size="14" font-weight="600">${metricName}</text>
<text x="${cardX + cardWidth - chartPadding.right}" y="${cardY + 26}" fill="${COLORS.text}" font-family="system-ui,sans-serif" font-size="13" font-weight="600" text-anchor="end">${subtitle}</text>
${subtitle2 ? `<text x="${cardX + cardWidth - chartPadding.right}" y="${cardY + 40}" fill="${COLORS.textMuted}" font-family="system-ui,sans-serif" font-size="10" text-anchor="end">${subtitle2}</text>` : ''}
<rect x="${chartLeft + 4}" y="${chartTop + 4}" width="${packageName.length * 6 + 32}" height="20" fill="#222222" rx="4" opacity="0.9"/>
<g transform="translate(${chartLeft + 8}, ${chartTop + 6}) scale(0.65)">
${logoSvg}
</g>
<text x="${chartLeft + 28}" y="${chartTop + 18}" fill="${COLORS.textMuted}" font-family="system-ui,-apple-system,sans-serif" font-size="10">${packageName}</text>
${yVals.slice(1).map(v => {
const y = chartTop + innerH - (v / niceMax) * innerH;
return `<line x1="${chartLeft}" y1="${y.toFixed(1)}" x2="${chartRight}" y2="${y.toFixed(1)}" stroke="${COLORS.grid}" stroke-width="1" stroke-dasharray="2,4"/>`;
}).join('')}
<g clip-path="url(#${clipId})">
<path d="${areaPath}" fill="url(#${gradId})"/>
<path d="${line}" fill="none" stroke="${COLORS.primary}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" opacity="0.3" filter="blur(3px)"/>
<path d="${line}" fill="none" stroke="${COLORS.primary}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
${yVals.map(v => {
const y = chartTop + innerH - (v / niceMax) * innerH;
return `<text x="${chartLeft - 6}" y="${(y + 3).toFixed(1)}" fill="${COLORS.textMuted}" font-family="system-ui,sans-serif" font-size="9" text-anchor="end">${formatNumber(v)}</text>`;
}).join('')}
${yAxisLabel ? `<text x="${chartLeft - 35}" y="${chartTop + innerH / 2}" fill="${COLORS.textMuted}" font-family="system-ui,-apple-system,sans-serif" font-size="11" text-anchor="middle" dominant-baseline="middle" transform="rotate(-90, ${chartLeft - 35}, ${chartTop + innerH / 2})">${yAxisLabel}</text>` : ''}
${xLabels.map(({ x, label }) => {
return `<text x="${x.toFixed(1)}" y="${cardY + cardHeight - 10}" fill="${COLORS.textMuted}" font-family="system-ui,sans-serif" font-size="9" text-anchor="middle">${label}</text>`;
}).join('')}
`;
}
const cardY = margin;
const card1X = margin;
const card2X = margin + cardWidth + gap;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="${COLORS.primary}" stop-opacity="0.2"/>
<stop offset="50%" stop-color="${COLORS.primary}" stop-opacity="0.08"/>
<stop offset="100%" stop-color="${COLORS.primary}" stop-opacity="0"/>
</linearGradient>
</defs>
${chartCard(starsData, card1X, cardY, 'assistant-ui/assistant-ui', ' GitHub Stars', formatNumber(currentStars) + ' total', 'grad', 'clip1', GITHUB_LOGO, null, 'Total')}
${chartCard(downloadsData, card2X, cardY, '@assistant-ui/react', ' npm Downloads', formatNumber(monthlyDownloads) + '/month', 'grad', 'clip2', NPM_LOGO, formatNumber(totalDownloads) + ' total', 'Weekly')}
</svg>`;
}
async function main() {
const [dailyData, stars] = await Promise.all([fetchNpmStats(PACKAGE_NAME), fetchCurrentStars(GITHUB_REPO)]);
const monthlyDownloads = getMonthlyRate(dailyData);
const totalDownloads = Object.values(dailyData).reduce((sum, v) => sum + v, 0);
const downloadsData = smoothData(aggregateByWeek(dailyData)).slice(0, -1);
const currentStars = stars || 0;
const starsData = currentStars ? await fetchStarHistory(GITHUB_REPO, currentStars) : [];
const svg = generateDualSVG(starsData, downloadsData, monthlyDownloads, currentStars, totalDownloads);
const png = await sharp(Buffer.from(svg), { density: 300 }).png().toBuffer();
fs.mkdirSync('.github/assets', { recursive: true });
fs.writeFileSync(OUTPUT_PATH, png);
console.log('Done');
}
main().catch(err => { console.error(err.message); process.exit(1); });
SCRIPT
- name: Commit and push changes
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add .github/assets/traction.png
if git diff --staged --quiet; then
echo "No changes to commit"
exit 0
fi
git checkout -B update-traction-chart
git commit -m "Update traction chart [skip ci]"
git push -f origin update-traction-chart
- name: Create or update Pull Request
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PREVIEW_URL="https://raw.githubusercontent.com/${{ github.repository }}/update-traction-chart/.github/assets/traction.png"
BODY="Automated weekly update of README traction chart.
![Traction Chart](${PREVIEW_URL})"
existing_pr=$(gh pr list --head update-traction-chart --json number --jq '.[0].number' || echo "")
if [ -n "$existing_pr" ]; then
gh pr edit "$existing_pr" --body "$BODY"
echo "Updated PR #$existing_pr"
else
gh pr create \
--title "Update README traction chart" \
--body "$BODY" \
--head update-traction-chart \
--base main
fi