From b17ddc3ff311a89f9387082bc7e9b05db89714df Mon Sep 17 00:00:00 2001 From: FenjuFu <92919259+FenjuFu@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:15:49 +0800 Subject: [PATCH 1/2] ci(reward): keep reward tags off Latest and out of git describe reward-* tags pointed at PR merge commits and statistic-* tags at the default branch HEAD, so they became the nearest tag in git describe for later commits. statistic-* releases were also created without --latest=false, so the GitHub API marked them as the Latest release. - Point new reward-* / statistic-* tags at a parentless empty-tree commit (the merge commit SHA is kept in its message) so no branch reaches them; tag contents and dates are read as before - Create statistic-* releases with --latest=false - Drop the describe-based "new commits since last statistic" check, which cannot see detached tags; count-reward.ts already exits when there is no reward data for the previous month Same fix as iflytek/astron-agent#1695. Signed-off-by: FenjuFu <92919259+FenjuFu@users.noreply.github.com> --- .github/scripts/count-reward.ts | 7 +++++-- .github/scripts/git.ts | 15 ++++++++++++++ .github/scripts/share-reward.ts | 6 +++++- .github/workflows/statistic-member-reward.yml | 20 +------------------ 4 files changed, 26 insertions(+), 22 deletions(-) create mode 100644 .github/scripts/git.ts diff --git a/.github/scripts/count-reward.ts b/.github/scripts/count-reward.ts index a6448cbc9..348e9c63b 100644 --- a/.github/scripts/count-reward.ts +++ b/.github/scripts/count-reward.ts @@ -1,5 +1,6 @@ import { $, YAML } from "npm:zx"; +import { createDetachedCommit } from "./git.ts"; import { Reward } from "./type.ts"; $.verbose = true; @@ -57,10 +58,12 @@ 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}`; diff --git a/.github/scripts/git.ts b/.github/scripts/git.ts new file mode 100644 index 000000000..aebbd463f --- /dev/null +++ b/.github/scripts/git.ts @@ -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(); +} diff --git a/.github/scripts/share-reward.ts b/.github/scripts/share-reward.ts index 018974482..da1ab20ff 100644 --- a/.github/scripts/share-reward.ts +++ b/.github/scripts/share-reward.ts @@ -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`; diff --git a/.github/workflows/statistic-member-reward.yml b/.github/workflows/statistic-member-reward.yml index 09e5b88fc..2b9bc8837 100644 --- a/.github/workflows/statistic-member-reward.yml +++ b/.github/workflows/statistic-member-reward.yml @@ -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 From 933a2d11772a3b59c4d77e9422bf36eeabf0944b Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:05:22 +0800 Subject: [PATCH 2/2] fix(ci): recover monthly reward release retries Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- .github/scripts/count-reward.test.ts | 125 +++++++++++++++++++++++++++ .github/scripts/count-reward.ts | 22 ++++- 2 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/count-reward.test.ts diff --git a/.github/scripts/count-reward.test.ts b/.github/scripts/count-reward.test.ts new file mode 100644 index 000000000..87d5fc09c --- /dev/null +++ b/.github/scripts/count-reward.test.ts @@ -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) => Promise, +) { + 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"); +})); diff --git a/.github/scripts/count-reward.ts b/.github/scripts/count-reward.ts index 348e9c63b..7aee4aaf3 100644 --- a/.github/scripts/count-reward.ts +++ b/.github/scripts/count-reward.ts @@ -5,6 +5,26 @@ 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)"`; @@ -53,8 +73,6 @@ 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"`;