ci: require explicit release tags before v2 promotion

This commit is contained in:
JackWang
2026-09-10 15:38:13 +08:00
parent cb993eaaf9
commit 448c9f9797
4 changed files with 112 additions and 33 deletions
+4 -26
View File
@@ -13,7 +13,7 @@ permissions:
contents: read
env:
SHOULD_RELEASE: ${{ github.event_name == 'push' && (github.ref_name == 'dev' || startsWith(github.ref, 'refs/tags/v')) }}
SHOULD_RELEASE: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
jobs:
test:
@@ -43,7 +43,7 @@ jobs:
path: package/ego-browser/dist/ego-browser-release-payload.tar.gz
release:
if: github.event_name == 'push' && (github.ref_name == 'dev' || startsWith(github.ref, 'refs/tags/v'))
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
needs: test
runs-on: ubuntu-latest
permissions:
@@ -68,29 +68,10 @@ jobs:
id: release
run: |
ref_name="${GITHUB_REF_NAME}"
short_sha="$(git rev-parse --short HEAD)"
supported="true"
previous_tag=""
if [ "$ref_name" = "dev" ]; then
latest_beta="$(git tag --sort=-v:refname --list 'v*.*.*-beta.*' | head -1 || true)"
latest_stable="$(git tag --sort=-v:refname --list 'v*.*.*' --list '[0-9]*.[0-9]*' | grep -E '^(v?[0-9]+\.[0-9]+\.[0-9]+|[0-9]+\.[0-9]+)$' | head -1 || true)"
if [ -n "$latest_beta" ]; then
base_version="${latest_beta%%-beta.*}"
elif [[ "$latest_stable" =~ ^v?([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
base_version="v${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))"
elif [[ "$latest_stable" =~ ^([0-9]+)\.([0-9]+)$ ]]; then
base_version="v${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.1"
else
base_version="v0.1.0"
fi
release_tag="$base_version-nightly.$(date -u +%Y%m%d).$short_sha"
prerelease="true"
latest="false"
previous_tag="$(git tag --merged HEAD --sort=-creatordate --list 'v*.*.*-nightly.*' | grep -v "^$release_tag$" | head -1 || true)"
elif [[ "$ref_name" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$ ]]; then
if [[ "$ref_name" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$ ]]; then
release_tag="$ref_name"
prerelease="true"
latest="false"
@@ -135,10 +116,7 @@ jobs:
release_args=()
notes_args=()
if [[ "$release_tag" == v*.*.*-nightly.* ]]; then
git tag -f "$release_tag" "$commit_sha"
git push -f origin "refs/tags/$release_tag"
elif [ "$prerelease" = "false" ] && ! git merge-base --is-ancestor "$commit_sha" origin/main; then
if [ "$prerelease" = "false" ] && ! git merge-base --is-ancestor "$commit_sha" origin/main; then
echo "Stable release tags must point to commits reachable from main."
exit 1
fi
+3 -1
View File
@@ -10,8 +10,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Require dev branch
env:
HEAD_REF: ${{ github.head_ref }}
run: |
if [ "${{ github.head_ref }}" != "dev" ]; then
if [ "$HEAD_REF" != "dev" ]; then
echo "Pull requests into main must come from the dev branch."
exit 1
fi
+5 -6
View File
@@ -211,12 +211,11 @@ E2E suite is a local gate and is not run by this GitHub-hosted workflow.
The current release behavior is:
| Trigger | Result |
| ------------------------------------ | --------------------------------------------------------------------------------------------------- |
| Push to `dev` | Publishes a nightly prerelease named `vX.Y.Z-nightly.YYYYMMDD.SHA`. |
| Push a `vX.Y.Z-beta.N` tag | Creates a beta prerelease **Draft**. |
| Push a `vX.Y.Z` tag | Publishes a stable release and marks it as Latest. The tagged commit must be reachable from `main`. |
| Push to `main` without a version tag | Runs CI without creating a release. |
| Trigger | Result |
| --------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| Push a `vX.Y.Z-beta.N` tag | Creates a beta prerelease **Draft**. |
| Push a `vX.Y.Z` tag | Publishes a stable release and marks it as Latest. The tagged commit must be reachable from `main`. |
| Push to `dev` or `main` without a version tag | Runs CI without creating a release. |
Release jobs reuse the tested build and package `dist/out/` into
`ego-browser-<tag>.zip`. Notes are generated from merged PRs using
+100
View File
@@ -0,0 +1,100 @@
import test from "node:test";
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
function workflow(name) {
return readFileSync(
new URL(`../../../.github/workflows/${name}.yml`, import.meta.url),
"utf8",
);
}
function releaseGuards(github) {
const source = workflow("ci");
const expressions = [
source.match(/SHOULD_RELEASE: \$\{\{\s*(.*?)\s*\}\}/)?.[1],
source.match(/^ release:\n if: (.+)$/m)?.[1],
];
return expressions.map((expression) => {
assert.ok(expression, "release guard must be present");
// These workflow guards use the shared JS/GitHub expression subset.
return new Function("github", "startsWith", `return (${expression});`)(
github,
(value, prefix) => value.toLowerCase().startsWith(prefix.toLowerCase()),
);
});
}
test("branch pushes and pull requests do not package or publish releases", () => {
for (const branch of ["dev", "main", "2.0.0-beta-dev"]) {
assert.deepEqual(
releaseGuards({
event_name: "push",
ref: `refs/heads/${branch}`,
ref_name: branch,
}),
[false, false],
branch,
);
}
assert.deepEqual(
releaseGuards({
event_name: "pull_request",
ref: "refs/pull/1/merge",
ref_name: "1/merge",
}),
[false, false],
);
});
test("beta and stable tag pushes retain both release guards", () => {
for (const tag of ["v2.0.0-beta.9", "v2.0.0"]) {
assert.deepEqual(
releaseGuards({
event_name: "push",
ref: `refs/tags/${tag}`,
ref_name: tag,
}),
[true, true],
tag,
);
}
});
function runSourceGuard(branch) {
const source = workflow("main-pr-source");
const script = source.split(" run: |\n")[1];
assert.ok(script, "source guard must have a shell step");
const env = { ...process.env };
delete env.HEAD_REF;
for (const match of source.matchAll(
/^ (\w+): \$\{\{ github\.head_ref \}\}$/gm,
)) {
env[match[1]] = branch;
}
return spawnSync(
"bash",
["-e", "-c", script.replaceAll("${{ github.head_ref }}", branch)],
{ env, encoding: "utf8", timeout: 5000 },
);
}
test("main source guard allows dev and rejects other branches", () => {
assert.equal(runSourceGuard("dev").status, 0);
assert.equal(runSourceGuard("feature/change").status, 1);
});
test("main source guard treats shell syntax in branch names as data", () => {
const directory = mkdtempSync(join(tmpdir(), "ego-branch-name-"));
const marker = join(directory, "unexpected-command");
try {
const result = runSourceGuard(`feature/$(touch\${IFS}${marker})`);
assert.equal(result.status, 1);
assert.equal(existsSync(marker), false, "branch name executed a command");
} finally {
rmSync(directory, { recursive: true, force: true });
}
});