From 214ae4944be70c3df9c7ea6bedb83deddd29753b Mon Sep 17 00:00:00 2001 From: Shinrai Date: Sun, 4 Oct 2026 01:57:27 -0700 Subject: [PATCH] feat(tags): create missing tags automatically; push tags with a scoped App token, no unsigned fallback The "refusing to allow a GitHub App to create or update workflow ... without workflows permission" error that blocked tags on older commits was a credential problem, not a platform limit: every tag-writing job checked out with the default GITHUB_TOKEN, actions/checkout persisted it, and git pushed with it. That token can never hold the workflows scope; the App token was only used for REST calls. - tag-health (orphaned releases, major/minor, bot signatures, unsigned, misaligned, orphaned tags): create the App token first, scoped to permission_contents + permission_workflows, and check out with it so pushes carry it. - publishing (create-release, update-version-tags, publish-extras) and sync-release-notes: same two scopes; checkout-code gains a token input and create-release / update-major-version-tags persist the App token. - tag/create: no REST fallback. A refused push throws, naming the tag and git's error; an unsigned tag is never created. Git calls use argv. fix-orphaned-releases drops its unsigned REST fallback too, and fix-unsigned-tags fails the job when a re-sign push is refused. - sync-release-notes: automatic runs create missing version tags at their release commits, bot-signed, with the changelog file as the verbatim message, capped by max_new_tags (default 20) per run; the rest are reported for the next run. Creating missing releases stays a dispatch switch (each new release fires release:published). - Tests: signed tag creation pushes a verified, heading-preserving tag; a refused push throws and makes no REST call and no tag. Refs #362 --- .../common/steps/checkout-code/action.yml | 5 + .../jobs/update-major-version-tags/action.yml | 3 + .../git/steps/fix-unsigned-tags/action.mjs | 5 + .../actions/github/api/tag/create/_impl.mjs | 228 ++++++------------ .../actions/github/api/tag/create/test.mjs | 123 ++++++++++ .../github/jobs/create-release/action.yml | 3 + .../steps/fix-orphaned-releases/action.mjs | 69 +----- .../steps/sync-release-notes/action.mjs | 32 ++- .../steps/sync-release-notes/action.yml | 6 +- .../workflows/local-sync-release-notes.yml | 13 +- .github/workflows/reusable-publishing.yml | 18 ++ .github/workflows/reusable-tag-health.yml | 172 ++++++++----- .../workflows/workflow-sync-release-notes.yml | 12 + docs/conventions/release-flow-v4.md | 2 +- examples/README.md | 12 +- examples/guides/WORKFLOW-SETUP-GUIDE.md | 6 +- .../release-companions/sync-release-notes.yml | 13 +- package.json | 2 +- 18 files changed, 429 insertions(+), 295 deletions(-) create mode 100644 .github/actions/github/api/tag/create/test.mjs 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-unsigned-tags/action.mjs b/.github/actions/git/steps/fix-unsigned-tags/action.mjs index 317760cc..042cf6a9 100644 --- a/.github/actions/git/steps/fix-unsigned-tags/action.mjs +++ b/.github/actions/git/steps/fix-unsigned-tags/action.mjs @@ -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/github/api/tag/create/_impl.mjs b/.github/actions/github/api/tag/create/_impl.mjs index d41203b2..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 --cleanup=verbatim -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 --cleanup=verbatim -F "${tmpFile}" ${tag} ${sha}`); - fs.unlinkSync(tmpFile); - } else if (willAnnotate) { - debugLog(`runGitSmartTag: Creating annotated tag: git tag -a -f --cleanup=verbatim -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 --cleanup=verbatim -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/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 0b16dd39..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 || ""; @@ -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 c26ea495..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, "")) @@ -149,18 +161,25 @@ for (const version of [...versions].sort(cmpVersion)) { // 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 falls back to the REST API (annotated, NOT signed) when - // GitHub refuses an App push of a tag on an older commit. Say so loudly. + // 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 SIGNATURE/.test(git(["cat-file", "-p", `refs/tags/${tagName}`]))) { - row.failures.push("tag was created WITHOUT a signature (GitHub refused the signed push; REST fallback used) โ€” re-sign it by hand"); + 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"; @@ -265,6 +284,7 @@ setOutputs({ "changed-count": String(changed), "problems-count": String(problems // 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 78bbdd34..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 diff --git a/.github/workflows/local-sync-release-notes.yml b/.github/workflows/local-sync-release-notes.yml index b803b717..edb88256 100644 --- a/.github/workflows/local-sync-release-notes.yml +++ b/.github/workflows/local-sync-release-notes.yml @@ -24,9 +24,12 @@ # 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 and re-publish draft releases -# whose tag exists (CI-created drafts โ€” CLDMV/.github#362). Creating missing -# tags and missing releases stays manual: use the dispatch switches. +# 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. @@ -64,7 +67,7 @@ on: 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 @@ -90,7 +93,7 @@ jobs: 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_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: 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 0a199920..2f4a1cf9 100644 --- a/.github/workflows/workflow-sync-release-notes.yml +++ b/.github/workflows/workflow-sync-release-notes.yml @@ -38,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 @@ -92,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 }} @@ -110,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/docs/conventions/release-flow-v4.md b/docs/conventions/release-flow-v4.md index a41d123d..4bf00bfe 100644 --- a/docs/conventions/release-flow-v4.md +++ b/docs/conventions/release-flow-v4.md @@ -105,7 +105,7 @@ hotfixes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - **Updates on every push to `next`** โ€” workflow recalculates version, regenerates body, syncs labels via delta (v3.2.4's label fix carries forward). - **A maintainer decision is required to merge โ€” never unattended.** By default that's the manual "Squash and merge" click. Optionally (per repo, via the `release-merge.yml` caller โ†’ `workflow-release-merge.yml`), the maintainer's **approval** instead triggers an API squash-merge once every check on the head is green โ€” required AND non-required (e.g. the coverage badge and this release-PR body refresh), so the body is never captured stale. The commit message is set explicitly to the PR body, so the release commit is exactly the curated body: no mobile Default-path title-only drop, and no GitHub squash-UI `Co-authored-by:` auto-append (which is neither deduped by account nor bot-stripped) โ€” the clean `` block already in the body is the whole credit. It merges via `PUT โ€ฆ/merge` (which succeeds precisely when the PR is mergeable, unlike native auto-merge's "clean status" refusal) and never approves as the bot. Re-evaluation when an approval precedes green CI uses `workflow_run`, not `check_suite` โ€” GitHub does not send `check_suite: completed` for a suite created by GitHub Actions itself (every check the repo's own CI produces), only for one created by a third-party App (#318: an approval given before CI finished left a release PR stuck fully green with nothing re-firing the merge check, because check_suite never fired for that case at all). Because the gate waits on every check on the head, the caller's `workflow_run` trigger must name **every** workflow that puts a check on that commit โ€” CI, CodeQL, dependency review, the release-PR refresh, the `pull_request_target` automations โ€” by literal workflow `name:`, filtered to `branches: [next, hotfixes]`. Only the last one to finish can see a fully green head, and CodeQL routinely outlasts CI: naming only `ci.yml` left PR #322 approved and green with nothing re-firing once CodeQL finished. The template lists the whole standard v4 set; `workflow_dispatch` remains available as a manual fallback. - On merge: master gets one `release: vX.Y.Z - ` commit. Tag + publish flow runs. `next` is **moved onto the release commit** (ยง7): reset to it, with anything merged into `next` after the release PR was cut carried forward on top. -- **GitHub Release notes.** The publish run creates the `vX.Y.Z` tag **signed** (GPG signing is on whenever the bot key secret is mapped) and the GitHub Release. When the release commit carries `docs/changelog[s]/v/v.md`, that file is the release body; otherwise the body is the release commit message. Either way the duplicated `release: vX.Y.Z - โ€ฆ` subject line and the squash trailers are dropped, the Contributors and coverage blocks are kept, and `@word` text outside code that isn't a listed contributor (for example a fix-headers `@Author` tag in prose) is wrapped in a code span, so GitHub doesn't mention that account or add it to the release's Contributors. The job then checks that the release is published and its tag exists, and fails if not. Every v4 repo also carries the standard `sync-release-notes.yml` companion. It runs on `release: published` and whenever a changelog file lands on the default branch: it re-syncs release bodies from changelog files written or backfilled later, and re-publishes drafts that CI left behind. Missing tags or releases are repaired by dispatching it with the matching switch (CLDMV/.github#362). +- **GitHub Release notes.** The publish run creates the `vX.Y.Z` tag **signed** (GPG signing is on whenever the bot key secret is mapped) and the GitHub Release. When the release commit carries `docs/changelog[s]/v/v.md`, that file is the release body; otherwise the body is the release commit message. Either way the duplicated `release: vX.Y.Z - โ€ฆ` subject line and the squash trailers are dropped, the Contributors and coverage blocks are kept, and `@word` text outside code that isn't a listed contributor (for example a fix-headers `@Author` tag in prose) is wrapped in a code span, so GitHub doesn't mention that account or add it to the release's Contributors. The job then checks that the release is published and its tag exists, and fails if not. Every v4 repo also carries the standard `sync-release-notes.yml` companion. It runs on `release: published` and whenever a changelog file lands on the default branch: it re-syncs release bodies from changelog files written or backfilled later, creates missing version tags at their `release: vX.Y.Z` commits (bot-signed, at most 20 per run), and re-publishes drafts that CI left behind. Missing releases are created by dispatching it with `create_missing_releases` (CLDMV/.github#362). Every tag-writing job (the publish run, tag-health, sync) pushes with the bot App token scoped to `contents` + `workflows`, never with the persisted `GITHUB_TOKEN`, and a refused tag push fails the job; there is no unsigned fallback. ### 5.4 Hotfix release PR (`hotfixes โ†’ master`) diff --git a/examples/README.md b/examples/README.md index 1e5d2236..889e083f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -47,12 +47,12 @@ After installing these, complete the cutover via the [v3โ†’v4 migration guide](. Every v4 repo carries `tag-health.yml`, `master-commit-audit.yml`, and `sync-release-notes.yml` (standard set, not optional). `release-notify.yml` and `pr-notify.yml` are opt-in. -| Template | Triggers | What it does | -| ------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `tag-health.yml` | weekly Sunday cron + dispatch | Validates tags, fixes bot-signature drift, recreates orphaned tags. | -| `release-notify.yml` | `release: published` | Posts to configured Discord/Slack/generic webhook channels. | -| `master-commit-audit.yml` | push to default | Files a GitHub Issue if a master commit doesn't match the expected release-flow subject pattern. | -| `sync-release-notes.yml` | `release: published`, push to default touching `docs/changelog[s]/**`, dispatch | **Standard.** Rewrites release bodies from `docs/changelog[s]/v/v.md` and re-publishes CI-drafted releases; reports missing tags/releases (repaired via dispatch switches). | +| Template | Triggers | What it does | +| ------------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tag-health.yml` | weekly Sunday cron + dispatch | Validates tags, fixes bot-signature drift, recreates orphaned tags. | +| `release-notify.yml` | `release: published` | Posts to configured Discord/Slack/generic webhook channels. | +| `master-commit-audit.yml` | push to default | Files a GitHub Issue if a master commit doesn't match the expected release-flow subject pattern. | +| `sync-release-notes.yml` | `release: published`, push to default touching `docs/changelog[s]/**`, dispatch | **Standard.** Rewrites release bodies from `docs/changelog[s]/v/v.md` creates missing version tags at their release commits (bot-signed, โ‰ค20 per run) and re-publishes CI-drafted releases; missing releases are created via a dispatch switch. | ### ๐Ÿ”’ [`security/`](individual-repo-workflows/security/) โ€” Security baseline (recommended for OSS repos) diff --git a/examples/guides/WORKFLOW-SETUP-GUIDE.md b/examples/guides/WORKFLOW-SETUP-GUIDE.md index ad53c3e3..60ea3c14 100644 --- a/examples/guides/WORKFLOW-SETUP-GUIDE.md +++ b/examples/guides/WORKFLOW-SETUP-GUIDE.md @@ -283,11 +283,11 @@ Repo secret overrides org secret of the same name (built-in GitHub precedence). **Triggers.** It runs on `release: published` (the publish workflow creates releases with the bot App token, so the event fires), on a push to the default branch that touches `docs/changelog/**` or `docs/changelogs/**`, and by manual dispatch. It is a companion, not a required check, so the `paths:` filter is fine here, unlike in `ci.yml`. -**What the automatic runs do.** They apply changes: they sync bodies and re-publish draft releases whose tag exists (drafts that CI left behind, CLDMV/.github#362). Creating missing tags (`create_missing_tags`, bot-signed at the `release: vX.Y.Z` commit) and missing releases (`create_missing_releases`) stays manual, through the dispatch switches. Dispatch defaults to `dry_run`; `normalize_all` also tidies bodies that have no changelog file. Every run is an idempotent full sweep: with nothing to change, it changes nothing and says so in the job summary. Drift that needs a decision is reported as a warning. The job fails only when a repair it attempted fails. Overlapping runs for one repo queue rather than run at the same time. +**What the automatic runs do.** They apply changes. They sync bodies. They create every missing version tag at its `release: vX.Y.Z` commit, bot-signed, with the changelog file as the message (kept verbatim, headings included), at most `max_new_tags` (20) per run; the rest are reported and created by the next run. They re-publish draft releases whose tag exists (drafts that CI left behind, CLDMV/.github#362). A refused tag push fails the job, naming the tag and git's error, and an unsigned tag is never created. Creating missing releases (`create_missing_releases`) stays manual: a new release fires `release: published`, so backfilling old versions would re-run notifications and provenance for each one. Dispatch defaults to `dry_run`; `normalize_all` also tidies bodies that have no changelog file. Every run is an idempotent full sweep: with nothing to change, it changes nothing and says so in the job summary. Drift that needs a decision is reported as a warning. The job fails only when a repair it attempted fails. Overlapping runs for one repo queue rather than run at the same time. -**Permissions.** The workflow `GITHUB_TOKEN` is read-only. Every write goes through the bot App token. +**Permissions.** The workflow `GITHUB_TOKEN` is read-only. Every write goes through the bot App token, which requests `contents: write` and `workflows: write`. The `workflows` scope is what lets it push a tag whose commit's `.github/workflows` differ from the tip; the persisted `GITHUB_TOKEN` can never hold that scope. -**Required secrets** โ€” `CLDMV_BOT_APP_CLIENT_ID` / `CLDMV_BOT_APP_PRIVATE_KEY`; the `CLDMV_BOT_NAME` / `CLDMV_BOT_EMAIL` / `CLDMV_BOT_GPG_*` secrets so manually created tags are signed (without the key, tag creation is refused). +**Required secrets** โ€” `CLDMV_BOT_APP_CLIENT_ID` / `CLDMV_BOT_APP_PRIVATE_KEY`; the `CLDMV_BOT_NAME` / `CLDMV_BOT_EMAIL` / `CLDMV_BOT_GPG_*` secrets so created tags are signed (without the key, tag creation is refused). **Prereqs** โ€” none. diff --git a/examples/individual-repo-workflows/release-companions/sync-release-notes.yml b/examples/individual-repo-workflows/release-companions/sync-release-notes.yml index 0cf38224..2feb24d1 100644 --- a/examples/individual-repo-workflows/release-companions/sync-release-notes.yml +++ b/examples/individual-repo-workflows/release-companions/sync-release-notes.yml @@ -23,9 +23,12 @@ # 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 and re-publish draft releases -# whose tag exists (CI-created drafts โ€” CLDMV/.github#362). Creating missing -# tags and missing releases stays manual: use the dispatch switches. +# 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. @@ -63,7 +66,7 @@ on: 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 @@ -89,7 +92,7 @@ jobs: 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_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: diff --git a/package.json b/package.json index 1db9af96..f19f56d8 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "format": "prettier --config .configs/.prettierrc --write .", "format:check": "prettier --config .configs/.prettierrc --check .", "prepare": "node -e \"import('./.githooks/install.mjs').catch(()=>{})\"", - "test": "node .github/actions/common/utilities/bot-detection.test.mjs && node .github/actions/npm/jobs/bundle-size/action.test.mjs && node .github/actions/common/steps/paths-gate/action.test.mjs && node .github/actions/github/steps/merge-master-into-branch/test.mjs && node .github/actions/github/steps/mark-implemented-issues/action.test.mjs && node .github/actions/git/steps/reset-branch-after-release/test.mjs && node .github/actions/github/utilities/release-notes.test.mjs && node .github/actions/git/utilities/tag-message.test.mjs" + "test": "node .github/actions/common/utilities/bot-detection.test.mjs && node .github/actions/npm/jobs/bundle-size/action.test.mjs && node .github/actions/common/steps/paths-gate/action.test.mjs && node .github/actions/github/steps/merge-master-into-branch/test.mjs && node .github/actions/github/steps/mark-implemented-issues/action.test.mjs && node .github/actions/git/steps/reset-branch-after-release/test.mjs && node .github/actions/github/utilities/release-notes.test.mjs && node .github/actions/git/utilities/tag-message.test.mjs && node .github/actions/github/api/tag/create/test.mjs" }, "devDependencies": { "@cldmv/eslint-plugin-jsonv": "^1.0.3",