fix(deploy): keep fork updater bootstrap stable

This commit is contained in:
tianma-if committed 2026-08-13 20:55:54 +08:00
1 parent 06297ff11f
commit d954ab7c29
7 files changed
+100 -43

No files matched your search

+30 -9
View File
@@ -1,8 +1,8 @@
name: Update deployed EdgeEver
# Keeps a deployment Fork aligned with upstream EdgeEver.
# Deployment Forks mirror product code by default while preserving their local
# workflow directory so GITHUB_TOKEN never needs permission to rewrite Actions.
# Deployment Forks mirror product code by default while preserving a small
# updater layer so GITHUB_TOKEN never needs permission to rewrite Actions.
# The rare customized Fork must explicitly opt in to merge preservation.
on:
@@ -97,8 +97,11 @@ jobs:
# GitHub blocks GITHUB_TOKEN pushes that add, modify, or remove Actions
# workflows. Deployment forks therefore compare and update only the
# managed product tree; their complete workflow directory stays local.
content_diff="$(git diff --name-only "${target_commit}" HEAD -- . ':(exclude).github/workflows/**' || true)"
# managed product tree; the small downstream updater layer stays local.
content_diff="$(git diff --name-only "${target_commit}" HEAD -- . \
':(exclude).github/workflows/**' \
':(exclude)scripts/prepare-upstream-sync.mjs' \
':(exclude)scripts/upstream-sync-plan.mjs' || true)"
content_matches_target=false
if [ -z "${content_diff}" ]; then
content_matches_target=true
@@ -122,6 +125,13 @@ jobs:
target_is_ancestor_of_head=true
fi
planner_path="${RUNNER_TEMP}/upstream-sync-plan.mjs"
if git cat-file -e "${target_commit}:scripts/upstream-sync-plan.mjs" 2>/dev/null; then
git show "${target_commit}:scripts/upstream-sync-plan.mjs" > "${planner_path}"
else
cp scripts/upstream-sync-plan.mjs "${planner_path}"
fi
IFS=$'\t' read -r update_required align_mode reason republish_only < <(
EDGE_SYNC_CONTENT_MATCHES_TARGET="${content_matches_target}" \
EDGE_SYNC_FORCE_REDEPLOY="${FORCE_REDEPLOY}" \
@@ -129,8 +139,13 @@ jobs:
EDGE_SYNC_HEAD_IS_ANCESTOR_OF_TARGET="${head_is_ancestor_of_target}" \
EDGE_SYNC_PRESERVE_FORK_CHANGES="${PRESERVE_FORK_CHANGES}" \
EDGE_SYNC_TARGET_IS_ANCESTOR_OF_HEAD="${target_is_ancestor_of_head}" \
node scripts/upstream-sync-plan.mjs
node "${planner_path}"
)
# Releases created before the snapshot updater called this mode reset.
# Normalize that legacy plan without executing a history rewrite.
if [ "${align_mode}" = "reset" ]; then
align_mode=snapshot
fi
{
echo "target_ref=${target_ref}"
@@ -186,7 +201,7 @@ jobs:
echo "\`force_redeploy\` is set: will push an empty commit so Cloudflare Workers Builds runs again."
fi
echo ""
echo "Prefer this workflow over GitHub **Sync fork**. Mirror mode owns the managed product tree while preserving the Fork's local \`.github/workflows/**\`."
echo "Prefer this workflow over GitHub **Sync fork**. Mirror mode owns the managed product tree while preserving the Fork's local updater layer and \`.github/workflows/**\`."
} >> "${GITHUB_STEP_SUMMARY}"
echo "channel=${UPDATE_CHANNEL} target=${target_ref}@${target_commit:0:12} (v${target_version}) head=${head_commit:0:12} (v${head_version}) reason=${reason} align=${align_mode} update=${update_required} republish=${republish_only}"
@@ -259,13 +274,19 @@ jobs:
-m "chore: update EdgeEver from ${TARGET_REF}" \
-m "EdgeEver-Upstream-Commit: ${TARGET_COMMIT}"
if ! git diff --quiet HEAD^ HEAD -- .github/workflows; then
echo "Refusing to publish an update that changes downstream GitHub Actions workflows." >&2
if ! git diff --quiet HEAD^ HEAD -- \
.github/workflows \
scripts/prepare-upstream-sync.mjs \
scripts/upstream-sync-plan.mjs; then
echo "Refusing to publish an update that changes the downstream updater layer." >&2
exit 1
fi
if [ "${ALIGN_MODE}" = "snapshot" ] && \
! git diff --quiet "${TARGET_COMMIT}" HEAD -- . ':(exclude).github/workflows/**'; then
! git diff --quiet "${TARGET_COMMIT}" HEAD -- . \
':(exclude).github/workflows/**' \
':(exclude)scripts/prepare-upstream-sync.mjs' \
':(exclude)scripts/upstream-sync-plan.mjs'; then
echo "Prepared product tree does not match ${TARGET_REF}." >&2
exit 1
fi
+1 -1
View File
@@ -14,7 +14,7 @@ This document defines the standard operating specifications and conventions for
2. **Enable Workflow**
- In the **Actions** tab of the Forked repository, enable the **Update deployed EdgeEver** automatic update workflow (required on public forks: scheduled workflows are disabled by default).
- An ordinary Fork is a deployment mirror by default. No update variable is required: the workflow applies the selected upstream Release's product snapshot to `main` before triggering Cloudflare.
- The updater preserves the Fork's complete `.github/workflows/**` directory. Official packaging, signing, testing, and Release workflows remain guarded to `tianma-if/edgeever` and are never rewritten by the downstream updater.
- The updater preserves the Fork's complete `.github/workflows/**` directory and its two updater helper scripts as a stable local bootstrap layer. Official packaging, signing, testing, and Release workflows remain guarded to `tianma-if/edgeever` and are never rewritten by downstream product updates.
- Only a Fork that intentionally maintains application-code changes should create the Actions repository variable `EDGE_EVER_PRESERVE_FORK_CHANGES=true`. This opts into merges and requires the owner to resolve future conflicts; it is not needed for normal deployments.
3. **Cloudflare Project Import**
+1 -1
View File
@@ -14,7 +14,7 @@
2. **启用工作流**
- 在 Fork 仓库的 **Actions** 标签页中,启用 **Update deployed EdgeEver** 自动更新工作流(公共 Fork 上定时任务默认关闭,必须手动启用)。
- 普通 Fork 默认作为部署镜像,无需配置任何更新变量:工作流会把所选上游 Release 的产品代码快照应用到 `main`,再触发 Cloudflare。
- 更新器会完整保留 Fork 自己的 `.github/workflows/**` 目录。官方打包、签名、测试与 Release 工作流继续通过 `tianma-if/edgeever` 仓库门禁限制执行,也不会被下游更新器改写。
- 更新器会把 Fork 自己的完整 `.github/workflows/**` 目录及两个更新辅助脚本作为稳定的本地引导层保留。官方打包、签名、测试与 Release 工作流继续通过 `tianma-if/edgeever` 仓库门禁限制执行,也不会被下游产品更新改写。
- 只有明确维护了应用代码修改的 Fork,才应创建 Actions 仓库变量 `EDGE_EVER_PRESERVE_FORK_CHANGES=true`。启用后改为 merge,并由仓库所有者处理未来冲突;普通部署无需设置。
3. **Cloudflare 项目导入**
+1 -1
View File
@@ -19,7 +19,7 @@ Configure `EDGE_EVER_AUTH_PASSWORD` under the Worker's **Settings -> Variables a
- Default channel `stable` tracks the latest formal Release tag.
- Set the GitHub Repository Variable `EDGE_EVER_UPDATE_CHANNEL=edge` to follow upstream `main`.
- Read-only forks (no app code changes) apply the target's product snapshot in a new linear commit; customized forks merge product changes and fail closed on conflicts.
- The complete downstream `.github/workflows/**` directory is preserved. Official packaging, signing, testing, and Release workflows are not part of automatic product updates, so `GITHUB_TOKEN` never needs permission to rewrite Actions workflows.
- The complete downstream `.github/workflows/**` directory and two updater helper scripts form a stable local bootstrap layer. Official packaging, signing, testing, and Release workflows are not part of automatic product updates, so `GITHUB_TOKEN` never needs permission to rewrite Actions workflows.
- Every run writes a job **Summary** with channel, target version, decision reason, and whether a push happened. A green run that says *Already on upstream target* is success, not a silent failure.
- Prefer this workflow over GitHub **Sync fork**. Sync fork follows upstream `main` history and can make the next stable run a deliberate no-op.
- Optional: repository secret `EDGE_EVER_CLOUDFLARE_DEPLOY_HOOK_URL` triggers a Cloudflare Deploy Hook after a successful push (useful when the Git integration misses a push).
+1 -1
View File
@@ -19,7 +19,7 @@
- 默认 `stable` 通道跟随最新正式 Release tag。
- 设置 GitHub Repository Variable `EDGE_EVER_UPDATE_CHANNEL=edge` 后跟随上游 `main`。
- 只读 Fork(未改应用代码)会用一个新的线性提交应用目标版本的产品代码快照;有本地业务改动的 Fork 合并产品代码,冲突则失败并保持线上不变。
- 下游完整的 `.github/workflows/**` 目录会原样保留。官方打包、签名、测试与 Release 工作流不参与产品代码自动更新,因此 `GITHUB_TOKEN` 无需取得改写 Actions 工作流的权限。
- 下游完整的 `.github/workflows/**` 目录和两个更新辅助脚本会作为稳定的本地引导层原样保留。官方打包、签名、测试与 Release 工作流不参与产品代码自动更新,因此 `GITHUB_TOKEN` 无需取得改写 Actions 工作流的权限。
- 每次运行都会写 Job **Summary**:通道、目标版本、判定原因、是否 push。若 Summary 写明 *Already on upstream target* / 已对齐,绿色成功表示「已是目标版本」,不是静默故障。
- 请优先用本工作流,而不是 GitHub **Sync fork**。Sync fork 跟的是上游 `main` 历史,可能让下一次 stable 运行合理变为 no-op。
- 可选:仓库 Secret `EDGE_EVER_CLOUDFLARE_DEPLOY_HOOK_URL`,在成功 push 后触发 Cloudflare Deploy Hook(Git 集成偶发未构建时有用)。
+29 -29
View File
@@ -2,7 +2,11 @@ import { execFileSync } from "node:child_process";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const protectedWorkflowPath = ".github/workflows";
const protectedUpdaterPaths = [
".github/workflows",
"scripts/prepare-upstream-sync.mjs",
"scripts/upstream-sync-plan.mjs",
];
function runGit(arguments_, { allowFailure = false, cwd = process.cwd() } = {}) {
try {
@@ -35,40 +39,34 @@ function resolveCommit(cwd, revision) {
return commit;
}
function restoreProtectedWorkflows(cwd, baseCommit) {
const workflowsExist = runGit(
["cat-file", "-e", `${baseCommit}:${protectedWorkflowPath}`],
{ allowFailure: true, cwd },
);
function restoreProtectedUpdater(cwd, baseCommit) {
for (const path of protectedUpdaterPaths) {
const pathExists = runGit(["cat-file", "-e", `${baseCommit}:${path}`], {
allowFailure: true,
cwd,
});
if (workflowsExist.status === 0) {
runGit(
[
"restore",
`--source=${baseCommit}`,
"--staged",
"--worktree",
"--",
protectedWorkflowPath,
],
{ cwd },
);
return;
if (pathExists.status === 0) {
runGit(
["restore", `--source=${baseCommit}`, "--staged", "--worktree", "--", path],
{ cwd },
);
} else {
runGit(["rm", "-r", "-f", "--ignore-unmatch", "--", path], { cwd });
}
}
runGit(["rm", "-r", "-f", "--ignore-unmatch", "--", protectedWorkflowPath], { cwd });
}
function requireNoWorkflowChanges(cwd, baseCommit) {
function requireNoUpdaterChanges(cwd, baseCommit) {
const result = runGit(
["diff", "--cached", "--quiet", baseCommit, "--", protectedWorkflowPath],
["diff", "--cached", "--quiet", baseCommit, "--", ...protectedUpdaterPaths],
{ allowFailure: true, cwd },
);
if (result.status === 1) {
throw new Error(`${protectedWorkflowPath} must remain unchanged in deployment forks`);
throw new Error("The downstream updater layer must remain unchanged in deployment forks");
}
if (result.status !== 0) {
throw new Error(`Could not verify the protected workflow tree (git diff exited ${result.status})`);
throw new Error(`Could not verify the protected updater layer (git diff exited ${result.status})`);
}
}
@@ -87,7 +85,7 @@ function requirePreparedChanges(cwd, baseCommit) {
function prepareSnapshot(cwd, baseCommit, targetCommit) {
runGit(["read-tree", "--reset", "-u", targetCommit], { cwd });
restoreProtectedWorkflows(cwd, baseCommit);
restoreProtectedUpdater(cwd, baseCommit);
const managedTreeMatches = runGit(
[
@@ -98,6 +96,8 @@ function prepareSnapshot(cwd, baseCommit, targetCommit) {
"--",
".",
":(exclude).github/workflows/**",
":(exclude)scripts/prepare-upstream-sync.mjs",
":(exclude)scripts/upstream-sync-plan.mjs",
],
{ allowFailure: true, cwd },
);
@@ -178,7 +178,7 @@ function prepareCustomizedMerge(cwd, baseCommit, targetCommit) {
);
}
restoreProtectedWorkflows(cwd, baseCommit);
restoreProtectedUpdater(cwd, baseCommit);
const unresolved = runGit(["diff", "--name-only", "--diff-filter=U"], { cwd })
.stdout.trim()
.split("\n")
@@ -227,7 +227,7 @@ export function prepareUpstreamSync({
prepareCustomizedMerge(cwd, baseCommit, targetCommit);
}
requireNoWorkflowChanges(cwd, baseCommit);
requireNoUpdaterChanges(cwd, baseCommit);
requirePreparedChanges(cwd, baseCommit);
return { alignMode, baseCommit, targetCommit };
@@ -244,7 +244,7 @@ function runCli() {
targetRevision: process.env.EDGE_SYNC_TARGET_COMMIT,
});
process.stdout.write(
`[ok] prepared ${result.alignMode} update ${result.baseCommit.slice(0, 12)} -> ${result.targetCommit.slice(0, 12)} without changing ${protectedWorkflowPath}\n`,
`[ok] prepared ${result.alignMode} update ${result.baseCommit.slice(0, 12)} -> ${result.targetCommit.slice(0, 12)} without changing the downstream updater layer\n`,
);
}
+37 -1
View File
@@ -317,7 +317,9 @@ describe("Cloudflare deployment entrypoints", () => {
expect(workflow).not.toContain("git reset --hard");
expect(workflow).toContain("scripts/prepare-upstream-sync.mjs");
expect(workflow).toContain("':(exclude).github/workflows/**'");
expect(workflow).toContain("Refusing to publish an update that changes downstream");
expect(workflow).toContain("planner_path");
expect(workflow).toContain('if [ "${align_mode}" = "reset" ]');
expect(workflow).toContain("Refusing to publish an update that changes the downstream updater");
expect(workflow).toContain("content_matches_target");
expect(workflow).toContain("already_on_target");
expect(workflow).toContain("fork_mode=mirror");
@@ -435,6 +437,16 @@ describe("Cloudflare deployment entrypoints", () => {
".github/workflows/sync-edgeever-upstream.yml",
"name: downstream base\n",
);
writeFixtureFile(
workingDirectory,
"scripts/prepare-upstream-sync.mjs",
"// downstream prepare helper\n",
);
writeFixtureFile(
workingDirectory,
"scripts/upstream-sync-plan.mjs",
"// downstream planner\n",
);
runFixtureGit(workingDirectory, "add", ".");
runFixtureGit(workingDirectory, "commit", "-m", "base");
@@ -451,6 +463,16 @@ describe("Cloudflare deployment entrypoints", () => {
".github/workflows/windows-test-signing.yml",
"name: official only\n",
);
writeFixtureFile(
workingDirectory,
"scripts/prepare-upstream-sync.mjs",
"// upstream prepare helper\n",
);
writeFixtureFile(
workingDirectory,
"scripts/upstream-sync-plan.mjs",
"// upstream planner\n",
);
rmSync(resolve(workingDirectory, "removed.txt"));
runFixtureGit(workingDirectory, "add", "-A");
runFixtureGit(workingDirectory, "commit", "-m", "upstream target");
@@ -478,6 +500,14 @@ describe("Cloudflare deployment entrypoints", () => {
workingDirectory,
".github/workflows/sync-edgeever-upstream.yml",
), "utf8")).toBe("name: downstream updater\n");
expect(readFileSync(resolve(
workingDirectory,
"scripts/prepare-upstream-sync.mjs",
), "utf8")).toBe("// downstream prepare helper\n");
expect(readFileSync(resolve(
workingDirectory,
"scripts/upstream-sync-plan.mjs",
), "utf8")).toBe("// downstream planner\n");
expect(readdirSync(resolve(workingDirectory, ".github/workflows"))).toEqual([
"sync-edgeever-upstream.yml",
]);
@@ -488,6 +518,8 @@ describe("Cloudflare deployment entrypoints", () => {
"--quiet",
"--",
".github/workflows",
"scripts/prepare-upstream-sync.mjs",
"scripts/upstream-sync-plan.mjs",
)).toBe(0);
runFixtureGit(workingDirectory, "commit", "-m", "sync upstream product");
@@ -504,6 +536,8 @@ describe("Cloudflare deployment entrypoints", () => {
"--",
".",
":(exclude).github/workflows/**",
":(exclude)scripts/prepare-upstream-sync.mjs",
":(exclude)scripts/upstream-sync-plan.mjs",
)).toBe(0);
expect(fixtureGitStatus(
workingDirectory,
@@ -513,6 +547,8 @@ describe("Cloudflare deployment entrypoints", () => {
"HEAD",
"--",
".github/workflows",
"scripts/prepare-upstream-sync.mjs",
"scripts/upstream-sync-plan.mjs",
)).toBe(0);
} finally {
rmSync(workingDirectory, { force: true, recursive: true });