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
817 lines
30 KiB
YAML
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}."
|