Files
wehub-resource-sync e30e75b5d4
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
chore: import upstream snapshot with attribution
2026-07-13 13:40:13 +08:00

817 lines
30 KiB
YAML

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}."