diff --git a/.github/workflows/sync-edgeever-upstream.yml b/.github/workflows/sync-edgeever-upstream.yml index 24c462bf..c40353af 100644 --- a/.github/workflows/sync-edgeever-upstream.yml +++ b/.github/workflows/sync-edgeever-upstream.yml @@ -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 diff --git a/docs/agent-deploy-cloudflare.md b/docs/agent-deploy-cloudflare.md index 6285d1e8..deb1d9a2 100644 --- a/docs/agent-deploy-cloudflare.md +++ b/docs/agent-deploy-cloudflare.md @@ -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** diff --git a/docs/agent-deploy-cloudflare.zh-CN.md b/docs/agent-deploy-cloudflare.zh-CN.md index c6b3054a..534d9c31 100644 --- a/docs/agent-deploy-cloudflare.zh-CN.md +++ b/docs/agent-deploy-cloudflare.zh-CN.md @@ -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 项目导入** diff --git a/docs/cloudflare-workers-builds.md b/docs/cloudflare-workers-builds.md index af961346..8e83cf5d 100644 --- a/docs/cloudflare-workers-builds.md +++ b/docs/cloudflare-workers-builds.md @@ -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). diff --git a/docs/cloudflare-workers-builds.zh-CN.md b/docs/cloudflare-workers-builds.zh-CN.md index ee52319d..f83349f2 100644 --- a/docs/cloudflare-workers-builds.zh-CN.md +++ b/docs/cloudflare-workers-builds.zh-CN.md @@ -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 集成偶发未构建时有用)。 diff --git a/scripts/prepare-upstream-sync.mjs b/scripts/prepare-upstream-sync.mjs index 059aebe6..7a23d33e 100644 --- a/scripts/prepare-upstream-sync.mjs +++ b/scripts/prepare-upstream-sync.mjs @@ -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`, ); } diff --git a/tests/deployment-entrypoints.test.ts b/tests/deployment-entrypoints.test.ts index 9d8125d6..b2c1545e 100644 --- a/tests/deployment-entrypoints.test.ts +++ b/tests/deployment-entrypoints.test.ts @@ -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 });