diff --git a/.github/actions/common/steps/checkout-code/action.yml b/.github/actions/common/steps/checkout-code/action.yml index 39103ac5..0a402db5 100644 --- a/.github/actions/common/steps/checkout-code/action.yml +++ b/.github/actions/common/steps/checkout-code/action.yml @@ -2,6 +2,10 @@ name: "Checkout Code" description: "Checkout repository code with configurable fetch depth and ref" inputs: + token: + description: "Token to check out with and (when persist-credentials is true) to persist for later git pushes. Empty (default) = the workflow GITHUB_TOKEN. Pass a GitHub App token when a later step pushes tags or branches: the persisted GITHUB_TOKEN can never hold the `workflows` scope, so GitHub refuses a push whose commit's .github/workflows differ from the tip (CLDMV/.github#362)." + required: false + default: "" fetch-depth: description: "Number of commits to fetch. 0 indicates all history for all branches and tags" required: false @@ -24,3 +28,4 @@ runs: fetch-depth: ${{ inputs.fetch-depth }} ref: ${{ inputs.ref }} persist-credentials: ${{ inputs.persist-credentials }} + token: ${{ inputs.token || github.token }} diff --git a/.github/actions/git/jobs/update-major-version-tags/action.yml b/.github/actions/git/jobs/update-major-version-tags/action.yml index 5680cb36..c94e451f 100644 --- a/.github/actions/git/jobs/update-major-version-tags/action.yml +++ b/.github/actions/git/jobs/update-major-version-tags/action.yml @@ -74,6 +74,9 @@ runs: uses: CLDMV/.github/.github/actions/common/steps/checkout-code@v4 with: fetch-depth: 0 + # Persist the App token, not GITHUB_TOKEN, so tag pushes carry the + # `workflows` scope (CLDMV/.github#362). + token: ${{ inputs.github_token }} # - name: Sanity - create/move a temp tag in this repo # env: diff --git a/.github/actions/git/steps/fix-non-bot-tags/action.mjs b/.github/actions/git/steps/fix-non-bot-tags/action.mjs index eec6a847..4371b231 100644 --- a/.github/actions/git/steps/fix-non-bot-tags/action.mjs +++ b/.github/actions/git/steps/fix-non-bot-tags/action.mjs @@ -7,7 +7,7 @@ import { writeFileSync, unlinkSync } from "fs"; import { execFileSync } from "node:child_process"; -import { getTagInfo } from "../../utilities/git-utils.mjs"; +import { getTagInfo, annotatedTagArgs } from "../../utilities/git-utils.mjs"; import { debugLog } from "../../../common/common/core.mjs"; import { importGpgIfNeeded, configureGitIdentity } from "../../../github/api/_api/gpg.mjs"; @@ -90,7 +90,7 @@ function fixNonBotTag(tagObj) { const msgFile = `${process.env.RUNNER_TEMP || "/tmp"}/tag-msg-${Date.now()}.txt`; writeFileSync(msgFile, tagMessage, "utf8"); try { - execFileSync("git", ["tag", "-f", "-a", ...(willSign ? ["-s"] : []), "-F", msgFile, tagObj.name, tagObj.commitSha], { + execFileSync("git", annotatedTagArgs({ tagName: tagObj.name, target: tagObj.commitSha, messageFile: msgFile, sign: !!willSign }), { stdio: ["ignore", "inherit", "inherit"] }); } finally { diff --git a/.github/actions/git/steps/fix-orphaned-tags/action.mjs b/.github/actions/git/steps/fix-orphaned-tags/action.mjs index 05f710de..c0572ca2 100644 --- a/.github/actions/git/steps/fix-orphaned-tags/action.mjs +++ b/.github/actions/git/steps/fix-orphaned-tags/action.mjs @@ -7,7 +7,7 @@ import { writeFileSync, unlinkSync } from "fs"; import { execFileSync } from "node:child_process"; -import { gitCommand } from "../../utilities/git-utils.mjs"; +import { gitCommand, annotatedTagArgs } from "../../utilities/git-utils.mjs"; import { importGpgIfNeeded, configureGitIdentity } from "../../../github/api/_api/gpg.mjs"; console.log("๐Ÿ” DEBUG: Orphaned tags action starting..."); @@ -208,9 +208,9 @@ function fixOrphanedTag(tagObj) { let tagArgs; if (GPG_ENABLED && GPG_PRIVATE_KEY) { // Always create signed annotated tags when GPG is enabled - tagArgs = ["tag", "-f", "-s", "-a", "-F", msgFile, tagName, equivalentCommit]; + tagArgs = annotatedTagArgs({ tagName, target: equivalentCommit, messageFile: msgFile, sign: true }); } else if (tagObj.isAnnotated) { - tagArgs = ["tag", "-f", "-a", "-F", msgFile, tagName, equivalentCommit]; + tagArgs = annotatedTagArgs({ tagName, target: equivalentCommit, messageFile: msgFile }); } else { tagArgs = ["tag", "-f", tagName, equivalentCommit]; } diff --git a/.github/actions/git/steps/fix-unsigned-tags/action.mjs b/.github/actions/git/steps/fix-unsigned-tags/action.mjs index 864f806e..042cf6a9 100644 --- a/.github/actions/git/steps/fix-unsigned-tags/action.mjs +++ b/.github/actions/git/steps/fix-unsigned-tags/action.mjs @@ -7,7 +7,7 @@ import { writeFileSync, unlinkSync } from "fs"; import { execFileSync } from "node:child_process"; -import { gitCommand } from "../../utilities/git-utils.mjs"; +import { gitCommand, annotatedTagArgs } from "../../utilities/git-utils.mjs"; import { debugLog } from "../../../common/common/core.mjs"; import { importGpgIfNeeded, configureGitIdentity } from "../../../github/api/_api/gpg.mjs"; import { api, parseRepo } from "../../../github/api/_api/core.mjs"; @@ -185,7 +185,7 @@ async function fixUnsignedTag(tagObj) { // spliced into a shell command line. const msgFile = `${process.env.RUNNER_TEMP || "/tmp"}/tag-msg-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`; writeFileSync(msgFile, tagMessage, "utf8"); - const tagArgs = ["tag", "-f", "-a", ...(GPG_ENABLED && GPG_PRIVATE_KEY ? ["-s"] : []), "-F", msgFile, tagName, commitSha]; + const tagArgs = annotatedTagArgs({ tagName, target: commitSha, messageFile: msgFile, sign: !!(GPG_ENABLED && GPG_PRIVATE_KEY) }); const made = git(tagArgs); try { unlinkSync(msgFile); @@ -456,6 +456,11 @@ if (githubOutput) { console.log("๐Ÿ” DEBUG: No GITHUB_OUTPUT file available"); } +// A refused signing push is a real error now: the job pushes with the bot App +// token, which requests the `workflows` scope (the old refusals came from the +// persisted GITHUB_TOKEN). The original tag is left untouched in that case. +for (const f of failedTags) console.error(`::error::Could not replace ${f.tagName} with a signed tag: ${f.reason}`); +if (failedTags.length > 0) process.exitCode = 1; if (brokenReleases.length > 0) { for (const b of brokenReleases) console.error(`::error::${b}`); console.error( diff --git a/.github/actions/git/utilities/git-utils.mjs b/.github/actions/git/utilities/git-utils.mjs index 4db879f3..46126191 100644 --- a/.github/actions/git/utilities/git-utils.mjs +++ b/.github/actions/git/utilities/git-utils.mjs @@ -175,3 +175,23 @@ export function getTagInfo(tagName, botPatterns = ["CLDMV Bot", "cldmv-bot", "gi return null; } } + +/** + * argv for creating (or replacing) an annotated tag from a message file. + * + * `--cleanup=verbatim` is required: git's default message cleanup treats every + * line starting with `#` as a comment and drops it, which stripped all the + * Markdown headings (`# โ€ฆ Changelog`, `## Overview`, โ€ฆ) from release tag + * messages built from changelog files. + * @public + * @param {object} opts + * @param {string} opts.tagName - Tag to create. + * @param {string} opts.target - Commit (or object) the tag points at. + * @param {string} opts.messageFile - Path of the file holding the tag message. + * @param {boolean} [opts.sign=false] - GPG-sign the tag (`-s`). + * @param {boolean} [opts.force=true] - Replace an existing local tag (`-f`). + * @returns {string[]} Arguments for `git` (pass to execFileSync, no shell). + */ +export function annotatedTagArgs({ tagName, target, messageFile, sign = false, force = true }) { + return ["tag", ...(force ? ["-f"] : []), "-a", ...(sign ? ["-s"] : []), "--cleanup=verbatim", "-F", messageFile, tagName, target]; +} diff --git a/.github/actions/git/utilities/tag-message.test.mjs b/.github/actions/git/utilities/tag-message.test.mjs new file mode 100644 index 00000000..f7b35e22 --- /dev/null +++ b/.github/actions/git/utilities/tag-message.test.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node +// Tag messages built from changelog files must keep their Markdown headings. +// git's default cleanup drops every line starting with `#` as a comment, which +// stripped `# โ€ฆ Changelog` / `## Overview` from release tag messages. +// Run: node .github/actions/git/utilities/tag-message.test.mjs +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { annotatedTagArgs } from "./git-utils.mjs"; + +const repo = mkdtempSync(path.join(tmpdir(), "tag-message-")); +const git = (...args) => + execFileSync( + "git", + ["-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "tag.gpgsign=false", "-c", "commit.gpgsign=false", ...args], + { + cwd: repo, + encoding: "utf8" + } + ); + +try { + git("init", "-q"); + git("commit", "-q", "--allow-empty", "-m", "init"); + const message = "# @cldmv/x v1.2.4 Changelog\n\n## Overview\n\nText.\n\n## ๐Ÿ› Bug Fixes\n\n- fix\n"; + const msgFile = path.join(repo, "msg.txt"); + writeFileSync(msgFile, message, "utf8"); + + git(...annotatedTagArgs({ tagName: "v1.2.4", target: "HEAD", messageFile: msgFile })); + const body = git("tag", "-l", "--format=%(contents)", "v1.2.4"); + assert.ok(body.includes("# @cldmv/x v1.2.4 Changelog"), "H1 heading kept"); + assert.ok(body.includes("## Overview"), "## heading kept"); + assert.ok(body.includes("## ๐Ÿ› Bug Fixes"), "emoji heading kept"); + + // Replacing the tag (-f, the tag-health re-sign path) keeps them too. + git(...annotatedTagArgs({ tagName: "v1.2.4", target: "HEAD", messageFile: msgFile })); + assert.ok(git("tag", "-l", "--format=%(contents)", "v1.2.4").includes("## Overview"), "## heading kept on replace"); + + // Control: without --cleanup=verbatim git drops the heading lines. + git("tag", "-a", "-F", msgFile, "v0.0.1", "HEAD"); + assert.ok(!git("tag", "-l", "--format=%(contents)", "v0.0.1").includes("## Overview"), "default cleanup strips headings (control)"); + + // Shell-string call sites keep the flag too. + const here = path.dirname(fileURLToPath(import.meta.url)); + for (const rel of [ + "../../github/api/tag/create/_impl.mjs", + "../../github/api/tag/update/_impl.mjs", + "../../github/steps/fix-orphaned-releases/action.mjs" + ]) { + const src = readFileSync(path.join(here, rel), "utf8"); + for (const line of src.split("\n").filter((l) => /\b(sh|gitCommand)\(`git tag -[as] /.test(l) && l.includes("-F"))) { + assert.ok(line.includes("--cleanup=verbatim"), `${rel}: ${line.trim()}`); + } + } + console.log("tag-message: all checks passed"); +} finally { + rmSync(repo, { recursive: true, force: true }); +} diff --git a/.github/actions/github/api/tag/create/_impl.mjs b/.github/actions/github/api/tag/create/_impl.mjs index 90b3f453..b73cb0b8 100644 --- a/.github/actions/github/api/tag/create/_impl.mjs +++ b/.github/actions/github/api/tag/create/_impl.mjs @@ -1,61 +1,70 @@ import fs from "node:fs"; -import { sh } from "../../../../common/common/core.mjs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; import { ensureGitAuthRemote, configureGitIdentity, importGpgIfNeeded } from "../../_api/gpg.mjs"; -import { - getRefTag, - getTagObject, - createRefToCommit, - forceMoveRefToCommit, - createAnnotatedTag, - createRefForTagObject, - forceMoveRefToTagObject -} from "../../_api/tag.mjs"; +import { getRefTag, getTagObject } from "../../_api/tag.mjs"; import { debugLog } from "../../../../common/common/core.mjs"; +import { annotatedTagArgs } from "../../../../git/utilities/git-utils.mjs"; -function runGitSmartTag({ repo, token, tag, sha, message, gpg_enabled, tagger_name, tagger_email, gpg_private_key, gpg_passphrase, push }) { - debugLog(`runGitSmartTag: repo=${repo}, tag=${tag}, sha=${sha}`); - debugLog(`runGitSmartTag: token starts with ${token?.substring(0, 10)}...`); - debugLog(`runGitSmartTag: gpg_enabled=${gpg_enabled}, push=${push}`); - debugLog(`runGitSmartTag: tagger_name=${tagger_name}, tagger_email=${tagger_email}`); - debugLog(`runGitSmartTag: gpg_private_key present=${!!gpg_private_key}`); - - ensureGitAuthRemote(repo, token); - const willSign = gpg_enabled && gpg_private_key; - const willAnnotate = gpg_enabled; // Always annotate when GPG is enabled - let keyid = ""; - if (willSign) keyid = importGpgIfNeeded({ gpg_private_key, gpg_passphrase }); - configureGitIdentity({ tagger_name, tagger_email, keyid, enableSign: willSign }); - - debugLog(`runGitSmartTag: willSign=${willSign}, willAnnotate=${willAnnotate}`); - - // Ensure we have a message for annotated/signed tags to prevent Git editor from opening - const tagMessage = message || `Update ${tag} tag`; - debugLog(`runGitSmartTag: received message="${message}"`); - debugLog(`runGitSmartTag: final tagMessage="${tagMessage}"`); +/** + * Create a tag locally with git and (optionally) push it. No shell: every git + * call takes an argument vector. Annotated/signed tags take their message from + * a file with `--cleanup=verbatim`, so Markdown headings survive. + * + * There is deliberately NO fallback: if the push is refused, this throws with + * the tag name and git's own error text. The old REST Git-Data fallback created + * an annotated but UNSIGNED tag, and release tags must be bot-signed. (The + * refusal it papered over โ€” "refusing to allow a GitHub App to create or update + * workflow โ€ฆ without workflows permission" โ€” came from pushing with the + * workflow GITHUB_TOKEN persisted by actions/checkout, which can never hold the + * `workflows` scope; callers now push with an App token that requests it.) + * @param {object} opts + * @param {string} opts.tag - Tag name. + * @param {string} opts.sha - Commit the tag points at. + * @param {string} [opts.message] - Tag message (annotated/signed tags). + * @param {boolean} [opts.annotate=false] - Create an annotated tag. + * @param {boolean} [opts.sign=false] - GPG-sign the tag (implies annotate). + * @param {boolean} [opts.push=true] - Push to `remote`. + * @param {string} [opts.remote="origin"] - Remote name or URL to push to. + * @param {string} [opts.cwd] - Repository directory. + * @returns {void} + */ +export function createAndPushTag({ + tag, + sha, + message = "", + annotate = false, + sign = false, + push = true, + remote = "origin", + cwd = process.cwd() +}) { + if (!/^[\w.@+/-]+$/.test(tag) || tag.startsWith("-")) + throw new Error(`Refusing to create a tag with an unexpected name: ${JSON.stringify(tag)}`); + if (!/^[0-9a-f]{7,64}$/i.test(sha)) throw new Error(`Refusing to tag an unexpected object id for ${tag}: ${JSON.stringify(sha)}`); + const git = (args) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); - if (willSign) { - debugLog(`runGitSmartTag: Creating signed tag: git tag -s -f -F tempfile ${tag} ${sha}`); - // Write message to temp file to handle multiline messages properly - const tmpFile = `${process.env.RUNNER_TEMP || process.env.TEMP || "/tmp"}/tag-message-${Date.now()}.txt`; - fs.writeFileSync(tmpFile, tagMessage, "utf8"); - sh(`git tag -s -f -F "${tmpFile}" ${tag} ${sha}`); - fs.unlinkSync(tmpFile); - } else if (willAnnotate) { - debugLog(`runGitSmartTag: Creating annotated tag: git tag -a -f -F tempfile ${tag} ${sha}`); - // Write message to temp file to handle multiline messages properly - const tmpFile = `${process.env.RUNNER_TEMP || process.env.TEMP || "/tmp"}/tag-message-${Date.now()}.txt`; - fs.writeFileSync(tmpFile, tagMessage, "utf8"); - sh(`git tag -a -f -F "${tmpFile}" ${tag} ${sha}`); - fs.unlinkSync(tmpFile); + if (sign || annotate) { + const dir = fs.mkdtempSync(path.join(process.env.RUNNER_TEMP || os.tmpdir(), "tag-msg-")); + const messageFile = path.join(dir, "message.txt"); + fs.writeFileSync(messageFile, message || tag, "utf8"); + try { + git(annotatedTagArgs({ tagName: tag, target: sha, messageFile, sign })); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } } else { - debugLog(`runGitSmartTag: Creating lightweight tag: git tag -f ${tag} ${sha}`); - sh(`git tag -f ${tag} ${sha}`); + git(["tag", "-f", tag, sha]); } - if (push) { - debugLog(`runGitSmartTag: Pushing tag: git push origin +refs/tags/${tag}`); - sh(`git push origin +refs/tags/${tag}`); + + if (!push) return; + try { + git(["push", remote, `+refs/tags/${tag}:refs/tags/${tag}`]); + } catch (error) { + const detail = `${error.stdout || ""}${error.stderr || ""}`.trim() || error.message; + throw new Error(`Push of tag ${tag} was refused โ€” no tag was created on the remote. git said: ${detail}`, { cause: error }); } - return { tag_obj_sha: "", ref_sha: sha }; } export async function run({ @@ -69,27 +78,29 @@ export async function run({ tagger_email = "", gpg_private_key = "", gpg_passphrase = "", - push = true + push = true, + // Test seams: skip the REST idempotency read and the token remote rewrite, + // and push to a given remote instead of origin. + skipPrecheck = false, + configureRemote = true, + remote = "origin", + cwd = process.cwd() }) { - debugLog(`create/_impl.run: Called with message="${message}"`); + debugLog(`create/_impl.run: tag=${tag}, sha=${sha}, gpg_enabled=${gpg_enabled}, push=${push}`); // Idempotency / tag-protection safety. The tags created here are IMMUTABLE // release tags (the rolling vN / vN.Y tags are MOVED by a separate action, not // this one). If the remote tag already points at the target commit it is already // correct, so skip creating and (force-)pushing it. This makes a re-run a true // no-op for tags already out, avoids needlessly re-signing an immutable tag, and - // โ€” crucially for retry โ€” never trips a tag-protection rule that forbids - // overwriting an existing tag (the force-push a retry would otherwise issue is - // what reds the job). Only relevant when we would push; a local-only tag - // (push=false) still goes through the normal path. The pre-check is best-effort: - // any error falls through to the normal create path, which has its own handling. - if (push) { + // never trips a tag-protection rule that forbids overwriting an existing tag. + // Best-effort: any error falls through to the normal create path. + if (push && !skipPrecheck) { try { const state = await getRefTag({ token, repo, tag }); if (state.exists) { let targetCommit = state.refSha; if (state.objectType === "tag" && state.refSha) { - // Annotated tag: deref the tag object to the commit it points at. const obj = await getTagObject({ token, repo, tagObjectSha: state.refSha }); targetCommit = obj.exists ? obj.tag?.object?.sha || "" : ""; } @@ -104,97 +115,12 @@ export async function run({ } } - // Fallback to API lightweight tag if push via git isn't possible - try { - return runGitSmartTag({ - repo, - token, - tag, - sha, - message, - gpg_enabled, - tagger_name, - tagger_email, - gpg_private_key, - gpg_passphrase, - push - }); - } catch (e) { - console.warn("Git-based tagging failed, falling back to API tag creation:", e.message); - debugLog(`create/_impl: API fallback starting for tag=${tag}, sha=${sha}`); - debugLog(`create/_impl: API fallback params - gpg_enabled=${gpg_enabled}, tagger_name=${tagger_name}, tagger_email=${tagger_email}`); - debugLog(`create/_impl: API fallback message="${message}"`); - - // Check if tag ref already exists - debugLog(`create/_impl: Checking if tag ref exists...`); - const state = await getRefTag({ token, repo, tag }); - debugLog(`create/_impl: Tag ref exists: ${state.exists}, refSha: ${state.refSha}, objectType: ${state.objectType}`); - - // Determine if we should create an annotated tag - const shouldAnnotate = gpg_enabled || (message && message !== tag); - debugLog(`create/_impl: shouldAnnotate=${shouldAnnotate} (gpg_enabled=${gpg_enabled}, message differs=${message !== tag})`); - - if (shouldAnnotate && tagger_name && tagger_email) { - debugLog(`create/_impl: Creating annotated tag with API...`); - // Always (re)create the annotated tag object and point the ref at it. Tag - // objects are immutable, but creating a fresh one and moving the ref onto - // it on a re-run is correct and keeps the tag ANNOTATED. Gating this on - // `!state.exists` was a bug: when the ref already existed (a re-run) it - // fell through to the lightweight branch below and force-moved the ref to a - // bare commit, silently DOWNGRADING a previously annotated/signed tag to a - // lightweight one. The ref upsert (create, else force-move) is handled - // right below, so an existing ref is fine here. - const tagger = { name: tagger_name, email: tagger_email }; - debugLog(`create/_impl: Tagger object: ${JSON.stringify(tagger)}`); - - try { - const tagObj = await createAnnotatedTag({ token, repo, tag, message: message || tag, objectSha: sha, tagger }); - debugLog(`create/_impl: Annotated tag created successfully, tagObj.sha=${tagObj.sha}`); + if (configureRemote) ensureGitAuthRemote(repo, token); + const sign = !!(gpg_enabled && gpg_private_key); + let keyid = ""; + if (sign) keyid = importGpgIfNeeded({ gpg_private_key, gpg_passphrase }); + configureGitIdentity({ tagger_name, tagger_email, keyid, enableSign: sign }); - // Create the ref to point to the tag object - debugLog(`create/_impl: Creating ref to point to tag object...`); - try { - const refResult = await createRefForTagObject({ token, repo, tag, tagObjectSha: tagObj.sha }); - debugLog(`create/_impl: Ref created successfully: ${JSON.stringify(refResult)}`); - } catch (refError) { - debugLog(`create/_impl: Ref creation failed, trying force move: ${refError.message}`); - const forceResult = await forceMoveRefToTagObject({ token, repo, tag, tagObjectSha: tagObj.sha }); - debugLog(`create/_impl: Force move successful: ${JSON.stringify(forceResult)}`); - } - return { tag_obj_sha: tagObj.sha, ref_sha: tagObj.sha }; - } catch (tagError) { - debugLog(`create/_impl: Annotated tag creation failed: ${tagError.message}`); - throw tagError; - } - } else { - debugLog(`create/_impl: Creating lightweight tag with API (shouldAnnotate=${shouldAnnotate}, state.exists=${state.exists})`); - // Fallback to lightweight tag (or move existing ref) - if (state.exists) { - debugLog(`create/_impl: Moving existing ref to new commit...`); - try { - const moveResult = await forceMoveRefToCommit({ token, repo, tag, commitSha: sha }); - debugLog(`create/_impl: Ref moved successfully: ${JSON.stringify(moveResult)}`); - } catch (moveError) { - debugLog(`create/_impl: Ref move failed: ${moveError.message}`); - throw moveError; - } - } else { - debugLog(`create/_impl: Creating new lightweight tag ref...`); - try { - const createResult = await createRefToCommit({ token, repo, tag, commitSha: sha }); - debugLog(`create/_impl: Lightweight ref created successfully: ${JSON.stringify(createResult)}`); - } catch (createError) { - debugLog(`create/_impl: Lightweight ref creation failed, trying force move: ${createError.message}`); - try { - const forceResult = await forceMoveRefToCommit({ token, repo, tag, commitSha: sha }); - debugLog(`create/_impl: Force move successful: ${JSON.stringify(forceResult)}`); - } catch (forceError) { - debugLog(`create/_impl: Force move failed: ${forceError.message}`); - throw forceError; - } - } - } - return { tag_obj_sha: "", ref_sha: sha }; - } - } + createAndPushTag({ tag, sha, message: message || tag, annotate: !!gpg_enabled, sign, push, remote, cwd }); + return { tag_obj_sha: "", ref_sha: sha }; } diff --git a/.github/actions/github/api/tag/create/test.mjs b/.github/actions/github/api/tag/create/test.mjs new file mode 100644 index 00000000..a5a22f9b --- /dev/null +++ b/.github/actions/github/api/tag/create/test.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +// tag/create: signed tag creation pushes a signed, verbatim-message tag; a +// refused push throws (naming the tag) and never falls back to an unsigned tag. +// Run: node .github/actions/github/api/tag/create/test.mjs +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync, chmodSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createAndPushTag, run } from "./_impl.mjs"; + +const root = mkdtempSync(path.join(tmpdir(), "tag-create-test-")); +const gnupg = path.join(root, "gnupg"); +mkdirSync(gnupg, { mode: 0o700 }); +const env = { ...process.env, GNUPGHOME: gnupg, GIT_CONFIG_GLOBAL: path.join(root, "gitconfig"), GIT_CONFIG_NOSYSTEM: "1" }; +const sh = (cmd, args, cwd = root) => execFileSync(cmd, args, { cwd, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + +const savedEnv = { + GNUPGHOME: process.env.GNUPGHOME, + GIT_CONFIG_GLOBAL: process.env.GIT_CONFIG_GLOBAL, + GIT_CONFIG_NOSYSTEM: process.env.GIT_CONFIG_NOSYSTEM, + RUNNER_TEMP: process.env.RUNNER_TEMP +}; +Object.assign(process.env, { GNUPGHOME: gnupg, GIT_CONFIG_GLOBAL: env.GIT_CONFIG_GLOBAL, GIT_CONFIG_NOSYSTEM: "1", RUNNER_TEMP: root }); + +try { + // Throwaway signing key, no passphrase. + sh("gpg", [ + "--batch", + "--pinentry-mode", + "loopback", + "--passphrase", + "", + "--quick-gen-key", + "Test Bot ", + "ed25519", + "sign", + "1d" + ]); + const keyid = sh("gpg", ["--list-secret-keys", "--with-colons"]) + .split("\n") + .find((l) => l.startsWith("sec:")) + .split(":")[4]; + + // Work repo + bare remote. + const remote = path.join(root, "remote.git"); + const work = path.join(root, "work"); + sh("git", ["init", "-q", "--bare", remote]); + sh("git", ["init", "-q", work]); + for (const [k, v] of [ + ["user.name", "Test Bot"], + ["user.email", "bot@example.com"], + ["user.signingkey", keyid], + ["commit.gpgsign", "false"] + ]) + sh("git", ["config", k, v], work); + sh("git", ["commit", "-q", "--allow-empty", "-m", "release: v1.2.3 - x"], work); + const sha = sh("git", ["rev-parse", "HEAD"], work).trim(); + const message = "# pkg v1.2.3 Changelog\n\n## Overview\n\nNotes.\n"; + + // 1. Signed path: the tag on the remote is signed and keeps the headings. + createAndPushTag({ tag: "v1.2.3", sha, message, annotate: true, sign: true, remote, cwd: work }); + const obj = sh("git", ["cat-file", "-p", "refs/tags/v1.2.3"], remote); + assert.match(obj, /-----BEGIN PGP SIGNATURE-----/, "remote tag is signed"); + assert.ok(obj.includes("## Overview") && obj.includes("# pkg v1.2.3 Changelog"), "headings kept verbatim"); + sh("git", ["tag", "-v", "v1.2.3"], work); // throws if the signature doesn't verify + + // 2. Refused push: throws naming the tag and git's reason; nothing on the remote. + const hook = path.join(remote, "hooks", "pre-receive"); + writeFileSync( + hook, + "#!/bin/sh\necho 'refusing to allow a GitHub App to create or update workflow without workflows permission' >&2\nexit 1\n" + ); + chmodSync(hook, 0o755); + assert.throws( + () => createAndPushTag({ tag: "v1.2.4", sha, message, annotate: true, sign: true, remote, cwd: work }), + (e) => /v1\.2\.4/.test(e.message) && /refused/.test(e.message) && /workflows permission/.test(e.message), + "refused push throws with tag name and git's error" + ); + assert.equal(sh("git", ["tag", "-l", "v1.2.4"], remote).trim(), "", "no tag created on the remote"); + + // 3. run(): same refusal propagates, and no REST call is attempted (no fallback). + const realFetch = globalThis.fetch; + let fetchCalls = 0; + globalThis.fetch = async () => { + fetchCalls++; + throw new Error("unexpected REST call"); + }; + try { + await assert.rejects( + run({ + token: "x", + repo: "o/r", + tag: "v1.2.5", + sha, + message, + gpg_enabled: true, + skipPrecheck: true, + configureRemote: false, + remote, + cwd: work + }), + /Push of tag v1\.2\.5 was refused/ + ); + } finally { + globalThis.fetch = realFetch; + } + assert.equal(fetchCalls, 0, "no REST fallback"); + assert.equal(sh("git", ["tag", "-l", "v1.2.5"], remote).trim(), "", "no unsigned tag created"); + + console.log("tag/create: all checks passed"); +} finally { + for (const [k, v] of Object.entries(savedEnv)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + try { + execFileSync("gpgconf", ["--kill", "gpg-agent"], { env, stdio: "ignore" }); + } catch { + // agent may not be running + } + rmSync(root, { recursive: true, force: true }); +} diff --git a/.github/actions/github/api/tag/update/_impl.mjs b/.github/actions/github/api/tag/update/_impl.mjs index 6bfab181..b79e30d6 100644 --- a/.github/actions/github/api/tag/update/_impl.mjs +++ b/.github/actions/github/api/tag/update/_impl.mjs @@ -28,14 +28,14 @@ function runGitSmartTag({ repo, token, tag, sha, message, gpg_enabled, tagger_na // Write message to temp file to handle multiline messages properly const tmpFile = `${process.env.RUNNER_TEMP || process.env.TEMP || "/tmp"}/tag-message-${Date.now()}.txt`; fs.writeFileSync(tmpFile, tagMessage, "utf8"); - sh(`git tag -s -f -F "${tmpFile}" ${tag} ${sha}`); + sh(`git tag -s -f --cleanup=verbatim -F "${tmpFile}" ${tag} ${sha}`); fs.unlinkSync(tmpFile); } else if (willAnnotate) { debugLog(`runGitSmartTag: Creating annotated tag: git tag -a -f -m "${tagMessage}" ${tag} ${sha}`); // Write message to temp file to handle multiline messages properly const tmpFile = `${process.env.RUNNER_TEMP || process.env.TEMP || "/tmp"}/tag-message-${Date.now()}.txt`; fs.writeFileSync(tmpFile, tagMessage, "utf8"); - sh(`git tag -a -f -F "${tmpFile}" ${tag} ${sha}`); + sh(`git tag -a -f --cleanup=verbatim -F "${tmpFile}" ${tag} ${sha}`); fs.unlinkSync(tmpFile); } else { debugLog(`runGitSmartTag: Creating lightweight tag: git tag -f ${tag} ${sha}`); diff --git a/.github/actions/github/jobs/create-release/action.yml b/.github/actions/github/jobs/create-release/action.yml index 28d4c29f..15a058d4 100644 --- a/.github/actions/github/jobs/create-release/action.yml +++ b/.github/actions/github/jobs/create-release/action.yml @@ -108,6 +108,9 @@ runs: uses: CLDMV/.github/.github/actions/common/steps/checkout-code@v4 with: fetch-depth: 0 + # Persist the App token, not GITHUB_TOKEN, so the tag push carries the + # `workflows` scope (CLDMV/.github#362). + token: ${{ inputs.github-token }} - name: Prepare release metadata id: prepare diff --git a/.github/actions/github/steps/fix-orphaned-releases/action.mjs b/.github/actions/github/steps/fix-orphaned-releases/action.mjs index a0a61869..004b52dd 100644 --- a/.github/actions/github/steps/fix-orphaned-releases/action.mjs +++ b/.github/actions/github/steps/fix-orphaned-releases/action.mjs @@ -10,7 +10,6 @@ import { execSync } from "node:child_process"; import { gitCommand } from "../../../git/utilities/git-utils.mjs"; import { importGpgIfNeeded, configureGitIdentity, ensureGitAuthRemote } from "../../api/_api/gpg.mjs"; import { api, parseRepo } from "../../api/_api/core.mjs"; -import { createAnnotatedTag, createRefForTagObject, forceMoveRefToTagObject } from "../../api/_api/tag.mjs"; const DEBUG = process.env.INPUT_DEBUG === "true"; const GITHUB_TOKEN = process.env.INPUT_GITHUB_TOKEN || process.env.GITHUB_TOKEN || process.env.GH_TOKEN || ""; @@ -252,7 +251,7 @@ async function createMissingTag(tagName, targetCommit, releaseName) { // Use temp file for message to handle multiline content properly const tempFile = `/tmp/tag-message-${Date.now()}.txt`; writeFileSync(tempFile, tagMessage, "utf8"); - gitCommand(`git tag -s -f -F "${tempFile}" ${tagName} ${targetCommit}`); + gitCommand(`git tag -s -f --cleanup=verbatim -F "${tempFile}" ${tagName} ${targetCommit}`); try { require("fs").unlinkSync(tempFile); } catch { @@ -263,7 +262,7 @@ async function createMissingTag(tagName, targetCommit, releaseName) { // Use temp file for message to handle multiline content properly const tempFile = `/tmp/tag-message-${Date.now()}.txt`; writeFileSync(tempFile, tagMessage, "utf8"); - gitCommand(`git tag -a -f -F "${tempFile}" ${tagName} ${targetCommit}`); + gitCommand(`git tag -a -f --cleanup=verbatim -F "${tempFile}" ${tagName} ${targetCommit}`); try { require("fs").unlinkSync(tempFile); } catch { @@ -299,64 +298,16 @@ async function createMissingTag(tagName, targetCommit, releaseName) { console.error(`โŒ Git command failed: git push origin +refs/tags/${tagName}`); console.error(pushErrorText); - // GitHub's git-protocol push path has a known, still-unresolved bug (see - // https://github.com/orgs/community/discussions/151442) where it rejects a - // GitHub-App-authored push with "refusing to allow a GitHub App to create or - // update workflow `` without `workflows` permission" whenever the target - // commit's .github/workflows/** content differs from the CURRENT default - // branch tip โ€” even when the App installation genuinely has Workflows: write. - // It reproduces reliably for exactly the case this action exists to handle: - // recreating an old, historical tag whose commit predates later workflow - // edits. It does NOT reproduce for a ref move onto the branch tip itself - // (e.g. update-major-version-tags), which is why that path never hit it. - // - // The fix isn't more App permission โ€” it's avoiding the git-protocol - // pre-receive hook entirely: github/api/tag/create/_impl.mjs already carries - // this same git-push -> REST Git Data API fallback for this exact reason. - // Mirror it here rather than giving up on the git push error. - if (/refusing to allow .* without .*workflow.* permission/i.test(pushErrorText)) { - console.warn( - `โš ๏ธ Git push rejected by GitHub's workflow-permission check (known platform bug for tags off the branch tip): ${pushErrorText}` - ); - console.log(`๐Ÿ” Falling back to the REST Git Data API to create the tag (bypasses the git-protocol check)...`); - - try { - gitCommand(`git tag -d ${tagName}`, true); - } catch { - // Ignore cleanup errors โ€” the local tag may not exist. - } - - try { - const tagger = { name: TAGGER_NAME, email: TAGGER_EMAIL }; - const tagObj = await createAnnotatedTag({ - token: GITHUB_TOKEN, - repo, - tag: tagName, - message: tagMessage, - objectSha: targetCommit, - tagger - }); - try { - await createRefForTagObject({ token: GITHUB_TOKEN, repo, tag: tagName, tagObjectSha: tagObj.sha }); - } catch { - await forceMoveRefToTagObject({ token: GITHUB_TOKEN, repo, tag: tagName, tagObjectSha: tagObj.sha }); - } - if (willSign) { - console.warn( - `โš ๏ธ Tag ${tagName} was created via the REST API, so it is annotated but NOT GPG-signed (the API has no signing path).` - ); - } - console.log(`โœ… Successfully created tag ${tagName} via REST API fallback`); - return true; - } catch (apiError) { - console.error(`โŒ REST API fallback also failed for tag ${tagName}: ${apiError.message}`); - return false; - } - } - + // No REST fallback: it created an annotated but UNSIGNED tag, and release + // tags must be bot-signed. The refusal it worked around ("refusing to allow + // a GitHub App to create or update workflow โ€ฆ without workflows permission") + // came from pushing with the workflow GITHUB_TOKEN that actions/checkout + // persisted โ€” that token can never hold the `workflows` scope. The job now + // checks out with the bot App token, which requests contents + workflows, + // so a refusal here is a real error and fails the job, naming the tag. throw new Error(pushErrorText || pushError.message); } catch (error) { - console.error(`โŒ Failed to create tag ${tagName}: ${error.message}`); + console.error(`::error::Failed to create tag ${tagName}: ${error.message}`); // Clean up local tag if remote push failed try { @@ -487,9 +438,7 @@ async function main() { if (failedReleases.length > 0) { console.log(`\nโŒ Failed to create tags:`); failedReleases.forEach((tag) => console.log(` ${tag}`)); - console.log( - `\n๐Ÿ’ก See the per-tag logs above for the specific failure reason (missing target commit, git push rejection, or REST API fallback error).` - ); + console.log(`\n๐Ÿ’ก See the per-tag logs above for the specific failure reason (missing target commit or git push rejection).`); } // Generate summary diff --git a/.github/actions/github/steps/sync-release-notes/action.mjs b/.github/actions/github/steps/sync-release-notes/action.mjs index 12703f1f..c397f813 100644 --- a/.github/actions/github/steps/sync-release-notes/action.mjs +++ b/.github/actions/github/steps/sync-release-notes/action.mjs @@ -23,7 +23,14 @@ import { execFileSync } from "node:child_process"; import { getInput, getBooleanInput, setOutputs, appendSummary } from "../../../common/common/core.mjs"; import { api, parseRepo } from "../../api/_api/core.mjs"; import { run as createTag } from "../../api/tag/create/_impl.mjs"; -import { buildReleaseBody, escapeTableCell, readChangelogAtRef, sameBody } from "../../utilities/release-notes.mjs"; +import { + buildReleaseBody, + escapeTableCell, + readChangelogAtRef, + sameBody, + stripCommitTrailers, + stripReleaseSubject +} from "../../utilities/release-notes.mjs"; const token = getInput("github-token", { required: true }); const repoFull = process.env.GITHUB_REPOSITORY || ""; @@ -34,6 +41,11 @@ const createTags = getBooleanInput("create-missing-tags", false); const createReleases = getBooleanInput("create-missing-releases", false); const publishDrafts = getBooleanInput("publish-drafts", false); const normalizeAll = getBooleanInput("normalize-all", false); +// Batch cap on tags created in one run: a backlog (e.g. eight untagged release +// commits) is fine, a runaway isn't. The rest are reported and picked up by the +// next run. +const maxNewTags = Math.max(0, Number.parseInt(getInput("max-new-tags", { default: "20" }), 10) || 0); +let tagsCreated = 0; const versionFilter = getInput("versions") .split(/[\s,]+/) .map((v) => v.trim().replace(/^v/i, "")) @@ -117,11 +129,12 @@ for (const r of releases) { const rows = []; let changed = 0; let problems = 0; +let failures = 0; for (const version of [...versions].sort(cmpVersion)) { if (versionFilter.length && !versionFilter.includes(version)) continue; const tagName = `v${version}`; - const row = { version, tag: "", release: "", changelog: "", actions: [], issues: [] }; + const row = { version, tag: "", release: "", changelog: "", actions: [], issues: [], failures: [] }; let tagSha = tags.get(version) || ""; const relCommit = releaseCommits.get(version) || ""; const rels = releases.filter((r) => r.tag_name === tagName); @@ -144,16 +157,33 @@ for (const version of [...versions].sort(cmpVersion)) { } else if (!relCommit) { row.tag = "missing"; row.issues.push("no tag and no release commit on the default branch"); + } else if (createTags && !dryRun && !gpg.gpg_private_key) { + // Release tags must be bot-signed; never create an unsigned one. + row.tag = `missing (release commit ${relCommit.slice(0, 7)})`; + row.failures.push("tag not created: no bot GPG key provided (release tags must be signed)"); + } else if (createTags && !dryRun && tagsCreated >= maxNewTags) { + row.tag = `missing (release commit ${relCommit.slice(0, 7)})`; + row.issues.push(`tag not created: per-run cap of ${maxNewTags} reached โ€” the next run continues`); } else if (createTags && !dryRun) { try { - await createTag({ token, repo: repoFull, tag: tagName, sha: relCommit, message: tagName, push: true, ...gpg }); + // Signed tag at the release commit; the message is the changelog file + // (else the release commit message), kept verbatim incl. headings. + const commitMsg = git(["log", "-1", "--format=%B", relCommit]); + const tagMessage = file?.content || stripCommitTrailers(stripReleaseSubject(commitMsg, { name: tagName, version })).trim() || tagName; + tagsCreated++; + await createTag({ token, repo: repoFull, tag: tagName, sha: relCommit, message: tagMessage, push: true, ...gpg }); tagSha = relCommit; row.tag = `created at ${relCommit.slice(0, 7)}`; row.actions.push("created tag"); changed++; + // tag/create has no unsigned fallback; assert the pushed tag is signed anyway. + git(["fetch", "--force", "origin", `+refs/tags/${tagName}:refs/tags/${tagName}`]); + if (!/BEGIN (PGP|SSH) SIGNATURE/.test(git(["cat-file", "-p", `refs/tags/${tagName}`]))) { + row.failures.push(`tag ${tagName} on the remote is not signed โ€” investigate`); + } } catch (e) { row.tag = "missing"; - row.issues.push(`tag creation failed: ${e.message}`); + row.failures.push(`tag creation failed: ${e.message}`); } } else { row.tag = `missing (release commit ${relCommit.slice(0, 7)})`; @@ -183,7 +213,7 @@ for (const version of [...versions].sort(cmpVersion)) { row.actions.push("created release"); changed++; } catch (e) { - row.issues.push(`release creation failed: ${e.message}`); + row.failures.push(`release creation failed: ${e.message}`); } } } else { @@ -215,15 +245,16 @@ for (const version of [...versions].sort(cmpVersion)) { row.release = updated.draft ? "draft" : "published"; row.actions.push(`updated ${what}`); changed++; - if (patch.draft === false && updated.draft) row.issues.push("still a draft after publishing"); + if (patch.draft === false && updated.draft) row.failures.push("still a draft after publishing"); } catch (e) { - row.issues.push(`update failed: ${e.message}`); + row.failures.push(`update failed: ${e.message}`); } } } } problems += row.issues.length; + failures += row.failures.length; rows.push(row); } @@ -231,14 +262,29 @@ const esc = escapeTableCell; let md = `## ๐Ÿ“ Release notes sync โ€” ${repoFull}${dryRun ? " (dry run)" : ""}\n\n`; md += "| Version | Tag | Release | Changelog | Actions | Problems |\n|---|---|---|---|---|---|\n"; for (const r of rows) { - md += `| ${r.version} | ${esc(r.tag)} | ${esc(r.release)} | ${esc(r.changelog || "โ€”")} | ${esc(r.actions.join("; ") || "โ€”")} | ${esc(r.issues.join("; ") || "โ€”")} |\n`; + md += `| ${r.version} | ${esc(r.tag)} | ${esc(r.release)} | ${esc(r.changelog || "โ€”")} | ${esc(r.actions.join("; ") || "โ€”")} | ${esc([...r.failures.map((f) => `โŒ ${f}`), ...r.issues].join("; ") || "โ€”")} |\n`; +} +const planned = rows.reduce((n, r) => n + r.actions.filter((a) => a.startsWith("would ")).length, 0); +if (changed === 0 && planned === 0 && failures === 0) { + md += "\nโœ… Nothing to change โ€” every release already matches its changelog file.\n"; +} else if (dryRun) { + md += `\n${planned} change(s) would be made (dry run).\n`; +} else { + md += `\n${changed} change(s) applied.\n`; } -md += `\n${changed} change(s) applied, ${problems} problem(s) left.\n`; +if (problems > 0) + md += `\n${problems} item(s) need a manual decision (see Problems; the dispatch switches can repair missing tags/releases).\n`; +if (failures > 0) md += `\nโŒ ${failures} attempted repair(s) failed.\n`; console.log(md); appendSummary(md); -setOutputs({ "changed-count": String(changed), "problems-count": String(problems) }); +setOutputs({ "changed-count": String(changed), "problems-count": String(problems), "failures-count": String(failures) }); -if (!dryRun && problems > 0) { - console.error(`::error::${problems} release/tag problem(s) remain โ€” see the job summary.`); +// Fail only when something this run attempted did not work. Pre-existing drift +// that needs a decision (old tags with no release, release commits with no tag) +// is reported as a warning, so the automatic runs don't stay red forever. +if (problems > 0) console.warn(`::warning::${problems} release/tag item(s) need a manual decision โ€” see the job summary.`); +if (failures > 0) { + for (const r of rows) for (const f of r.failures) console.error(`::error::v${r.version}: ${f}`); + console.error(`::error::${failures} attempted repair(s) failed โ€” see the job summary.`); process.exitCode = 1; } diff --git a/.github/actions/github/steps/sync-release-notes/action.yml b/.github/actions/github/steps/sync-release-notes/action.yml index 793c6f55..5cfb69fd 100644 --- a/.github/actions/github/steps/sync-release-notes/action.yml +++ b/.github/actions/github/steps/sync-release-notes/action.yml @@ -23,9 +23,13 @@ inputs: required: false default: "" create-missing-tags: - description: "Create a missing vX.Y.Z tag at its `release: vX.Y.Z` commit on the default branch (signed when the GPG key is provided)." + description: "Create a missing vX.Y.Z tag at its `release: vX.Y.Z` commit on the default branch: bot-signed (refused without the GPG key), message = the changelog file kept verbatim. A refused push fails the step; there is no unsigned fallback." required: false default: "false" + max-new-tags: + description: "Cap on tags created in one run; the rest are reported and created by the next run." + required: false + default: "20" create-missing-releases: description: "Create a missing GitHub Release for a version that has a tag." required: false @@ -59,7 +63,9 @@ outputs: changed-count: description: "Number of tags/releases created or updated." problems-count: - description: "Number of problems left (drafts, missing tags/releases, duplicates, failures)." + description: "Number of items left that need a manual decision (drafts not published, missing tags/releases, duplicates)." + failures-count: + description: "Number of repairs this run attempted that failed. The step fails only when this is non-zero." runs: using: node24 diff --git a/.github/workflows/local-sync-release-notes.yml b/.github/workflows/local-sync-release-notes.yml index 428d0901..edb88256 100644 --- a/.github/workflows/local-sync-release-notes.yml +++ b/.github/workflows/local-sync-release-notes.yml @@ -7,23 +7,45 @@ # @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. # -# Dogfood: runs the local workflow-sync-release-notes.yml against this repo. +# Dogfood: runs the local workflow-sync-release-notes.yml against this repo +# (relative uses:, so a dispatch from a branch tests that branch's version). # -# Manual repair tool. Rewrites this repository's GitHub Release bodies from the -# committed per-version changelog files (docs/changelog[s]/v/v.md) -# โ€” the way to give releases made before a changelog existed (or with a -# backfilled one) their curated notes. Keeps the Contributors and coverage -# blocks, drops the duplicated "release: vX.Y.Z - โ€ฆ" subject line, neutralizes -# accidental @word mentions. +# Part of the STANDARD v4 set โ€” every v4 repo carries this file. # -# Also reports release/tag drift: draft releases, versions with no tag, tags -# with no release, duplicate releases. Dry run by default; each repair is its -# own opt-in. Results land in the job summary. +# Keeps the GitHub Release pages in step with the committed per-version +# changelog files (docs/changelog[s]/v/v.md): rewrites each +# release body from its file, keeping the Contributors and coverage blocks, +# dropping the duplicated "release: vX.Y.Z - โ€ฆ" subject line and neutralizing +# accidental @word mentions. Also reports release/tag drift (draft releases, +# versions with no tag, tags with no release, duplicate releases). # -# Relative uses: so a dispatch from a branch tests that branch's version. +# Runs automatically: +# - when a release is published (the publish workflow creates releases with +# the bot App token, so release:published fires โ€” a GITHUB_TOKEN-created +# release would not trigger workflows); +# - when a changelog file lands on the default branch (backfills and fixes). +# Automatic runs apply changes: they sync bodies, create missing version tags +# at their `release: vX.Y.Z` commits (bot-signed, changelog as the message, at +# most 20 per run) and re-publish draft releases whose tag exists (CI-created +# drafts โ€” CLDMV/.github#362). Creating missing releases stays manual (dispatch +# switch): a new release fires release:published, so backfilling old versions +# would re-run notifications and provenance for each one. +# Every run is an idempotent full sweep โ€” with nothing to change it changes +# nothing and says so. Not a required check (companion workflow), so the +# `paths:` filter below is fine here, unlike in ci.yml. +# +# Thin caller: all logic lives in workflow-sync-release-notes.yml@v4, which +# also queues overlapping runs per repo and picks the runner. name: ๐Ÿ“ Sync Release Notes on: + release: + types: [published] + push: + branches: [master, main] + paths: + - "docs/changelog/**" + - "docs/changelogs/**" workflow_dispatch: inputs: dry_run: @@ -36,21 +58,21 @@ on: type: string required: false default: "" + publish_drafts: + description: "Publish draft releases whose tag exists" + type: boolean + required: false + default: true create_missing_tags: - description: "Create missing vX.Y.Z tags at their release commits (signed)" + description: "Create missing vX.Y.Z tags at their release commits (bot-signed)" type: boolean required: false - default: false + default: true create_missing_releases: description: "Create missing GitHub Releases for tagged versions" type: boolean required: false default: false - publish_drafts: - description: "Publish draft releases whose tag exists" - type: boolean - required: false - default: false normalize_all: description: "Also tidy bodies of releases with no changelog file" type: boolean @@ -62,16 +84,18 @@ permissions: jobs: sync: + # All writes go through the bot App token; GITHUB_TOKEN only reads. permissions: - contents: write + contents: read uses: ./.github/workflows/workflow-sync-release-notes.yml with: - dry_run: ${{ inputs.dry_run }} - versions: ${{ inputs.versions }} - create_missing_tags: ${{ inputs.create_missing_tags }} - create_missing_releases: ${{ inputs.create_missing_releases }} - publish_drafts: ${{ inputs.publish_drafts }} - normalize_all: ${{ inputs.normalize_all }} + # Automatic runs (release / push) apply changes; manual runs use the switches. + dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} + versions: ${{ inputs.versions || '' }} + publish_drafts: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_drafts }} + create_missing_tags: ${{ github.event_name != 'workflow_dispatch' || inputs.create_missing_tags }} + create_missing_releases: ${{ github.event_name == 'workflow_dispatch' && inputs.create_missing_releases }} + normalize_all: ${{ github.event_name == 'workflow_dispatch' && inputs.normalize_all }} secrets: BOT_APP_CLIENT_ID: ${{ secrets.CLDMV_BOT_APP_CLIENT_ID }} BOT_APP_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_APP_PRIVATE_KEY }} diff --git a/.github/workflows/reusable-publishing.yml b/.github/workflows/reusable-publishing.yml index 278f347c..00ef3d73 100644 --- a/.github/workflows/reusable-publishing.yml +++ b/.github/workflows/reusable-publishing.yml @@ -473,6 +473,12 @@ jobs: with: client_id: ${{ secrets.BOT_APP_CLIENT_ID }} private_key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + # Fine-grained mode: every scope this job's token is used for. + # contents = tag push, release create/edit, asset uploads; + # workflows = pushing a tag whose commit's .github/workflows + # differ from the branch tip (CLDMV/.github#362). + permission_contents: "true" + permission_workflows: "true" env: BOT_APP_CLIENT_ID: ${{ secrets.BOT_APP_CLIENT_ID }} BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} @@ -623,6 +629,12 @@ jobs: with: client_id: ${{ secrets.BOT_APP_CLIENT_ID }} private_key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + # Fine-grained mode: every scope this job's token is used for. + # contents = tag push, release create/edit, asset uploads; + # workflows = pushing a tag whose commit's .github/workflows + # differ from the branch tip (CLDMV/.github#362). + permission_contents: "true" + permission_workflows: "true" env: BOT_APP_CLIENT_ID: ${{ secrets.BOT_APP_CLIENT_ID }} BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} @@ -725,6 +737,12 @@ jobs: with: client_id: ${{ secrets.BOT_APP_CLIENT_ID }} private_key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + # Fine-grained mode: every scope this job's token is used for. + # contents = tag push, release create/edit, asset uploads; + # workflows = pushing a tag whose commit's .github/workflows + # differ from the branch tip (CLDMV/.github#362). + permission_contents: "true" + permission_workflows: "true" env: BOT_APP_CLIENT_ID: ${{ secrets.BOT_APP_CLIENT_ID }} BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} diff --git a/.github/workflows/reusable-tag-health.yml b/.github/workflows/reusable-tag-health.yml index 3dcbd25b..23d3f396 100644 --- a/.github/workflows/reusable-tag-health.yml +++ b/.github/workflows/reusable-tag-health.yml @@ -260,22 +260,32 @@ jobs: fixed_count: ${{ steps.fix-orphaned-releases.outputs.fixed-count }} summary-json: ${{ steps.fix-orphaned-releases.outputs.summary-json }} steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - ref: ${{ github.sha }} - fetch-depth: 0 - + # App token BEFORE checkout: tag pushes use the credential checkout + # persists. With the default GITHUB_TOKEN (which can never hold the + # `workflows` scope) GitHub refuses tags on commits whose workflows + # differ from the tip โ€” the refusal behind CLDMV/.github#362. - name: Create App token id: app-token uses: CLDMV/.github/.github/actions/github/steps/create-app-token@v4 with: client_id: ${{ secrets.BOT_APP_CLIENT_ID }} private_key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + # Fine-grained mode: every scope this job uses. contents = + # tags + release reads/edits; workflows = pushing a tag whose + # commit's .github/workflows differ from the branch tip. + permission_contents: "true" + permission_workflows: "true" env: BOT_APP_CLIENT_ID: ${{ secrets.BOT_APP_CLIENT_ID }} BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} + - name: Checkout repository + uses: actions/checkout@v6 + with: + token: ${{ steps.app-token.outputs.token }} + ref: ${{ github.sha }} + fetch-depth: 0 + - name: Fix orphaned releases id: fix-orphaned-releases uses: CLDMV/.github/.github/actions/github/steps/fix-orphaned-releases@v4 @@ -309,9 +319,29 @@ jobs: orphans-found: ${{ steps.update-major-tags.outputs.orphans-found }} summary-json: ${{ steps.generate-summary.outputs.summary-json }} steps: + # App token BEFORE checkout: tag pushes use the credential checkout + # persists. With the default GITHUB_TOKEN (which can never hold the + # `workflows` scope) GitHub refuses tags on commits whose workflows + # differ from the tip โ€” the refusal behind CLDMV/.github#362. + - name: Create App token + id: app-token + uses: CLDMV/.github/.github/actions/github/steps/create-app-token@v4 + with: + client_id: ${{ secrets.BOT_APP_CLIENT_ID }} + private_key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + # Fine-grained mode: every scope this job uses. contents = + # tags + release reads/edits; workflows = pushing a tag whose + # commit's .github/workflows differ from the branch tip. + permission_contents: "true" + permission_workflows: "true" + env: + BOT_APP_CLIENT_ID: ${{ secrets.BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} + - name: Checkout repository uses: actions/checkout@v6 with: + token: ${{ steps.app-token.outputs.token }} ref: ${{ github.sha }} fetch-depth: 0 @@ -333,16 +363,6 @@ jobs: with: tags-json: ${{ steps.get-tags.outputs.tags_detailed }} - - name: Create App token - id: app-token - uses: CLDMV/.github/.github/actions/github/steps/create-app-token@v4 - with: - client_id: ${{ secrets.BOT_APP_CLIENT_ID }} - private_key: ${{ secrets.BOT_APP_PRIVATE_KEY }} - env: - BOT_APP_CLIENT_ID: ${{ secrets.BOT_APP_CLIENT_ID }} - BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} - - name: Update major/minor version tags id: update-major-tags if: steps.find-latest.outputs.has-tag == 'true' @@ -377,9 +397,29 @@ jobs: fixed_count: ${{ steps.fix-bot-sigs.outputs.fixed-count }} summary-json: ${{ steps.fix-bot-sigs.outputs.summary-json }} steps: + # App token BEFORE checkout: tag pushes use the credential checkout + # persists. With the default GITHUB_TOKEN (which can never hold the + # `workflows` scope) GitHub refuses tags on commits whose workflows + # differ from the tip โ€” the refusal behind CLDMV/.github#362. + - name: Create App token + id: app-token + uses: CLDMV/.github/.github/actions/github/steps/create-app-token@v4 + with: + client_id: ${{ secrets.BOT_APP_CLIENT_ID }} + private_key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + # Fine-grained mode: every scope this job uses. contents = + # tags + release reads/edits; workflows = pushing a tag whose + # commit's .github/workflows differ from the branch tip. + permission_contents: "true" + permission_workflows: "true" + env: + BOT_APP_CLIENT_ID: ${{ secrets.BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} + - name: Checkout repository uses: actions/checkout@v6 with: + token: ${{ steps.app-token.outputs.token }} ref: ${{ github.sha }} fetch-depth: 0 @@ -395,16 +435,6 @@ jobs: include_patterns: ${{ inputs.include_patterns }} exclude_patterns: ${{ inputs.exclude_patterns }} - - name: Create App token - id: app-token - uses: CLDMV/.github/.github/actions/github/steps/create-app-token@v4 - with: - client_id: ${{ secrets.BOT_APP_CLIENT_ID }} - private_key: ${{ secrets.BOT_APP_PRIVATE_KEY }} - env: - BOT_APP_CLIENT_ID: ${{ secrets.BOT_APP_CLIENT_ID }} - BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} - - name: Fix non-bot signatures id: fix-bot-sigs uses: CLDMV/.github/.github/actions/git/steps/fix-non-bot-tags@v4 @@ -434,9 +464,29 @@ jobs: - name: Debug job start run: echo "๐Ÿ” DEBUG - Unsigned tags job is starting..." + # App token BEFORE checkout: tag pushes use the credential checkout + # persists. With the default GITHUB_TOKEN (which can never hold the + # `workflows` scope) GitHub refuses tags on commits whose workflows + # differ from the tip โ€” the refusal behind CLDMV/.github#362. + - name: Create App token + id: app-token + uses: CLDMV/.github/.github/actions/github/steps/create-app-token@v4 + with: + client_id: ${{ secrets.BOT_APP_CLIENT_ID }} + private_key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + # Fine-grained mode: every scope this job uses. contents = + # tags + release reads/edits; workflows = pushing a tag whose + # commit's .github/workflows differ from the branch tip. + permission_contents: "true" + permission_workflows: "true" + env: + BOT_APP_CLIENT_ID: ${{ secrets.BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} + - name: Checkout repository uses: actions/checkout@v6 with: + token: ${{ steps.app-token.outputs.token }} ref: ${{ github.sha }} fetch-depth: 0 @@ -452,16 +502,6 @@ jobs: include_patterns: ${{ inputs.include_patterns }} exclude_patterns: ${{ inputs.exclude_patterns }} - - name: Create App token - id: app-token - uses: CLDMV/.github/.github/actions/github/steps/create-app-token@v4 - with: - client_id: ${{ secrets.BOT_APP_CLIENT_ID }} - private_key: ${{ secrets.BOT_APP_PRIVATE_KEY }} - env: - BOT_APP_CLIENT_ID: ${{ secrets.BOT_APP_CLIENT_ID }} - BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} - - name: Fix unsigned tags id: fix-unsigned uses: CLDMV/.github/.github/actions/git/steps/fix-unsigned-tags@v4 @@ -491,9 +531,29 @@ jobs: - name: Debug job start run: echo "๐Ÿ” DEBUG - Misaligned major/minor tags job is starting..." + # App token BEFORE checkout: tag pushes use the credential checkout + # persists. With the default GITHUB_TOKEN (which can never hold the + # `workflows` scope) GitHub refuses tags on commits whose workflows + # differ from the tip โ€” the refusal behind CLDMV/.github#362. + - name: Create App token + id: app-token + uses: CLDMV/.github/.github/actions/github/steps/create-app-token@v4 + with: + client_id: ${{ secrets.BOT_APP_CLIENT_ID }} + private_key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + # Fine-grained mode: every scope this job uses. contents = + # tags + release reads/edits; workflows = pushing a tag whose + # commit's .github/workflows differ from the branch tip. + permission_contents: "true" + permission_workflows: "true" + env: + BOT_APP_CLIENT_ID: ${{ secrets.BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} + - name: Checkout repository uses: actions/checkout@v6 with: + token: ${{ steps.app-token.outputs.token }} ref: ${{ github.sha }} fetch-depth: 0 @@ -509,16 +569,6 @@ jobs: include_patterns: ${{ inputs.include_patterns }} exclude_patterns: ${{ inputs.exclude_patterns }} - - name: Create App token - id: app-token - uses: CLDMV/.github/.github/actions/github/steps/create-app-token@v4 - with: - client_id: ${{ secrets.BOT_APP_CLIENT_ID }} - private_key: ${{ secrets.BOT_APP_PRIVATE_KEY }} - env: - BOT_APP_CLIENT_ID: ${{ secrets.BOT_APP_CLIENT_ID }} - BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} - - name: Fix misaligned major/minor version tags id: fix-misaligned uses: CLDMV/.github/.github/actions/git/steps/fix-misaligned-major-tags@v4 @@ -548,9 +598,29 @@ jobs: - name: Debug job start run: echo "๐Ÿ” DEBUG - Orphaned tags job is starting..." + # App token BEFORE checkout: tag pushes use the credential checkout + # persists. With the default GITHUB_TOKEN (which can never hold the + # `workflows` scope) GitHub refuses tags on commits whose workflows + # differ from the tip โ€” the refusal behind CLDMV/.github#362. + - name: Create App token + id: app-token + uses: CLDMV/.github/.github/actions/github/steps/create-app-token@v4 + with: + client_id: ${{ secrets.BOT_APP_CLIENT_ID }} + private_key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + # Fine-grained mode: every scope this job uses. contents = + # tags + release reads/edits; workflows = pushing a tag whose + # commit's .github/workflows differ from the branch tip. + permission_contents: "true" + permission_workflows: "true" + env: + BOT_APP_CLIENT_ID: ${{ secrets.BOT_APP_CLIENT_ID }} + BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} + - name: Checkout repository uses: actions/checkout@v6 with: + token: ${{ steps.app-token.outputs.token }} ref: ${{ github.sha }} fetch-depth: 0 @@ -566,16 +636,6 @@ jobs: include_patterns: ${{ inputs.include_patterns }} exclude_patterns: ${{ inputs.exclude_patterns }} - - name: Create App token - id: app-token - uses: CLDMV/.github/.github/actions/github/steps/create-app-token@v4 - with: - client_id: ${{ secrets.BOT_APP_CLIENT_ID }} - private_key: ${{ secrets.BOT_APP_PRIVATE_KEY }} - env: - BOT_APP_CLIENT_ID: ${{ secrets.BOT_APP_CLIENT_ID }} - BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} - - name: Fix orphaned tags id: fix-orphaned uses: CLDMV/.github/.github/actions/git/steps/fix-orphaned-tags@v4 diff --git a/.github/workflows/workflow-sync-release-notes.yml b/.github/workflows/workflow-sync-release-notes.yml index 5c4cf98a..2f4a1cf9 100644 --- a/.github/workflows/workflow-sync-release-notes.yml +++ b/.github/workflows/workflow-sync-release-notes.yml @@ -5,10 +5,14 @@ # and report โ€” optionally repair โ€” release/tag drift: draft releases, versions # with no tag, tags with no release, duplicate releases. # -# Consumers call it from a workflow_dispatch wrapper -# (examples/individual-repo-workflows/release-companions/sync-release-notes.yml). -# Runs against the CALLING repository with the bot App token. Dry run by -# default; every repair is a separate opt-in. See CLDMV/.github#362. +# Part of the standard v4 set: every repo carries the caller +# (examples/individual-repo-workflows/release-companions/sync-release-notes.yml), +# which runs it automatically when a release is published and when a changelog +# file lands on the default branch, and by manual dispatch. Runs against the +# CALLING repository with the bot App token. Concurrent runs for one repo queue +# (one running + the newest pending); every run is a full, idempotent sweep, so +# a superseded pending run loses nothing. The inputs default to a dry run; the +# caller passes the automatic-run settings. See CLDMV/.github#362. name: ๐Ÿ“ Sync Release Notes (Org-Level) on: @@ -34,6 +38,11 @@ on: required: false type: boolean default: false + max_new_tags: + description: "Cap on tags created in one run; the rest are reported and created by the next run." + required: false + type: number + default: 20 create_missing_releases: description: "Create a missing GitHub Release for a version that has a tag." required: false @@ -70,13 +79,16 @@ jobs: sync: name: "๐Ÿ“ Sync release notes" runs-on: ${{ inputs.runs_on != '' && inputs.runs_on || (vars.RUNS_ON_DEFAULT != '' && vars.RUNS_ON_DEFAULT || ((github.repository_owner == 'CLDMV' && github.event.repository.private) && 'cldmv-runners' || 'ubuntu-latest')) }} + # Every write (release PATCH/POST, tag push) goes through the bot App + # token, so the workflow GITHUB_TOKEN only needs to read. permissions: - contents: write + contents: read concurrency: group: sync-release-notes-${{ github.repository }} cancel-in-progress: false steps: - # App token: creating a tag on a release commit that touches + # App token: release edits and tag pushes run as the bot. Creating a + # tag on a release commit that touches # .github/workflows/ needs the `workflows` scope the Actions # GITHUB_TOKEN can never hold. - name: Create App token (auto-detect) @@ -85,6 +97,12 @@ jobs: with: client_id: ${{ secrets.BOT_APP_CLIENT_ID }} private_key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + # Fine-grained mode: every scope this job uses. contents = + # checkout, release reads/edits/creation, tag push; workflows = + # pushing a tag whose commit's .github/workflows differ from + # the branch tip. No PR/issue access is needed. + permission_contents: "true" + permission_workflows: "true" env: BOT_APP_CLIENT_ID: ${{ secrets.BOT_APP_CLIENT_ID }} BOT_APP_PRIVATE_KEY: ${{ secrets.BOT_APP_PRIVATE_KEY }} @@ -103,6 +121,7 @@ jobs: dry-run: ${{ inputs.dry_run }} versions: ${{ inputs.versions }} create-missing-tags: ${{ inputs.create_missing_tags }} + max-new-tags: ${{ inputs.max_new_tags }} create-missing-releases: ${{ inputs.create_missing_releases }} publish-drafts: ${{ inputs.publish_drafts }} normalize-all: ${{ inputs.normalize_all }} diff --git a/README.md b/README.md index b24fbfbf..0496bf19 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,27 @@ Shared GitHub Actions workflows for the CLDMV organization. +## โœจ What's New + +### Latest: v4.30.7 (October 2026) + +- **Late merges survive a release** โ€” after a release squash-merges, `next-reset` now carries any PR that merged into `next` or `hotfixes` after the release PR was cut onto the release commit instead of dropping it, with every branch update guarded by a compare-and-swap. A conflict leaves the branch alone, opens an issue and comments on the affected PRs. Consumer repos also stop waiting up to two minutes for their own major tag before syncing. +- **Releases stay published** โ€” `tag-health` re-signs tags in place instead of deleting and recreating them, which had been turning published releases into drafts and sometimes losing the tag. Release tags are signed at creation, and publishing fails if a release is left a draft or untagged. The committed changelog file becomes the release body, and a new manual `sync-release-notes` workflow rewrites existing releases from their changelog files. +- [View full v4.30.7 Changelog](https://github.com/CLDMV/.github/blob/master/docs/changelogs/v4.30.7.md) + +### Recent Releases + +- **v4.30.6** (October 2026) โ€” Bundle size falls back to an empty baseline when the base branch's build fails ([Changelog](https://github.com/CLDMV/.github/blob/master/docs/changelogs/v4.30.6.md)) +- **v4.30.5** (October 2026) โ€” The CLA bot never records a bot as a signer, and ledger commits are signed again ([Changelog](https://github.com/CLDMV/.github/blob/master/docs/changelogs/v4.30.5.md)) +- **v4.30.4** (October 2026) โ€” The in-repo PR mirror job runs as a no-op instead of skipping, so its check name renders ([Changelog](https://github.com/CLDMV/.github/blob/master/docs/changelogs/v4.30.4.md)) +- **v4.30.3** (October 2026) โ€” A skipped PR-run mirror no longer satisfies `โœ… Required PR Check` mid-test ([Changelog](https://github.com/CLDMV/.github/blob/master/docs/changelogs/v4.30.3.md)) + ## ๐Ÿ“‹ Quick Start These workflows ship a complete CI / release / publish pipeline tuned for the **v4 staging-branch release flow** โ€” feature PRs land on `next`, urgent work on `hotfixes`, and `master` is a clean release-only history. New repos should adopt v4 directly; existing v3 repos have a [migration guide](docs/migration/v3-to-v4.md). 1. **Adopt the v4 release-flow workflows** โ€” copy the set from [`examples/individual-repo-workflows/release-flow-v4/`](examples/individual-repo-workflows/release-flow-v4/) into your repo's `.github/workflows/`. These are adopted as a set (they depend on each other). -2. **Copy the core CI / publish / tag / bundle-size templates** from [`examples/individual-repo-workflows/core-cicd/`](examples/individual-repo-workflows/core-cicd/) (every v4 repo carries all of them except the v3-only `release.yml`), update `package_name` to your NPM package name, and set `bundle-size.yml`'s `build_command` + `dist_paths` to the repo's real build and published files. Add the security / automation templates you want from the other subfolders. +2. **Copy the core CI / publish / tag / bundle-size templates** from [`examples/individual-repo-workflows/core-cicd/`](examples/individual-repo-workflows/core-cicd/) (every v4 repo carries all of them except the v3-only `release.yml`), update `package_name` to your NPM package name, and set `bundle-size.yml`'s `build_command` + `dist_paths` to the repo's real build and published files. Also copy the standard release companions from [`examples/individual-repo-workflows/release-companions/`](examples/individual-repo-workflows/release-companions/): `tag-health.yml`, `master-commit-audit.yml` and `sync-release-notes.yml`. Add the security / automation templates you want from the other subfolders. 3. **Bootstrap the repo** โ€” applies branches + rulesets + security toggles + repo settings in one shot. Two ways: - **Org-wide fanout (recommended for โ‰ฅ3 repos)** โ€” dispatch `org-onboarding.yml` from the org's **private** org-admin repo (template: [`examples/individual-repo-workflows/packaging-docs/org-onboarding.yml`](examples/individual-repo-workflows/packaging-docs/org-onboarding.yml)), with the targets inline or in a batch file kept in that private repo. Never run it from a public repo: its job names and run summary list every target, so an auto-discovery run would publish the names of the org's private repos. Runs against N repos in parallel; idempotent. Repos the baseline would break, such as the CLA signatures ledger, go in `data/onboarding-exclude.txt` in that private repo and are skipped in every mode. - **Per-repo dispatch (one-offs)** โ€” dispatch `v4-bootstrap.yml` from the target repo's Actions tab. Same baseline, scoped to the one repo. diff --git a/docs/changelogs/v4.0.1.md b/docs/changelogs/v4.0.1.md new file mode 100644 index 00000000..5bce00aa --- /dev/null +++ b/docs/changelogs/v4.0.1.md @@ -0,0 +1,8 @@ +# v4.0.1 โ€” 2026-05-22 + +Patch release fixing two problems found in the first hours of running the v4 staging-branch flow. + +## Fixed + +- **Integration branches are kept alive across releases.** The post-release reset path in `local-next-reset.yml` could leave `next` and `hotfixes` in a state that broke later operations; the reset, branch-retention and bootstrap workflows were revised, and `check-release-commit` gained tighter semantics with test coverage. ([#27](https://github.com/CLDMV/.github/pull/27)) +- **Release-PR label churn on refresh stopped.** Refreshing the release PR re-applied labels even when nothing had changed, producing noisy timelines; the pull-requests API helper now only edits labels that actually need changing. ([#27](https://github.com/CLDMV/.github/pull/27)) diff --git a/docs/changelogs/v4.0.2.md b/docs/changelogs/v4.0.2.md new file mode 100644 index 00000000..44b68eec --- /dev/null +++ b/docs/changelogs/v4.0.2.md @@ -0,0 +1,7 @@ +# v4.0.2 โ€” 2026-05-22 + +Patch release that activates the v4.0.1 fixes for this repository's own workflows. + +## Fixed + +- **Internal references moved from `@v3` to `@v4`.** The repository's own reusable workflows and local callers still pointed at the `v3` tag, so the v4.0.1 fixes were not in effect for them. All `uses:` references across 47 workflow and action files now point at `v4`. No logic changed. ([#28](https://github.com/CLDMV/.github/pull/28)) diff --git a/docs/changelogs/v4.11.1.md b/docs/changelogs/v4.11.1.md new file mode 100644 index 00000000..f12d60d5 --- /dev/null +++ b/docs/changelogs/v4.11.1.md @@ -0,0 +1,12 @@ +# v4.11.1 โ€” 2026-05-29 + +Patch release so that batches containing only maintenance commits still open a release PR, along with new org-onboarding defaults and stricter branch rulesets. + +## Fixed + +- **Chore-only batches trigger release-PR creation.** The release-commit check only proceeded when the range held a feature, fix, perf, revert or breaking commit, so a batch of `chore`, `docs`, `ci` or similar commits stalled the release lane. Any non-merge commit now qualifies. ([#101](https://github.com/CLDMV/.github/pull/101)) + +## Changed + +- **Org-onboarding defaults flipped.** `dry_run` now defaults to `false` (changes are applied unless preview is requested), and `code_security` and `secret_protection` default to `public-only` instead of `off`. ([#100](https://github.com/CLDMV/.github/pull/100)) +- **Rulesets restrict creation and update on `master`, `next` and `hotfixes`.** The three ruleset definitions gained `creation` and `update` rules. ([#100](https://github.com/CLDMV/.github/pull/100)) diff --git a/docs/changelogs/v4.11.2.md b/docs/changelogs/v4.11.2.md new file mode 100644 index 00000000..5be0bf44 --- /dev/null +++ b/docs/changelogs/v4.11.2.md @@ -0,0 +1,7 @@ +# v4.11.2 โ€” 2026-05-31 + +Patch release that gives `audit-commit-subject` a built-in default pattern set so callers no longer have to carry their own copy. + +## Fixed + +- **`allowed_patterns` in `audit-commit-subject` has a default.** The input is no longer required and defaults to the canonical release-flow patterns (release commits with an optional subject suffix and `(#N)`, `chore:` commits, and standard merge commits), so pattern fixes propagate through the pinned action ref instead of drifting in per-repo copies. The master-commit-audit example and workflow, and the setup guide, were updated to omit the input. An explicit empty override is still rejected. ([#104](https://github.com/CLDMV/.github/pull/104)) diff --git a/docs/changelogs/v4.12.1.md b/docs/changelogs/v4.12.1.md new file mode 100644 index 00000000..adb04bee --- /dev/null +++ b/docs/changelogs/v4.12.1.md @@ -0,0 +1,7 @@ +# v4.12.1 โ€” 2026-06-01 + +Patch release that stops release-PR bodies from listing the same change twice. + +## Fixed + +- **PR-titled merge commits are dropped from generated release-PR bodies.** The v4 flow merges feature PRs into `next` with merge commits titled like the PR (`feat: โ€ฆ (#N)`), so subject-based merge detection missed them and they appeared alongside their squashed content. Merge commits are now detected structurally by parent count, and `create-release-pr` passes the `base..head` range so the merge filter and patch-id dedup actually run. ([#109](https://github.com/CLDMV/.github/pull/109)) diff --git a/docs/changelogs/v4.13.1.md b/docs/changelogs/v4.13.1.md new file mode 100644 index 00000000..b4a88ca8 --- /dev/null +++ b/docs/changelogs/v4.13.1.md @@ -0,0 +1,8 @@ +# v4.13.1 โ€” 2026-06-08 + +Patch release fixing the OSSF Scorecard job and an over-long label description. + +## Fixed + +- **Scorecard job uses `actions/checkout` directly.** OSSF Scorecard's publish step permits only a fixed set of steps, and the custom `checkout-code` composite action caused "job has unallowed step" and an HTTP 400 on publish. ([#115](https://github.com/CLDMV/.github/pull/115)) +- **The `! feature โ†’ next` label description fits GitHub's 100-character cap.** The description in the label catalog was shortened. ([#116](https://github.com/CLDMV/.github/pull/116)) diff --git a/docs/changelogs/v4.13.2.md b/docs/changelogs/v4.13.2.md new file mode 100644 index 00000000..fe317500 --- /dev/null +++ b/docs/changelogs/v4.13.2.md @@ -0,0 +1,7 @@ +# v4.13.2 โ€” 2026-06-09 + +Patch release adding the organization profile README. + +## Added + +- **Organization profile README.** A `profile/README.md` was added to introduce the organization on its GitHub profile page. No workflow or action changes. ([#118](https://github.com/CLDMV/.github/pull/118)) diff --git a/docs/changelogs/v4.14.1.md b/docs/changelogs/v4.14.1.md new file mode 100644 index 00000000..e0acab14 --- /dev/null +++ b/docs/changelogs/v4.14.1.md @@ -0,0 +1,7 @@ +# v4.14.1 โ€” 2026-06-14 + +Patch release completing the satellite-package publishing support introduced in v4.14.0. + +## Fixed + +- **Satellite entry-point inputs are now wired through the publish workflow.** `workflow-publish.yml` did not forward the satellite-package inputs to the reusable publishing workflow, so satellite publishing added in v4.14.0 could not be driven from the entry point. The missing inputs are now passed through. ([#123](https://github.com/CLDMV/.github/pull/123), released in [#124](https://github.com/CLDMV/.github/pull/124)) diff --git a/docs/changelogs/v4.14.2.md b/docs/changelogs/v4.14.2.md new file mode 100644 index 00000000..9fcbd879 --- /dev/null +++ b/docs/changelogs/v4.14.2.md @@ -0,0 +1,8 @@ +# v4.14.2 โ€” 2026-06-14 + +Patch release making satellite publishing safe to retry after a partial failure. + +## Fixed + +- **A re-run now republishes only the satellite packages that are still missing.** Satellite discovery and publishing were gated on "a new core version exists", so once the core package was out and one satellite leg failed, a re-run skipped every satellite and the failed one never retried. The gate is removed from `reusable-publishing.yml`; each satellite publish and release step is idempotent (an already-published version is skipped), so the full matrix can safely run again. ([#125](https://github.com/CLDMV/.github/pull/125), released in [#126](https://github.com/CLDMV/.github/pull/126)) +- **Tag and release creation are idempotent and no longer downgrade annotated tags.** The tag-create action skips work when the remote tag already points at the target commit, which avoids re-signing or force-pushing an immutable release tag (and tripping tag protection) on retry. An existing annotated tag is also no longer replaced by a lightweight one when a re-run found the ref already present. Release creation was adjusted to upsert in the same way. ([#125](https://github.com/CLDMV/.github/pull/125), released in [#126](https://github.com/CLDMV/.github/pull/126)) diff --git a/docs/changelogs/v4.14.3.md b/docs/changelogs/v4.14.3.md new file mode 100644 index 00000000..e525a650 --- /dev/null +++ b/docs/changelogs/v4.14.3.md @@ -0,0 +1,8 @@ +# v4.14.3 โ€” 2026-06-14 + +Patch release making the flow label on auto-opened PRs reflect the kind of change, and hardening Dependabot auto-merge. + +## Fixed + +- **The flow label reflects the change type instead of always saying `feature`.** `feature-pr.yml` labelled every next-lane branch `! feature โ†’ next`, so docs, fix and chore PRs were mislabelled. The label is now `! โ†’ `, with the type taken from the branch prefix (`fix/`, `docs/`, `chore/`, `refactor/`, `ci/`, `perf/`, `test/`, `style/`, `release/`, `hotfix/`). The matching labels were added to `data/github-labels.json`, and the example template was updated to match. ([#128](https://github.com/CLDMV/.github/pull/128), released in [#130](https://github.com/CLDMV/.github/pull/130)) +- **Dependabot auto-merge gates on the PR's live base branch and on ruleset-aware protection.** The action now re-fetches the PR rather than trusting the frozen event payload (a re-run after a retargeted security update could check the wrong branch), skips PRs that are no longer open, refuses to act when the repository or PR payload cannot be resolved, and detects required checks from the effective branch rules so ruleset-protected branches are recognised. The parsing helpers moved to a unit-tested `_impl.mjs`. ([#129](https://github.com/CLDMV/.github/pull/129), released in [#130](https://github.com/CLDMV/.github/pull/130)) diff --git a/docs/changelogs/v4.14.4.md b/docs/changelogs/v4.14.4.md new file mode 100644 index 00000000..fb96d592 --- /dev/null +++ b/docs/changelogs/v4.14.4.md @@ -0,0 +1,8 @@ +# v4.14.4 โ€” 2026-06-16 + +Patch release making Dependabot auto-merge work on merge-only branches. + +## Fixed + +- **Auto-merge picks a merge method the target branch allows.** The `merge_method` input now defaults to `merge` instead of `squash`, matching the merge-only rulesets on `next` and `hotfixes`, and the action reads the branch's allowed merge methods and falls back to a permitted one when the configured method is rejected. ([#131](https://github.com/CLDMV/.github/pull/131), released in [#132](https://github.com/CLDMV/.github/pull/132)) +- **Already-mergeable PRs are merged directly.** When GitHub refuses to queue auto-merge because the PR is already in a clean or unstable state, the action now falls back to a direct merge. The merge endpoint still enforces required checks, and any other auto-merge failure still surfaces as an error. ([#131](https://github.com/CLDMV/.github/pull/131), released in [#132](https://github.com/CLDMV/.github/pull/132)) diff --git a/docs/changelogs/v4.15.1.md b/docs/changelogs/v4.15.1.md new file mode 100644 index 00000000..c338a4b3 --- /dev/null +++ b/docs/changelogs/v4.15.1.md @@ -0,0 +1,7 @@ +# v4.15.1 โ€” 2026-06-23 + +Patch release removing a ruleset rule that deadlocked auto-merge on `hotfixes`. + +## Fixed + +- **`required_linear_history` is dropped from the `hotfixes` ruleset.** Combined with a merge-only merge method, linear history made native auto-merge impossible on `hotfixes`, so PRs queued for auto-merge never landed. ([#135](https://github.com/CLDMV/.github/pull/135), released in [#136](https://github.com/CLDMV/.github/pull/136)) diff --git a/docs/changelogs/v4.15.2.md b/docs/changelogs/v4.15.2.md new file mode 100644 index 00000000..b03a47a1 --- /dev/null +++ b/docs/changelogs/v4.15.2.md @@ -0,0 +1,7 @@ +# v4.15.2 โ€” 2026-06-24 + +Patch release letting bot-merged PRs open the release PR. + +## Fixed + +- **The release-PR lane no longer ignores merges made by the bot.** The next and hotfixes release workflows skipped runs triggered by the bot actor, so a PR auto-merged by the bot never opened or refreshed the release PR. This was fatal on `hotfixes`, where every merge is a bot auto-merge. The actor guard is removed from `local-next-release.yml`, `local-hotfixes-release.yml` and their `release-flow-v4` example templates. ([#137](https://github.com/CLDMV/.github/pull/137), released in [#138](https://github.com/CLDMV/.github/pull/138)) diff --git a/docs/changelogs/v4.16.1.md b/docs/changelogs/v4.16.1.md new file mode 100644 index 00000000..7aa5bd20 --- /dev/null +++ b/docs/changelogs/v4.16.1.md @@ -0,0 +1,7 @@ +# v4.16.1 โ€” 2026-07-09 + +Patch release changing how Dependabot security updates are redirected to `hotfixes`. + +## Fixed + +- **Dependabot security redirects are cherry-picked onto the hotfix tip.** Previously the redirector retargeted Dependabot's PR, whose branch was forked from `next`, and merged it as-is, dragging unreleased `next` history into a hotfix. The `redirect-hotfix-pr` step now cherry-picks the dependency change onto the current `hotfixes` tip and redirects from there. Shared auto-merge helpers were extracted into `_api/auto-merge.mjs`, and the hotfix-redirector workflow was extended accordingly. ([#141](https://github.com/CLDMV/.github/pull/141), released in [#142](https://github.com/CLDMV/.github/pull/142)) diff --git a/docs/changelogs/v4.16.10.md b/docs/changelogs/v4.16.10.md new file mode 100644 index 00000000..06dfc56b --- /dev/null +++ b/docs/changelogs/v4.16.10.md @@ -0,0 +1,7 @@ +# v4.16.10 โ€” 2026-07-26 + +Patch release fixing detection of Dependabot security updates so they are redirected to the hotfix lane. + +## Fixed + +- **Dependabot security PRs are detected by base-branch mismatch instead of advisory text.** The previous check looked for a GHSA id or advisory link in the PR body, but current Dependabot security-update bodies do not embed one, so the redirect never fired. GitHub opens security updates against the default branch regardless of `target-branch`, so any Dependabot PR whose base differs from the routine target is now treated as a security update. A new `dependabot-base` input on `redirect-hotfix-pr` (default `next`) names the routine target, and the body-text check is kept as a secondary signal. The hotfix-redirector templates and documentation are updated, and tests are added. ([#161](https://github.com/CLDMV/.github/pull/161), released in [#162](https://github.com/CLDMV/.github/pull/162)) diff --git a/docs/changelogs/v4.16.11.md b/docs/changelogs/v4.16.11.md new file mode 100644 index 00000000..b0d98542 --- /dev/null +++ b/docs/changelogs/v4.16.11.md @@ -0,0 +1,14 @@ +# v4.16.11 โ€” 2026-07-28 + +Patch release hardening the Dependabot security lane so nothing auto-merges onto the release branch, fixing npm publish idempotency, and fixing the coverage build. + +## Fixed + +- **Dependabot PRs are never auto-merged onto the release branch.** `dependabot-auto-merge` now declines when the PR's base is the repository default branch or `master`/`main`, leaves the PR open with a warning and summary note, and treats only `next` and `hotfixes` as valid targets. This backstops `redirect-hotfix-pr`. The master commit audit deliberately does not allow-list Dependabot subjects, so a raw Dependabot commit on `master` still raises the audit issue. ([#163](https://github.com/CLDMV/.github/pull/163)) +- **`redirect-hotfix-pr` fails loudly when the cherry-pick path cannot run.** A caller workflow without a checkout or a configured git identity made the cherry-pick look like a conflict, post a misleading comment and exit green, leaving the security PR to land on the default branch. The action now checks both prerequisites up front, posts an explanatory comment, and fails the job. ([#164](https://github.com/CLDMV/.github/pull/164)) +- **`publish-package` recognizes npm's real "already published" error.** The old exact-substring match missed npm's wording ("You cannot publish over the previously published versions: โ€ฆ"), so re-runs of non-version-gated satellite publishes failed instead of skipping. The check is now a case-insensitive pattern in a tested helper. ([#165](https://github.com/CLDMV/.github/pull/165)) +- **Coverage is measured against a build that preserves `src/`.** The coverage-badge and coverage-pr-comment legs reused the matrix `build_command`, which can end with `ci:cleanup-src` and delete `src/`, leaving coverage nothing to measure. A new `coverage_build_command` input (default `npm run build`) is used for those legs; `build_command` remains a fallback when it is set empty. ([#166](https://github.com/CLDMV/.github/pull/166)) + +## Changed + +- **The release-flow caller workflows are now thin wrappers around new reusable workflows.** `feature-pr`, `hotfix-redirector`, `hotfixes-release`, `next-release`, `next-reset` and `pr-title-normalizer` logic moved into `workflow-*.yml` reusables, and the local workflows and `release-flow-v4` example templates now call them. This shipped with the cherry-pick prerequisite fix. ([#164](https://github.com/CLDMV/.github/pull/164)) diff --git a/docs/changelogs/v4.16.12.md b/docs/changelogs/v4.16.12.md new file mode 100644 index 00000000..2b15ce98 --- /dev/null +++ b/docs/changelogs/v4.16.12.md @@ -0,0 +1,8 @@ +# v4.16.12 โ€” 2026-07-29 + +Patch release making the CodeQL `code_scanning` ruleset rule in org bootstrap aware of repository visibility. + +## Fixed + +- **Bootstrap no longer requires CodeQL results where scanning cannot run.** The `code_scanning` rule is a merge gate, so on a private repository without GitHub Advanced Security it was unsatisfiable and blocked every PR with "code scanning needs to be enabled". The rule is now omitted when the repository is private, GHAS is off, and the run's `code_security` policy would not enable it, with a note in the run summary. ([#170](https://github.com/CLDMV/.github/pull/170), released in [#171](https://github.com/CLDMV/.github/pull/171)) +- **Default CodeQL setup is disabled only when `codeql.yml` exists.** Previously it was turned off whenever it was configured, which could leave a repository with no code scanning at all. ([#170](https://github.com/CLDMV/.github/pull/170)) diff --git a/docs/changelogs/v4.16.13.md b/docs/changelogs/v4.16.13.md new file mode 100644 index 00000000..579a69fa --- /dev/null +++ b/docs/changelogs/v4.16.13.md @@ -0,0 +1,7 @@ +# v4.16.13 โ€” 2026-07-30 + +Patch release omitting the CodeQL `code_scanning` ruleset rule for repositories with nothing CodeQL can analyze. + +## Fixed + +- **Bootstrap omits the `code_scanning` rule when the repository has no CodeQL-supported language.** Declarative repositories, such as a VS Code grammar extension with only JSON and icons, make CodeQL fail with "no source code seen", so requiring the rule blocked every PR. Org bootstrap now reads the repository's languages and drops the rule when none of the CodeQL-supported ones (C, C++, C#, Go, Java, Kotlin, JavaScript, TypeScript, Python, Ruby, Swift) are present, keeping the rule if the language lookup fails. The run note recommends deleting `codeql.yml` for such repositories. ([#172](https://github.com/CLDMV/.github/pull/172), released in [#173](https://github.com/CLDMV/.github/pull/173)) diff --git a/docs/changelogs/v4.16.2.md b/docs/changelogs/v4.16.2.md new file mode 100644 index 00000000..f956e3ef --- /dev/null +++ b/docs/changelogs/v4.16.2.md @@ -0,0 +1,7 @@ +# v4.16.2 โ€” 2026-07-12 + +Patch release making release-time issue closing find keywords in a PR's own commits. + +## Fixed + +- **`close-resolved-issues` scans the source PR's commit messages.** In the v4 merge flow a closing keyword (`Fixes #N`) lives only in the PR's individual commit bodies, whose subjects carry no `(#N)`, so the earlier passes never saw it and releases closed no issues. The step now also reads `/pulls/{n}/commits` for each source PR and sweeps those messages alongside the PR description and comments. ([#143](https://github.com/CLDMV/.github/pull/143), released in [#144](https://github.com/CLDMV/.github/pull/144)) diff --git a/docs/changelogs/v4.16.3.md b/docs/changelogs/v4.16.3.md new file mode 100644 index 00000000..559359af --- /dev/null +++ b/docs/changelogs/v4.16.3.md @@ -0,0 +1,12 @@ +# v4.16.3 โ€” 2026-07-18 + +Patch release defaulting the Node test matrix to LTS-only majors. + +## ๐Ÿ’ฅ Breaking Changes + +- **`lts_only_matrix` now defaults to `true`, despite being a patch release.** `workflow-ci.yml` and the `ci.yml` example template previously defaulted to `false`, and the template enabled LTS-only only on `master`/`main`. The matrix now covers even-numbered (LTS) Node majors only on every event, because odd majors are excluded by the native-binding test toolchain (vitest, rolldown, vite) and failed with "Cannot find native binding" rather than exposing real per-version regressions. ([#145](https://github.com/CLDMV/.github/pull/145), released in [#146](https://github.com/CLDMV/.github/pull/146)) + +## Upgrade notes + +- Callers that relied on testing odd Node majors (for example 21 or 23) must now pass `lts_only_matrix: false` explicitly. In the example `ci.yml`, set `lts_only_matrix` to `false` on the `workflow_dispatch` input to opt out. +- Callers already on LTS-only, or that never use odd majors, need no change. diff --git a/docs/changelogs/v4.16.4.md b/docs/changelogs/v4.16.4.md new file mode 100644 index 00000000..92de8397 --- /dev/null +++ b/docs/changelogs/v4.16.4.md @@ -0,0 +1,7 @@ +# v4.16.4 โ€” 2026-07-18 + +Patch release correcting the scorecard example template so the default configuration can publish to the OpenSSF transparency log. + +## Fixed + +- **The `scorecard.yml` example no longer grants `security-events: write`.** The scorecard action's publish step rejects submissions from a workflow whose token has `security-events` write access ("workflow verification failed: global perm is set to write"), so consumers copying the example with `publish_results: true` hit that failure. The permission is removed and a comment explains the trade-off: the SARIF upload to the Security tab and the public badge are mutually exclusive, and `security-events: write` should only be added back when `publish_results` is `false`. ([#147](https://github.com/CLDMV/.github/pull/147), released in [#148](https://github.com/CLDMV/.github/pull/148)) diff --git a/docs/changelogs/v4.16.5.md b/docs/changelogs/v4.16.5.md new file mode 100644 index 00000000..741a40f7 --- /dev/null +++ b/docs/changelogs/v4.16.5.md @@ -0,0 +1,7 @@ +# v4.16.5 โ€” 2026-07-18 + +Patch release that stops `reusable-scorecard.yml` from failing at startup for callers that do not grant `security-events: write`. + +## Fixed + +- **`reusable-scorecard.yml` no longer hard-requires `security-events: write`.** A reusable workflow whose own `permissions:` block requests more than the caller grants fails at startup with zero jobs created, and the scorecard publish step independently rejects any token carrying that permission, so the requirement was incompatible with the default public-badge mode for every caller. The permission is dropped from the workflow, and the SARIF-to-Security-tab upload step now runs only when `publish_results` is `false` (the caller must then grant `security-events: write` itself). The `publish_results` input description now documents that the two modes are mutually exclusive. ([#149](https://github.com/CLDMV/.github/pull/149), released in [#150](https://github.com/CLDMV/.github/pull/150)) diff --git a/docs/changelogs/v4.16.6.md b/docs/changelogs/v4.16.6.md new file mode 100644 index 00000000..9b9847cb --- /dev/null +++ b/docs/changelogs/v4.16.6.md @@ -0,0 +1,9 @@ +# v4.16.6 โ€” 2026-07-19 + +Patch release fixing the coverage badge and coverage PR comment jobs, which were silently skipped on every real push and pull request, and tightening release naming for satellite packages. + +## Fixed + +- **`coverage-badge` and `coverage-pr-comment` no longer skip silently.** Both jobs depend on a chain that runs through `ci` and `sync-gate`, and `sync-gate` is skipped on pushes to `master` (and on pull requests for the PR comment job). Without an explicit guard, GitHub's implicit `success()` treated the upstream skip as cascading, so the jobs never ran. Both now include `!cancelled()` in their conditions in `workflow-ci.yml`. ([#152](https://github.com/CLDMV/.github/pull/152), released in [#153](https://github.com/CLDMV/.github/pull/153)) +- **Release creation finds draft releases for a tag.** The create-release action looked up existing releases through the tag-scoped endpoint, which does not return drafts, so a stale draft was invisible and a duplicate release was created. It now pages through the full release list and matches by tag name, and the separate `enforce-published.mjs` step is removed in favor of a single retrying publish-and-verify pass (up to 15 attempts, 15 seconds apart) that guards against GitHub reverting a release to draft. ([#151](https://github.com/CLDMV/.github/pull/151)) +- **Satellite package releases are titled with their package name and no longer steal "Latest release".** Release names for packages other than the repo's own package are now prefixed with the package name, and satellite releases pass `make-latest: "false"` through a new `make_latest` input so they never take the badge from the core release. ([#151](https://github.com/CLDMV/.github/pull/151)) diff --git a/docs/changelogs/v4.16.7.md b/docs/changelogs/v4.16.7.md new file mode 100644 index 00000000..614d67f3 --- /dev/null +++ b/docs/changelogs/v4.16.7.md @@ -0,0 +1,9 @@ +# v4.16.7 โ€” 2026-07-19 + +Patch release that moves scorecard permissions to job scope and makes orphaned-release repair work around a GitHub tag-push bug. + +## Fixed + +- **Scorecard permissions are scoped to the job, not the workflow.** The scorecard action's publish step rejects workflows that grant write permissions at the workflow (global) level. `reusable-scorecard.yml` no longer has a workflow-level `permissions:` block; `id-token: write`, `contents: read` and `actions: read` are declared on the `analyze` job instead, matching the OSSF example workflow. The example template is updated to match. ([#154](https://github.com/CLDMV/.github/pull/154), released in [#155](https://github.com/CLDMV/.github/pull/155)) +- **Orphaned-release repair falls back to the REST Git Data API when a tag push is rejected.** GitHub rejects a git-protocol push of an old tag from a GitHub App with "refusing to allow a GitHub App to create or update workflow โ€ฆ without `workflows` permission" whenever the tagged commit's workflow files differ from the default branch tip. `fix-orphaned-releases` now detects that error and creates the annotated tag and ref through the REST API instead. ([#156](https://github.com/CLDMV/.github/pull/156)) +- **`target_commitish` resolves in detached-HEAD checkouts.** The fallback now tries `origin/` first, since `actions/checkout` leaves no local branch pointer and a bare `git rev-parse master` fails. ([#156](https://github.com/CLDMV/.github/pull/156)) diff --git a/docs/changelogs/v4.16.8.md b/docs/changelogs/v4.16.8.md new file mode 100644 index 00000000..7764e4e6 --- /dev/null +++ b/docs/changelogs/v4.16.8.md @@ -0,0 +1,7 @@ +# v4.16.8 โ€” 2026-07-19 + +Patch release making the tag-push fallback added in v4.16.7 actually trigger. + +## Fixed + +- **`fix-orphaned-releases` captures real `git push` stderr.** The shared git helpers stream stderr to the log but never attach it to the thrown error, so the workflow-permission detection added in v4.16.7 only ever saw "Command failed" and the REST fallback never fired. The tag push now runs through `execSync` with captured output, the detection matches against the real git message, and the failure log includes git's rejection text. ([#157](https://github.com/CLDMV/.github/pull/157), released in [#158](https://github.com/CLDMV/.github/pull/158)) diff --git a/docs/changelogs/v4.16.9.md b/docs/changelogs/v4.16.9.md new file mode 100644 index 00000000..1b8af3bc --- /dev/null +++ b/docs/changelogs/v4.16.9.md @@ -0,0 +1,7 @@ +# v4.16.9 โ€” 2026-07-19 + +Patch release that keeps a published release published after CI recreates its tag to sign it. + +## Fixed + +- **Release publish state is re-asserted after a tag is recreated.** When `fix-unsigned-tags` deletes and recreates a tag, GitHub can later revert an already-published release for that tag back to draft. The action now looks up the release bound to the tag (published or draft) and, when it is published, re-applies `draft: false` with verification, retrying up to six times at 20-second intervals and warning if the release still reads as draft. Releases intentionally left as drafts are not touched. ([#159](https://github.com/CLDMV/.github/pull/159), released in [#160](https://github.com/CLDMV/.github/pull/160)) diff --git a/docs/changelogs/v4.18.1.md b/docs/changelogs/v4.18.1.md new file mode 100644 index 00000000..c154e39b --- /dev/null +++ b/docs/changelogs/v4.18.1.md @@ -0,0 +1,7 @@ +# v4.18.1 โ€” 2026-08-02 + +Patch release restoring checkout access on the coverage badge job for private repositories. + +## Fixed + +- **Coverage badge job can check out private repositories again.** A job-level `permissions:` block replaces the workflow-level one wholesale, so `contents: read` has to be repeated on the badge job in `reusable-coverage-pr-comment.yml`. Without it the job token had `contents: none` and `actions/checkout` failed with `Repository not found` on private repos; public repos read anonymously, which hid the problem until the first private v4 consumer's release PR went red. ([#183](https://github.com/CLDMV/.github/pull/183)) diff --git a/docs/changelogs/v4.18.2.md b/docs/changelogs/v4.18.2.md new file mode 100644 index 00000000..b9407165 --- /dev/null +++ b/docs/changelogs/v4.18.2.md @@ -0,0 +1,7 @@ +# v4.18.2 โ€” 2026-08-02 + +Patch release that resolves licenses for first-party and third-party GitHub Actions references in the dependency-review check, which previously all showed as unknown. + +## Fixed + +- **Action and reusable-workflow dependencies no longer report an unknown license.** GitHub's dependency graph surfaces no license for the `actions` ecosystem, so every action or reusable-workflow reference landed in the unlicensed bucket even when its repository has a LICENSE. `reusable-dependency-review.yml` now looks up each action repository's license at the pinned ref (falling back to the default branch), deduplicated per repo, and applies the same allow/deny policy as the underlying action: an allowed license clears, a violating one moves to the incompatible-licenses bucket, and one that cannot be resolved stays flagged. ([#185](https://github.com/CLDMV/.github/pull/185)) diff --git a/docs/changelogs/v4.18.3.md b/docs/changelogs/v4.18.3.md new file mode 100644 index 00000000..c0b45377 --- /dev/null +++ b/docs/changelogs/v4.18.3.md @@ -0,0 +1,11 @@ +# v4.18.3 โ€” 2026-08-03 + +Patch release adding a way to keep the required code-scanning gate satisfiable on repositories with no analyzable source, and fixing the hotfix-redirector's App token permissions. + +## Added + +- **`CLDMV_SKIP_CODE_SCANNING` repository variable.** When set, `reusable-codeql.yml` skips the CodeQL analysis and uploads an empty, zero-alert SARIF instead. Repositories with no analyzable source (declarative packages, grammar-only extensions) fail CodeQL with "no source code seen during build", and dropping the workflow deadlocks the `code_scanning` ruleset rule waiting for a result; the empty SARIF is a passing result that satisfies the gate. ([#191](https://github.com/CLDMV/.github/pull/191)) + +## Fixed + +- **Hotfix-redirector App token can read and retarget pull requests.** The `create-app-token` step gained a `permission_pull_requests` input, and `workflow-hotfix-redirector.yml` now requests `pull-requests` and `issues` write alongside `contents`. The fine-grained token previously had no PR access, so the first `GET /pulls/{n}` returned 403 (`Resource not accessible by integration`) and the labeling call had no issues scope. ([#189](https://github.com/CLDMV/.github/pull/189)) diff --git a/docs/changelogs/v4.18.4.md b/docs/changelogs/v4.18.4.md new file mode 100644 index 00000000..85f39249 --- /dev/null +++ b/docs/changelogs/v4.18.4.md @@ -0,0 +1,8 @@ +# v4.18.4 โ€” 2026-08-02 + +Patch release making the release machinery resolve the repository's release base branch instead of assuming `master`, plus a next-reset fix for private repositories. + +## Fixed + +- **Release base branch is resolved, not hardcoded to `master`.** A new `resolve-release-base` utility action determines the base (the `CLDMV_RELEASE_BASE` variable, else the repo's default branch) and is used by the next/hotfixes release workflows, the create/update release-PR jobs, divergence detection, next-reset and the pending-release reminder, so repositories whose release branch is `main` or another name work. The release-PR flow label is derived from the resolved base, and base-interpolated git commands run without a shell (a CodeQL finding). Major bumps are classified by the `!` marker or a `BREAKING CHANGE` footer rather than a substring match. The action is referenced by a `next` commit SHA in this release so the release opener could resolve it before the tag rolled; v4.18.5 flips the pin back. ([#193](https://github.com/CLDMV/.github/pull/193), [#196](https://github.com/CLDMV/.github/pull/196)) +- **next-reset tag gate works on private repositories.** The tag lookup used an anonymous `git ls-remote`, which exits 128 on private repos and, under `set -eo pipefail`, killed the gate so `next`/`hotfixes` were never reset after a release. The lookup is now authenticated with the job token, and a failed lookup degrades to proceeding with a logged warning rather than aborting. ([#195](https://github.com/CLDMV/.github/pull/195)) diff --git a/docs/changelogs/v4.18.5.md b/docs/changelogs/v4.18.5.md new file mode 100644 index 00000000..d130681f --- /dev/null +++ b/docs/changelogs/v4.18.5.md @@ -0,0 +1,7 @@ +# v4.18.5 โ€” 2026-08-02 + +Patch release completing the pin-then-flip for the `resolve-release-base` action introduced in v4.18.4. + +## Changed + +- **`resolve-release-base` is referenced at the rolling `v4` tag again.** The next/hotfixes release workflows pinned the new action to a `next` commit SHA so the release opener could resolve it before it existed on the tag; with v4.18.4 shipped, both references now use `v4`. No behavior change. ([#198](https://github.com/CLDMV/.github/pull/198)) diff --git a/docs/changelogs/v4.19.1.md b/docs/changelogs/v4.19.1.md new file mode 100644 index 00000000..6406f190 --- /dev/null +++ b/docs/changelogs/v4.19.1.md @@ -0,0 +1,7 @@ +# v4.19.1 โ€” 2026-08-04 + +Patch release adding CLDMV-private auto-routing to the runner selection and version-locking the nested reusable-workflow calls. + +## Fixed + +- **Runner auto-routing falls back to `cldmv-runners` for CLDMV-owned private repositories.** When `runs_on` is empty, runner resolution now goes: the caller's `RUNS_ON_DEFAULT` variable, then `cldmv-runners` for private repositories owned by CLDMV, then `ubuntu-latest`. The fallback is applied to every job in the workflow chain, covering contexts where the event payload cannot answer the routing question. Nested reusable calls inside the `workflow-*.yml` chain were switched from local `./` paths to explicit `CLDMV/.github` references. v4.19.2 restored the deliberate `v4` pins. ([#203](https://github.com/CLDMV/.github/pull/203)) diff --git a/docs/changelogs/v4.19.2.md b/docs/changelogs/v4.19.2.md new file mode 100644 index 00000000..747ac371 --- /dev/null +++ b/docs/changelogs/v4.19.2.md @@ -0,0 +1,8 @@ +# v4.19.2 โ€” 2026-08-03 + +Patch release restoring the intended rolling `v4` pins on nested reusable calls and making release-relevant CI runs immune to cancellation. + +## Fixed + +- **Nested reusable calls pin the rolling `v4` tag again.** The version-lock in v4.19.1 overcorrected; rolling-tag pins are the intended contract between first-party reusables, so `workflow-ci.yml`, `workflow-publish.yml`, `workflow-release.yml` and `workflow-update-major-version-tags.yml` reference `CLDMV/.github/.github/workflows/.yml@v4` for their nested calls. ([#206](https://github.com/CLDMV/.github/pull/206)) +- **Release-relevant CI runs are never superseded.** Pushes to the release base branch, to `next`/`hotfixes`, and the `next`/`hotfixes` release PRs now get a unique concurrency group per run, so every run completes and posts a green check rather than an earlier one being cancelled into a red X on the release PR. Feature branches keep cancel-superseded behavior. The base branch is derived from `CLDMV_RELEASE_BASE` or the default branch instead of assuming `master`/`main`, and the `ci.yml` example template was updated to match. ([#205](https://github.com/CLDMV/.github/pull/205)) diff --git a/docs/changelogs/v4.19.3.md b/docs/changelogs/v4.19.3.md new file mode 100644 index 00000000..f5e3c875 --- /dev/null +++ b/docs/changelogs/v4.19.3.md @@ -0,0 +1,8 @@ +# v4.19.3 โ€” 2026-08-08 + +Patch release fixing private-repository CI that could not obtain a runner, and adding the source and publishing workflow for the self-hosted runner image. + +## Fixed + +- **The `โœ… Required PR Check` mirror job is routed like the reusables.** The `ci.yml` template hardcoded `runs-on: ubuntu-latest` for the mirror job, which failed to provision a runner on private repositories once the reusables moved to `cldmv-runners` in v4.19.1. The job now uses the same expression: `RUNS_ON_DEFAULT`, else `cldmv-runners` for CLDMV-owned private repos, else `ubuntu-latest`. ([#209](https://github.com/CLDMV/.github/pull/209)) +- **Self-hosted runners have `gh`.** The stock ARC runner image ships no `gh`, so every routed job that shelled out to it died with exit 127. The image is now built from `images/actions-runner/` (ARC base plus `gh`) and published as `ghcr.io/cldmv/actions-runner` by the new `build-runner-image.yml` workflow, with a README and Helm values file for the runner scale set. ([#212](https://github.com/CLDMV/.github/pull/212)) diff --git a/docs/changelogs/v4.21.1.md b/docs/changelogs/v4.21.1.md new file mode 100644 index 00000000..eacbc44e --- /dev/null +++ b/docs/changelogs/v4.21.1.md @@ -0,0 +1,7 @@ +# v4.21.1 โ€” 2026-08-09 + +Patch release baking the Node.js LTS runtime into the self-hosted runner image. + +## Fixed + +- **The `cldmv-runners` image includes Node.js.** The image previously carried only `gh` and `jq`, so run steps invoking `node` or `npm` directly failed on self-hosted runners, unlike GitHub-hosted ones. The Dockerfile now installs the current LTS from the NodeSource `lts` channel (including npm and npx), so the weekly rebuild tracks LTS promotions, and the build fails if `gh`, `jq`, `node` or `npm` is missing. Jobs that need a specific version still use `actions/setup-node`. ([#228](https://github.com/CLDMV/.github/pull/228)) diff --git a/docs/changelogs/v4.22.1.md b/docs/changelogs/v4.22.1.md new file mode 100644 index 00000000..e622454a --- /dev/null +++ b/docs/changelogs/v4.22.1.md @@ -0,0 +1,8 @@ +# v4.22.1 โ€” 2026-08-20 + +Patch release scoping coverage PR-body injection to the persistent release PRs and deriving the coverage badge filename per branch. + +## Fixed + +- **Coverage is injected only into release PR descriptions.** The coverage PR-comment job ran for every pull request, writing whole-repo coverage into feature PR descriptions. It now runs only when the PR head is `next` or `hotfixes` and comes from the same repository, which also keeps fork PRs with a branch literally named `next` away from a job holding write permission and bot secrets. ([#239](https://github.com/CLDMV/.github/pull/239)) +- **Coverage badge filename is derived per branch.** The badge job passes `coverage-next.json` or `coverage-hotfixes.json` on those branches and `coverage.json` otherwise, so widening the trigger later cannot let one branch overwrite another's badge. An explicit non-default `badge_filename` from the caller always wins. ([#239](https://github.com/CLDMV/.github/pull/239)) diff --git a/docs/changelogs/v4.22.2.md b/docs/changelogs/v4.22.2.md new file mode 100644 index 00000000..01acdf12 --- /dev/null +++ b/docs/changelogs/v4.22.2.md @@ -0,0 +1,7 @@ +# v4.22.2 โ€” 2026-08-26 + +Patch release teaching the generated changelog to recognize the org's own commit-signing bot, so it is not listed as a contributor. + +## Fixed + +- **The commit-signing bot is excluded from changelog contributor lists.** `generate-comprehensive-changelog` gained `bot-name` and `bot-email` inputs, with matching optional `BOT_NAME` and `BOT_EMAIL` secrets on the feature-PR, next-release and hotfixes-release workflows (and their example callers), passed in from the repo's `CLDMV_BOT_NAME`/`CLDMV_BOT_EMAIL` secrets. Bot detection tests cover the new identity matching. Unset secrets exclude nothing extra, so existing callers are unaffected. ([#241](https://github.com/CLDMV/.github/pull/241)) diff --git a/docs/changelogs/v4.25.1.md b/docs/changelogs/v4.25.1.md new file mode 100644 index 00000000..21c6e57c --- /dev/null +++ b/docs/changelogs/v4.25.1.md @@ -0,0 +1,11 @@ +# v4.25.1 โ€” 2026-09-02 + +Patch release fixing the hotfix-redirector's cherry-pick, which was unsigned and therefore silently blocked by the `hotfixes` required-signatures rule. + +## Fixed + +- **Redirected Dependabot security PRs carry a signed cherry-pick.** The dependabot-security redirect cherry-picked onto `hotfixes` without importing the bot GPG key and committed under the App-slug identity, producing an unsigned commit that required-signatures blocked. The redirector now imports the signing key, authors the commit under the real-user bot identity, signs it (`-S`), fails loudly when signing prerequisites are missing, and refuses to push an unsigned commit. The reusable gains four optional secrets for this: `BOT_NAME`, `BOT_EMAIL`, `BOT_GPG_PRIVATE_KEY` and `BOT_GPG_PASSPHRASE`. ([#259](https://github.com/CLDMV/.github/pull/259), fixes [#257](https://github.com/CLDMV/.github/issues/257)) + +## Upgrade notes + +- A security redirect only succeeds when the caller passes the signing secrets. Re-sync `hotfix-redirector.yml` from the `release-flow-v4` template, or add `BOT_NAME: ${{ secrets.CLDMV_BOT_NAME }}`, `BOT_EMAIL: ${{ secrets.CLDMV_BOT_EMAIL }}`, `BOT_GPG_PRIVATE_KEY: ${{ secrets.CLDMV_BOT_GPG_PRIVATE_KEY }}` and `BOT_GPG_PASSPHRASE: ${{ secrets.CLDMV_BOT_GPG_PASSPHRASE }}` to its `secrets:` block. Without them, a redirect that needs the cherry-pick now fails loudly instead of leaving an unsigned, blocked PR behind. Routine PRs that need no redirect are unaffected. diff --git a/docs/changelogs/v4.26.1.md b/docs/changelogs/v4.26.1.md new file mode 100644 index 00000000..12a8f3fa --- /dev/null +++ b/docs/changelogs/v4.26.1.md @@ -0,0 +1,7 @@ +# v4.26.1 โ€” 2026-09-05 + +Patch release fixing the `CLDMV_SKIP_FROZEN_LOCKFILE` opt-out, which composite actions could not read, so the variable had no effect inside them. + +## Fixed + +- **`CLDMV_SKIP_FROZEN_LOCKFILE` is now passed to composite actions as an input.** The `create-release-pr`, `create-release`, `update-release-pr` and `build-and-test` composite actions read the variable through the `vars` context, which is not available inside composite actions, so the value was always empty and installs stayed frozen regardless of the repo or org setting. Each action now takes an optional `skip-frozen-lockfile` input (empty means frozen install), and the calling reusable and entry-point workflows (build-and-test, publishing, release-management, next/hotfixes release, release) supply `${{ vars.CLDMV_SKIP_FROZEN_LOCKFILE }}`. Callers need no change. ([#276](https://github.com/CLDMV/.github/pull/276)) diff --git a/docs/changelogs/v4.26.2.md b/docs/changelogs/v4.26.2.md new file mode 100644 index 00000000..d5586448 --- /dev/null +++ b/docs/changelogs/v4.26.2.md @@ -0,0 +1,9 @@ +# v4.26.2 โ€” 2026-09-06 + +Patch release hardening the post-release `next-reset` and release-PR flow so a hotfix release can no longer reset `next` and drop its queued work, and a release version can no longer be recomputed after it already shipped. + +## Fixed + +- **`next-reset` no longer force-resets `next` after a hotfix release.** Lane detection reads the released PR's head ref with `gh pr view`, but the app token was created in fine-grained mode without any pull-requests scope, so the call was silently denied, the lane resolved to "other" for every hotfix release, and `next` was reset to `master` instead of having `master` merged in. The token now requests pull-requests read (via the new `permission_pull_requests_read` input on `create-app-token`), the `gh` error is logged instead of swallowed, and the reset path runs only when the head ref is positively a non-`hotfixes` branch. An unreadable or unknown head now falls through to the merge (preserve) path. ([#279](https://github.com/CLDMV/.github/pull/279), refs [#278](https://github.com/CLDMV/.github/issues/278)) +- **Release version is floored at the highest released tag.** The base version came from `package.json` on the default branch, which can lag a tag pushed by a concurrent release, causing an already-shipped version to be recomputed. `find-divergence` and the release-PR `divergence` step now take the maximum of that base and the highest semver tag (read via `git ls-remote --tags`), degrading to no floor if tags cannot be read. ([#280](https://github.com/CLDMV/.github/pull/280), refs [#278](https://github.com/CLDMV/.github/issues/278)) +- **A spuriously closed release PR is reopened instead of orphaned.** When no open `next` โ†’ base PR exists, the `next` release workflow now reopens the most recent closed-but-unmerged one, preserving its number and history, and creates a fresh PR only if none exists. ([#280](https://github.com/CLDMV/.github/pull/280)) diff --git a/docs/changelogs/v4.27.0.md b/docs/changelogs/v4.27.0.md new file mode 100644 index 00000000..4094d0b3 --- /dev/null +++ b/docs/changelogs/v4.27.0.md @@ -0,0 +1,15 @@ +# v4.27.0 โ€” 2026-09-06 + +Minor release extending the coverage badge to the integration branches: `next` and `hotfixes` now publish their own badge JSON alongside the default branch's, with the push step rewritten to be safe under concurrent runs. + +## Added + +- **Per-branch coverage badge JSON for `next` and `hotfixes`.** The coverage-badge job in `workflow-ci.yml` previously fired only on pushes to the default branch. It now also runs on pushes to `next` and `hotfixes`, each publishing under a per-branch filename (`coverage-next.json`, `coverage-hotfixes.json`) on the shared `badges` branch, while the default branch keeps `coverage.json`. The job is gated on branch refs rather than `ref_name`. ([#283](https://github.com/CLDMV/.github/pull/283)) + +## Fixed + +- **Concurrent badge pushes no longer drop a badge.** Because several branches now push to the single `badges` branch at once, `push-badge` re-syncs onto the latest tip and replays its badge file when a push is rejected as non-fast-forward, retrying up to five times with jitter. Only non-fast-forward rejections are retried; auth, permission and network failures surface immediately. ([#283](https://github.com/CLDMV/.github/pull/283)) + +## Changed + +- **`push-badge` is hardened against injection and token leaks.** Git commands now run through `execFileSync` with argument arrays instead of shell strings (the CodeQL-recommended fix for indirect command-line injection), `badge-filename` must be a bare filename (no separators or traversal), a push token is required up front with a clear error, and the token is redacted from any logged git output. ([#283](https://github.com/CLDMV/.github/pull/283)) diff --git a/docs/changelogs/v4.27.1.md b/docs/changelogs/v4.27.1.md new file mode 100644 index 00000000..f44e270e --- /dev/null +++ b/docs/changelogs/v4.27.1.md @@ -0,0 +1,20 @@ +# v4.27.1 โ€” 2026-09-13 + +Patch release raising the default Node.js test matrix to the vitest 5 floor (22.12.0) and Node 26, and fixing the self-hosted runner image so jobs that pin Node 26 can start. The new defaults change the matrix for every caller that does not set the inputs explicitly, so this release is listed as breaking despite being a patch. + +## ๐Ÿ’ฅ Breaking Changes + +- **Default Node.js matrix bounds changed, despite being a patch release.** `min_node_version` now defaults to `22.12.0` (was `20`) and `max_node_major` to `26` (was `22`) in `workflow-ci.yml`, `workflow-publish.yml`, `workflow-release.yml`, `reusable-build-and-test.yml` and the `generate-matrix` action, and in the matching `core-cicd` example callers. Repos that rely on the defaults stop testing Node 20 and start testing newer majors up to 26 on their next run. ([#285](https://github.com/CLDMV/.github/pull/285)) + +## Fixed + +- **Self-hosted runner image now includes `libatomic1`.** `actions/setup-node` downloads its own Node builds, and the official Node 26 Linux build links against `libatomic.so.1`, which the minimal runner base image lacked, so any job requesting Node 26 failed before printing `node --version`. The package is installed in `images/actions-runner/Dockerfile`. ([#288](https://github.com/CLDMV/.github/pull/288)) + +## Changed + +- **Dependabot example groups vitest, eslint and prettier families.** The example `dependabot.yml` gains groups that bump `vitest` with `@vitest/*`, `eslint` with `@eslint/*` and the CLDMV eslint plugins, and `prettier` with its CLDMV plugins, because these packages peer each other tightly and a partial bump breaks `npm ci` with an `ERESOLVE`. ([#287](https://github.com/CLDMV/.github/pull/287)) + +## Upgrade notes + +- To keep the previous matrix, pass `min_node_version: "20"` and `max_node_major: "22"` in the caller's `ci.yml`, `publish.yml` and `release.yml` `with:` blocks (or the workflow-dispatch defaults). +- A `min_node_version` of `22.12.0` matches the floor of the current vitest toolchain, so repos on that toolchain should not need to change anything. diff --git a/docs/changelogs/v4.27.2.md b/docs/changelogs/v4.27.2.md new file mode 100644 index 00000000..fbbd9fbd --- /dev/null +++ b/docs/changelogs/v4.27.2.md @@ -0,0 +1,7 @@ +# v4.27.2 โ€” 2026-09-13 + +Patch release so routine pull requests are no longer failed by the hotfix-redirector when the `hotfixes` branch is briefly missing. + +## Fixed + +- **Hotfix-redirector no longer hard-fails when `hotfixes` is transiently absent.** Right after a `hotfixes` โ†’ `master` release squash, `hotfixes` can be missing until it is recreated, and the redirector's checkout of it failed the whole job with a "branch not found" error on unrelated PRs such as routine Dependabot bumps. That checkout is now `continue-on-error` with a warning, and the dependent identity and signing steps run only when it succeeded. The redirect decision itself does not need the checkout, so PRs that need no redirect skip cleanly, and one that does need the cherry-pick path still gets the existing actionable prerequisite error. ([#289](https://github.com/CLDMV/.github/pull/289)) diff --git a/docs/changelogs/v4.27.3.md b/docs/changelogs/v4.27.3.md new file mode 100644 index 00000000..f7f3cee2 --- /dev/null +++ b/docs/changelogs/v4.27.3.md @@ -0,0 +1,16 @@ +# v4.27.3 โ€” 2026-09-15 + +Patch release crediting release contributors through `Co-authored-by` trailers on the squashed release commit, adding a PR-time docs-generation check, and installing `zip`/`unzip` on the self-hosted runner image. The docs check is on by default, so this release is listed as breaking despite being a patch. + +## ๐Ÿ’ฅ Breaking Changes + +- **New default-on docs-generation check on PRs, despite being a patch release.** `workflow-ci.yml` and `reusable-build-and-test.yml` gain a `docs_check_command` input (default `npm run docs:build --if-present`) that runs once per PR in a new "๐Ÿ“– Validate Docs Generation" job. Repos that define a `docs:build` script now run it on every PR, and a doc-tooling failure (for example a JSDoc type the generator cannot parse) will fail the PR, where it previously surfaced only at the post-release docs publish. Repos without the script are unaffected. ([#293](https://github.com/CLDMV/.github/pull/293)) + +## Fixed + +- **Release contributors are credited via `Co-authored-by` trailers.** A markdown `@mention` in the release body gives no contributor-graph credit, only a trailer does. `generate-comprehensive-changelog` now emits a `co-authors` output (human commit authors plus trailers found in commit bodies, bots and the signing bot excluded), `update-pr-changelog` appends it as the final paragraph of the release-PR body behind a `` marker, and `update-pr-coverage` inserts its badge above that marker so the trailers stay last, as GitHub requires. ([#292](https://github.com/CLDMV/.github/pull/292)) +- **`zip` and `unzip` are installed on the self-hosted runner image.** The package-file step of `workflow-publish.yml` shells out to `zip` and failed with "zip: not found" on private-repo releases routed to `cldmv-runners`. ([#295](https://github.com/CLDMV/.github/pull/295), refs [#291](https://github.com/CLDMV/.github/issues/291)) + +## Upgrade notes + +- To opt out of the docs check, set `docs_check_command: ""` in the caller's `ci.yml` `with:` block, or override it with a lighter command such as a source-only docs build. diff --git a/docs/changelogs/v4.27.4.md b/docs/changelogs/v4.27.4.md new file mode 100644 index 00000000..5582fd0e --- /dev/null +++ b/docs/changelogs/v4.27.4.md @@ -0,0 +1,7 @@ +# v4.27.4 โ€” 2026-09-15 + +Patch release so a contributor is no longer credited twice in the release `Co-authored-by` trailers when their commits carry different GitHub noreply email forms. + +## Fixed + +- **Co-author trailers are deduplicated by GitHub login, not raw email.** The ID-prefixed (`{id}+{login}@users.noreply.github.com`) and bare (`{login}@users.noreply.github.com`) noreply addresses identify the same account but were treated as two people, producing duplicate trailers. They now collapse to one entry keyed on the login, preferring the ID-prefixed form for a deterministic result. Non-noreply emails still dedupe on the email itself. ([#296](https://github.com/CLDMV/.github/pull/296)) diff --git a/docs/changelogs/v4.27.5.md b/docs/changelogs/v4.27.5.md new file mode 100644 index 00000000..3292826f --- /dev/null +++ b/docs/changelogs/v4.27.5.md @@ -0,0 +1,8 @@ +# v4.27.5 โ€” 2026-09-15 + +Patch release making release notes survive a title-only squash merge and ensuring a freshly created release PR carries the co-author trailers from the start. + +## Fixed + +- **Release notes are recovered from the PR body when the squash drops it.** A `next` โ†’ `master` or `hotfixes` โ†’ `master` squash can land with an empty commit body despite `squash_merge_commit_message: PR_BODY`, which stripped the curated changelog and coverage from the GitHub Release and tag. When the release commit body is empty, `generate-comprehensive-changelog` now reads the trailing `(#N)` from the subject and fetches that PR's body, using it verbatim. The lookup is best-effort and falls back to the previous behavior on any miss. ([#299](https://github.com/CLDMV/.github/pull/299), fixes [#298](https://github.com/CLDMV/.github/issues/298)) +- **Co-author trailers are threaded into the release-PR create path.** The trailer block was applied only when an existing release PR was refreshed, so a newly opened release PR lacked it until its first refresh. The `pull-requests` API action now accepts a `co-authors` input and `create-release-pr` passes it, so the trailers are present from creation. ([#302](https://github.com/CLDMV/.github/pull/302), refs [#301](https://github.com/CLDMV/.github/issues/301)) diff --git a/docs/changelogs/v4.28.0.md b/docs/changelogs/v4.28.0.md new file mode 100644 index 00000000..fd17d810 --- /dev/null +++ b/docs/changelogs/v4.28.0.md @@ -0,0 +1,12 @@ +# v4.28.0 โ€” 2026-09-16 + +Adds an opt-in, approval-triggered merge for the persistent release PR. A maintainer approves the `next` โ†’ `master` (or `hotfixes` โ†’ `master`) release PR, and once every check on the head has passed, the PR is squash-merged through the REST API with the PR body as the commit message. A human approval is still required; nothing merges unattended. + +## Added + +- **`workflow-release-merge.yml` reusable workflow and `release-merge` job action.** The merge uses `PUT .../merge` with an explicit commit message equal to the release PR body, so the release commit is exactly the curated changelog: no title-only commit from the mobile default path, and no GitHub UI `Co-authored-by:` auto-append (the `` block already in the body is the whole credit). The merge waits until every check on the head, required and non-required, has finished and passed, so the body it reads is never stale. It never approves as the bot. Inputs: `release_base_branches` (default `master,main`), `integration_branches` (default `next,hotfixes`), `allowed_associations` (default `MEMBER,OWNER`), `require_approval` (default `true`), `merge_method` (default `squash`), `allow_failing_checks` (default none), plus `runs_on`. Secrets: `BOT_APP_CLIENT_ID` and `BOT_APP_PRIVATE_KEY`. ([#304](https://github.com/CLDMV/.github/pull/304), see [#303](https://github.com/CLDMV/.github/issues/303)) +- **`release-merge.yml` caller template** under `examples/individual-repo-workflows/release-flow-v4/`, and updates to `docs/conventions/release-flow-v4.md` describing the opt-in approval-triggered merge alongside the manual "Squash and merge" click. ([#304](https://github.com/CLDMV/.github/pull/304)) + +## Fixed + +- **Event fields are passed as inputs instead of being read from the event file.** The release-merge action receives `pr_number` and `head_branch` from the workflow's `github` context rather than reading `GITHUB_EVENT_PATH`, which avoids CodeQL's `js/file-access-to-http` finding. ([#304](https://github.com/CLDMV/.github/pull/304)) diff --git a/docs/changelogs/v4.28.1.md b/docs/changelogs/v4.28.1.md new file mode 100644 index 00000000..8694dc11 --- /dev/null +++ b/docs/changelogs/v4.28.1.md @@ -0,0 +1,9 @@ +# v4.28.1 โ€” 2026-09-17 + +Patch release that stops branch-retention from deleting the permanent integration branches. Deleting `next` or `hotfixes` when its release PR merged made GitHub auto-close every other open PR based on it. + +## Fixed + +- **Branch retention no longer deletes `next` / `hotfixes`.** The default `exempt_patterns` in `reusable-branch-retention.yml` changed from `["master","main","badges","gh-pages"]` to `["master","main","badges","gh-pages","dev","next","hotfixes"]`. The release PR merges with one of the integration branches as its head, and deleting a merged PR's head branch auto-closes all open PRs still based on it. Callers that pass their own `exempt_patterns` replace the default and should include `next` and `hotfixes`. ([#308](https://github.com/CLDMV/.github/pull/308)) +- **Hard guard for permanent branches.** The branch-retention action now refuses to delete `master`, `main`, `next` or `hotfixes` regardless of configuration, and filters them out of retention-rule pruning, so a misconfigured caller cannot remove them. ([#308](https://github.com/CLDMV/.github/pull/308)) +- **Individual-repo templates inherit `max_node_major`.** The `ci.yml`, `publish.yml` and `release.yml` example workflows now default `max_node_major` to blank, so the reusable workflow's default applies instead of a hardcoded `26`. ([#306](https://github.com/CLDMV/.github/pull/306)) diff --git a/docs/changelogs/v4.28.2.md b/docs/changelogs/v4.28.2.md new file mode 100644 index 00000000..d6fdf093 --- /dev/null +++ b/docs/changelogs/v4.28.2.md @@ -0,0 +1,7 @@ +# v4.28.2 โ€” 2026-09-17 + +Patch release that removes the duplicate Node.js test leg when `lts/*` resolves to a major already in the matrix. Note: the approach taken here (dropping `lts/*`) was reversed in v4.28.3 because it broke publishing. + +## Fixed + +- **Node.js matrix is de-duplicated against the resolved `lts/*` version.** `reusable-build-and-test.yml` gains a step that installs `lts/*` with `actions/setup-node` and passes the resolved version to `generate-matrix` through a new `current-lts-version` input (empty disables dedup). When that major was already an explicit matrix entry, the trailing `lts/*` entry was dropped so the same Node version was not tested twice under two labels. The default `max-node-major` description was also corrected to 26. ([#309](https://github.com/CLDMV/.github/pull/309)) diff --git a/docs/changelogs/v4.28.3.md b/docs/changelogs/v4.28.3.md new file mode 100644 index 00000000..ed6a28c0 --- /dev/null +++ b/docs/changelogs/v4.28.3.md @@ -0,0 +1,8 @@ +# v4.28.3 โ€” 2026-09-20 + +Patch release that restores publishing after the v4.28.2 matrix dedup, and fixes how `next-reset` identifies the released PR. + +## Fixed + +- **Matrix dedup keeps `lts/*` and drops the redundant numeric entry.** v4.28.2 removed `lts/*` when its major was already explicit, but the publish flow downloads the artifact named `build-artifacts-lts`, which only the `lts/*` leg produces, so publishing failed with "Artifact not found for name: build-artifacts-lts". The explicit numeric entry is now replaced by `lts/*` in place, giving one leg with the artifact intact. ([#311](https://github.com/CLDMV/.github/pull/311)) +- **`next-reset` resolves the released PR from the commit.** The lane check previously took the last `#N` found in the release commit message, which is the changelog body, so it often picked an issue number instead of the release PR. `gh pr view` then failed and every normal release fell through to the fail-safe merge path, leaving `next` un-reset and re-cutting shipped work as a phantom release. The workflow now uses the commit-to-PRs API (`commits//pulls`) to find the merged PR's head ref. ([#311](https://github.com/CLDMV/.github/pull/311)) diff --git a/docs/changelogs/v4.28.4.md b/docs/changelogs/v4.28.4.md new file mode 100644 index 00000000..a4a8def5 --- /dev/null +++ b/docs/changelogs/v4.28.4.md @@ -0,0 +1,7 @@ +# v4.28.4 โ€” 2026-09-20 + +Patch release so that `close-resolved-issues` actually closes issues after a release. + +## Fixed + +- **`close-resolved-issues` finds the release PR through the commit-to-PR API.** The release squash commit has a custom subject (the release PR title) with no trailing `(#N)`, so parsing the subject found nothing and each release closed zero issues. The action now resolves the release PR with `GET /commits//pulls` (preferring the most recently merged PR) and falls back to the subject reference only when the API returns nothing. ([#313](https://github.com/CLDMV/.github/pull/313)) diff --git a/docs/changelogs/v4.29.0.md b/docs/changelogs/v4.29.0.md new file mode 100644 index 00000000..7205b659 --- /dev/null +++ b/docs/changelogs/v4.29.0.md @@ -0,0 +1,16 @@ +# v4.29.0 โ€” 2026-09-20 + +This release wires the approval-triggered release merge into this repository's own release PR, and corrects the trigger the caller template uses to re-evaluate it. GitHub does not send `check_suite: completed` for suites created by GitHub Actions, so an approval given before CI finished left a fully green release PR waiting indefinitely. Coverage jobs were also decoupled from the Node test matrix to shorten pipeline time. + +## Added + +- **`local-release-merge.yml`** enables the approval-triggered merge for this repository's own `next` โ†’ `master` release PR, calling the local `workflow-release-merge.yml`. ([#317](https://github.com/CLDMV/.github/pull/317)) + +## Fixed + +- **Release merge re-evaluates on `workflow_run`, not `check_suite`.** The reusable workflow now reads the head branch from `workflow_run.head_branch` and falls back to `check_suite.head_branch`, so callers that have not re-synced keep working. The `release-merge.yml` caller template switches its trigger to `workflow_run: completed` for the repository's CI workflow (matched by literal workflow `name:`); callers should re-sync their copy and check that `workflows:` matches their own CI workflow name. ([#319](https://github.com/CLDMV/.github/pull/319), see [#318](https://github.com/CLDMV/.github/issues/318)) +- **Coverage runs in parallel with the Node test matrix.** In `workflow-ci.yml` the `coverage-badge` and `coverage-pr-comment` jobs no longer `need` the `ci` matrix job, since they build and test independently. They now `need` `sync-gate` directly to read `is_master_sync` and skip on master-sync pushes; they no longer wait for matrix success, and a coverage job failure is reported on its own. ([#315](https://github.com/CLDMV/.github/pull/315)) + +## Changed + +- **Release-flow docs** describe the opt-in approval-triggered merge as part of the release flow. ([#317](https://github.com/CLDMV/.github/pull/317)) diff --git a/docs/changelogs/v4.29.1.md b/docs/changelogs/v4.29.1.md new file mode 100644 index 00000000..279b0410 --- /dev/null +++ b/docs/changelogs/v4.29.1.md @@ -0,0 +1,16 @@ +# v4.29.1 โ€” 2026-09-28 + +Patch release that makes the default publish command pass `--ignore-scripts`. The publish jobs run inside the packed `package-contents/` artifact, which has no `node_modules` and no lockfile, so a lifecycle script such as `prepack` that needs devDependencies crashed the publish. + +## ๐Ÿ’ฅ Breaking Changes + +- **The default publish command now includes `--ignore-scripts`, despite being a patch release.** Auto-generated commands for npm and GitHub Packages (with any package manager) become, for example, `npm publish --access public --ignore-scripts --provenance` on npm for public repositories and `npm publish --access restricted --ignore-scripts` for private ones. Lifecycle scripts (`prepack`, `prepublishOnly`, `postpack`, ...) no longer run during publish. The build job has already run `build_command` before packing, so most repositories are unaffected; a repository that relied on a lifecycle script running against the packed tree must move that work into `build_command`, or supply its own `publish_command` / `github_packages_publish_command`. Caller-supplied commands are used verbatim and are not modified. ([#321](https://github.com/CLDMV/.github/pull/321), fixes [#320](https://github.com/CLDMV/.github/issues/320)) + +## Fixed + +- **Publish no longer fails on `prepack` scripts that need devDependencies.** The command is built by a new shared helper, `utilities/publish-command/build.mjs`, used by repo-detection and both publish fallbacks; `--provenance` remains limited to public npm-CLI publishes to npm. `docs/conventions/package-manager.md` gained a "Publishing from the build artifact" section. ([#321](https://github.com/CLDMV/.github/pull/321)) + +## Upgrade notes + +1. If a `prepack` / `prepublishOnly` script performs real work, run it from `build_command` instead. +2. If a custom publish command is set, add `--ignore-scripts` to it unless a lifecycle script genuinely has to run against the packed tree. diff --git a/docs/changelogs/v4.29.2.md b/docs/changelogs/v4.29.2.md new file mode 100644 index 00000000..6e520b41 --- /dev/null +++ b/docs/changelogs/v4.29.2.md @@ -0,0 +1,7 @@ +# v4.29.2 โ€” 2026-09-28 + +Patch release so the approval-triggered release merge re-evaluates when any check-producing workflow finishes, not only CI. The release commit and PR title still read "pass --ignore-scripts on the default publish command" because the title was set when the release PR was first opened; the only code change in this release is the release-merge fix. The `--ignore-scripts` change shipped in v4.29.1. + +## Fixed + +- **Release merge is re-armed by every check-producing workflow.** The merge gate waits on every check-run on the release PR head, but only CI was listed under `workflow_run`, so when CodeQL finished after CI nothing re-fired and an approved, fully green release PR stayed unmerged (PR [#322](https://github.com/CLDMV/.github/pull/322)). The `release-merge.yml` template now lists the standard v4 workflow names (CI, CodeQL, Dependency Review, Bundle Size, release workflows, labeler, title normalizer, CLA, and others) and limits re-fires with `branches: [next, hotfixes]`. The local caller and reusable-workflow comments and release-flow docs were updated to match. Callers should re-sync the template; names are matched literally, and listing a workflow the repository lacks is harmless. ([#323](https://github.com/CLDMV/.github/pull/323)) diff --git a/docs/changelogs/v4.29.3.md b/docs/changelogs/v4.29.3.md new file mode 100644 index 00000000..e7d2aaa2 --- /dev/null +++ b/docs/changelogs/v4.29.3.md @@ -0,0 +1,9 @@ +# v4.29.3 โ€” 2026-09-28 + +Patch release that makes `bundle-size.yml` a standard v4 workflow, hardens `dist_paths` handling, and stops the paths gate from failing on rewritten history. + +## Fixed + +- **`bundle-size.yml` is treated as a standard v4 workflow.** The template moved from `packaging-docs/` to `core-cicd/`, and its header, the README, the migration docs, the setup guides and the `release-merge.yml` template now list it with the other core workflows. It documents that `dist_paths` should match what the package publishes. A `dist_paths` set that matches no files now logs a warning (the step still passes). ([#326](https://github.com/CLDMV/.github/pull/326)) +- **`dist_paths` entries with a leading `./` are normalized and the measure walk prunes non-matching directories.** Plain file paths match that one file, and only `*` and `**` wildcards are supported. ([#328](https://github.com/CLDMV/.github/pull/328)) +- **Paths gate falls back to full CI when the diff cannot be computed.** On a push with a missing or zero `before` SHA, a compare API failure (for example `404 No common ancestor` after a force-pushed new root commit), a compare status other than `ahead`/`identical`, or a failure listing PR files, the gate now emits `docs_only=false` with a notice instead of failing the run or, after a force-push back to an older commit, wrongly reporting a docs-only change. ([#331](https://github.com/CLDMV/.github/pull/331)) diff --git a/docs/changelogs/v4.29.4.md b/docs/changelogs/v4.29.4.md new file mode 100644 index 00000000..29e4f96f --- /dev/null +++ b/docs/changelogs/v4.29.4.md @@ -0,0 +1,8 @@ +# v4.29.4 โ€” 2026-09-28 + +Patch release adding a working `pinned` exemption to the stale sweep, and letting `next-reset` merge a hotfix into `next` when the only conflicts are release version fields. + +## Fixed + +- **`pinned` label exempts issues and PRs from the stale sweep.** A `pinned` label (colour `5319e7`) was added to `data/github-labels.json` and removed from the `semver: explicit` aliases it previously collided with. Exempt items that were already marked stale (for example, pinned during the grace period) now have the stale label removed. The default exemption lists were also corrected to the real label names: issues exempt `pinned,security,help wanted,good first issue,status: blocked`, PRs exempt `pinned,work-in-progress,dependencies,type: dependencies,status: blocked`. The stale messages mention `pinned`, and the `stale.yml` template documents it. Callers that override `exempt_issue_labels` or `exempt_pr_labels` replace the defaults and should keep `pinned`. ([#332](https://github.com/CLDMV/.github/pull/332)) +- **Hotfix releases no longer fail to merge `master` into `next` on version-only conflicts.** When `next` has a pending release, both sides bump the `version` fields and the Merges API returns 409. `merge-master-into-branch` now redoes the merge locally and, when every conflict is the root `version` in `package.json` or the root `version` / `packages[""].version` in `package-lock.json`, keeps the target branch's version and publishes the two-parent merge commit through the Git Data API. Any other conflict still fails. The action gains a `conflict-resolved` output and needs a full-history checkout. ([#335](https://github.com/CLDMV/.github/pull/335), see [#334](https://github.com/CLDMV/.github/issues/334)) diff --git a/docs/changelogs/v4.3.1.md b/docs/changelogs/v4.3.1.md new file mode 100644 index 00000000..2b3fb252 --- /dev/null +++ b/docs/changelogs/v4.3.1.md @@ -0,0 +1,7 @@ +# v4.3.1 โ€” 2026-05-25 + +Patch release so branch retention runs when pull requests close against the v4 integration branches. + +## Fixed + +- **Branch retention fires on PR-close against `next` and `hotfixes`.** `local-branch-retention.yml` did not trigger for PRs closing against the v4 integration branches, so merged feature-branch heads were not cleaned up. The trigger and the `branch-retention.yml` consumer template were updated, and the branch-naming and release-flow-v4 conventions docs describe the behavior. ([#34](https://github.com/CLDMV/.github/pull/34), released in [#35](https://github.com/CLDMV/.github/pull/35)) diff --git a/docs/changelogs/v4.3.2.md b/docs/changelogs/v4.3.2.md new file mode 100644 index 00000000..0945534b --- /dev/null +++ b/docs/changelogs/v4.3.2.md @@ -0,0 +1,7 @@ +# v4.3.2 โ€” 2026-05-26 + +Patch release moving the CLA check and record flow onto a central signature ledger. + +## Fixed + +- **CLA check and record use the central ledger.** The `cla-record` job action and the `reusable-cla.yml` workflow were rewritten to look up and write signatures in the shared ledger repository (`ledger_repo`, default `CLDMV/.cla-signatures`), and the example `cla.yml` consumer was rewired to match. A private-ledger seed template (README, versioning notes, `v1.0` CLA text and checksum, audit and verify tools) was added under `examples/repo-seeds/.cla-signatures`. The setup guide, README and scaffolding docs were updated. ([#36](https://github.com/CLDMV/.github/pull/36), released in [#37](https://github.com/CLDMV/.github/pull/37)) diff --git a/docs/changelogs/v4.30.0.md b/docs/changelogs/v4.30.0.md new file mode 100644 index 00000000..f625d525 --- /dev/null +++ b/docs/changelogs/v4.30.0.md @@ -0,0 +1,11 @@ +# v4.30.0 โ€” 2026-09-28 + +Minor release that marks an issue as `status: implemented` the moment its fix lands on `next` or `hotfixes`, so the tracker shows finished work before the release ships and closes it. + +## Added + +- **Issues resolved on an integration branch move to `status: implemented`.** In the v4 flow a fix merges into `next` or `hotfixes` long before it reaches `master`, and until `close-resolved-issues` closed the issue at release, nothing on it said the work was done. A new `mark-implemented-issues` action runs as a `mark-implemented` job in `workflow-next-release.yml` and `workflow-hotfixes-release.yml` on every non-bump push that leaves the branch ahead of the release base. It walks the pushed range, minus anything already on the base (so a master sync or a reset never re-marks shipped work), and collects `Fixes` / `Closes` / `Resolves #N` keywords and `gh-broker:resolves:` markers from each merged PR's description, comments and commits, and from direct-pushed commit messages. Each referenced open issue gets `status: implemented` in place of its other `status:` labels, never downgrading `status: verified`, plus a comment naming the resolving PR. The issue stays open for the release to close. The job is best-effort and can never turn the release lane red. The label colour for a repo that lacks the label comes from an input rather than a file read. ([#336](https://github.com/CLDMV/.github/pull/336)) + +## Changed + +- **CodeQL is skipped for `CLDMV/configs`.** The declarative shared-config package has no JS/TS outside its tests, so CodeQL failed with "no source code seen". It is added to the `skip` list in `data/code-scanning-skips.json`, which makes bootstrap upload an empty, passing SARIF and keeps the `code_scanning` ruleset gate satisfiable. ([#338](https://github.com/CLDMV/.github/pull/338)) diff --git a/docs/changelogs/v4.30.1.md b/docs/changelogs/v4.30.1.md new file mode 100644 index 00000000..e6bfa3e9 --- /dev/null +++ b/docs/changelogs/v4.30.1.md @@ -0,0 +1,11 @@ +# v4.30.1 โ€” 2026-09-28 + +Patch release that moves the org-wide onboarding fanout out of this public repository, because its runs published the names of the org's private repositories. + +## Changed + +- **The onboarding fanout now lives in a private org-admin repository.** `local-org-onboarding.yml` names every matrix job after its target repo and writes the full target list to the run summary, so an auto-discovery run from this public repository exposed the names of private repos. The workflow is removed from `.github/workflows/` and ships as a template instead, `examples/individual-repo-workflows/packaging-docs/org-onboarding.yml`, to be run from a private repository along with its batch files (the `data/onboarding-batches/` example is removed here). The logic stays in the public `org-bootstrap-repo` action. The template filters the org's `.github` repo out of the matrix by name rather than by the calling repo, and notes that a private repo's name must never go into the public `data/code-scanning-skips.json`. The README, setup guide and scaffolding guide point at the new location. ([#340](https://github.com/CLDMV/.github/pull/340)) + +## Upgrade notes + +- Org admins who dispatched `local-org-onboarding.yml` from `CLDMV/.github` should copy the template into the org's private admin repository and dispatch it there. Per-repo `v4-bootstrap.yml` is unchanged. diff --git a/docs/changelogs/v4.30.2.md b/docs/changelogs/v4.30.2.md new file mode 100644 index 00000000..42e35e3e --- /dev/null +++ b/docs/changelogs/v4.30.2.md @@ -0,0 +1,7 @@ +# v4.30.2 โ€” 2026-09-28 + +Patch release so the bundle-size check works on the first release PR of a newly onboarded repo. + +## Fixed + +- **Bundle size measures an empty baseline when the base has no build script.** On a freshly onboarded repo, `master` is still the initial commit and has no `build` script, so the base build in `reusable-bundle-size.yml` failed and took the check down with it. When the build command is `npm` / `pnpm` / `yarn run