diff --git a/.changeset/shared-github-workflows.md b/.changeset/shared-github-workflows.md new file mode 100644 index 0000000..7c81f14 --- /dev/null +++ b/.changeset/shared-github-workflows.md @@ -0,0 +1,5 @@ +--- +"@zemd/gha": major +--- + +Publish the CI, CodeQL, Scorecard and release pipelines as reusable `shared-*.yml` workflows that other monorepos call by pinned SHA, and move the release tooling into this typed, tested package whose bundle is committed to `.github/scripts`. diff --git a/.gitattributes b/.gitattributes index 6313b56..132401d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,4 @@ * text=auto eol=lf + +# Built from internal/gha, collapsed in pull request diffs. +.github/scripts/*.mjs linguist-generated=true diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4b6cdd1..0337620 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1,6 @@ * @zemd + +# Consumed by other repositories through `uses: zemd/js/.github/workflows/shared-*.yml@`. +/.github/workflows/shared-*.yml @zemd +/.github/scripts/ @zemd +/internal/gha/ @zemd diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a7dd67b..c4c8f62 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,6 +10,7 @@ updates: - "/" - "/packages/*" - "/http-clients/*" + - "/internal/*" schedule: interval: "weekly" versioning-strategy: "increase" # the workspace pins exact versions (saveExact) diff --git a/.github/scripts/gha.mjs b/.github/scripts/gha.mjs new file mode 100644 index 0000000..ba8235c --- /dev/null +++ b/.github/scripts/gha.mjs @@ -0,0 +1,600 @@ +// Generated by `pnpm --filter @zemd/gha run build` from internal/gha/src. Do not edit. +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { execFileSync } from "node:child_process"; + +//#region src/env.ts +const requireEnv = (name) => { + const value = process.env[name]; + if (!value) throw new Error(`${name} must be set`); + return value; +}; +const optionalEnv = (name, fallback) => { + const value = process.env[name]; + return value ? value : fallback; +}; + +//#endregion +//#region src/changelog.ts +const changelogEntry = (packagePath, version) => { + let changelog; + try { + changelog = readFileSync(join(packagePath, "CHANGELOG.md"), "utf8"); + } catch { + return ""; + } + const lines = changelog.split("\n"); + const start = lines.findIndex((line) => line.trim().replace(/\s+-\s+\d{4}-\d{2}-\d{2}$/, "").replace(/[[\]]/g, "") === `## ${version}`); + if (start === -1) return ""; + const rest = lines.slice(start + 1); + const end = rest.findIndex((line) => line.startsWith("## ")); + return (end === -1 ? rest : rest.slice(0, end)).join("\n").replace(/^#{1,6}\s+(.+)$/gm, "**$1**").trim(); +}; + +//#endregion +//#region src/github-releases.ts +const RELEASE_TAG_PREFIX = "release-"; +const renderCombinedReleaseBody = ({ published, paths, notes }) => { + const out = []; + out.push("## Published packages"); + out.push(""); + out.push("| Package | Version |"); + out.push("| :--- | ---: |"); + for (const { name, version } of published) out.push(`| [\`${name}\`](https://www.npmjs.com/package/${name}) | \`${version}\` |`); + out.push(""); + out.push("### Changelogs"); + out.push(""); + for (const { name, version } of published) { + const packagePath = paths.get(name); + out.push("
"); + out.push(`${name}@${version}`); + out.push(""); + out.push("
"); + out.push(""); + out.push((packagePath ? changelogEntry(packagePath, version) : "") || "_No changelog entry recorded._"); + out.push(""); + out.push("
"); + out.push(""); + } + if (notes?.trim()) { + out.push("---"); + out.push(""); + out.push(notes.trim()); + } + return out.join("\n").trim(); +}; +const nextReleaseTag = async (api, now) => { + const stamp = now.toISOString().slice(0, 16).replace("T", "-").replace(":", ""); + const base = `${RELEASE_TAG_PREFIX}${stamp}`; + for (let counter = 1; counter < 100; counter += 1) { + const tag = counter === 1 ? base : `${base}.${counter}`; + if (!await api.tagExists(tag)) return tag; + } + throw new Error(`could not find a free release tag for ${base}`); +}; +const previousReleaseTag = async (api) => { + return (await api.listReleases()).filter((release) => release.tag_name.startsWith(RELEASE_TAG_PREFIX)).sort((a, b) => b.created_at.localeCompare(a.created_at))[0]?.tag_name ?? ""; +}; +const createTag = async (api, tag, sha) => { + const response = await api.createRef(`refs/tags/${tag}`, sha); + if (response.ok) { + console.log(`created tag ${tag}`); + return true; + } + if (response.status === 422 && await api.tagExists(tag)) { + console.log(`tag ${tag} already exists, skipping`); + return true; + } + console.error(`failed to create tag ${tag}:`, response.payload); + return false; +}; +const releasePublishedPackages = async ({ api, sha, published, workspace, now = /* @__PURE__ */ new Date() }) => { + if (published.length === 0) { + console.log("no packages were published, nothing to release"); + return; + } + const releases = [...published].sort((a, b) => a.name.localeCompare(b.name)); + const paths = new Map(workspace.map((entry) => [entry.name, entry.path])); + let failed = false; + for (const { name, version } of releases) if (!await createTag(api, `${name}@${version}`, sha)) failed = true; + const releaseTag = await nextReleaseTag(api, now); + const notes = await api.generateNotes(releaseTag, sha, await previousReleaseTag(api)); + const created = await api.createRelease({ + tag: releaseTag, + name: releaseTag, + targetCommitish: sha, + body: renderCombinedReleaseBody({ + published: releases, + paths, + notes + }), + prerelease: releases.every(({ version }) => version.includes("-")) + }); + if (created.ok) console.log(`created release ${releaseTag}`); + else { + failed = true; + console.error(`failed to create release ${releaseTag}:`, created.payload); + } + if (failed) throw new Error("one or more release steps failed"); +}; + +//#endregion +//#region src/pnpm.ts +const asArray = (value, context) => { + if (!Array.isArray(value)) throw new Error(`${context}: expected an array, got ${typeof value}`); + return value; +}; +const asRecord = (value, context) => { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${context}: expected an object, got ${JSON.stringify(value)}`); + return value; +}; +const asString = (source, key, context) => { + const value = source[key]; + if (typeof value !== "string") throw new Error(`${context}: expected "${key}" to be a string, got ${JSON.stringify(value)}`); + return value; +}; +const parseAppliedReleases = (json) => asArray(JSON.parse(json), "pnpm version -r --json").map((entry, index) => { + const context = `pnpm version -r --json[${index}]`; + const record = asRecord(entry, context); + return { + name: asString(record, "name", context), + currentVersion: asString(record, "currentVersion", context), + newVersion: asString(record, "newVersion", context) + }; +}); +const parseWorkspacePackages = (json) => asArray(JSON.parse(json), "pnpm list -r --json").map((entry, index) => { + const context = `pnpm list -r --json[${index}]`; + const record = asRecord(entry, context); + return { + name: asString(record, "name", context), + version: asString(record, "version", context), + path: asString(record, "path", context), + private: record["private"] === true + }; +}); +const parsePublishSummary = (json) => { + const published = asRecord(JSON.parse(json), "pnpm publish --report-summary")["publishedPackages"]; + if (published === void 0) return []; + return asArray(published, "pnpm publish --report-summary.publishedPackages").map((entry, index) => { + const context = `pnpm publish --report-summary.publishedPackages[${index}]`; + const record = asRecord(entry, context); + return { + name: asString(record, "name", context), + version: asString(record, "version", context) + }; + }); +}; + +//#endregion +//#region src/github.ts +const COMMIT_MUTATION = ` + mutation ($input: CreateCommitOnBranchInput!) { + createCommitOnBranch(input: $input) { + commit { + oid + } + } + } + `; +const createGitHubApi = (options) => { + const { token, repository } = options; + const apiUrl = options.apiUrl ?? "https://api.github.com"; + const graphqlUrl = options.graphqlUrl ?? "https://api.github.com/graphql"; + const doFetch = options.fetch ?? globalThis.fetch; + const requestUrl = async (url, method, body) => { + const response = await doFetch(url, { + method, + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "content-type": "application/json", + "x-github-api-version": "2022-11-28" + }, + ...body === void 0 ? {} : { body: JSON.stringify(body) } + }); + const text = await response.text(); + return { + ok: response.ok, + status: response.status, + payload: text ? JSON.parse(text) : {} + }; + }; + const request = (path, method, body) => requestUrl(`${apiUrl}${path}`, method, body); + const api = { + repository, + request, + graphql: (query, variables) => requestUrl(graphqlUrl, "POST", { + query, + variables + }), + tagExists: async (tag) => (await request(`/repos/${repository}/git/ref/tags/${encodeURIComponent(tag)}`, "GET")).ok, + createRef: (ref, sha) => request(`/repos/${repository}/git/refs`, "POST", { + ref, + sha + }), + updateRef: (ref, sha) => request(`/repos/${repository}/git/${ref}`, "PATCH", { + sha, + force: true + }), + listReleases: async () => { + const response = await request(`/repos/${repository}/releases?per_page=100`, "GET"); + return response.ok ? response.payload : []; + }, + generateNotes: async (tag, sha, previousTag) => { + const response = await request(`/repos/${repository}/releases/generate-notes`, "POST", { + tag_name: tag, + target_commitish: sha, + ...previousTag ? { previous_tag_name: previousTag } : {} + }); + if (!response.ok) { + console.warn(`failed to generate notes for ${tag}:`, response.payload); + return ""; + } + return (response.payload.body ?? "").trim(); + }, + createRelease: (release) => request(`/repos/${repository}/releases`, "POST", { + tag_name: release.tag, + name: release.name, + body: release.body, + draft: false, + prerelease: release.prerelease, + ...release.targetCommitish === void 0 ? {} : { target_commitish: release.targetCommitish } + }), + createCommitOnBranch: async (branch, expectedHeadOid, message, fileChanges) => { + const [headline, ...body] = message.split("\n\n"); + const rest = body.join("\n\n"); + const result = await api.graphql(COMMIT_MUTATION, { input: { + branch: { + repositoryNameWithOwner: repository, + branchName: branch + }, + expectedHeadOid, + message: { + headline, + ...rest ? { body: rest } : {} + }, + fileChanges + } }); + const oid = result.payload.data?.createCommitOnBranch.commit.oid; + if (!result.ok || result.payload.errors || !oid) throw new Error(`failed to create commit: ${JSON.stringify(result.payload.errors ?? result.payload)}`); + return oid; + } + }; + return api; +}; + +//#endregion +//#region src/commands/context.ts +const apiFromEnv = () => createGitHubApi({ + token: requireEnv("GITHUB_TOKEN"), + repository: requireEnv("GITHUB_REPOSITORY"), + apiUrl: optionalEnv("GITHUB_API_URL", "https://api.github.com"), + graphqlUrl: optionalEnv("GITHUB_GRAPHQL_URL", "https://api.github.com/graphql") +}); + +//#endregion +//#region src/commands/github-releases.ts +const githubReleases = { + usage: " ", + run: async (argv) => { + const [summaryPath, workspacePath] = argv; + if (!summaryPath || !workspacePath) throw new Error("usage: github-releases "); + await releasePublishedPackages({ + api: apiFromEnv(), + sha: requireEnv("GITHUB_SHA"), + published: parsePublishSummary(readFileSync(summaryPath, "utf8")), + workspace: parseWorkspacePackages(readFileSync(workspacePath, "utf8")) + }); + } +}; + +//#endregion +//#region src/semver.ts +const SEMVER = /^\d+\.\d+\.\d+$/; +const isReleaseVersion = (version) => SEMVER.test(version); +const parseVersion = (version) => { + const [core = "", ...prerelease] = version.split("-"); + const [major = 0, minor = 0, patch = 0] = core.split(".").map(Number); + return { + major, + minor, + patch, + prerelease: prerelease.join("-") + }; +}; +const bumpType = (from, to) => { + const a = parseVersion(from); + const b = parseVersion(to); + if (b.prerelease || a.prerelease) return "prerelease"; + if (b.major !== a.major) return "major"; + if (b.minor !== a.minor) return "minor"; + return "patch"; +}; + +//#endregion +//#region src/release-pr-body.ts +const badge = { + major: "**major**", + minor: "**minor**", + patch: "patch", + prerelease: "prerelease", + new: "first release" +}; +const prepare = (applied, workspace) => { + const packages = new Map(workspace.map((entry) => [entry.name, entry])); + return applied.map((release) => { + const entry = packages.get(release.name); + if (!entry) throw new Error(`release package ${release.name} is missing from the workspace snapshot`); + const firstRelease = release.currentVersion === release.newVersion; + return { + name: release.name, + path: entry.path, + from: firstRelease ? void 0 : release.currentVersion, + to: release.newVersion, + kind: firstRelease ? "new" : bumpType(release.currentVersion, release.newVersion), + internal: entry.private + }; + }).sort((a, b) => a.name.localeCompare(b.name)); +}; +const changelogDetails = (out, release) => { + const transition = release.from ? `${release.from} → ${release.to}` : `${release.to}`; + out.push("
"); + out.push(`${release.name}  ·  ${transition}`); + out.push(""); + out.push("
"); + out.push(""); + out.push(changelogEntry(release.path, release.to) || "_No changelog entry recorded._"); + out.push(""); + out.push("
"); + out.push(""); +}; +const renderReleasePrBody = (applied, workspace) => { + const prepared = prepare(applied, workspace); + const releases = prepared.filter((release) => !release.internal); + const internal = prepared.filter((release) => release.internal); + const out = []; + out.push("## Release summary"); + out.push(""); + if (releases.length === 0) out.push("No publishable packages were prepared for release."); + else { + const count = releases.length; + out.push(`\`pnpm version -r\` consumed the pending change intents and prepared **${count}** package${count === 1 ? "" : "s"} for release.`); + out.push("Merging this pull request publishes the versions listed below."); + out.push(""); + out.push("| Package | Bump | Current | Next |"); + out.push("| :--- | :---: | ---: | ---: |"); + for (const release of releases) { + const current = release.from ? `\`${release.from}\`` : "—"; + out.push(`| \`${release.name}\` | ${badge[release.kind]} | ${current} | \`${release.to}\` |`); + } + out.push(""); + out.push("### Changelogs"); + out.push(""); + for (const release of releases) changelogDetails(out, release); + } + if (internal.length > 0) { + out.push(""); + out.push("### Internal packages"); + out.push(""); + out.push("Not published to npm."); + out.push(""); + out.push("| Package | Bump | Current | Next |"); + out.push("| :--- | :---: | ---: | ---: |"); + for (const release of internal) { + const current = release.from ? `\`${release.from}\`` : "—"; + out.push(`| \`${release.name}\` | ${badge[release.kind]} | ${current} | \`${release.to}\` |`); + } + out.push(""); + for (const release of internal) changelogDetails(out, release); + } + out.push(""); + return `${out.join("\n")}\n`; +}; + +//#endregion +//#region src/commands/release-pr-body.ts +const releasePrBody = { + usage: " ", + run: (argv) => { + const [releasesPath, workspacePath] = argv; + if (!releasesPath || !workspacePath) throw new Error("usage: release-pr-body "); + process.stdout.write(renderReleasePrBody(parseAppliedReleases(readFileSync(releasesPath, "utf8")), parseWorkspacePackages(readFileSync(workspacePath, "utf8")))); + } +}; + +//#endregion +//#region src/shared-workflows.ts +const renderSharedReleaseBody = ({ repository, version, sha, workflows, changelog, notes }) => { + const major = version.split(".")[0]; + const out = []; + out.push("Shared GitHub Actions workflows. Add one job per workflow to a caller in"); + out.push("`.github/workflows/`, pinned to this commit:"); + out.push(""); + out.push("```yaml"); + for (const workflow of workflows) out.push(`uses: ${repository}/.github/workflows/${workflow}@${sha} # v${version}`); + out.push("```"); + out.push(""); + out.push(`Ready-to-copy callers: [\`.github/workflows-examples\`](https://github.com/${repository}/tree/${sha}/.github/workflows-examples).`); + out.push(""); + out.push("Re-resolve this commit at any time:"); + out.push(""); + out.push("```sh"); + out.push(`gh api repos/${repository}/git/ref/tags/v${major} --jq .object.sha`); + out.push("```"); + if (changelog?.trim()) { + out.push(""); + out.push("## Changes"); + out.push(""); + out.push(changelog.trim()); + } + if (notes?.trim()) { + out.push(""); + out.push("---"); + out.push(""); + out.push(notes.trim()); + } + return `${out.join("\n")}\n`; +}; +const putTag = async (api, tag, sha, force) => { + const created = await api.createRef(`refs/tags/${tag}`, sha); + if (created.ok) return true; + if (!force) { + console.error(`failed to create tag ${tag}:`, created.payload); + return false; + } + const updated = await api.updateRef(`refs/tags/${tag}`, sha); + if (updated.ok) return true; + console.error(`failed to move tag ${tag}:`, updated.payload); + return false; +}; +const releaseSharedWorkflows = async ({ api, sha, version, packagePath, workflows }) => { + if (!isReleaseVersion(version)) throw new Error(`expected a plain semver version, got "${version}"`); + if (workflows.length === 0) throw new Error("no shared-*.yml workflows found"); + const tag = `v${version}`; + const releases = await api.listReleases(); + if (releases.some((release) => release.tag_name === tag)) { + console.log(`${tag} already released, nothing to do`); + return; + } + if (!await api.tagExists(tag) && !await putTag(api, tag, sha, false)) throw new Error(`could not create ${tag}`); + if (!await putTag(api, `v${version.split(".")[0]}`, sha, true)) throw new Error(`could not move the major tag for ${tag}`); + const previousTag = releases.filter((release) => isReleaseVersion(release.tag_name.replace(/^v/, ""))).sort((a, b) => b.created_at.localeCompare(a.created_at))[0]?.tag_name ?? ""; + const created = await api.createRelease({ + tag, + name: `Shared workflows ${tag}`, + body: renderSharedReleaseBody({ + repository: api.repository, + version, + sha, + workflows, + changelog: changelogEntry(packagePath, version), + notes: await api.generateNotes(tag, sha, previousTag) + }), + prerelease: false + }); + if (!created.ok) throw new Error(`failed to create release ${tag}: ${JSON.stringify(created.payload)}`); + console.log(`created release ${tag} at ${sha}`); +}; + +//#endregion +//#region src/commands/shared-workflows-release.ts +const manifestVersion = (packagePath) => { + const manifest = JSON.parse(readFileSync(packagePath, "utf8")); + if (typeof manifest !== "object" || manifest === null || !("version" in manifest)) throw new Error(`${packagePath} has no version field`); + const { version } = manifest; + if (typeof version !== "string") throw new Error(`${packagePath}: expected a string version, got ${JSON.stringify(version)}`); + return version; +}; +const sharedWorkflowsRelease = { + usage: " ", + run: async (argv) => { + const [packagePath, workflowsDir] = argv; + if (!packagePath || !workflowsDir) throw new Error("usage: shared-workflows-release "); + await releaseSharedWorkflows({ + api: apiFromEnv(), + sha: requireEnv("GITHUB_SHA"), + version: manifestVersion(packagePath), + packagePath: dirname(packagePath), + workflows: readdirSync(workflowsDir).filter((file) => file.startsWith("shared-") && file.endsWith(".yml")).sort() + }); + } +}; + +//#endregion +//#region src/signed-commit.ts +const collectChanges = ({ git, read }) => { + const records = git([ + "status", + "--porcelain=v1", + "-z", + "--untracked-files=all" + ]).split("\0").filter(Boolean); + const added = /* @__PURE__ */ new Set(); + const deleted = /* @__PURE__ */ new Set(); + for (let index = 0; index < records.length; index += 1) { + const record = records[index] ?? ""; + const state = record.slice(0, 2); + const path = record.slice(3); + if (state.includes("R") || state.includes("C")) { + const origin = records[index + 1]; + index += 1; + if (state.includes("R") && origin) deleted.add(origin); + added.add(path); + continue; + } + if (state.includes("D")) { + deleted.add(path); + continue; + } + added.add(path); + } + const readFile = read ?? ((path) => readFileSync(path)); + return { + additions: [...added].map((path) => ({ + path, + contents: readFile(path).toString("base64") + })), + deletions: [...deleted].map((path) => ({ path })) + }; +}; +const createSignedCommit = async ({ api, git, branch, message, changes }) => { + if (changes.additions.length === 0 && changes.deletions.length === 0) throw new Error("nothing to commit"); + const baseOid = git(["rev-parse", "HEAD"]).trim(); + const ref = `refs/heads/${branch}`; + if (!(await api.createRef(ref, baseOid)).ok) { + const updated = await api.updateRef(ref, baseOid); + if (!updated.ok) throw new Error(`failed to point ${branch} at ${baseOid}: ${JSON.stringify(updated.payload)}`); + } + return api.createCommitOnBranch(branch, baseOid, message, changes); +}; + +//#endregion +//#region src/commands/signed-commit.ts +const signedCommit = { + usage: " ", + run: async (argv) => { + const [branch, message] = argv; + if (!branch || !message) throw new Error("usage: signed-commit "); + const git = (args) => execFileSync("git", [...args], { encoding: "utf8" }); + const oid = await createSignedCommit({ + api: apiFromEnv(), + git, + branch, + message, + changes: collectChanges({ git }) + }); + console.log(`created signed commit ${oid}`); + } +}; + +//#endregion +//#region src/commands/index.ts +const commands = { + "github-releases": githubReleases, + "release-pr-body": releasePrBody, + "shared-workflows-release": sharedWorkflowsRelease, + "signed-commit": signedCommit +}; +const usage = () => [ + "usage: gha.mjs [args]", + "", + ...Object.entries(commands).map(([name, command]) => ` ${name} ${command.usage}`) +].join("\n"); + +//#endregion +//#region src/cli.ts +const [name, ...argv] = process.argv.slice(2); +const command = name === void 0 ? void 0 : commands[name]; +if (!command) { + console.error(usage()); + process.exit(1); +} +try { + await command.run(argv); +} catch (error) { + console.error(error); + process.exitCode = 1; +} + +//#endregion +export { }; \ No newline at end of file diff --git a/.github/scripts/github-releases.mjs b/.github/scripts/github-releases.mjs deleted file mode 100644 index 0bf339f..0000000 --- a/.github/scripts/github-releases.mjs +++ /dev/null @@ -1,225 +0,0 @@ -#!/usr/bin/env node -// Tags the published commit once per package and publishes a single combined -// GitHub release for the run. `pnpm publish` only talks to the registry, it -// neither tags the commit nor creates releases. The release body lists every -// published package with its changelog entry, followed by GitHub's own -// automated release notes for the commits since the previous combined release. -// -// Usage: node .github/scripts/github-releases.mjs -// where the first file is written by `pnpm publish --report-summary` and the -// second is the output of `pnpm list -r --depth -1 --json`. -// Requires GITHUB_TOKEN (contents: write), GITHUB_REPOSITORY and GITHUB_SHA. - -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - -const RELEASE_TAG_PREFIX = "release-"; - -const [summaryPath, workspacePath] = process.argv.slice(2); - -if (!summaryPath || !workspacePath) { - console.error("usage: github-releases.mjs "); - process.exit(1); -} - -const token = process.env.GITHUB_TOKEN; -const repository = process.env.GITHUB_REPOSITORY; -const sha = process.env.GITHUB_SHA; -const apiUrl = process.env.GITHUB_API_URL ?? "https://api.github.com"; - -if (!token || !repository || !sha) { - console.error("GITHUB_TOKEN, GITHUB_REPOSITORY and GITHUB_SHA must be set"); - process.exit(1); -} - -/** - * @param {string} path - * @param {"GET" | "POST"} method - * @param {unknown} [body] - */ -const api = async (path, method, body) => { - const response = await fetch(`${apiUrl}${path}`, { - method, - headers: { - accept: "application/vnd.github+json", - authorization: `Bearer ${token}`, - "content-type": "application/json", - "x-github-api-version": "2022-11-28", - }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - }); - const text = await response.text(); - const payload = text ? JSON.parse(text) : {}; - return { ok: response.ok, status: response.status, payload }; -}; - -/** - * @param {string} packagePath - * @param {string} version - */ -const changelogEntry = (packagePath, version) => { - let changelog; - try { - changelog = readFileSync(join(packagePath, "CHANGELOG.md"), "utf8"); - } catch { - return ""; - } - const lines = changelog.split("\n"); - const start = lines.findIndex((line) => line.trim().replace(/[[\]]/g, "") === `## ${version}`); - if (start === -1) return ""; - const rest = lines.slice(start + 1); - const end = rest.findIndex((line) => line.startsWith("## ")); - // Headings would render oversized inside
, so demote them to bold. - return (end === -1 ? rest : rest.slice(0, end)) - .join("\n") - .replace(/^#{1,6}\s+(.+)$/gm, "**$1**") - .trim(); -}; - -/** @param {string} tag */ -const tagExists = async (tag) => { - const response = await api(`/repos/${repository}/git/ref/tags/${encodeURIComponent(tag)}`, "GET"); - return response.ok; -}; - -/** @param {string} tag */ -const createTag = async (tag) => { - const response = await api(`/repos/${repository}/git/refs`, "POST", { - ref: `refs/tags/${tag}`, - sha, - }); - if (response.ok) { - console.log(`created tag ${tag}`); - return true; - } - if (response.status === 422 && (await tagExists(tag))) { - console.log(`tag ${tag} already exists, skipping`); - return true; - } - console.error(`failed to create tag ${tag}:`, response.payload); - return false; -}; - -// Tag of the previous combined release, so the generated notes cover exactly -// the commits released since then. -const previousReleaseTag = async () => { - const response = await api(`/repos/${repository}/releases?per_page=100`, "GET"); - if (!response.ok) return ""; - /** @type {Array<{ tag_name: string, created_at: string }>} */ - const releases = response.payload; - return ( - releases - .filter((release) => release.tag_name.startsWith(RELEASE_TAG_PREFIX)) - .sort((a, b) => b.created_at.localeCompare(a.created_at))[0]?.tag_name ?? "" - ); -}; - -// Same notes GitHub renders behind "Generate release notes" in the UI. -/** - * @param {string} tag - * @param {string} previousTag - */ -const generatedNotes = async (tag, previousTag) => { - const response = await api(`/repos/${repository}/releases/generate-notes`, "POST", { - tag_name: tag, - target_commitish: sha, - ...(previousTag ? { previous_tag_name: previousTag } : {}), - }); - if (!response.ok) { - console.warn(`failed to generate notes for ${tag}:`, response.payload); - return ""; - } - return (response.payload.body ?? "").trim(); -}; - -// `release-YYYY-MM-DD-HHmm`, suffixed with `.2`, `.3`, ... when that tag is -// taken (git tags cannot contain `:`, hence the compact time). -const nextReleaseTag = async () => { - const stamp = new Date().toISOString().slice(0, 16).replace("T", "-").replace(":", ""); - const base = `${RELEASE_TAG_PREFIX}${stamp}`; - for (let counter = 1; counter < 100; counter += 1) { - const tag = counter === 1 ? base : `${base}.${counter}`; - if (!(await tagExists(tag))) return tag; - } - console.error(`could not find a free release tag for ${base}`); - process.exit(1); -}; - -/** @type {{ publishedPackages?: Array<{ name: string, version: string }> }} */ -const summary = JSON.parse(readFileSync(summaryPath, "utf8")); -const published = summary.publishedPackages ?? []; - -if (published.length === 0) { - console.log("no packages were published, nothing to release"); - process.exit(0); -} - -/** @type {Array<{ name: string, path: string }>} */ -const workspace = JSON.parse(readFileSync(workspacePath, "utf8")); -const paths = new Map(workspace.map((entry) => [entry.name, entry.path])); - -const releases = [...published].sort((a, b) => a.name.localeCompare(b.name)); - -let failed = false; - -for (const { name, version } of releases) { - if (!(await createTag(`${name}@${version}`))) failed = true; -} - -const releaseTag = await nextReleaseTag(); -const previousTag = await previousReleaseTag(); - -const out = []; - -out.push("## Published packages"); -out.push(""); -out.push("| Package | Version |"); -out.push("| :--- | ---: |"); - -for (const { name, version } of releases) { - out.push(`| [\`${name}\`](https://www.npmjs.com/package/${name}) | \`${version}\` |`); -} - -out.push(""); -out.push("### Changelogs"); -out.push(""); - -for (const { name, version } of releases) { - const packagePath = paths.get(name); - const entry = packagePath ? changelogEntry(packagePath, version) : ""; - out.push("
"); - out.push(`${name}@${version}`); - out.push(""); - out.push("
"); - out.push(""); - out.push(entry || "_No changelog entry recorded._"); - out.push(""); - out.push("
"); - out.push(""); -} - -const notes = await generatedNotes(releaseTag, previousTag); -if (notes) { - out.push("---"); - out.push(""); - out.push(notes); -} - -// `target_commitish` makes GitHub create the tag when it does not exist yet. -const created = await api(`/repos/${repository}/releases`, "POST", { - tag_name: releaseTag, - target_commitish: sha, - name: releaseTag, - body: out.join("\n").trim(), - draft: false, - prerelease: releases.every(({ version }) => version.includes("-")), -}); - -if (created.ok) { - console.log(`created release ${releaseTag}`); -} else { - failed = true; - console.error(`failed to create release ${releaseTag}:`, created.payload); -} - -if (failed) process.exit(1); diff --git a/.github/scripts/release-pr-body.mjs b/.github/scripts/release-pr-body.mjs deleted file mode 100644 index 902d332..0000000 --- a/.github/scripts/release-pr-body.mjs +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env node -// Renders the body of the automated release pull request from the releases -// applied by `pnpm version -r --json` and the current workspace package list. -// -// Usage: node .github/scripts/release-pr-body.mjs -// where the first file is written by `pnpm version -r --json` and the second -// is the output of `pnpm list -r --depth -1 --json`. - -import { readFileSync } from "node:fs"; -import { join, resolve } from "node:path"; -import { pathToFileURL } from "node:url"; - -/** @param {string} version */ -const parseVersion = (version) => { - const [core = "", ...prerelease] = version.split("-"); - const [major = 0, minor = 0, patch = 0] = core.split(".").map(Number); - return { major, minor, patch, prerelease: prerelease.join("-") }; -}; - -/** - * @param {string} from - * @param {string} to - */ -const bumpType = (from, to) => { - const a = parseVersion(from); - const b = parseVersion(to); - if (b.prerelease || a.prerelease) return "prerelease"; - if (b.major !== a.major) return "major"; - if (b.minor !== a.minor) return "minor"; - return "patch"; -}; - -/** - * @param {string} packagePath - * @param {string} version - */ -const changelogEntry = (packagePath, version) => { - let changelog; - try { - changelog = readFileSync(join(packagePath, "CHANGELOG.md"), "utf8"); - } catch { - return ""; - } - const lines = changelog.split("\n"); - const start = lines.findIndex((line) => line.trim().replace(/[[\]]/g, "") === `## ${version}`); - if (start === -1) return ""; - const rest = lines.slice(start + 1); - const end = rest.findIndex((line) => line.startsWith("## ")); - // Headings would render oversized inside
, so demote them to bold. - return (end === -1 ? rest : rest.slice(0, end)) - .join("\n") - .replace(/^#{1,6}\s+(.+)$/gm, "**$1**") - .trim(); -}; - -const badge = { - major: "**major**", - minor: "**minor**", - patch: "patch", - prerelease: "prerelease", - new: "first release", -}; - -/** - * @param {Array<{ name: string, currentVersion: string, newVersion: string }>} applied - * @param {Array<{ name: string, path: string, private?: boolean }>} workspace - */ -export const renderReleasePrBody = (applied, workspace) => { - const packages = new Map( - workspace.filter((entry) => !entry.private).map((entry) => [entry.name, entry]), - ); - const releases = applied - .map((release) => { - const entry = packages.get(release.name); - if (!entry) { - throw new Error(`release package ${release.name} is missing from the workspace snapshot`); - } - const firstRelease = release.currentVersion === release.newVersion; - return { - name: release.name, - path: entry.path, - from: firstRelease ? undefined : release.currentVersion, - to: release.newVersion, - kind: firstRelease ? "new" : bumpType(release.currentVersion, release.newVersion), - }; - }) - .sort((a, b) => a.name.localeCompare(b.name)); - - const out = []; - - out.push("## Release summary"); - out.push(""); - - if (releases.length === 0) { - out.push("No publishable packages were prepared for release."); - } else { - const count = releases.length; - out.push( - `\`pnpm version -r\` consumed the pending change intents and prepared **${count}** package${count === 1 ? "" : "s"} for release.`, - ); - out.push("Merging this pull request publishes the versions listed below."); - out.push(""); - out.push("| Package | Bump | Current | Next |"); - out.push("| :--- | :---: | ---: | ---: |"); - - for (const release of releases) { - const current = release.from ? `\`${release.from}\`` : "—"; - out.push(`| \`${release.name}\` | ${badge[release.kind]} | ${current} | \`${release.to}\` |`); - } - - out.push(""); - out.push("### Changelogs"); - out.push(""); - - for (const release of releases) { - const entry = changelogEntry(release.path, release.to); - const transition = release.from - ? `${release.from} → ${release.to}` - : `${release.to}`; - out.push("
"); - out.push( - `${release.name}  ·  ${transition}`, - ); - out.push(""); - out.push("
"); - out.push(""); - out.push(entry || "_No changelog entry recorded._"); - out.push(""); - out.push("
"); - out.push(""); - } - } - - out.push(""); - - return `${out.join("\n")}\n`; -}; - -const main = () => { - const [releasesPath, workspacePath] = process.argv.slice(2); - - if (!releasesPath || !workspacePath) { - console.error("usage: release-pr-body.mjs "); - process.exit(1); - } - - /** @type {Array<{ name: string, currentVersion: string, newVersion: string }>} */ - const releases = JSON.parse(readFileSync(releasesPath, "utf8")); - /** @type {Array<{ name: string, path: string, private?: boolean }>} */ - const workspace = JSON.parse(readFileSync(workspacePath, "utf8")); - - process.stdout.write(renderReleasePrBody(releases, workspace)); -}; - -if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { - main(); -} diff --git a/.github/scripts/release-pr-body.test.mjs b/.github/scripts/release-pr-body.test.mjs deleted file mode 100644 index 37e4f27..0000000 --- a/.github/scripts/release-pr-body.test.mjs +++ /dev/null @@ -1,66 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import test from "node:test"; - -import { renderReleasePrBody } from "./release-pr-body.mjs"; - -const packageFixture = (t, changelog) => { - const directory = mkdtempSync(join(tmpdir(), "release-pr-body-")); - t.after(() => rmSync(directory, { recursive: true })); - writeFileSync(join(directory, "CHANGELOG.md"), changelog); - return directory; -}; - -void test("renders a same-version unpublished package as a first release", (t) => { - const path = packageFixture( - t, - `# @zemd/new-package - -## 0.0.0 - -### Major Changes - -- Publish the initial package. -`, - ); - - const body = renderReleasePrBody( - [{ name: "@zemd/new-package", currentVersion: "0.0.0", newVersion: "0.0.0" }], - [{ name: "@zemd/new-package", version: "0.0.0", path }], - ); - - assert.match(body, /prepared \*\*1\*\* package for release/); - assert.ok(body.includes("| `@zemd/new-package` | first release | — | `0.0.0` |")); - assert.ok(body.includes("**Major Changes**")); - assert.ok(body.includes("Publish the initial package.")); -}); - -void test("renders an ordinary version bump from pnpm's applied release data", (t) => { - const path = packageFixture( - t, - `# example - -## [2.0.0] - -### Major Changes - -- Break the old API. - -## 1.2.3 - -- Previous release. -`, - ); - - const body = renderReleasePrBody( - [{ name: "example", currentVersion: "1.2.3", newVersion: "2.0.0" }], - [{ name: "example", version: "2.0.0", path }], - ); - - assert.ok(body.includes("| `example` | **major** | `1.2.3` | `2.0.0` |")); - assert.ok(body.includes("1.2.3 → 2.0.0")); - assert.ok(body.includes("Break the old API.")); - assert.ok(!body.includes("Previous release.")); -}); diff --git a/.github/scripts/release-workflow.test.mjs b/.github/scripts/release-workflow.test.mjs deleted file mode 100644 index 60313fe..0000000 --- a/.github/scripts/release-workflow.test.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import test from "node:test"; - -const workflow = readFileSync(new URL("../workflows/release.yml", import.meta.url), "utf8"); - -void test("keeps OIDC with an npm token fallback for first publishes", () => { - assert.match(workflow, /id-token: write # npm trusted publishing \(OIDC\)/); - assert.match(workflow, /registry-url: "https:\/\/registry\.npmjs\.org"/); - assert.match( - workflow, - /- name: Publish to npm\n\s+env:\n(?:\s+#.*\n){2}\s+NODE_AUTH_TOKEN: \$\{\{ secrets\.NPM_TOKEN \}\}\n\s+run: pnpm publish -r/, - ); -}); diff --git a/.github/scripts/signed-commit.mjs b/.github/scripts/signed-commit.mjs deleted file mode 100644 index 418a73b..0000000 --- a/.github/scripts/signed-commit.mjs +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env node -// Publishes the current working-tree changes as a single commit on a branch via -// the GitHub GraphQL API. Commits created through the API are signed with -// GitHub's own key, so they show up as "Verified" instead of unsigned. -// -// Usage: node .github/scripts/signed-commit.mjs -// Requires GITHUB_TOKEN (contents: write) and GITHUB_REPOSITORY. - -import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; - -const [branch, message] = process.argv.slice(2); - -if (!branch || !message) { - console.error("usage: signed-commit.mjs "); - process.exit(1); -} - -const token = process.env.GITHUB_TOKEN; -const repository = process.env.GITHUB_REPOSITORY; -const apiUrl = process.env.GITHUB_API_URL ?? "https://api.github.com"; - -if (!token || !repository) { - console.error("GITHUB_TOKEN and GITHUB_REPOSITORY must be set"); - process.exit(1); -} - -/** - * @param {string} path - * @param {"POST" | "PATCH"} method - * @param {unknown} [body] - */ -const api = async (path, method, body) => { - const response = await fetch(`${apiUrl}${path}`, { - method, - headers: { - accept: "application/vnd.github+json", - authorization: `Bearer ${token}`, - "content-type": "application/json", - "x-github-api-version": "2022-11-28", - }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - }); - const text = await response.text(); - const payload = text ? JSON.parse(text) : {}; - return { ok: response.ok, status: response.status, payload }; -}; - -/** @param {string[]} args */ -const git = (args) => execFileSync("git", args, { encoding: "utf8" }).trim(); - -// `-z` records are NUL separated; a rename record is followed by an extra -// record holding the original path. -const collectChanges = () => { - const raw = execFileSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { - encoding: "utf8", - }); - const records = raw.split("\0").filter(Boolean); - - /** @type {Set} */ - const added = new Set(); - /** @type {Set} */ - const deleted = new Set(); - - for (let index = 0; index < records.length; index += 1) { - const record = records[index] ?? ""; - const state = record.slice(0, 2); - const path = record.slice(3); - - if (state[0] === "R" || state[0] === "C") { - const origin = records[index + 1]; - index += 1; - if (state[0] === "R" && origin) deleted.add(origin); - added.add(path); - continue; - } - - if (state.includes("D")) { - deleted.add(path); - continue; - } - - added.add(path); - } - - return { - additions: [...added].map((path) => ({ - path, - contents: readFileSync(path).toString("base64"), - })), - deletions: [...deleted].map((path) => ({ path })), - }; -}; - -const fileChanges = collectChanges(); - -if (fileChanges.additions.length === 0 && fileChanges.deletions.length === 0) { - console.error("nothing to commit"); - process.exit(1); -} - -// The API commit is built on top of the checked-out commit, so the remote -// branch has to be reset to that same commit first. -const baseOid = git(["rev-parse", "HEAD"]); -const ref = `refs/heads/${branch}`; - -const created = await api(`/repos/${repository}/git/refs`, "POST", { ref, sha: baseOid }); -if (!created.ok) { - const updated = await api(`/repos/${repository}/git/${ref}`, "PATCH", { - sha: baseOid, - force: true, - }); - if (!updated.ok) { - console.error(`failed to point ${branch} at ${baseOid}:`, updated.payload); - process.exit(1); - } -} - -const [headline, ...body] = message.split("\n\n"); - -const result = await api("/graphql", "POST", { - query: ` - mutation ($input: CreateCommitOnBranchInput!) { - createCommitOnBranch(input: $input) { - commit { - oid - } - } - } - `, - variables: { - input: { - branch: { repositoryNameWithOwner: repository, branchName: branch }, - expectedHeadOid: baseOid, - message: { headline, body: body.join("\n\n") || undefined }, - fileChanges, - }, - }, -}); - -if (!result.ok || result.payload.errors) { - console.error("failed to create commit:", result.payload.errors ?? result.payload); - process.exit(1); -} - -console.log(`created signed commit ${result.payload.data.createCommitOnBranch.commit.oid}`); diff --git a/.github/workflows-examples/README.md b/.github/workflows-examples/README.md new file mode 100644 index 0000000..bceb3a8 --- /dev/null +++ b/.github/workflows-examples/README.md @@ -0,0 +1,78 @@ +# Shared workflows + +Copy-paste callers for the reusable workflows published from this repository. + +| File | Calls | Purpose | +| :----------------------------------- | :--------------------- | :------------------------------------------------------------------------- | +| [`ci.yml`](./ci.yml) | `shared-ci.yml` | Lint, format, typecheck, build, test matrix, Playwright, dependency review | +| [`release.yml`](./release.yml) | `shared-release.yml` | Release pull request, npm publish, git tags, GitHub release | +| [`codeql.yml`](./codeql.yml) | `shared-codeql.yml` | CodeQL analysis | +| [`scorecard.yml`](./scorecard.yml) | `shared-scorecard.yml` | OpenSSF Scorecard | +| [`dependabot.yml`](./dependabot.yml) | — | Keeps the pinned SHAs current | + +They assume a pnpm workspace with `lint-check`, `format-check`, `typecheck`, +`build`, `test` and `lint-publish` scripts in the root `package.json`. Any of +those can be renamed or disabled through workflow inputs — see the commented +`with:` block in each file. + +## Install + +1. Copy the four workflow files into `.github/workflows/` of the target + repository and `dependabot.yml` into `.github/`. + +2. Replace the `__SHA__` placeholder with the commit of the release you want: + + ```sh + SHA="$(gh api repos/zemd/js/git/ref/tags/v1 --jq .object.sha)" + sed -i.bak "s|__SHA__|${SHA}|g" .github/workflows/*.yml && rm .github/workflows/*.bak + ``` + + Every [`v*` release](https://github.com/zemd/js/releases) also lists the + `uses:` lines already pinned, ready to copy. + +3. Adjust the `with:` inputs if the repository's scripts differ from the + defaults, and delete the workflows you do not need. + +Dependabot rewrites both the SHA and the trailing `# v1` comment from then on. +When it updates `release.yml`, keep `shared-tooling-ref` equal to the SHA in the +`uses:` line so the release scripts and reusable workflow stay on one revision. + +## Release setup + +`shared-release.yml` expects [`pnpm change`](https://pnpm.io) intents on `main`. +On every push it either opens/refreshes a `release/main` pull request, or — when +no intents are pending — publishes, tags and creates a combined GitHub release. + +For npm **trusted publishing**: + +- Keep the caller named `release.yml`. npm validates the calling workflow's + filename, not the reusable workflow that runs the publish. +- Register the trusted publisher per package with the _consumer_ repository and + `release.yml`. +- `id-token: write` must be granted by the caller job, which the example does. +- Keep `NPM_TOKEN` until every package exists on npm; a trusted publisher cannot + be configured for a package that was never published. +- `repository.url` in each `package.json` must match the repository exactly. + +## Repository settings + +- **Settings → Actions → General → Actions permissions** must allow actions and + reusable workflows from outside the repository. +- Both this repository and the consumer are public, so no extra access policy is + needed. A public repository can only call reusable workflows that live in + public repositories. +- GitHub does not follow redirects for reusable workflows: renaming `zemd/js` + breaks every consumer. + +## Notes + +- `env` set at the caller's workflow level is **not** propagated into a reusable + workflow. Pass an input instead. +- A called workflow's `github.workflow` is the _caller's_ name, so do not reuse + the same `concurrency.group` on both sides with `cancel-in-progress: true`. +- Permissions can only be narrowed by the called workflow, never widened, which + is why each example declares them on the calling job. +- `shared-release.yml` checks out the explicit `shared-tooling-repository` and + `shared-tooling-ref` into `.shared-ci/` to reach the bundled `gha.mjs` CLI. + Keep the ref equal to the SHA that pins the reusable workflow. The checkout is + added to `.git/info/exclude` so it can never land in a release commit. diff --git a/.github/workflows-examples/ci.yml b/.github/workflows-examples/ci.yml new file mode 100644 index 0000000..8b4727f --- /dev/null +++ b/.github/workflows-examples/ci.yml @@ -0,0 +1,38 @@ +name: CI + +permissions: {} + +on: + pull_request: + branches: + - main + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci: + name: CI + uses: zemd/js/.github/workflows/shared-ci.yml@__SHA__ # v1 + permissions: + contents: read + # Every input is optional; the defaults below match the shared workflow. + # with: + # node-version: "lts/*" + # os-matrix: '["ubuntu-latest", "macos-latest", "windows-latest"]' + # lint-script: lint-check + # format-script: format-check + # typecheck-script: typecheck + # build-script: build + # test-script: test + # publint-script: lint-publish + # report-change-intents: true + # dependency-review: true + # dependency-review-severity: high + # dependency-review-scopes: "runtime, development, unknown" + # + # Opt in to Playwright by naming the script and the package that owns the + # playwright dependency. Leave `browser-test-script` empty to skip the job. + # browser-test-script: test-browser + # playwright-filter: "@acme/browser-tests" diff --git a/.github/workflows-examples/codeql.yml b/.github/workflows-examples/codeql.yml new file mode 100644 index 0000000..9d67128 --- /dev/null +++ b/.github/workflows-examples/codeql.yml @@ -0,0 +1,29 @@ +name: CodeQL + +permissions: {} + +on: + push: + branches: + - main + pull_request: + branches: + - main + schedule: + # Catches advisories published after the last commit. + - cron: "24 5 * * 1" + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + +jobs: + codeql: + name: CodeQL + uses: zemd/js/.github/workflows/shared-codeql.yml@__SHA__ # v1 + permissions: + contents: read + actions: read + security-events: write + # with: + # languages: '["actions", "javascript-typescript"]' diff --git a/.github/workflows-examples/dependabot.yml b/.github/workflows-examples/dependabot.yml new file mode 100644 index 0000000..08696f5 --- /dev/null +++ b/.github/workflows-examples/dependabot.yml @@ -0,0 +1,27 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directories: # Every workspace project plus the root manifest + - "/" + - "/packages/*" + schedule: + interval: "weekly" + versioning-strategy: "increase" # the workspace pins exact versions (saveExact) + open-pull-requests-limit: 10 + groups: + production-dependencies: + dependency-type: "production" + development-dependencies: + dependency-type: "development" + + # Also keeps the `zemd/js/.github/workflows/shared-*.yml@` pins current, + # rewriting both the SHA and the `# v1` comment. + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows-examples/release.yml b/.github/workflows-examples/release.yml new file mode 100644 index 0000000..5e2b4b7 --- /dev/null +++ b/.github/workflows-examples/release.yml @@ -0,0 +1,36 @@ +# Keep this file named `release.yml`: npm trusted publishing validates the +# *calling* workflow filename, not the reusable workflow that runs `npm publish`. +name: Release + +permissions: {} + +on: + push: + branches: + - main + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + release: + name: Release + uses: zemd/js/.github/workflows/shared-release.yml@__SHA__ # v1 + permissions: + contents: write # release branch, git tags and GitHub releases + pull-requests: write # open and refresh the release pull request + id-token: write # npm trusted publishing (OIDC) + with: + # Keep this SHA equal to the one in `uses` above. + shared-tooling-repository: zemd/js + shared-tooling-ref: __SHA__ + # node-version: "lts/*" + # base-branch: main + # release-branch: release/main + # release-title: "chore(release): version packages" + # build-script: build + # publint-script: lint-publish + # registry-url: "https://registry.npmjs.org" + secrets: + # Only needed until every package exists on npm: a trusted publisher + # cannot be configured for a package that has never been published. + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows-examples/scorecard.yml b/.github/workflows-examples/scorecard.yml new file mode 100644 index 0000000..74f06eb --- /dev/null +++ b/.github/workflows-examples/scorecard.yml @@ -0,0 +1,28 @@ +name: Scorecard + +permissions: {} + +on: + push: + branches: + - main + branch_protection_rule: + schedule: + - cron: "41 5 * * 1" + +concurrency: + group: scorecard-${{ github.ref }} + cancel-in-progress: true + +jobs: + scorecard: + name: Scorecard + uses: zemd/js/.github/workflows/shared-scorecard.yml@__SHA__ # v1 + permissions: + contents: read + actions: read + security-events: write + id-token: write + # with: + # publish-results: true + # artifact-retention-days: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3576bf3..7bb19ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,87 +11,18 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true -env: - # https://consoledonottrack.com - opts out of Turborepo and other CLI telemetry - DO_NOT_TRACK: 1 - jobs: - quality: - name: Lint & Format - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Checkout Repo - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Setup pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - with: - run_install: false - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: "lts/*" - cache: "pnpm" - - - name: Install Dependencies - run: pnpm install --frozen-lockfile --prefer-offline - - - name: Lint - run: pnpm run lint-check - - - name: Check formatting - run: pnpm run format-check - - - name: Report pending release intents - run: pnpm change status - - test: - name: "Build & Test: ${{ matrix.os }}" - runs-on: ${{ matrix.os }} + ci: + name: CI + uses: ./.github/workflows/shared-ci.yml permissions: contents: read + with: + browser-test-script: test-browser + playwright-filter: "@zemd/std-modules" - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - - steps: - - name: Checkout Repo - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Setup pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - with: - run_install: false - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: "lts/*" - cache: "pnpm" - - - name: Install Dependencies - run: pnpm install --frozen-lockfile --prefer-offline - - - name: Typecheck - run: pnpm run typecheck - - - name: Build - run: pnpm run build - - - name: Run tests - run: pnpm run test - - - name: Validate publishable packages - run: pnpm run lint-publish - - browser: - name: Browser Tests + shared-version: + name: Shared workflow contract runs-on: ubuntu-latest permissions: contents: read @@ -99,6 +30,9 @@ jobs: steps: - name: Checkout Repo uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false - name: Setup pnpm uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 @@ -114,43 +48,38 @@ jobs: - name: Install Dependencies run: pnpm install --frozen-lockfile --prefer-offline - - name: Resolve Playwright version - id: playwright - run: echo "version=$(pnpm --filter @zemd/std-modules exec playwright --version | awk '{print $NF}')" >> "$GITHUB_OUTPUT" - - - name: Restore Playwright browsers - id: playwright-cache - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ steps.playwright.outputs.version }}-chromium - - - name: Install Playwright browsers - if: steps.playwright-cache.outputs.cache-hit != 'true' - run: pnpm --filter @zemd/std-modules exec playwright install --with-deps chromium - - # Cached browser binaries still need the OS-level libraries they link against. - - name: Install Playwright system dependencies - if: steps.playwright-cache.outputs.cache-hit == 'true' - run: pnpm --filter @zemd/std-modules exec playwright install-deps chromium - - - name: Run browser tests - run: pnpm run test-browser - - dependency-review: - name: Dependency Review - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Checkout Repo - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Review dependency changes - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 - with: - fail-on-severity: high - fail-on-scopes: runtime, development, unknown + # .github/scripts is committed build output, so a stale copy would ship + # different behaviour than internal/gha/src describes. + - name: Check the committed tooling matches its source + run: | + pnpm --filter @zemd/gha run build + if [ -n "$(git status --porcelain -- .github/scripts)" ]; then + echo "::error::.github/scripts is stale, rebuild @zemd/gha and commit the result" + git --no-pager diff -- .github/scripts + exit 1 + fi + + # Consumers pin `shared-*.yml` by SHA, so a behaviour change that skips the + # version bump would never reach them through the moving `vX` tag. + - name: Require a release intent when the shared contract changes + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + if [ -z "$BASE_SHA" ] || [ -z "$HEAD_SHA" ]; then + echo "pull request SHAs unavailable; skipping shared contract check" + exit 0 + fi + changed="$(git diff --name-only "$BASE_SHA...$HEAD_SHA" -- \ + '.github/workflows/shared-*.yml' .github/scripts)" + if [ -z "$changed" ]; then + echo "shared contract untouched" + exit 0 + fi + if ! grep -rlF '"@zemd/gha"' .changeset >/dev/null 2>&1; then + echo "::error::Add a change intent for @zemd/gha, these shared files changed:" + printf '%s\n' "$changed" + exit 1 + fi + echo "release intent recorded alongside:" + printf '%s\n' "$changed" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 86ff4ea..09bcc42 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -18,35 +18,10 @@ concurrency: cancel-in-progress: true jobs: - analyze: - name: "Analyze: ${{ matrix.language }}" - runs-on: ubuntu-latest + codeql: + name: CodeQL + uses: ./.github/workflows/shared-codeql.yml permissions: contents: read actions: read security-events: write - - strategy: - fail-fast: false - matrix: - include: - - language: actions - - language: javascript-typescript - - steps: - - name: Checkout Repo - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Initialize CodeQL - uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 - with: - languages: ${{ matrix.language }} - # Interpreted languages need no compilation step. - build-mode: none - - - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 - with: - category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab7ad25..01df9b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,129 +9,38 @@ on: concurrency: ${{ github.workflow }}-${{ github.ref }} -env: - # https://consoledonottrack.com - opts out of Turborepo and other CLI telemetry - DO_NOT_TRACK: 1 - jobs: - version: - name: Version - runs-on: ubuntu-latest + release: + name: Release + uses: ./.github/workflows/shared-release.yml permissions: contents: write pull-requests: write - outputs: - pending: ${{ steps.version.outputs.pending }} - steps: - - name: Checkout Repo - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - - name: Setup pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - with: - run_install: false - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: "lts/*" - cache: "pnpm" - - - name: Install Dependencies - run: pnpm install --frozen-lockfile --prefer-offline - - # Consumes the change intents in .changeset/, bumps every affected package - # and its workspace dependents, and writes the changelog entries. - - name: Apply pending release intents - id: version - run: | - pnpm version -r --json > "${RUNNER_TEMP}/releases.json" - pnpm install --lockfile-only - if [ -n "$(git status --porcelain)" ]; then - echo "pending=true" >> "$GITHUB_OUTPUT" - else - echo "pending=false" >> "$GITHUB_OUTPUT" - fi - - - name: Render release pull request body - if: steps.version.outputs.pending == 'true' - run: | - pnpm list -r --depth -1 --json > "${RUNNER_TEMP}/workspace.json" - node .github/scripts/release-pr-body.mjs \ - "${RUNNER_TEMP}/releases.json" \ - "${RUNNER_TEMP}/workspace.json" > "${RUNNER_TEMP}/pr-body.md" - cat "${RUNNER_TEMP}/pr-body.md" >> "$GITHUB_STEP_SUMMARY" - - - name: Open release pull request - if: steps.version.outputs.pending == 'true' - env: - GH_TOKEN: ${{ github.token }} - GITHUB_TOKEN: ${{ github.token }} - run: | - node .github/scripts/signed-commit.mjs release/main "chore(release): version packages" - pr="$(gh pr list --head release/main --base main --state open --json number --jq '.[].number')" - if [ -z "$pr" ]; then - gh pr create \ - --base main \ - --head release/main \ - --title "chore(release): version packages" \ - --body-file "${RUNNER_TEMP}/pr-body.md" - else - gh pr edit "$pr" --body-file "${RUNNER_TEMP}/pr-body.md" - fi - - # No intents were pending, so main already holds the released versions. - # `pnpm publish -r` skips anything the registry already serves. - publish: - name: Publish - needs: version - if: needs.version.outputs.pending == 'false' + id-token: write + with: + shared-tooling-repository: ${{ github.repository }} + shared-tooling-ref: ${{ github.sha }} + secrets: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + # Publishes the `shared-*.yml` contract itself: a `vX.Y.Z` tag, a moving `vX` + # tag and a release whose body carries SHA-pinned `uses:` lines to copy. + shared-workflows: + name: Publish shared workflows + needs: release + if: needs.release.outputs.pending == 'false' runs-on: ubuntu-latest permissions: - contents: write # create git tags and GitHub releases - id-token: write # npm trusted publishing (OIDC) + contents: write + steps: - name: Checkout Repo uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Setup pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: - run_install: false - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: "lts/*" - cache: "pnpm" - # OIDC cannot authenticate the first publish because an npm package - # must exist before its trusted publisher can be configured. - registry-url: "https://registry.npmjs.org" - - - name: Install Dependencies - run: pnpm install --frozen-lockfile --prefer-offline - - - name: Build - run: pnpm run build - - - name: Validate publishable packages - run: pnpm run lint-publish - - - name: Publish to npm - env: - # pnpm prefers OIDC when it succeeds and falls back to this token for - # the first publish of a package that does not exist in npm yet. - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: pnpm publish -r --access public --no-git-checks --report-summary + persist-credentials: false - # pnpm only talks to the registry, so tags and the release are created here. - - name: Tag packages and create GitHub release + - name: Tag and release the shared workflows env: GITHUB_TOKEN: ${{ github.token }} run: | - pnpm list -r --depth -1 --json > "${RUNNER_TEMP}/versions.json" - node .github/scripts/github-releases.mjs \ - pnpm-publish-summary.json \ - "${RUNNER_TEMP}/versions.json" + node .github/scripts/gha.mjs shared-workflows-release internal/gha/package.json .github/workflows diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 4106a93..8541078 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -15,38 +15,11 @@ concurrency: cancel-in-progress: true jobs: - analysis: - name: Scorecard analysis - runs-on: ubuntu-latest + scorecard: + name: Scorecard + uses: ./.github/workflows/shared-scorecard.yml permissions: contents: read - # Read the workflow run history that the Dangerous-Workflow checks rely on. actions: read security-events: write - # Publish the result to the OpenSSF REST API. id-token: write - - steps: - - name: Checkout Repo - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Run analysis - uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 - with: - results_file: results.sarif - results_format: sarif - publish_results: true - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: scorecard-results - path: results.sarif - retention-days: 5 - - - name: Upload to code scanning - uses: github/codeql-action/upload-sarif@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 - with: - sarif_file: results.sarif diff --git a/.github/workflows/shared-ci.yml b/.github/workflows/shared-ci.yml new file mode 100644 index 0000000..597f10f --- /dev/null +++ b/.github/workflows/shared-ci.yml @@ -0,0 +1,251 @@ +name: Shared CI + +# Shared pull request pipeline for the pnpm + turbo monorepos. +# Call it from a repository with: +# uses: zemd/js/.github/workflows/shared-ci.yml@ # v1 +# See .github/workflows-examples/ for ready-to-copy caller workflows. + +permissions: {} + +on: + workflow_call: + inputs: + node-version: + description: Node.js version passed to actions/setup-node. + type: string + default: "lts/*" + os-matrix: + description: JSON array of runner labels for the build & test matrix. + type: string + default: '["ubuntu-latest", "macos-latest", "windows-latest"]' + lint-script: + description: package.json script that lints. Empty skips the step. + type: string + default: "lint-check" + format-script: + description: package.json script that checks formatting. Empty skips the step. + type: string + default: "format-check" + typecheck-script: + description: package.json script that typechecks. Empty skips the step. + type: string + default: "typecheck" + build-script: + description: package.json script that builds. Empty skips the step. + type: string + default: "build" + test-script: + description: package.json script that runs the test suite. Empty skips the step. + type: string + default: "test" + publint-script: + description: package.json script that validates publishable packages. Empty skips the step. + type: string + default: "lint-publish" + browser-test-script: + description: package.json script that runs Playwright tests. Empty skips the whole job. + type: string + default: "" + playwright-filter: + description: pnpm --filter selector for the package that owns the Playwright dependency. + type: string + default: "" + report-change-intents: + description: Report pending `pnpm change` release intents in the job log. + type: boolean + default: true + dependency-review: + description: Run actions/dependency-review-action on pull requests. + type: boolean + default: true + dependency-review-severity: + description: Lowest advisory severity that fails the dependency review. + type: string + default: "high" + dependency-review-scopes: + description: Dependency scopes the review applies to. + type: string + default: "runtime, development, unknown" + +# Not inherited from the caller: `env` defined at the caller's workflow level is +# never propagated into a reusable workflow. +env: + # https://consoledonottrack.com - opts out of Turborepo and other CLI telemetry + DO_NOT_TRACK: 1 + +# Windows runners default to pwsh, where `$VAR` is not an environment variable. +defaults: + run: + shell: bash + +jobs: + quality: + name: Lint & Format + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ inputs.node-version }} + cache: "pnpm" + + - name: Install Dependencies + run: pnpm install --frozen-lockfile --prefer-offline + + - name: Lint + if: inputs.lint-script != '' + env: + SCRIPT: ${{ inputs.lint-script }} + run: pnpm run "$SCRIPT" + + - name: Check formatting + if: inputs.format-script != '' + env: + SCRIPT: ${{ inputs.format-script }} + run: pnpm run "$SCRIPT" + + - name: Report pending release intents + if: inputs.report-change-intents + run: pnpm change status + + test: + name: "Build & Test: ${{ matrix.os }}" + runs-on: ${{ matrix.os }} + permissions: + contents: read + + strategy: + fail-fast: false + matrix: + os: ${{ fromJSON(inputs.os-matrix) }} + + steps: + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ inputs.node-version }} + cache: "pnpm" + + - name: Install Dependencies + run: pnpm install --frozen-lockfile --prefer-offline + + - name: Typecheck + if: inputs.typecheck-script != '' + env: + SCRIPT: ${{ inputs.typecheck-script }} + run: pnpm run "$SCRIPT" + + - name: Build + if: inputs.build-script != '' + env: + SCRIPT: ${{ inputs.build-script }} + run: pnpm run "$SCRIPT" + + - name: Run tests + if: inputs.test-script != '' + env: + SCRIPT: ${{ inputs.test-script }} + run: pnpm run "$SCRIPT" + + - name: Validate publishable packages + if: inputs.publint-script != '' + env: + SCRIPT: ${{ inputs.publint-script }} + run: pnpm run "$SCRIPT" + + browser: + name: Browser Tests + if: inputs.browser-test-script != '' + runs-on: ubuntu-latest + permissions: + contents: read + + env: + PLAYWRIGHT_FILTER: ${{ inputs.playwright-filter }} + + steps: + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ inputs.node-version }} + cache: "pnpm" + + - name: Install Dependencies + run: pnpm install --frozen-lockfile --prefer-offline + + - name: Resolve Playwright version + id: playwright + run: echo "version=$(pnpm --filter "$PLAYWRIGHT_FILTER" exec playwright --version | awk '{print $NF}')" >> "$GITHUB_OUTPUT" + + - name: Restore Playwright browsers + id: playwright-cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ steps.playwright.outputs.version }}-chromium + + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: pnpm --filter "$PLAYWRIGHT_FILTER" exec playwright install --with-deps chromium + + # Cached browser binaries still need the OS-level libraries they link against. + - name: Install Playwright system dependencies + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: pnpm --filter "$PLAYWRIGHT_FILTER" exec playwright install-deps chromium + + - name: Run browser tests + env: + SCRIPT: ${{ inputs.browser-test-script }} + run: pnpm run "$SCRIPT" + + dependency-review: + name: Dependency Review + if: inputs.dependency-review && github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Review dependency changes + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + with: + fail-on-severity: ${{ inputs.dependency-review-severity }} + fail-on-scopes: ${{ inputs.dependency-review-scopes }} diff --git a/.github/workflows/shared-codeql.yml b/.github/workflows/shared-codeql.yml new file mode 100644 index 0000000..f89b117 --- /dev/null +++ b/.github/workflows/shared-codeql.yml @@ -0,0 +1,47 @@ +name: Shared CodeQL + +# Shared CodeQL analysis. +# Call it from a repository with: +# uses: zemd/js/.github/workflows/shared-codeql.yml@ # v1 + +permissions: {} + +on: + workflow_call: + inputs: + languages: + description: JSON array of CodeQL languages to analyze. + type: string + default: '["actions", "javascript-typescript"]' + +jobs: + analyze: + name: "Analyze: ${{ matrix.language }}" + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: ${{ fromJSON(inputs.languages) }} + + steps: + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + with: + languages: ${{ matrix.language }} + # Interpreted languages need no compilation step. + build-mode: none + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/shared-release.yml b/.github/workflows/shared-release.yml new file mode 100644 index 0000000..d0e45f7 --- /dev/null +++ b/.github/workflows/shared-release.yml @@ -0,0 +1,229 @@ +name: Shared Release + +# Shared release pipeline: opens/refreshes a release pull request while intents +# are pending, and publishes to npm once main holds the released versions. +# Call it from a repository with: +# uses: zemd/js/.github/workflows/shared-release.yml@ # v1 +# +# npm trusted publishing validates the *caller* workflow filename, so name the +# caller `release.yml` and register that filename as the trusted publisher. + +permissions: {} + +on: + workflow_call: + inputs: + shared-tooling-repository: + description: Repository containing the scripts bundled with this reusable workflow. + type: string + required: true + shared-tooling-ref: + description: Commit SHA for the shared tooling; keep it equal to the workflow's pinned SHA. + type: string + required: true + node-version: + description: Node.js version passed to actions/setup-node. + type: string + default: "lts/*" + release-branch: + description: Branch that carries the version bump commit. + type: string + default: "release/main" + release-title: + description: Commit message and title of the release pull request. + type: string + default: "chore(release): version packages" + base-branch: + description: Branch the release pull request targets. + type: string + default: "main" + build-script: + description: package.json script that builds before publishing. Empty skips the step. + type: string + default: "build" + publint-script: + description: package.json script that validates publishable packages. Empty skips the step. + type: string + default: "lint-publish" + registry-url: + description: Registry written to .npmrc so the NODE_AUTH_TOKEN fallback works. + type: string + default: "https://registry.npmjs.org" + secrets: + NPM_TOKEN: + description: Fallback token for the first publish of a package that does not exist on npm yet. + required: false + outputs: + pending: + description: "'true' when the run opened or refreshed a release pull request instead of publishing." + value: ${{ jobs.version.outputs.pending }} + +# Not inherited from the caller: `env` defined at the caller's workflow level is +# never propagated into a reusable workflow. +env: + # https://consoledonottrack.com - opts out of Turborepo and other CLI telemetry + DO_NOT_TRACK: 1 + # This workflow's own repository checked out next to the caller's, so the + # release CLI is available even though `actions/checkout` above pulled the + # *caller* repository. + SHARED_CLI: .shared-ci/.github/scripts/gha.mjs + +jobs: + version: + name: Version + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + outputs: + pending: ${{ steps.version.outputs.pending }} + steps: + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ inputs.node-version }} + cache: "pnpm" + + - name: Install Dependencies + run: pnpm install --frozen-lockfile --prefer-offline + + # Consumes the change intents in .changeset/, bumps every affected package + # and its workspace dependents, and writes the changelog entries. + - name: Apply pending release intents + id: version + run: | + pnpm version -r --json > "${RUNNER_TEMP}/releases.json" + pnpm install --lockfile-only + if [ -n "$(git status --porcelain)" ]; then + echo "pending=true" >> "$GITHUB_OUTPUT" + else + echo "pending=false" >> "$GITHUB_OUTPUT" + fi + + # Kept out of git's view so it never lands in the release commit that + # signed-commit.mjs builds from the working tree. + - name: Ignore the shared tooling checkout + if: steps.version.outputs.pending == 'true' + run: echo "/.shared-ci/" >> .git/info/exclude + + - name: Checkout shared tooling + if: steps.version.outputs.pending == 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The caller must pass the repository and commit SHA matching its + # pinned workflow reference, so the scripts use the same revision. + repository: ${{ inputs.shared-tooling-repository }} + ref: ${{ inputs.shared-tooling-ref }} + path: .shared-ci + persist-credentials: false + + - name: Render release pull request body + if: steps.version.outputs.pending == 'true' + run: | + pnpm list -r --depth -1 --json > "${RUNNER_TEMP}/workspace.json" + node "${SHARED_CLI}" release-pr-body \ + "${RUNNER_TEMP}/releases.json" \ + "${RUNNER_TEMP}/workspace.json" > "${RUNNER_TEMP}/pr-body.md" + cat "${RUNNER_TEMP}/pr-body.md" >> "$GITHUB_STEP_SUMMARY" + + - name: Open release pull request + if: steps.version.outputs.pending == 'true' + env: + GH_TOKEN: ${{ github.token }} + GITHUB_TOKEN: ${{ github.token }} + RELEASE_BRANCH: ${{ inputs.release-branch }} + RELEASE_TITLE: ${{ inputs.release-title }} + BASE_BRANCH: ${{ inputs.base-branch }} + run: | + node "${SHARED_CLI}" signed-commit "$RELEASE_BRANCH" "$RELEASE_TITLE" + pr="$(gh pr list --head "$RELEASE_BRANCH" --base "$BASE_BRANCH" --state open --json number --jq '.[].number')" + if [ -z "$pr" ]; then + gh pr create \ + --base "$BASE_BRANCH" \ + --head "$RELEASE_BRANCH" \ + --title "$RELEASE_TITLE" \ + --body-file "${RUNNER_TEMP}/pr-body.md" + else + gh pr edit "$pr" --body-file "${RUNNER_TEMP}/pr-body.md" + fi + + # No intents were pending, so main already holds the released versions. + # `pnpm publish -r` skips anything the registry already serves. + publish: + name: Publish + needs: version + if: needs.version.outputs.pending == 'false' + runs-on: ubuntu-latest + permissions: + contents: write # create git tags and GitHub releases + id-token: write # npm trusted publishing (OIDC) + steps: + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ inputs.node-version }} + cache: "pnpm" + # OIDC cannot authenticate the first publish because an npm package + # must exist before its trusted publisher can be configured. + registry-url: ${{ inputs.registry-url }} + + - name: Install Dependencies + run: pnpm install --frozen-lockfile --prefer-offline + + - name: Build + if: inputs.build-script != '' + env: + SCRIPT: ${{ inputs.build-script }} + run: pnpm run "$SCRIPT" + + - name: Validate publishable packages + if: inputs.publint-script != '' + env: + SCRIPT: ${{ inputs.publint-script }} + run: pnpm run "$SCRIPT" + + - name: Publish to npm + env: + # pnpm prefers OIDC when it succeeds and falls back to this token for + # the first publish of a package that does not exist in npm yet. + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: pnpm publish -r --access public --no-git-checks --report-summary + + - name: Checkout shared tooling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ inputs.shared-tooling-repository }} + ref: ${{ inputs.shared-tooling-ref }} + path: .shared-ci + persist-credentials: false + + # pnpm only talks to the registry, so tags and the release are created here. + - name: Tag packages and create GitHub release + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + pnpm list -r --depth -1 --json > "${RUNNER_TEMP}/versions.json" + node "${SHARED_CLI}" github-releases \ + pnpm-publish-summary.json \ + "${RUNNER_TEMP}/versions.json" diff --git a/.github/workflows/shared-scorecard.yml b/.github/workflows/shared-scorecard.yml new file mode 100644 index 0000000..df6efde --- /dev/null +++ b/.github/workflows/shared-scorecard.yml @@ -0,0 +1,56 @@ +name: Shared Scorecard + +# Shared OpenSSF Scorecard analysis. +# Call it from a repository with: +# uses: zemd/js/.github/workflows/shared-scorecard.yml@ # v1 + +permissions: {} + +on: + workflow_call: + inputs: + publish-results: + description: Publish the result to the OpenSSF REST API so the badge stays current. + type: boolean + default: true + artifact-retention-days: + description: How long the raw SARIF artifact is kept. + type: number + default: 5 + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-latest + permissions: + contents: read + # Read the workflow run history that the Dangerous-Workflow checks rely on. + actions: read + security-events: write + # Publish the result to the OpenSSF REST API. + id-token: write + + steps: + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run analysis + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: results.sarif + results_format: sarif + publish_results: ${{ inputs.publish-results }} + + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: scorecard-results + path: results.sarif + retention-days: ${{ inputs.artifact-retention-days }} + + - name: Upload to code scanning + uses: github/codeql-action/upload-sarif@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + with: + sarif_file: results.sarif diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..5c8adb7 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +# Generated from internal/gha, see internal/gha/scripts/sync.ts +.github/scripts/ diff --git a/internal/gha/README.md b/internal/gha/README.md new file mode 100644 index 0000000..abca312 --- /dev/null +++ b/internal/gha/README.md @@ -0,0 +1,40 @@ +# @zemd/gha + +The `gha` CLI executed by the shared GitHub Actions workflows. + +Private: it is never published to npm. Instead `pnpm --filter @zemd/gha run build` +bundles `src/cli.ts` into a single `dist/gha.mjs` and syncs it to +`.github/scripts/gha.mjs`, which **is** committed. + +That indirection exists because `shared-release.yml` runs the CLI from a bare +checkout of this repository at the revision a consumer pinned — no package +manager, no `node_modules`, so it has to be one self-contained file. Committing +it also means a behaviour change shows up as a diff under `.github/scripts`, +which is what CI keys on to require a release intent for this package. + +Its version is the shared workflow contract version: the release workflow tags +`vX.Y.Z` and moves `vX` to match. + +## Commands + +``` +gha.mjs github-releases +gha.mjs release-pr-body +gha.mjs shared-workflows-release +gha.mjs signed-commit +``` + +Each lives in `src/commands/` as a thin argument-parsing adapter over a tested +module in `src/`. A test cross-checks that every command the workflows invoke is +registered. + +## Working on it + +```sh +pnpm --filter @zemd/gha run test +pnpm --filter @zemd/gha run build # regenerate .github/scripts, then commit it +``` + +Never edit `.github/scripts` by hand. CI rebuilds and fails on any difference. + +`scripts/sync.ts` runs under Node's type stripping, so keep its syntax erasable. diff --git a/internal/gha/package.json b/internal/gha/package.json new file mode 100644 index 0000000..4c87266 --- /dev/null +++ b/internal/gha/package.json @@ -0,0 +1,31 @@ +{ + "name": "@zemd/gha", + "version": "1.0.0", + "private": true, + "description": "Release tooling executed by the shared GitHub Actions workflows", + "license": "Apache-2.0", + "author": { + "name": "Dmytro Zelenetskyi", + "email": "oss@zemd.dev", + "url": "https://zemd.dev" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/zemd/js.git", + "directory": "internal/gha" + }, + "type": "module", + "scripts": { + "build": "tsdown && node ./scripts/sync.ts", + "dev": "tsdown --watch", + "test": "vitest --run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "catalog:", + "@zemd/tsconfig": "workspace:*", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/internal/gha/scripts/sync.ts b/internal/gha/scripts/sync.ts new file mode 100644 index 0000000..81f28f1 --- /dev/null +++ b/internal/gha/scripts/sync.ts @@ -0,0 +1,33 @@ +// Copies the bundled CLI into .github/scripts, where the shared workflows run it +// straight from a checkout. The output is committed so a change to the tooling +// is visible to the release-intent guard in CI. +// +// Runs under Node's type stripping, so keep the syntax erasable. +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const BANNER = + "// Generated by `pnpm --filter @zemd/gha run build` from internal/gha/src. Do not edit.\n"; +const BUNDLE = "gha.mjs"; + +const dist: string = fileURLToPath(new URL("../dist/", import.meta.url)); +const target: string = fileURLToPath(new URL("../../../.github/scripts/", import.meta.url)); +const bundle: string = join(dist, BUNDLE); + +if (!existsSync(bundle)) { + throw new Error(`${bundle} is missing, run tsdown first`); +} + +mkdirSync(target, { recursive: true }); + +// Drops files left behind by an earlier output layout. +for (const file of readdirSync(target)) { + if (file !== BUNDLE) { + rmSync(join(target, file), { recursive: true }); + } +} + +writeFileSync(join(target, BUNDLE), BANNER + readFileSync(bundle, "utf8")); + +console.log(`synced ${BUNDLE} to .github/scripts`); diff --git a/internal/gha/src/changelog.ts b/internal/gha/src/changelog.ts new file mode 100644 index 0000000..bc36afd --- /dev/null +++ b/internal/gha/src/changelog.ts @@ -0,0 +1,32 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +// Pulls the section for `version` out of a Keep a Changelog style file. Heading +// levels are demoted to bold because the entry is rendered inside
, +// where a real heading would dwarf the summary line. +export const changelogEntry = (packagePath: string, version: string): string => { + let changelog: string; + try { + changelog = readFileSync(join(packagePath, "CHANGELOG.md"), "utf8"); + } catch { + return ""; + } + + const lines = changelog.split("\n"); + const start = lines.findIndex( + (line) => + line + .trim() + .replace(/\s+-\s+\d{4}-\d{2}-\d{2}$/, "") + .replace(/[[\]]/g, "") === `## ${version}`, + ); + if (start === -1) return ""; + + const rest = lines.slice(start + 1); + const end = rest.findIndex((line) => line.startsWith("## ")); + + return (end === -1 ? rest : rest.slice(0, end)) + .join("\n") + .replace(/^#{1,6}\s+(.+)$/gm, "**$1**") + .trim(); +}; diff --git a/internal/gha/src/cli.ts b/internal/gha/src/cli.ts new file mode 100644 index 0000000..65d4e76 --- /dev/null +++ b/internal/gha/src/cli.ts @@ -0,0 +1,16 @@ +import { commands, usage } from "./commands"; + +const [name, ...argv] = process.argv.slice(2); +const command = name === undefined ? undefined : commands[name]; + +if (!command) { + console.error(usage()); + process.exit(1); +} + +try { + await command.run(argv); +} catch (error) { + console.error(error); + process.exitCode = 1; +} diff --git a/internal/gha/src/commands/command.ts b/internal/gha/src/commands/command.ts new file mode 100644 index 0000000..1033b37 --- /dev/null +++ b/internal/gha/src/commands/command.ts @@ -0,0 +1,5 @@ +export interface Command { + /** Argument list shown in the CLI usage text. */ + readonly usage: string; + readonly run: (argv: readonly string[]) => void | Promise; +} diff --git a/internal/gha/src/commands/context.test.ts b/internal/gha/src/commands/context.test.ts new file mode 100644 index 0000000..c512c26 --- /dev/null +++ b/internal/gha/src/commands/context.test.ts @@ -0,0 +1,29 @@ +import { afterEach, expect, test, vi } from "vitest"; + +import { apiFromEnv } from "./context"; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +test("uses the REST and GraphQL endpoints provided by GitHub Actions", async () => { + vi.stubEnv("GITHUB_TOKEN", "secret"); + vi.stubEnv("GITHUB_REPOSITORY", "acme/repo"); + vi.stubEnv("GITHUB_API_URL", "https://github.example.com/api/v3"); + vi.stubEnv("GITHUB_GRAPHQL_URL", "https://github.example.com/api/graphql"); + + const fetch = vi + .fn() + .mockImplementation(async () => new Response(JSON.stringify({}), { status: 200 })); + vi.stubGlobal("fetch", fetch); + + const api = apiFromEnv(); + await api.request("/user", "GET"); + await api.graphql("query { viewer { login } }", {}); + + expect(fetch.mock.calls.map(([url]) => url)).toEqual([ + "https://github.example.com/api/v3/user", + "https://github.example.com/api/graphql", + ]); +}); diff --git a/internal/gha/src/commands/context.ts b/internal/gha/src/commands/context.ts new file mode 100644 index 0000000..f915efd --- /dev/null +++ b/internal/gha/src/commands/context.ts @@ -0,0 +1,10 @@ +import { optionalEnv, requireEnv } from "../env"; +import { createGitHubApi, type GitHubApi } from "../github"; + +export const apiFromEnv = (): GitHubApi => + createGitHubApi({ + token: requireEnv("GITHUB_TOKEN"), + repository: requireEnv("GITHUB_REPOSITORY"), + apiUrl: optionalEnv("GITHUB_API_URL", "https://api.github.com"), + graphqlUrl: optionalEnv("GITHUB_GRAPHQL_URL", "https://api.github.com/graphql"), + }); diff --git a/internal/gha/src/commands/github-releases.ts b/internal/gha/src/commands/github-releases.ts new file mode 100644 index 0000000..9722f4f --- /dev/null +++ b/internal/gha/src/commands/github-releases.ts @@ -0,0 +1,27 @@ +import { readFileSync } from "node:fs"; + +import { requireEnv } from "../env"; +import { releasePublishedPackages } from "../github-releases"; +import { parsePublishSummary, parseWorkspacePackages } from "../pnpm"; +import type { Command } from "./command"; +import { apiFromEnv } from "./context"; + +// Tags the published commit once per package and publishes a single combined +// GitHub release for the run. +export const githubReleases: Command = { + usage: " ", + run: async (argv) => { + const [summaryPath, workspacePath] = argv; + + if (!summaryPath || !workspacePath) { + throw new Error("usage: github-releases "); + } + + await releasePublishedPackages({ + api: apiFromEnv(), + sha: requireEnv("GITHUB_SHA"), + published: parsePublishSummary(readFileSync(summaryPath, "utf8")), + workspace: parseWorkspacePackages(readFileSync(workspacePath, "utf8")), + }); + }, +}; diff --git a/internal/gha/src/commands/index.test.ts b/internal/gha/src/commands/index.test.ts new file mode 100644 index 0000000..fc4646c --- /dev/null +++ b/internal/gha/src/commands/index.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "vitest"; + +import { commands, usage } from "./index"; + +test("exposes every release step the shared workflows need", () => { + expect(Object.keys(commands).sort()).toEqual([ + "github-releases", + "release-pr-body", + "shared-workflows-release", + "signed-commit", + ]); +}); + +test("lists every command with its arguments in the usage text", () => { + const text = usage(); + + expect(text).toContain("usage: gha.mjs [args]"); + for (const [name, command] of Object.entries(commands)) { + expect(text).toContain(` ${name} ${command.usage}`); + } +}); + +test("rejects a command invoked without its arguments", async () => { + for (const [name, command] of Object.entries(commands)) { + await expect( + async () => command.run([]), + `${name} must reject an empty argument list`, + ).rejects.toThrow(new RegExp(`usage: ${name}`)); + } +}); diff --git a/internal/gha/src/commands/index.ts b/internal/gha/src/commands/index.ts new file mode 100644 index 0000000..688d708 --- /dev/null +++ b/internal/gha/src/commands/index.ts @@ -0,0 +1,19 @@ +import type { Command } from "./command"; +import { githubReleases } from "./github-releases"; +import { releasePrBody } from "./release-pr-body"; +import { sharedWorkflowsRelease } from "./shared-workflows-release"; +import { signedCommit } from "./signed-commit"; + +export const commands: Readonly> = { + "github-releases": githubReleases, + "release-pr-body": releasePrBody, + "shared-workflows-release": sharedWorkflowsRelease, + "signed-commit": signedCommit, +}; + +export const usage = (): string => + [ + "usage: gha.mjs [args]", + "", + ...Object.entries(commands).map(([name, command]) => ` ${name} ${command.usage}`), + ].join("\n"); diff --git a/internal/gha/src/commands/release-pr-body.ts b/internal/gha/src/commands/release-pr-body.ts new file mode 100644 index 0000000..a08641e --- /dev/null +++ b/internal/gha/src/commands/release-pr-body.ts @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs"; + +import { parseAppliedReleases, parseWorkspacePackages } from "../pnpm"; +import { renderReleasePrBody } from "../release-pr-body"; +import type { Command } from "./command"; + +// Renders the body of the automated release pull request from the releases +// applied by `pnpm version -r --json` and the current workspace package list. +export const releasePrBody: Command = { + usage: " ", + run: (argv) => { + const [releasesPath, workspacePath] = argv; + + if (!releasesPath || !workspacePath) { + throw new Error("usage: release-pr-body "); + } + + process.stdout.write( + renderReleasePrBody( + parseAppliedReleases(readFileSync(releasesPath, "utf8")), + parseWorkspacePackages(readFileSync(workspacePath, "utf8")), + ), + ); + }, +}; diff --git a/internal/gha/src/commands/shared-workflows-release.ts b/internal/gha/src/commands/shared-workflows-release.ts new file mode 100644 index 0000000..c773898 --- /dev/null +++ b/internal/gha/src/commands/shared-workflows-release.ts @@ -0,0 +1,47 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname } from "node:path"; + +import { requireEnv } from "../env"; +import { releaseSharedWorkflows } from "../shared-workflows"; +import type { Command } from "./command"; +import { apiFromEnv } from "./context"; + +const manifestVersion = (packagePath: string): string => { + const manifest: unknown = JSON.parse(readFileSync(packagePath, "utf8")); + + if (typeof manifest !== "object" || manifest === null || !("version" in manifest)) { + throw new Error(`${packagePath} has no version field`); + } + + const { version } = manifest as { version: unknown }; + + if (typeof version !== "string") { + throw new Error(`${packagePath}: expected a string version, got ${JSON.stringify(version)}`); + } + + return version; +}; + +// Publishes the shared workflow contract: a `vX.Y.Z` tag, a moving `vX` tag and +// a GitHub release whose body carries `uses:` lines already pinned to this +// commit. The version is @zemd/gha's, which is what the workflows execute. +export const sharedWorkflowsRelease: Command = { + usage: " ", + run: async (argv) => { + const [packagePath, workflowsDir] = argv; + + if (!packagePath || !workflowsDir) { + throw new Error("usage: shared-workflows-release "); + } + + await releaseSharedWorkflows({ + api: apiFromEnv(), + sha: requireEnv("GITHUB_SHA"), + version: manifestVersion(packagePath), + packagePath: dirname(packagePath), + workflows: readdirSync(workflowsDir) + .filter((file) => file.startsWith("shared-") && file.endsWith(".yml")) + .sort(), + }); + }, +}; diff --git a/internal/gha/src/commands/signed-commit.ts b/internal/gha/src/commands/signed-commit.ts new file mode 100644 index 0000000..cd3c317 --- /dev/null +++ b/internal/gha/src/commands/signed-commit.ts @@ -0,0 +1,30 @@ +import { execFileSync } from "node:child_process"; + +import { collectChanges, createSignedCommit, type GitRunner } from "../signed-commit"; +import type { Command } from "./command"; +import { apiFromEnv } from "./context"; + +// Publishes the current working-tree changes as a single signed commit on a +// branch via the GitHub GraphQL API. +export const signedCommit: Command = { + usage: " ", + run: async (argv) => { + const [branch, message] = argv; + + if (!branch || !message) { + throw new Error("usage: signed-commit "); + } + + const git: GitRunner = (args) => execFileSync("git", [...args], { encoding: "utf8" }); + + const oid = await createSignedCommit({ + api: apiFromEnv(), + git, + branch, + message, + changes: collectChanges({ git }), + }); + + console.log(`created signed commit ${oid}`); + }, +}; diff --git a/internal/gha/src/env.ts b/internal/gha/src/env.ts new file mode 100644 index 0000000..aceea06 --- /dev/null +++ b/internal/gha/src/env.ts @@ -0,0 +1,12 @@ +export const requireEnv = (name: string): string => { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} must be set`); + } + return value; +}; + +export const optionalEnv = (name: string, fallback: string): string => { + const value = process.env[name]; + return value ? value : fallback; +}; diff --git a/internal/gha/src/github-releases.test.ts b/internal/gha/src/github-releases.test.ts new file mode 100644 index 0000000..294df00 --- /dev/null +++ b/internal/gha/src/github-releases.test.ts @@ -0,0 +1,133 @@ +import { expect, test } from "vitest"; + +import { + nextReleaseTag, + previousReleaseTag, + releasePublishedPackages, + renderCombinedReleaseBody, +} from "./github-releases"; +import { fakeGitHub } from "./testing/fake-github"; + +const SHA = "b".repeat(40); +const NOW = new Date("2026-08-05T09:41:07.000Z"); + +test("renders one npm link and one changelog block per package", () => { + const body = renderCombinedReleaseBody({ + published: [{ name: "@acme/one", version: "1.0.0" }], + paths: new Map([["@acme/one", "/nowhere"]]), + notes: "## What's Changed", + }); + + expect(body).toContain("| [`@acme/one`](https://www.npmjs.com/package/@acme/one) | `1.0.0` |"); + expect(body).toContain("@acme/one@1.0.0"); + expect(body).toContain("_No changelog entry recorded._"); + expect(body).toContain("## What's Changed"); +}); + +test("builds a minute-stamped release tag", async () => { + const github = fakeGitHub(); + + expect(await nextReleaseTag(github.api, NOW)).toBe("release-2026-08-05-0941"); +}); + +test("suffixes the release tag when the minute is taken", async () => { + const github = fakeGitHub({ + existingTags: ["release-2026-08-05-0941", "release-2026-08-05-0941.2"], + }); + + expect(await nextReleaseTag(github.api, NOW)).toBe("release-2026-08-05-0941.3"); +}); + +test("picks the newest previous combined release", async () => { + const github = fakeGitHub({ + releases: [ + { tag_name: "release-2026-01-01-0000", created_at: "2026-01-01T00:00:00Z" }, + { tag_name: "v1.0.0", created_at: "2026-07-01T00:00:00Z" }, + { tag_name: "release-2026-06-01-0000", created_at: "2026-06-01T00:00:00Z" }, + ], + }); + + expect(await previousReleaseTag(github.api)).toBe("release-2026-06-01-0000"); +}); + +test("tags every published package and creates one combined release", async () => { + const github = fakeGitHub(); + + await releasePublishedPackages({ + api: github.api, + sha: SHA, + published: [ + { name: "@acme/two", version: "2.0.0" }, + { name: "@acme/one", version: "1.0.0" }, + ], + workspace: [], + now: NOW, + }); + + expect(github.createdRefs.map((entry) => entry.ref)).toEqual([ + "refs/tags/@acme/one@1.0.0", + "refs/tags/@acme/two@2.0.0", + ]); + expect(github.createdReleases[0]?.tag).toBe("release-2026-08-05-0941"); + expect(github.createdReleases[0]?.prerelease).toBe(false); +}); + +test("marks the release as a prerelease when every version is one", async () => { + const github = fakeGitHub(); + + await releasePublishedPackages({ + api: github.api, + sha: SHA, + published: [{ name: "@acme/one", version: "1.0.0-beta.1" }], + workspace: [], + now: NOW, + }); + + expect(github.createdReleases[0]?.prerelease).toBe(true); +}); + +test("skips a package tag that already exists", async () => { + const github = fakeGitHub({ + existingTags: ["@acme/one@1.0.0"], + refusedRefs: ["refs/tags/@acme/one@1.0.0"], + }); + + await releasePublishedPackages({ + api: github.api, + sha: SHA, + published: [{ name: "@acme/one", version: "1.0.0" }], + workspace: [], + now: NOW, + }); + + expect(github.createdReleases).toHaveLength(1); +}); + +test("does nothing when pnpm published nothing", async () => { + const github = fakeGitHub(); + + await releasePublishedPackages({ + api: github.api, + sha: SHA, + published: [], + workspace: [], + now: NOW, + }); + + expect(github.createdRefs).toEqual([]); + expect(github.createdReleases).toEqual([]); +}); + +test("fails the run when the release cannot be created", async () => { + const github = fakeGitHub({ refuseReleaseCreation: true }); + + await expect( + releasePublishedPackages({ + api: github.api, + sha: SHA, + published: [{ name: "@acme/one", version: "1.0.0" }], + workspace: [], + now: NOW, + }), + ).rejects.toThrow(/one or more release steps failed/); +}); diff --git a/internal/gha/src/github-releases.ts b/internal/gha/src/github-releases.ts new file mode 100644 index 0000000..3d4b0cf --- /dev/null +++ b/internal/gha/src/github-releases.ts @@ -0,0 +1,143 @@ +import { changelogEntry } from "./changelog"; +import type { GitHubApi } from "./github"; +import type { PublishedPackage, WorkspacePackage } from "./pnpm"; + +const RELEASE_TAG_PREFIX = "release-"; + +export interface CombinedRelease { + readonly published: readonly PublishedPackage[]; + readonly paths: ReadonlyMap; + readonly notes?: string; +} + +export const renderCombinedReleaseBody = ({ published, paths, notes }: CombinedRelease): string => { + const out: string[] = []; + + out.push("## Published packages"); + out.push(""); + out.push("| Package | Version |"); + out.push("| :--- | ---: |"); + + for (const { name, version } of published) { + out.push(`| [\`${name}\`](https://www.npmjs.com/package/${name}) | \`${version}\` |`); + } + + out.push(""); + out.push("### Changelogs"); + out.push(""); + + for (const { name, version } of published) { + const packagePath = paths.get(name); + out.push("
"); + out.push(`${name}@${version}`); + out.push(""); + out.push("
"); + out.push(""); + out.push( + (packagePath ? changelogEntry(packagePath, version) : "") || "_No changelog entry recorded._", + ); + out.push(""); + out.push("
"); + out.push(""); + } + + if (notes?.trim()) { + out.push("---"); + out.push(""); + out.push(notes.trim()); + } + + return out.join("\n").trim(); +}; + +// `release-YYYY-MM-DD-HHmm`, suffixed with `.2`, `.3`, ... when that tag is +// taken (git tags cannot contain `:`, hence the compact time). +export const nextReleaseTag = async (api: GitHubApi, now: Date): Promise => { + const stamp = now.toISOString().slice(0, 16).replace("T", "-").replace(":", ""); + const base = `${RELEASE_TAG_PREFIX}${stamp}`; + + for (let counter = 1; counter < 100; counter += 1) { + const tag = counter === 1 ? base : `${base}.${counter}`; + if (!(await api.tagExists(tag))) return tag; + } + + throw new Error(`could not find a free release tag for ${base}`); +}; + +// Tag of the previous combined release, so the generated notes cover exactly +// the commits released since then. +export const previousReleaseTag = async (api: GitHubApi): Promise => { + const releases = await api.listReleases(); + return ( + releases + .filter((release) => release.tag_name.startsWith(RELEASE_TAG_PREFIX)) + .sort((a, b) => b.created_at.localeCompare(a.created_at))[0]?.tag_name ?? "" + ); +}; + +const createTag = async (api: GitHubApi, tag: string, sha: string): Promise => { + const response = await api.createRef(`refs/tags/${tag}`, sha); + if (response.ok) { + console.log(`created tag ${tag}`); + return true; + } + if (response.status === 422 && (await api.tagExists(tag))) { + console.log(`tag ${tag} already exists, skipping`); + return true; + } + console.error(`failed to create tag ${tag}:`, response.payload); + return false; +}; + +export interface PackageReleaseInput { + readonly api: GitHubApi; + readonly sha: string; + readonly published: readonly PublishedPackage[]; + readonly workspace: readonly WorkspacePackage[]; + readonly now?: Date; +} + +// `pnpm publish` only talks to the registry, so the tags and the combined +// GitHub release for the run are created here. +export const releasePublishedPackages = async ({ + api, + sha, + published, + workspace, + now = new Date(), +}: PackageReleaseInput): Promise => { + if (published.length === 0) { + console.log("no packages were published, nothing to release"); + return; + } + + const releases = [...published].sort((a, b) => a.name.localeCompare(b.name)); + const paths = new Map(workspace.map((entry) => [entry.name, entry.path])); + + let failed = false; + + for (const { name, version } of releases) { + if (!(await createTag(api, `${name}@${version}`, sha))) failed = true; + } + + const releaseTag = await nextReleaseTag(api, now); + const notes = await api.generateNotes(releaseTag, sha, await previousReleaseTag(api)); + + // `targetCommitish` makes GitHub create the tag when it does not exist yet. + const created = await api.createRelease({ + tag: releaseTag, + name: releaseTag, + targetCommitish: sha, + body: renderCombinedReleaseBody({ published: releases, paths, notes }), + prerelease: releases.every(({ version }) => version.includes("-")), + }); + + if (created.ok) { + console.log(`created release ${releaseTag}`); + } else { + failed = true; + console.error(`failed to create release ${releaseTag}:`, created.payload); + } + + if (failed) throw new Error("one or more release steps failed"); +}; diff --git a/internal/gha/src/github.test.ts b/internal/gha/src/github.test.ts new file mode 100644 index 0000000..3e91a23 --- /dev/null +++ b/internal/gha/src/github.test.ts @@ -0,0 +1,131 @@ +import { expect, test, vi } from "vitest"; + +import { createGitHubApi } from "./github"; + +const okResponse = (body: unknown): Response => new Response(JSON.stringify(body), { status: 200 }); + +test("authenticates and versions every REST call", async () => { + const fetch = vi.fn().mockResolvedValue(okResponse({ ok: true })); + const api = createGitHubApi({ token: "secret", repository: "acme/repo", fetch }); + + await api.request("/repos/acme/repo/releases", "POST", { tag_name: "v1" }); + + const [url, init] = fetch.mock.calls[0] ?? []; + expect(url).toBe("https://api.github.com/repos/acme/repo/releases"); + expect(init?.method).toBe("POST"); + expect(init?.body).toBe(JSON.stringify({ tag_name: "v1" })); + expect(init?.headers).toMatchObject({ + authorization: "Bearer secret", + "x-github-api-version": "2022-11-28", + }); +}); + +test("uses GitHub.com's GraphQL endpoint by default", async () => { + const fetch = vi.fn().mockResolvedValue(okResponse({})); + const api = createGitHubApi({ token: "secret", repository: "acme/repo", fetch }); + + await api.graphql("query { viewer { login } }", {}); + + expect(fetch.mock.calls[0]?.[0]).toBe("https://api.github.com/graphql"); +}); + +test("uses the configured GraphQL endpoint independently of the REST endpoint", async () => { + const fetch = vi.fn().mockResolvedValue(okResponse({})); + const api = createGitHubApi({ + token: "secret", + repository: "acme/repo", + apiUrl: "https://github.example.com/api/v3", + graphqlUrl: "https://github.example.com/api/graphql", + fetch, + }); + + await api.graphql("query { viewer { login } }", {}); + + expect(fetch.mock.calls[0]?.[0]).toBe("https://github.example.com/api/graphql"); +}); + +test("omits a body for GET requests", async () => { + const fetch = vi.fn().mockResolvedValue(okResponse({})); + const api = createGitHubApi({ token: "secret", repository: "acme/repo", fetch }); + + await api.tagExists("v1.0.0"); + + expect(fetch.mock.calls[0]?.[1]).not.toHaveProperty("body"); +}); + +test("escapes tags when checking whether they exist", async () => { + const fetch = vi.fn().mockResolvedValue(okResponse({})); + const api = createGitHubApi({ token: "secret", repository: "acme/repo", fetch }); + + await api.tagExists("@acme/pkg@1.0.0"); + + expect(fetch.mock.calls[0]?.[0]).toBe( + "https://api.github.com/repos/acme/repo/git/ref/tags/%40acme%2Fpkg%401.0.0", + ); +}); + +test("tolerates an empty response body", async () => { + const fetch = vi + .fn() + .mockResolvedValue(new Response(null, { status: 204 })); + const api = createGitHubApi({ token: "secret", repository: "acme/repo", fetch }); + + const response = await api.request("/anything", "GET"); + + expect(response.ok).toBe(true); + expect(response.payload).toEqual({}); +}); + +test("returns no releases when the listing fails", async () => { + const fetch = vi + .fn() + .mockResolvedValue(new Response("{}", { status: 500 })); + const api = createGitHubApi({ token: "secret", repository: "acme/repo", fetch }); + + expect(await api.listReleases()).toEqual([]); +}); + +test("falls back to empty notes when generation fails", async () => { + const fetch = vi + .fn() + .mockResolvedValue(new Response("{}", { status: 422 })); + const api = createGitHubApi({ token: "secret", repository: "acme/repo", fetch }); + + expect(await api.generateNotes("v1", "sha", "")).toBe(""); +}); + +test("surfaces GraphQL errors when creating a commit", async () => { + const fetch = vi + .fn() + .mockResolvedValue(okResponse({ errors: [{ message: "branch is protected" }] })); + const api = createGitHubApi({ token: "secret", repository: "acme/repo", fetch }); + + await expect( + api.createCommitOnBranch("release/main", "abc", "chore: release", { + additions: [], + deletions: [], + }), + ).rejects.toThrow(/branch is protected/); +}); + +test("splits a commit message into headline and body", async () => { + const fetch = vi + .fn() + .mockResolvedValue(okResponse({ data: { createCommitOnBranch: { commit: { oid: "def" } } } })); + const api = createGitHubApi({ token: "secret", repository: "acme/repo", fetch }); + + const oid = await api.createCommitOnBranch("release/main", "abc", "headline\n\nthe body", { + additions: [], + deletions: [], + }); + + expect(oid).toBe("def"); + const requestBody = fetch.mock.calls[0]?.[1]?.body; + if (typeof requestBody !== "string") { + throw new Error("expected a serialised request body"); + } + const body = JSON.parse(requestBody) as { + variables: { input: { message: { headline: string; body?: string } } }; + }; + expect(body.variables.input.message).toEqual({ headline: "headline", body: "the body" }); +}); diff --git a/internal/gha/src/github.ts b/internal/gha/src/github.ts new file mode 100644 index 0000000..4f3f112 --- /dev/null +++ b/internal/gha/src/github.ts @@ -0,0 +1,179 @@ +export type HttpMethod = "GET" | "POST" | "PATCH"; + +export interface GitHubResponse { + readonly ok: boolean; + readonly status: number; + readonly payload: T; +} + +export interface ReleaseSummary { + readonly tag_name: string; + readonly created_at: string; +} + +export interface NewRelease { + readonly tag: string; + readonly name: string; + readonly body: string; + readonly targetCommitish?: string; + readonly prerelease: boolean; +} + +export interface FileChanges { + readonly additions: ReadonlyArray<{ readonly path: string; readonly contents: string }>; + readonly deletions: ReadonlyArray<{ readonly path: string }>; +} + +export interface GitHubApi { + readonly repository: string; + request(path: string, method: HttpMethod, body?: unknown): Promise>; + graphql(query: string, variables: unknown): Promise>; + tagExists(tag: string): Promise; + createRef(ref: string, sha: string): Promise>; + updateRef(ref: string, sha: string): Promise>; + listReleases(): Promise; + generateNotes(tag: string, sha: string, previousTag: string): Promise; + createRelease(release: NewRelease): Promise>; + createCommitOnBranch( + branch: string, + expectedHeadOid: string, + message: string, + fileChanges: FileChanges, + ): Promise; +} + +export interface GitHubApiOptions { + readonly token: string; + readonly repository: string; + readonly apiUrl?: string; + readonly graphqlUrl?: string; + readonly fetch?: typeof globalThis.fetch; +} + +const COMMIT_MUTATION = ` + mutation ($input: CreateCommitOnBranchInput!) { + createCommitOnBranch(input: $input) { + commit { + oid + } + } + } + `; + +export const createGitHubApi = (options: GitHubApiOptions): GitHubApi => { + const { token, repository } = options; + const apiUrl = options.apiUrl ?? "https://api.github.com"; + const graphqlUrl = options.graphqlUrl ?? "https://api.github.com/graphql"; + const doFetch = options.fetch ?? globalThis.fetch; + + const requestUrl = async ( + url: string, + method: HttpMethod, + body?: unknown, + ): Promise> => { + const response = await doFetch(url, { + method, + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "content-type": "application/json", + "x-github-api-version": "2022-11-28", + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + const text = await response.text(); + return { + ok: response.ok, + status: response.status, + payload: (text ? JSON.parse(text) : {}) as T, + }; + }; + + const request = ( + path: string, + method: HttpMethod, + body?: unknown, + ): Promise> => requestUrl(`${apiUrl}${path}`, method, body); + + const api: GitHubApi = { + repository, + request, + + graphql: (query, variables) => requestUrl(graphqlUrl, "POST", { query, variables }), + + tagExists: async (tag) => + (await request(`/repos/${repository}/git/ref/tags/${encodeURIComponent(tag)}`, "GET")).ok, + + createRef: (ref, sha) => request(`/repos/${repository}/git/refs`, "POST", { ref, sha }), + + updateRef: (ref, sha) => + request(`/repos/${repository}/git/${ref}`, "PATCH", { sha, force: true }), + + listReleases: async () => { + const response = await request( + `/repos/${repository}/releases?per_page=100`, + "GET", + ); + return response.ok ? response.payload : []; + }, + + // The same notes GitHub renders behind "Generate release notes" in the UI. + generateNotes: async (tag, sha, previousTag) => { + const response = await request<{ body?: string }>( + `/repos/${repository}/releases/generate-notes`, + "POST", + { + tag_name: tag, + target_commitish: sha, + ...(previousTag ? { previous_tag_name: previousTag } : {}), + }, + ); + if (!response.ok) { + console.warn(`failed to generate notes for ${tag}:`, response.payload); + return ""; + } + return (response.payload.body ?? "").trim(); + }, + + createRelease: (release) => + request(`/repos/${repository}/releases`, "POST", { + tag_name: release.tag, + name: release.name, + body: release.body, + draft: false, + prerelease: release.prerelease, + ...(release.targetCommitish === undefined + ? {} + : { target_commitish: release.targetCommitish }), + }), + + // Commits created through the API are signed with GitHub's own key, so they + // show up as "Verified" instead of unsigned. + createCommitOnBranch: async (branch, expectedHeadOid, message, fileChanges) => { + const [headline, ...body] = message.split("\n\n"); + const rest = body.join("\n\n"); + + const result = await api.graphql<{ + data?: { createCommitOnBranch: { commit: { oid: string } } }; + errors?: unknown; + }>(COMMIT_MUTATION, { + input: { + branch: { repositoryNameWithOwner: repository, branchName: branch }, + expectedHeadOid, + message: { headline, ...(rest ? { body: rest } : {}) }, + fileChanges, + }, + }); + + const oid = result.payload.data?.createCommitOnBranch.commit.oid; + if (!result.ok || result.payload.errors || !oid) { + throw new Error( + `failed to create commit: ${JSON.stringify(result.payload.errors ?? result.payload)}`, + ); + } + return oid; + }, + }; + + return api; +}; diff --git a/internal/gha/src/pnpm.test.ts b/internal/gha/src/pnpm.test.ts new file mode 100644 index 0000000..a9a3816 --- /dev/null +++ b/internal/gha/src/pnpm.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from "vitest"; + +import { parseAppliedReleases, parsePublishSummary, parseWorkspacePackages } from "./pnpm"; + +test("parses the applied releases pnpm reports", () => { + const releases = parseAppliedReleases( + JSON.stringify([{ name: "example", currentVersion: "1.0.0", newVersion: "1.1.0" }]), + ); + + expect(releases).toEqual([{ name: "example", currentVersion: "1.0.0", newVersion: "1.1.0" }]); +}); + +test("names the offending field when pnpm's release shape changes", () => { + expect(() => + parseAppliedReleases(JSON.stringify([{ name: "example", currentVersion: 1 }])), + ).toThrow(/pnpm version -r --json\[0\]: expected "currentVersion" to be a string, got 1/); +}); + +test("rejects a non-array release payload", () => { + expect(() => parseAppliedReleases(JSON.stringify({}))).toThrow(/expected an array/); +}); + +test("normalises the private flag on workspace packages", () => { + const workspace = parseWorkspacePackages( + JSON.stringify([ + { name: "public", version: "1.0.0", path: "/a" }, + { name: "internal", version: "1.0.0", path: "/b", private: true }, + ]), + ); + + expect(workspace).toEqual([ + { name: "public", version: "1.0.0", path: "/a", private: false }, + { name: "internal", version: "1.0.0", path: "/b", private: true }, + ]); +}); + +test("treats a publish summary without published packages as empty", () => { + expect(parsePublishSummary(JSON.stringify({}))).toEqual([]); +}); + +test("parses the published packages pnpm reports", () => { + expect( + parsePublishSummary(JSON.stringify({ publishedPackages: [{ name: "a", version: "1.0.0" }] })), + ).toEqual([{ name: "a", version: "1.0.0" }]); +}); diff --git a/internal/gha/src/pnpm.ts b/internal/gha/src/pnpm.ts new file mode 100644 index 0000000..a7cc313 --- /dev/null +++ b/internal/gha/src/pnpm.ts @@ -0,0 +1,83 @@ +// Validators for the JSON that pnpm hands to the release scripts. They exist so +// a change in pnpm's output shape fails here, with the offending value in the +// message, instead of somewhere deep in a half-finished release. + +export interface AppliedRelease { + readonly name: string; + readonly currentVersion: string; + readonly newVersion: string; +} + +export interface WorkspacePackage { + readonly name: string; + readonly version: string; + readonly path: string; + readonly private: boolean; +} + +export interface PublishedPackage { + readonly name: string; + readonly version: string; +} + +const asArray = (value: unknown, context: string): readonly unknown[] => { + if (!Array.isArray(value)) { + throw new Error(`${context}: expected an array, got ${typeof value}`); + } + return value; +}; + +const asRecord = (value: unknown, context: string): Record => { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${context}: expected an object, got ${JSON.stringify(value)}`); + } + return value as Record; +}; + +const asString = (source: Record, key: string, context: string): string => { + const value = source[key]; + if (typeof value !== "string") { + throw new Error(`${context}: expected "${key}" to be a string, got ${JSON.stringify(value)}`); + } + return value; +}; + +export const parseAppliedReleases = (json: string): readonly AppliedRelease[] => + asArray(JSON.parse(json), "pnpm version -r --json").map((entry, index) => { + const context = `pnpm version -r --json[${index}]`; + const record = asRecord(entry, context); + return { + name: asString(record, "name", context), + currentVersion: asString(record, "currentVersion", context), + newVersion: asString(record, "newVersion", context), + }; + }); + +export const parseWorkspacePackages = (json: string): readonly WorkspacePackage[] => + asArray(JSON.parse(json), "pnpm list -r --json").map((entry, index) => { + const context = `pnpm list -r --json[${index}]`; + const record = asRecord(entry, context); + return { + name: asString(record, "name", context), + version: asString(record, "version", context), + path: asString(record, "path", context), + private: record["private"] === true, + }; + }); + +export const parsePublishSummary = (json: string): readonly PublishedPackage[] => { + const summary = asRecord(JSON.parse(json), "pnpm publish --report-summary"); + const published = summary["publishedPackages"]; + if (published === undefined) return []; + + return asArray(published, "pnpm publish --report-summary.publishedPackages").map( + (entry, index) => { + const context = `pnpm publish --report-summary.publishedPackages[${index}]`; + const record = asRecord(entry, context); + return { + name: asString(record, "name", context), + version: asString(record, "version", context), + }; + }, + ); +}; diff --git a/internal/gha/src/release-pr-body.test.ts b/internal/gha/src/release-pr-body.test.ts new file mode 100644 index 0000000..ac6eb3c --- /dev/null +++ b/internal/gha/src/release-pr-body.test.ts @@ -0,0 +1,116 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; + +import type { WorkspacePackage } from "./pnpm"; +import { renderReleasePrBody } from "./release-pr-body"; + +const directories: string[] = []; + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +const packageFixture = (changelog: string): string => { + const directory = mkdtempSync(join(tmpdir(), "release-pr-body-")); + directories.push(directory); + writeFileSync(join(directory, "CHANGELOG.md"), changelog); + return directory; +}; + +const workspaceEntry = ( + name: string, + version: string, + path: string, + isPrivate = false, +): WorkspacePackage => ({ name, version, path, private: isPrivate }); + +test("renders a same-version unpublished package as a first release", () => { + const path = packageFixture(`# @zemd/new-package + +## 0.0.0 + +### Major Changes + +- Publish the initial package. +`); + + const body = renderReleasePrBody( + [{ name: "@zemd/new-package", currentVersion: "0.0.0", newVersion: "0.0.0" }], + [workspaceEntry("@zemd/new-package", "0.0.0", path)], + ); + + expect(body).toMatch(/prepared \*\*1\*\* package for release/); + expect(body).toContain("| `@zemd/new-package` | first release | — | `0.0.0` |"); + expect(body).toContain("**Major Changes**"); + expect(body).toContain("Publish the initial package."); +}); + +test("renders an ordinary version bump from a dated Keep a Changelog entry", () => { + const path = packageFixture(`# example + +## [2.0.0] - 2026-01-01 + +### Major Changes + +- Break the old API. + +## 1.2.3 + +- Previous release. +`); + + const body = renderReleasePrBody( + [{ name: "example", currentVersion: "1.2.3", newVersion: "2.0.0" }], + [workspaceEntry("example", "2.0.0", path)], + ); + + expect(body).toContain("| `example` | **major** | `1.2.3` | `2.0.0` |"); + expect(body).toContain("1.2.3 → 2.0.0"); + expect(body).toContain("Break the old API."); + expect(body).not.toContain("Previous release."); +}); + +test("lists private packages separately from the published ones", () => { + const publicPath = packageFixture("# example\n\n## 1.1.0\n\n- Public change.\n"); + const internalPath = packageFixture("# @zemd/gha\n\n## 1.1.0\n\n- Workflow change.\n"); + + const body = renderReleasePrBody( + [ + { name: "example", currentVersion: "1.0.0", newVersion: "1.1.0" }, + { name: "@zemd/gha", currentVersion: "1.0.0", newVersion: "1.1.0" }, + ], + [ + workspaceEntry("example", "1.1.0", publicPath), + workspaceEntry("@zemd/gha", "1.1.0", internalPath, true), + ], + ); + + expect(body).toMatch(/prepared \*\*1\*\* package for release/); + expect(body).toContain("### Internal packages"); + expect(body).toContain("Not published to npm."); + expect(body).toContain("| `@zemd/gha` | **minor** | `1.0.0` | `1.1.0` |"); + expect(body).toContain("Workflow change."); + expect(body.indexOf("### Changelogs")).toBeLessThan(body.indexOf("### Internal packages")); +}); + +test("reports when only private packages were prepared", () => { + const path = packageFixture("# @zemd/gha\n\n## 1.1.0\n\n- Workflow change.\n"); + + const body = renderReleasePrBody( + [{ name: "@zemd/gha", currentVersion: "1.0.0", newVersion: "1.1.0" }], + [workspaceEntry("@zemd/gha", "1.1.0", path, true)], + ); + + expect(body).toContain("No publishable packages were prepared for release."); + expect(body).toContain("### Internal packages"); +}); + +test("refuses to render a release for a package outside the workspace snapshot", () => { + expect(() => + renderReleasePrBody([{ name: "ghost", currentVersion: "1.0.0", newVersion: "1.0.1" }], []), + ).toThrow(/ghost is missing from the workspace snapshot/); +}); diff --git a/internal/gha/src/release-pr-body.ts b/internal/gha/src/release-pr-body.ts new file mode 100644 index 0000000..72c9a65 --- /dev/null +++ b/internal/gha/src/release-pr-body.ts @@ -0,0 +1,130 @@ +import { changelogEntry } from "./changelog"; +import type { AppliedRelease, WorkspacePackage } from "./pnpm"; +import { bumpType } from "./semver"; + +type ReleaseKind = "major" | "minor" | "patch" | "prerelease" | "new"; + +interface PreparedRelease { + readonly name: string; + readonly path: string; + readonly from: string | undefined; + readonly to: string; + readonly kind: ReleaseKind; + readonly internal: boolean; +} + +const badge: Record = { + major: "**major**", + minor: "**minor**", + patch: "patch", + prerelease: "prerelease", + new: "first release", +}; + +const prepare = ( + applied: readonly AppliedRelease[], + workspace: readonly WorkspacePackage[], +): readonly PreparedRelease[] => { + const packages = new Map(workspace.map((entry) => [entry.name, entry])); + + return applied + .map((release): PreparedRelease => { + const entry = packages.get(release.name); + if (!entry) { + throw new Error(`release package ${release.name} is missing from the workspace snapshot`); + } + const firstRelease = release.currentVersion === release.newVersion; + return { + name: release.name, + path: entry.path, + from: firstRelease ? undefined : release.currentVersion, + to: release.newVersion, + kind: firstRelease ? "new" : bumpType(release.currentVersion, release.newVersion), + internal: entry.private, + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); +}; + +const changelogDetails = (out: string[], release: PreparedRelease): void => { + const transition = release.from + ? `${release.from} → ${release.to}` + : `${release.to}`; + + out.push("
"); + out.push(`${release.name}  ·  ${transition}`); + out.push(""); + out.push("
"); + out.push(""); + out.push(changelogEntry(release.path, release.to) || "_No changelog entry recorded._"); + out.push(""); + out.push("
"); + out.push(""); +}; + +export const renderReleasePrBody = ( + applied: readonly AppliedRelease[], + workspace: readonly WorkspacePackage[], +): string => { + const prepared = prepare(applied, workspace); + const releases = prepared.filter((release) => !release.internal); + const internal = prepared.filter((release) => release.internal); + + const out: string[] = []; + + out.push("## Release summary"); + out.push(""); + + if (releases.length === 0) { + out.push("No publishable packages were prepared for release."); + } else { + const count = releases.length; + out.push( + `\`pnpm version -r\` consumed the pending change intents and prepared **${count}** package${count === 1 ? "" : "s"} for release.`, + ); + out.push("Merging this pull request publishes the versions listed below."); + out.push(""); + out.push("| Package | Bump | Current | Next |"); + out.push("| :--- | :---: | ---: | ---: |"); + + for (const release of releases) { + const current = release.from ? `\`${release.from}\`` : "—"; + out.push(`| \`${release.name}\` | ${badge[release.kind]} | ${current} | \`${release.to}\` |`); + } + + out.push(""); + out.push("### Changelogs"); + out.push(""); + + for (const release of releases) { + changelogDetails(out, release); + } + } + + // Private packages never reach npm, but @zemd/gha carries the shared workflow + // contract version, so a reviewer still needs to see it move. + if (internal.length > 0) { + out.push(""); + out.push("### Internal packages"); + out.push(""); + out.push("Not published to npm."); + out.push(""); + out.push("| Package | Bump | Current | Next |"); + out.push("| :--- | :---: | ---: | ---: |"); + + for (const release of internal) { + const current = release.from ? `\`${release.from}\`` : "—"; + out.push(`| \`${release.name}\` | ${badge[release.kind]} | ${current} | \`${release.to}\` |`); + } + + out.push(""); + + for (const release of internal) { + changelogDetails(out, release); + } + } + + out.push(""); + + return `${out.join("\n")}\n`; +}; diff --git a/internal/gha/src/semver.ts b/internal/gha/src/semver.ts new file mode 100644 index 0000000..b909c14 --- /dev/null +++ b/internal/gha/src/semver.ts @@ -0,0 +1,27 @@ +export interface SemVer { + readonly major: number; + readonly minor: number; + readonly patch: number; + readonly prerelease: string; +} + +export type BumpType = "major" | "minor" | "patch" | "prerelease"; + +const SEMVER = /^\d+\.\d+\.\d+$/; + +export const isReleaseVersion = (version: string): boolean => SEMVER.test(version); + +export const parseVersion = (version: string): SemVer => { + const [core = "", ...prerelease] = version.split("-"); + const [major = 0, minor = 0, patch = 0] = core.split(".").map(Number); + return { major, minor, patch, prerelease: prerelease.join("-") }; +}; + +export const bumpType = (from: string, to: string): BumpType => { + const a = parseVersion(from); + const b = parseVersion(to); + if (b.prerelease || a.prerelease) return "prerelease"; + if (b.major !== a.major) return "major"; + if (b.minor !== a.minor) return "minor"; + return "patch"; +}; diff --git a/internal/gha/src/shared-workflows.test.ts b/internal/gha/src/shared-workflows.test.ts new file mode 100644 index 0000000..e021cc8 --- /dev/null +++ b/internal/gha/src/shared-workflows.test.ts @@ -0,0 +1,167 @@ +import { expect, test } from "vitest"; + +import { releaseSharedWorkflows, renderSharedReleaseBody } from "./shared-workflows"; +import { fakeGitHub } from "./testing/fake-github"; + +const SHA = "a".repeat(40); +const WORKFLOWS = ["shared-ci.yml", "shared-release.yml"]; + +test("renders copy-paste pins for every shared workflow", () => { + const body = renderSharedReleaseBody({ + repository: "zemd/js", + version: "1.2.3", + sha: SHA, + workflows: WORKFLOWS, + }); + + expect(body).toContain(`uses: zemd/js/.github/workflows/shared-ci.yml@${SHA} # v1.2.3`); + expect(body).toContain(`uses: zemd/js/.github/workflows/shared-release.yml@${SHA} # v1.2.3`); + expect(body).toContain("gh api repos/zemd/js/git/ref/tags/v1 --jq .object.sha"); + expect(body).toContain(`https://github.com/zemd/js/tree/${SHA}/.github/workflows-examples`); +}); + +test("includes the changelog entry and the generated notes", () => { + const body = renderSharedReleaseBody({ + repository: "zemd/js", + version: "1.2.3", + sha: SHA, + workflows: WORKFLOWS, + changelog: "- Added an input.", + notes: "## What's Changed\n\n- #1", + }); + + expect(body).toContain("## Changes"); + expect(body).toContain("- Added an input."); + expect(body).toContain("- #1"); +}); + +test("creates the exact tag and moves the major tag", async () => { + const github = fakeGitHub({ repository: "zemd/js" }); + + await releaseSharedWorkflows({ + api: github.api, + sha: SHA, + version: "2.1.0", + packagePath: "internal/gha", + workflows: WORKFLOWS, + }); + + expect(github.createdRefs).toEqual([ + { ref: "refs/tags/v2.1.0", sha: SHA }, + { ref: "refs/tags/v2", sha: SHA }, + ]); + expect(github.updatedRefs).toEqual([]); + expect(github.createdReleases[0]?.tag).toBe("v2.1.0"); + expect(github.createdReleases[0]?.name).toBe("Shared workflows v2.1.0"); +}); + +test("is a no-op when the version was already released", async () => { + const github = fakeGitHub({ + existingTags: ["v1.0.0", "v1"], + releases: [{ tag_name: "v1.0.0", created_at: "2026-08-01T00:00:00Z" }], + }); + + await releaseSharedWorkflows({ + api: github.api, + sha: SHA, + version: "1.0.0", + packagePath: "internal/gha", + workflows: WORKFLOWS, + }); + + expect(github.createdRefs).toEqual([]); + expect(github.createdReleases).toEqual([]); +}); + +test("resumes after moving the major tag fails", async () => { + const github = fakeGitHub({ + existingTags: ["v2"], + updateRefFailures: { "refs/tags/v2": 1 }, + }); + const input = { + api: github.api, + sha: SHA, + version: "2.1.0", + packagePath: "internal/gha", + workflows: WORKFLOWS, + }; + + await expect(releaseSharedWorkflows(input)).rejects.toThrow(/could not move the major tag/); + + expect(github.tags.has("v2.1.0")).toBe(true); + expect(github.createdReleases).toEqual([]); + + await releaseSharedWorkflows(input); + + expect(github.createdRefs).toEqual([{ ref: "refs/tags/v2.1.0", sha: SHA }]); + expect(github.updatedRefs).toEqual([{ ref: "refs/tags/v2", sha: SHA }]); + expect(github.createdReleases).toHaveLength(1); +}); + +test("resumes after creating the GitHub release fails", async () => { + const github = fakeGitHub({ releaseCreationFailures: 1 }); + const input = { + api: github.api, + sha: SHA, + version: "3.0.0", + packagePath: "internal/gha", + workflows: WORKFLOWS, + }; + + await expect(releaseSharedWorkflows(input)).rejects.toThrow(/failed to create release/); + + expect(github.tags.has("v3.0.0")).toBe(true); + expect(github.tags.has("v3")).toBe(true); + expect(github.createdReleases).toEqual([]); + + await releaseSharedWorkflows(input); + + expect(github.createdRefs).toEqual([ + { ref: "refs/tags/v3.0.0", sha: SHA }, + { ref: "refs/tags/v3", sha: SHA }, + ]); + expect(github.updatedRefs).toEqual([{ ref: "refs/tags/v3", sha: SHA }]); + expect(github.createdReleases).toHaveLength(1); +}); + +test("moves an existing major tag instead of failing", async () => { + const github = fakeGitHub({ existingTags: ["v2"] }); + + await releaseSharedWorkflows({ + api: github.api, + sha: SHA, + version: "2.0.1", + packagePath: "internal/gha", + workflows: WORKFLOWS, + }); + + expect(github.updatedRefs).toEqual([{ ref: "refs/tags/v2", sha: SHA }]); +}); + +test("refuses a prerelease version", async () => { + const github = fakeGitHub(); + + await expect( + releaseSharedWorkflows({ + api: github.api, + sha: SHA, + version: "1.0.0-beta.1", + packagePath: "internal/gha", + workflows: WORKFLOWS, + }), + ).rejects.toThrow(/plain semver/); +}); + +test("refuses to release when no shared workflows were found", async () => { + const github = fakeGitHub(); + + await expect( + releaseSharedWorkflows({ + api: github.api, + sha: SHA, + version: "1.0.0", + packagePath: "internal/gha", + workflows: [], + }), + ).rejects.toThrow(/no shared-\*\.yml workflows found/); +}); diff --git a/internal/gha/src/shared-workflows.ts b/internal/gha/src/shared-workflows.ts new file mode 100644 index 0000000..33cfdea --- /dev/null +++ b/internal/gha/src/shared-workflows.ts @@ -0,0 +1,146 @@ +import { changelogEntry } from "./changelog"; +import type { GitHubApi } from "./github"; +import { isReleaseVersion } from "./semver"; + +export interface SharedReleaseBody { + readonly repository: string; + readonly version: string; + readonly sha: string; + readonly workflows: readonly string[]; + readonly changelog?: string; + readonly notes?: string; +} + +export const renderSharedReleaseBody = ({ + repository, + version, + sha, + workflows, + changelog, + notes, +}: SharedReleaseBody): string => { + const major = version.split(".")[0]; + const out: string[] = []; + + out.push("Shared GitHub Actions workflows. Add one job per workflow to a caller in"); + out.push("`.github/workflows/`, pinned to this commit:"); + out.push(""); + out.push("```yaml"); + for (const workflow of workflows) { + out.push(`uses: ${repository}/.github/workflows/${workflow}@${sha} # v${version}`); + } + out.push("```"); + out.push(""); + out.push( + `Ready-to-copy callers: [\`.github/workflows-examples\`](https://github.com/${repository}/tree/${sha}/.github/workflows-examples).`, + ); + out.push(""); + out.push("Re-resolve this commit at any time:"); + out.push(""); + out.push("```sh"); + out.push(`gh api repos/${repository}/git/ref/tags/v${major} --jq .object.sha`); + out.push("```"); + + if (changelog?.trim()) { + out.push(""); + out.push("## Changes"); + out.push(""); + out.push(changelog.trim()); + } + + if (notes?.trim()) { + out.push(""); + out.push("---"); + out.push(""); + out.push(notes.trim()); + } + + return `${out.join("\n")}\n`; +}; + +export interface SharedReleaseInput { + readonly api: GitHubApi; + readonly sha: string; + readonly version: string; + readonly packagePath: string; + readonly workflows: readonly string[]; +} + +const putTag = async ( + api: GitHubApi, + tag: string, + sha: string, + force: boolean, +): Promise => { + const created = await api.createRef(`refs/tags/${tag}`, sha); + if (created.ok) return true; + + if (!force) { + console.error(`failed to create tag ${tag}:`, created.payload); + return false; + } + + const updated = await api.updateRef(`refs/tags/${tag}`, sha); + if (updated.ok) return true; + + console.error(`failed to move tag ${tag}:`, updated.payload); + return false; +}; + +// Publishes the workflow contract itself: an immutable `vX.Y.Z` tag, a moving +// `vX` tag, and a release whose body carries `uses:` lines already pinned to +// this commit so consumers can copy them. +export const releaseSharedWorkflows = async ({ + api, + sha, + version, + packagePath, + workflows, +}: SharedReleaseInput): Promise => { + if (!isReleaseVersion(version)) { + throw new Error(`expected a plain semver version, got "${version}"`); + } + if (workflows.length === 0) { + throw new Error("no shared-*.yml workflows found"); + } + + const tag = `v${version}`; + const releases = await api.listReleases(); + + if (releases.some((release) => release.tag_name === tag)) { + console.log(`${tag} already released, nothing to do`); + return; + } + + if (!(await api.tagExists(tag)) && !(await putTag(api, tag, sha, false))) { + throw new Error(`could not create ${tag}`); + } + if (!(await putTag(api, `v${version.split(".")[0]}`, sha, true))) { + throw new Error(`could not move the major tag for ${tag}`); + } + + const previousTag = + releases + .filter((release) => isReleaseVersion(release.tag_name.replace(/^v/, ""))) + .sort((a, b) => b.created_at.localeCompare(a.created_at))[0]?.tag_name ?? ""; + + const created = await api.createRelease({ + tag, + name: `Shared workflows ${tag}`, + body: renderSharedReleaseBody({ + repository: api.repository, + version, + sha, + workflows, + changelog: changelogEntry(packagePath, version), + notes: await api.generateNotes(tag, sha, previousTag), + }), + prerelease: false, + }); + + if (!created.ok) { + throw new Error(`failed to create release ${tag}: ${JSON.stringify(created.payload)}`); + } + + console.log(`created release ${tag} at ${sha}`); +}; diff --git a/internal/gha/src/signed-commit.test.ts b/internal/gha/src/signed-commit.test.ts new file mode 100644 index 0000000..5e3a6cd --- /dev/null +++ b/internal/gha/src/signed-commit.test.ts @@ -0,0 +1,110 @@ +import { expect, test } from "vitest"; + +import { collectChanges, createSignedCommit, type GitRunner } from "./signed-commit"; +import { fakeGitHub } from "./testing/fake-github"; + +const gitStub = (status: string, head = "abc123"): GitRunner => { + return (args) => { + if (args[0] === "status") return status; + if (args[0] === "rev-parse") return `${head}\n`; + throw new Error(`unexpected git ${args.join(" ")}`); + }; +}; + +const read = (path: string): Buffer => Buffer.from(`contents of ${path}`); + +test("collects modified and untracked files as additions", () => { + const changes = collectChanges({ + git: gitStub(" M packages/a/package.json\0?? packages/b/CHANGELOG.md\0"), + read, + }); + + expect(changes.additions.map((entry) => entry.path)).toEqual([ + "packages/a/package.json", + "packages/b/CHANGELOG.md", + ]); + expect(changes.deletions).toEqual([]); + expect(changes.additions[0]?.contents).toBe( + Buffer.from("contents of packages/a/package.json").toString("base64"), + ); +}); + +test("records a deletion", () => { + const changes = collectChanges({ git: gitStub(" D packages/a/old.ts\0"), read }); + + expect(changes.additions).toEqual([]); + expect(changes.deletions).toEqual([{ path: "packages/a/old.ts" }]); +}); + +// A rename record is followed by an extra NUL separated record holding the +// original path, which must not be mistaken for another change. +test("splits a rename into an addition and a deletion", () => { + const changes = collectChanges({ git: gitStub("R new.ts\0old.ts\0"), read }); + + expect(changes.additions.map((entry) => entry.path)).toEqual(["new.ts"]); + expect(changes.deletions).toEqual([{ path: "old.ts" }]); +}); + +test("splits a work-tree rename into an addition and a deletion", () => { + const changes = collectChanges({ git: gitStub(" R new.ts\0old.ts\0"), read }); + + expect(changes.additions.map((entry) => entry.path)).toEqual(["new.ts"]); + expect(changes.deletions).toEqual([{ path: "old.ts" }]); +}); + +test("treats a copy as an addition only", () => { + const changes = collectChanges({ git: gitStub("C copy.ts\0source.ts\0"), read }); + + expect(changes.additions.map((entry) => entry.path)).toEqual(["copy.ts"]); + expect(changes.deletions).toEqual([]); +}); + +test("treats a work-tree copy as an addition only", () => { + const changes = collectChanges({ git: gitStub(" C copy.ts\0source.ts\0"), read }); + + expect(changes.additions.map((entry) => entry.path)).toEqual(["copy.ts"]); + expect(changes.deletions).toEqual([]); +}); + +test("refuses to commit an unchanged working tree", async () => { + const github = fakeGitHub(); + + await expect( + createSignedCommit({ + api: github.api, + git: gitStub(""), + branch: "release/main", + message: "chore(release): version packages", + changes: { additions: [], deletions: [] }, + }), + ).rejects.toThrow(/nothing to commit/); +}); + +test("points the branch at HEAD before committing on top of it", async () => { + const github = fakeGitHub(); + + const oid = await createSignedCommit({ + api: github.api, + git: gitStub("", "deadbeef"), + branch: "release/main", + message: "chore(release): version packages", + changes: { additions: [{ path: "a", contents: "" }], deletions: [] }, + }); + + expect(github.createdRefs).toEqual([{ ref: "refs/heads/release/main", sha: "deadbeef" }]); + expect(oid).toBe("commit-oid"); +}); + +test("force-resets the branch when it already exists", async () => { + const github = fakeGitHub({ refusedRefs: ["refs/heads/release/main"] }); + + await createSignedCommit({ + api: github.api, + git: gitStub("", "deadbeef"), + branch: "release/main", + message: "chore(release): version packages", + changes: { additions: [{ path: "a", contents: "" }], deletions: [] }, + }); + + expect(github.updatedRefs).toEqual([{ ref: "refs/heads/release/main", sha: "deadbeef" }]); +}); diff --git a/internal/gha/src/signed-commit.ts b/internal/gha/src/signed-commit.ts new file mode 100644 index 0000000..cedbadf --- /dev/null +++ b/internal/gha/src/signed-commit.ts @@ -0,0 +1,91 @@ +import { readFileSync } from "node:fs"; + +import type { FileChanges, GitHubApi } from "./github"; + +export type GitRunner = (args: readonly string[]) => string; +export type FileReader = (path: string) => Buffer; + +export interface WorkingTreeOptions { + readonly git: GitRunner; + readonly read?: FileReader; +} + +// `-z` records are NUL separated; a rename record is followed by an extra +// record holding the original path. +export const collectChanges = ({ git, read }: WorkingTreeOptions): FileChanges => { + const records = git(["status", "--porcelain=v1", "-z", "--untracked-files=all"]) + .split("\0") + .filter(Boolean); + + const added = new Set(); + const deleted = new Set(); + + for (let index = 0; index < records.length; index += 1) { + const record = records[index] ?? ""; + const state = record.slice(0, 2); + const path = record.slice(3); + + if (state.includes("R") || state.includes("C")) { + const origin = records[index + 1]; + index += 1; + if (state.includes("R") && origin) deleted.add(origin); + added.add(path); + continue; + } + + if (state.includes("D")) { + deleted.add(path); + continue; + } + + added.add(path); + } + + const readFile: FileReader = read ?? ((path) => readFileSync(path)); + + return { + additions: [...added].map((path) => ({ + path, + contents: readFile(path).toString("base64"), + })), + deletions: [...deleted].map((path) => ({ path })), + }; +}; + +export interface SignedCommitInput { + readonly api: GitHubApi; + readonly git: GitRunner; + readonly branch: string; + readonly message: string; + readonly changes: FileChanges; +} + +// Publishes the current working-tree changes as a single commit on `branch`. +export const createSignedCommit = async ({ + api, + git, + branch, + message, + changes, +}: SignedCommitInput): Promise => { + if (changes.additions.length === 0 && changes.deletions.length === 0) { + throw new Error("nothing to commit"); + } + + // The API commit is built on top of the checked-out commit, so the remote + // branch has to be reset to that same commit first. + const baseOid = git(["rev-parse", "HEAD"]).trim(); + const ref = `refs/heads/${branch}`; + + const created = await api.createRef(ref, baseOid); + if (!created.ok) { + const updated = await api.updateRef(ref, baseOid); + if (!updated.ok) { + throw new Error( + `failed to point ${branch} at ${baseOid}: ${JSON.stringify(updated.payload)}`, + ); + } + } + + return api.createCommitOnBranch(branch, baseOid, message, changes); +}; diff --git a/internal/gha/src/testing/fake-github.ts b/internal/gha/src/testing/fake-github.ts new file mode 100644 index 0000000..00cf038 --- /dev/null +++ b/internal/gha/src/testing/fake-github.ts @@ -0,0 +1,84 @@ +import type { GitHubApi, NewRelease, ReleaseSummary } from "../github"; + +export interface FakeGitHubOptions { + readonly repository?: string; + readonly existingTags?: readonly string[]; + readonly releases?: readonly ReleaseSummary[]; + readonly notes?: string; + /** Refs the API rejects with 422, as GitHub does for an existing ref. */ + readonly refusedRefs?: readonly string[]; + /** Number of times an update for each ref fails before succeeding. */ + readonly updateRefFailures?: Readonly>; + readonly refuseReleaseCreation?: boolean; + /** Number of release creations that fail before succeeding. */ + readonly releaseCreationFailures?: number; +} + +export interface FakeGitHub { + readonly api: GitHubApi; + readonly tags: Set; + readonly createdRefs: Array<{ ref: string; sha: string }>; + readonly updatedRefs: Array<{ ref: string; sha: string }>; + readonly createdReleases: NewRelease[]; +} + +const unsupported = (): never => { + throw new Error("not used by the code under test"); +}; + +export const fakeGitHub = (options: FakeGitHubOptions = {}): FakeGitHub => { + const tags = new Set(options.existingTags ?? []); + const refused = new Set(options.refusedRefs ?? []); + const updateRefFailures = new Map(Object.entries(options.updateRefFailures ?? {})); + let releaseCreationFailures = options.releaseCreationFailures ?? 0; + const releases = [...(options.releases ?? [])]; + const createdRefs: Array<{ ref: string; sha: string }> = []; + const updatedRefs: Array<{ ref: string; sha: string }> = []; + const createdReleases: NewRelease[] = []; + + const api: GitHubApi = { + repository: options.repository ?? "acme/repo", + request: unsupported, + graphql: unsupported, + + tagExists: (tag) => Promise.resolve(tags.has(tag)), + + createRef: (ref, sha) => { + const tag = ref.replace(/^refs\/tags\//, ""); + if (refused.has(ref) || (ref.startsWith("refs/tags/") && tags.has(tag))) { + return Promise.resolve({ ok: false, status: 422, payload: { message: "already exists" } }); + } + createdRefs.push({ ref, sha }); + if (ref.startsWith("refs/tags/")) tags.add(tag); + return Promise.resolve({ ok: true, status: 201, payload: {} }); + }, + + updateRef: (ref, sha) => { + const failures = updateRefFailures.get(ref) ?? 0; + if (failures > 0) { + updateRefFailures.set(ref, failures - 1); + return Promise.resolve({ ok: false, status: 500, payload: { message: "boom" } }); + } + updatedRefs.push({ ref, sha }); + return Promise.resolve({ ok: true, status: 200, payload: {} }); + }, + + listReleases: () => Promise.resolve(releases), + + generateNotes: () => Promise.resolve(options.notes ?? ""), + + createRelease: (release) => { + if (options.refuseReleaseCreation || releaseCreationFailures > 0) { + releaseCreationFailures -= 1; + return Promise.resolve({ ok: false, status: 500, payload: { message: "boom" } }); + } + createdReleases.push(release); + releases.push({ tag_name: release.tag, created_at: new Date().toISOString() }); + return Promise.resolve({ ok: true, status: 201, payload: {} }); + }, + + createCommitOnBranch: () => Promise.resolve("commit-oid"), + }; + + return { api, tags, createdRefs, updatedRefs, createdReleases }; +}; diff --git a/internal/gha/src/workflows.test.ts b/internal/gha/src/workflows.test.ts new file mode 100644 index 0000000..fccd44b --- /dev/null +++ b/internal/gha/src/workflows.test.ts @@ -0,0 +1,171 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; + +import { commands } from "./commands"; + +const root = fileURLToPath(new URL("../../../", import.meta.url)); +const workflowsDir = `${root}.github/workflows/`; +const examplesDir = `${root}.github/workflows-examples/`; +const scriptsDir = `${root}.github/scripts/`; + +const BUNDLE = "gha.mjs"; + +const yamlFiles = (directory: string): string[] => + readdirSync(directory).filter((file) => file.endsWith(".yml")); + +const read = (directory: string, file: string): string => readFileSync(directory + file, "utf8"); + +// `- uses: owner/repo/path@ref # comment` +const USES = /^\s*(?:-\s+)?uses:\s*(\S+?)\s*(?:#\s*(\S+))?$/gm; + +// `node .github/scripts/gha.mjs ` or `node "${SHARED_CLI}" `, +// on one line so the `SHARED_CLI` declaration itself is not mistaken for a call. +const INVOCATION = /(?:gha\.mjs|\$\{SHARED_CLI\})"?[ ]+([a-z-]+)/g; + +const usesReferences = (source: string): Array<{ reference: string; comment?: string }> => + [...source.matchAll(USES)].map(([, reference, comment]) => ({ + reference: reference ?? "", + ...(comment === undefined ? {} : { comment }), + })); + +test("every action and workflow reference is pinned to a full commit SHA", () => { + for (const file of yamlFiles(workflowsDir)) { + for (const { reference, comment } of usesReferences(read(workflowsDir, file))) { + // Same-repository calls resolve to the caller's own commit. + if (reference.startsWith("./")) continue; + + expect(reference, `${file}: "${reference}" must be pinned to a commit SHA`).toMatch( + /@[0-9a-f]{40}$/, + ); + expect(comment, `${file}: "${reference}" must carry a "# " comment`).toBeDefined(); + } + } +}); + +test("shared workflows are callable and self-contained", () => { + const shared = yamlFiles(workflowsDir).filter((file) => file.startsWith("shared-")); + expect(shared.length).toBeGreaterThan(0); + + for (const file of shared) { + const source = read(workflowsDir, file); + expect(source, `${file} must only trigger on workflow_call`).toMatch( + /^on:\n {2}workflow_call:$/m, + ); + // `env` declared by a caller is never propagated into a reusable workflow. + expect(source, `${file} must not read the caller's env context`).not.toContain("${{ env."); + } +}); + +test("callers delegate to the shared workflows", () => { + const callers: Array<[string, string]> = [ + ["ci.yml", "shared-ci.yml"], + ["codeql.yml", "shared-codeql.yml"], + ["scorecard.yml", "shared-scorecard.yml"], + // npm trusted publishing validates the caller filename, so it is part of the contract. + ["release.yml", "shared-release.yml"], + ]; + + for (const [caller, shared] of callers) { + expect(read(workflowsDir, caller), `${caller} must call ${shared}`).toContain( + `uses: ./.github/workflows/${shared}`, + ); + } +}); + +test("shared workflows set the telemetry opt-out themselves", () => { + for (const file of ["shared-ci.yml", "shared-release.yml"]) { + expect(read(workflowsDir, file), `${file} must set DO_NOT_TRACK`).toMatch( + /^ {2}DO_NOT_TRACK: 1$/m, + ); + } +}); + +test("the release tooling is checked out from the pinned shared revision", () => { + const source = read(workflowsDir, "shared-release.yml"); + const caller = read(workflowsDir, "release.yml"); + const example = read(examplesDir, "release.yml"); + + // `actions/checkout` pulls the *caller* repository, so the CLI this workflow + // runs has to come from an explicitly configured second checkout. + expect(source).toMatch(/shared-tooling-repository:\n(?: {8}.*\n){2} {8}required: true/); + expect(source).toMatch(/shared-tooling-ref:\n(?: {8}.*\n){2} {8}required: true/); + expect(source).toMatch(/repository: \$\{\{ inputs\.shared-tooling-repository \}\}/); + expect(source).toMatch(/ref: \$\{\{ inputs\.shared-tooling-ref \}\}/); + expect(source).not.toContain("job.workflow_"); + expect(caller).toMatch(/shared-tooling-repository: \$\{\{ github\.repository \}\}/); + expect(caller).toMatch(/shared-tooling-ref: \$\{\{ github\.sha \}\}/); + expect(example).toMatch(/shared-tooling-repository: zemd\/js/); + expect(example).toMatch(/shared-tooling-ref: __SHA__/); + expect(source).toMatch(/path: \.shared-ci/); + expect(source).toMatch(/echo "\/\.shared-ci\/" >> \.git\/info\/exclude/); + expect(source).toContain(`SHARED_CLI: .shared-ci/.github/scripts/${BUNDLE}`); +}); + +test("keeps OIDC with an npm token fallback for first publishes", () => { + const source = read(workflowsDir, "shared-release.yml"); + + expect(source).toMatch(/id-token: write # npm trusted publishing \(OIDC\)/); + expect(source).toMatch(/default: "https:\/\/registry\.npmjs\.org"/); + expect(source).toMatch(/registry-url: \$\{\{ inputs\.registry-url \}\}/); + expect(source).toMatch( + /- name: Publish to npm\n\s+env:\n(?:\s+#.*\n){2}\s+NODE_AUTH_TOKEN: \$\{\{ secrets\.NPM_TOKEN \}\}\n\s+run: pnpm publish -r/, + ); +}); + +test("examples pin the shared workflows through a replaceable placeholder", () => { + const examples = yamlFiles(examplesDir); + expect(examples.length).toBeGreaterThan(0); + + for (const file of examples) { + for (const { reference, comment } of usesReferences(read(examplesDir, file))) { + expect(reference, `${file}: "${reference}" must reference a shared workflow`).toMatch( + /^zemd\/js\/\.github\/workflows\/shared-[a-z]+\.yml@__SHA__$/, + ); + expect(comment, `${file}: "${reference}" must carry a "# " comment`).toBeDefined(); + } + } +}); + +test("the committed tooling is a single generated bundle", () => { + expect(readdirSync(scriptsDir)).toEqual([BUNDLE]); + expect(read(scriptsDir, BUNDLE)).toMatch( + /^\/\/ Generated by `pnpm --filter @zemd\/gha run build`/, + ); +}); + +test("every command the workflows invoke is registered in the CLI", () => { + const invoked = new Set(); + + for (const file of yamlFiles(workflowsDir)) { + for (const [, command] of read(workflowsDir, file).matchAll(INVOCATION)) { + if (command) invoked.add(command); + } + } + + expect(invoked.size).toBeGreaterThan(0); + + for (const command of invoked) { + expect( + Object.keys(commands), + `a workflow invokes "${command}", which the CLI does not register`, + ).toContain(command); + } +}); + +test("the release workflow reads the contract version from the package manifest", () => { + expect(read(workflowsDir, "release.yml")).toContain( + "gha.mjs shared-workflows-release internal/gha/package.json .github/workflows", + ); +}); + +test("the contract version is plain semver", () => { + const manifest = JSON.parse(readFileSync(`${root}internal/gha/package.json`, "utf8")) as { + version: string; + private: boolean; + }; + + expect(manifest.version).toMatch(/^\d+\.\d+\.\d+$/); + // A published package would drag the workflow contract into the npm release. + expect(manifest.private).toBe(true); +}); diff --git a/internal/gha/tsconfig.json b/internal/gha/tsconfig.json new file mode 100644 index 0000000..d53f3e0 --- /dev/null +++ b/internal/gha/tsconfig.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "@zemd/gha", + "extends": "@zemd/tsconfig/tsconfig-library.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src", "scripts"], + "exclude": ["node_modules", "dist"] +} diff --git a/internal/gha/tsdown.config.js b/internal/gha/tsdown.config.js new file mode 100644 index 0000000..0e1d30a --- /dev/null +++ b/internal/gha/tsdown.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + // A single entry keeps the output a single file: the shared workflows run it + // straight from a checkout, without a package manager or node_modules around. + entry: { gha: "src/cli.ts" }, + format: ["esm"], + platform: "node", + dts: false, + // Readable output keeps the committed diff reviewable and CodeQL useful. + minify: false, + target: false, +}); diff --git a/package.json b/package.json index 0062a76..70cf101 100644 --- a/package.json +++ b/package.json @@ -10,16 +10,15 @@ }, "scripts": { "build": "turbo run build", - "test": "turbo run test && pnpm run test-release", - "test-release": "node --test .github/scripts/release-pr-body.test.mjs .github/scripts/release-workflow.test.mjs", + "test": "turbo run test", "test-browser": "turbo run test-browser", "test-browser-setup": "pnpm --filter @zemd/std-modules exec playwright install --with-deps chromium", "typecheck": "turbo run typecheck", "format": "oxfmt", "format-check": "oxfmt --check", - "lint": "oxlint --type-aware --fix", - "lint-check": "oxlint --type-aware --deny-warnings", - "lint-publish": "pnpm -r exec publint", + "lint": "oxlint --type-aware --fix --ignore-pattern=.github/scripts", + "lint-check": "oxlint --type-aware --deny-warnings --ignore-pattern=.github/scripts", + "lint-publish": "pnpm -r --filter='!./internal/**' exec publint", "release-change": "pnpm change", "release-status": "pnpm change status", "prepare": "husky" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0459247..5b1b033 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,24 @@ importers: specifier: 'catalog:' version: 7.0.2 + internal/gha: + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 26.1.2 + '@zemd/tsconfig': + specifier: workspace:* + version: link:../../packages/tsconfig + tsdown: + specifier: 'catalog:' + version: 0.22.14(publint@0.3.23)(typescript@7.0.2) + typescript: + specifier: 'catalog:' + version: 7.0.2 + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@26.1.2)(@vitest/browser-playwright@4.1.10)(happy-dom@20.11.1)(vite@8.2.0(@types/node@26.1.2)(yaml@2.9.0)) + packages/color: dependencies: '@zemd/std-modules': @@ -2706,7 +2724,7 @@ snapshots: picomatch: 4.0.5 std-env: 4.2.0 tinybench: 2.9.0 - tinyexec: 1.2.4 + tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 vite: 8.2.0(@types/node@26.1.2)(yaml@2.9.0) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f446876..cf1de03 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,7 @@ saveExact: true packages: - packages/* - http-clients/* + - internal/* versioning: changelog: diff --git a/turbo.json b/turbo.json index cfd7ae4..b47b1ec 100644 --- a/turbo.json +++ b/turbo.json @@ -1,11 +1,16 @@ { "$schema": "https://turborepo.dev/schema.json", "ui": "stream", - "globalDependencies": ["packages/tsconfig/tsconfig-*.json"], + "globalDependencies": [ + "packages/tsconfig/tsconfig-*.json", + ".github/workflows/*.yml", + ".github/workflows-examples/*.yml", + ".github/scripts/*.mjs" + ], "tasks": { "build": { "dependsOn": ["^build"], - "inputs": ["src/**", "tsconfig.json", "tsdown.config.js", "package.json"], + "inputs": ["src/**", "scripts/**", "tsconfig.json", "tsdown.config.js", "package.json"], "outputs": ["dist/**"] }, "test": {