Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/actions/common/steps/checkout-code/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,3 +28,4 @@ runs:
fetch-depth: ${{ inputs.fetch-depth }}
ref: ${{ inputs.ref }}
persist-credentials: ${{ inputs.persist-credentials }}
token: ${{ inputs.token || github.token }}
3 changes: 3 additions & 0 deletions .github/actions/git/jobs/update-major-version-tags/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions .github/actions/git/steps/fix-unsigned-tags/action.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
228 changes: 77 additions & 151 deletions .github/actions/github/api/tag/create/_impl.mjs
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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 || "" : "";
}
Expand All @@ -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 };
}
Loading
Loading