mirror of
https://github.com/iflytek/skillhub.git
synced 2026-11-14 09:01:16 +00:00
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:
commit
6966362e9b
5 changed files with 171 additions and 24 deletions
125
.github/scripts/count-reward.test.ts
vendored
Normal file
125
.github/scripts/count-reward.test.ts
vendored
Normal 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");
|
||||
}));
|
||||
29
.github/scripts/count-reward.ts
vendored
29
.github/scripts/count-reward.ts
vendored
|
|
@ -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
15
.github/scripts/git.ts
vendored
Normal 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();
|
||||
}
|
||||
6
.github/scripts/share-reward.ts
vendored
6
.github/scripts/share-reward.ts
vendored
|
|
@ -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`;
|
||||
|
|
|
|||
20
.github/workflows/statistic-member-reward.yml
vendored
20
.github/workflows/statistic-member-reward.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue