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
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:
@@ -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(", ")}`);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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")"
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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}."
|
||||
@@ -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}."
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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.
|
||||
"
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user