Merge pull request #920 from FenjuFu/ci/reward-tags-detached

ci(reward): keep reward tags off Latest and out of git describe
This commit is contained in:
XiaoSeS 2026-10-16 15:16:26 +08:00 • committed by GitHub
commit 6966362e9b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 171 additions and 24 deletions

125
.github/scripts/count-reward.test.ts vendored Normal file
View file

@ -0,0 +1,125 @@
const script = new URL("./count-reward.ts", import.meta.url).pathname;
const decoder = new TextDecoder();
function assert(value: unknown, message: string): asserts value {
if (!value) throw new Error(message);
}
async function fixture(
run: (repo: string, tag: string, git: (...args: string[]) => Promise<string>) => Promise<void>,
) {
const root = await Deno.makeTempDir({ prefix: "skillhub-reward-test-" });
const repo = `${root}/repo`;
await Deno.mkdir(repo);
const git = async (...args: string[]) => {
const result = await new Deno.Command("git", {
args, cwd: repo, env: { GIT_TERMINAL_PROMPT: "0" },
}).output();
assert(result.success, decoder.decode(result.stderr));
return decoder.decode(result.stdout).trim();
};
try {
await git("init", "--bare", `${root}/origin.git`);
await git("init");
await git("config", "user.name", "Reward Test");
await git("config", "user.email", "reward-test@example.invalid");
await git("commit", "--allow-empty", "-m", "product");
await git("tag", "-a", "v1.0.0", "-m", "product");
await git("remote", "add", "origin", `${root}/origin.git`);
await Deno.mkdir(`${repo}/bin`);
await Deno.writeTextFile(`${repo}/bin/gh`, `#!/bin/sh
printf '%s\\0' "$@" >> gh-calls
if [ "$1" = api ]; then
if [ "$RELEASE_STATUS" = 200 ]; then
printf 'HTTP/2.0 200 OK\\n\\n{}\\n'
exit 0
fi
printf 'HTTP/2.0 %s Error\\n\\n{}\\n' "$RELEASE_STATUS"
exit 1
fi
if [ "$1" = release ] && [ "$2" = create ] && [ "$4" = --verify-tag ]; then
git ls-remote --exit-code --tags origin "refs/tags/$3" >/dev/null
exit $?
fi
exit 99
`);
await Deno.chmod(`${repo}/bin/gh`, 0o700);
await run(repo, `statistic-${new Date().toJSON().slice(0, 7)}`, git);
} finally {
await Deno.remove(root, { recursive: true });
}
}
async function count(repo: string, releaseStatus: number) {
return await new Deno.Command(Deno.execPath(), {
args: ["run", "--no-lock", "--allow-run", "--allow-sys", "--allow-env", "--allow-read", script],
cwd: repo,
env: {
PATH: `${repo}/bin:${Deno.env.get("PATH")}`,
GH_TOKEN: "test-only", GH_HOST: "example.invalid", GH_REPO: "example/reward-test",
RELEASE_STATUS: String(releaseStatus),
},
}).output();
}
Deno.test("completed statistic rerun leaves tags, product and identity unchanged", () => fixture(async (repo, tag, git) => {
await git("tag", "-a", tag, "-m", "original summary");
const before = await git("show-ref");
const description = await git("describe", "--tags", "HEAD");
const result = await count(repo, 200);
assert(result.success, decoder.decode(result.stderr));
assert(await git("show-ref") === before, "refs changed");
assert(await git("describe", "--tags", "HEAD") === description, "product changed");
assert(await git("config", "user.name") === "Reward Test", "identity changed");
assert(!(await Deno.readTextFile(`${repo}/gh-calls`)).includes("create"), "duplicate release");
}));
Deno.test("missing release recovers notes from existing annotation without new rewards", () => fixture(async (repo, tag, git) => {
const notes = "- payee: '@original'\n reward: 17";
await git("tag", "-a", tag, "-m", notes);
await git("push", "origin", `refs/tags/${tag}`);
const before = await git("show-ref");
const result = await count(repo, 404);
assert(result.success, decoder.decode(result.stderr));
assert(await git("show-ref") === before, "existing tag changed");
const calls = await Deno.readTextFile(`${repo}/gh-calls`);
assert(calls.includes(`release\0create\0${tag}\0--verify-tag\0--latest=false\0--notes\0${notes}\0`), "wrong release notes or latest flag");
assert(await git("config", "user.email") === "reward-test@example.invalid", "identity changed");
}));
Deno.test("API server failure is not treated as missing release", () => fixture(async (repo, tag, git) => {
await git("tag", "-a", tag, "-m", "original summary");
const result = await count(repo, 500);
assert(!result.success, "server failure swallowed");
assert(!(await Deno.readTextFile(`${repo}/gh-calls`)).includes("create"), "created on API failure");
}));
Deno.test("lightweight statistic cannot supply recovery notes", () => fixture(async (repo, tag, git) => {
await git("tag", tag);
const result = await count(repo, 404);
assert(!result.success, "lightweight tag accepted");
assert(!(await Deno.readTextFile(`${repo}/gh-calls`)).includes("create"), "created without annotation");
}));
Deno.test("empty annotation cannot supply recovery notes", () => fixture(async (repo, tag, git) => {
await git("-c", "tag.gpgSign=false", "tag", "-a", tag, "--cleanup=verbatim", "-m", "");
const result = await count(repo, 404);
assert(!result.success, "empty notes accepted");
assert(!(await Deno.readTextFile(`${repo}/gh-calls`)).includes("create"), "created without summary");
}));
Deno.test("no reward data creates no statistic or release", () => fixture(async (repo, _tag, git) => {
const before = await git("show-ref");
const result = await count(repo, 404);
assert(result.success, decoder.decode(result.stderr));
assert(await git("show-ref") === before, "unexpected tag");
assert(!(await Deno.stat(`${repo}/gh-calls`).catch(() => null)), "unexpected gh call");
}));
Deno.test("unpublished local tag cannot cause gh to create a product tag", () => fixture(async (repo, tag, git) => {
await git("tag", "-a", tag, "-m", "not pushed");
const result = await count(repo, 404);
assert(!result.success, "missing remote tag should fail verify-tag");
assert(await git("ls-remote", "--tags", "origin") === "", "unexpected remote tag");
assert((await Deno.readTextFile(`${repo}/gh-calls`)).includes("--verify-tag"), "missing remote guard");
}));

