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
4 changes: 2 additions & 2 deletions .github/actions/git/steps/fix-non-bot-tags/action.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions .github/actions/git/steps/fix-orphaned-tags/action.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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...");
Expand Down Expand Up @@ -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];
}
Expand Down
9 changes: 7 additions & 2 deletions .github/actions/git/steps/fix-unsigned-tags/action.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down 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
20 changes: 20 additions & 0 deletions .github/actions/git/utilities/git-utils.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
61 changes: 61 additions & 0 deletions .github/actions/git/utilities/tag-message.test.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
Loading
Loading