View file

@ -1,9 +1,30 @@
import { $, YAML } from "npm:zx";
import { createDetachedCommit } from "./git.ts";
import { Reward } from "./type.ts";
$.verbose = true;
const tagName = `statistic-${new Date().toJSON().slice(0, 7)}`;
const existingTag = await $`git show-ref --verify --quiet ${`refs/tags/${tagName}`}`.nothrow();
if (existingTag.exitCode === 0) {
const release = await $`gh api ${`repos/{owner}/{repo}/releases/tags/${tagName}`} --include`.nothrow();
if (release.exitCode === 0) process.exit(0);
if (!/^HTTP\/[\d.]+ 404\b/m.test(release.stdout)) {
throw new Error(`Could not check the existing statistic release: ${release.stderr}`);
}
// Recover from a previous push that succeeded before release creation failed.
// The published tag is the source of truth, not a new calculation of rewards.
const tagType = (await $`git cat-file -t ${`refs/tags/${tagName}`}`).stdout.trim();
if (tagType !== "tag") throw new Error("The existing statistic tag is not annotated.");
const notes = (await $`git tag -l --format="%(contents)" ${tagName}`).stdout.trimEnd();
if (!notes.trim()) throw new Error("The existing statistic tag has no summary.");
await $`gh release create ${tagName} --verify-tag --latest=false --notes ${notes}`;
process.exit(0);
}
if (existingTag.exitCode !== 1) throw new Error("Could not check the statistic tag.");
const rawTags =
await $`git tag --list "reward-*" --format="%(refname:short) %(creatordate:short)"`;
@ -52,15 +73,15 @@ const summaryText = YAML.stringify(summaryList);
console.log(summaryText);
const tagName = `statistic-${new Date().toJSON().slice(0, 7)}`;
await $`git config user.name "github-actions[bot]"`;
await $`git config user.email "github-actions[bot]@users.noreply.github.com"`;
await $`git tag -a ${tagName} $(git rev-parse HEAD) -m ${summaryText}`;
const tagTarget = await createDetachedCommit(tagName);
await $`git tag -a ${tagName} ${tagTarget} -m ${summaryText}`;
await $`git push origin --tags --no-verify`;
await $`git config unset user.name`;
await $`git config unset user.email`;
await $`gh release create ${tagName} --notes ${summaryText}`;
// Keep "Latest" pointing at the newest product release
await $`gh release create ${tagName} --latest=false --notes ${summaryText}`;

15
.github/scripts/git.ts vendored Normal file
View file

@ -0,0 +1,15 @@
import { $ } from "npm:zx";
/**
* Create a parentless commit with an empty tree to carry reward/statistic tags.
*
* Tags pointing at product commits become the nearest tag in `git describe`,
* so downstream builds would report versions like `reward-42-3-gabc1234`
* instead of the latest release tag. Tags on a detached commit are never
* reachable from any branch and keep release tags authoritative.
*/
export async function createDetachedCommit(message: string) {
const emptyTree = (await $`git hash-object -w -t tree /dev/null`).stdout.trim();
return (await $`git commit-tree ${emptyTree} -m ${message}`).stdout.trim();
}

View file

@ -3,6 +3,7 @@ import "npm:array-unique-proposal";
import { components } from "npm:@octokit/openapi-types";
import { $, argv, YAML } from "npm:zx";
import { createDetachedCommit } from "./git.ts";
import { Reward } from "./type.ts";
$.verbose = true;
@ -110,7 +111,10 @@ console.log(listText);
await $`git config user.name "github-actions[bot]"`;
await $`git config user.email "github-actions[bot]@users.noreply.github.com"`;
await $`git tag -a "reward-${issueNumber}" ${mergeCommitSha} -m ${listText}`;
const tagTarget = await createDetachedCommit(
`reward-${issueNumber}: merged in ${mergeCommitSha}`,
);
await $`git tag -a "reward-${issueNumber}" ${tagTarget} -m ${listText}`;
await $`git push origin --tags --no-verify`;
await $`git config unset user.name`;

View file

@ -14,30 +14,12 @@ jobs:
fetch-depth: 0
fetch-tags: true
- name: Check for new commits since last statistic
run: |
last_tag=$(git describe --tags --abbrev=0 --match "statistic-*" || echo "")
if [ -z "$last_tag" ]; then
echo "No previous statistic tags found."
echo "NEW_COMMITS=true" >> $GITHUB_ENV
else
new_commits=$(git log $last_tag..HEAD --oneline)
if [ -z "$new_commits" ]; then
echo "No new commits since last statistic tag."
echo "NEW_COMMITS=false" >> $GITHUB_ENV
else
echo "New commits found."
echo "NEW_COMMITS=true" >> $GITHUB_ENV
fi
fi
# count-reward.ts exits early when no reward tags were created last month
- uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4
if: env.NEW_COMMITS == 'true'
with:
deno-version: v2.x
- name: Statistic rewards
if: env.NEW_COMMITS == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: deno --allow-run --allow-sys --allow-env --allow-read --allow-net=api.github.com .github/scripts/count-reward.ts