diff --git a/.github/workflows/sync-edgeever-upstream.yml b/.github/workflows/sync-edgeever-upstream.yml index de6375e8..24c462bf 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 are read-only mirrors by default: when content differs, -# main resets to the selected Release (or edge main) and triggers Cloudflare. +# Deployment Forks mirror product code by default while preserving their local +# workflow directory so GITHUB_TOKEN never needs permission to rewrite Actions. # The rare customized Fork must explicitly opt in to merge preservation. on: @@ -95,8 +95,10 @@ jobs: head_version="unknown" fi - # Updater-only differences do not trigger a product update. - content_diff="$(git diff --name-only "${target_commit}" HEAD -- ':!.github/workflows/sync-edgeever-upstream.yml' ':!scripts/upstream-sync-plan.mjs' || true)" + # 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)" content_matches_target=false if [ -z "${content_diff}" ]; then content_matches_target=true @@ -161,13 +163,13 @@ jobs: echo "**Application content already matches upstream target** (history tip may differ). No git update is needed for deployment." ;; behind_target) - echo "**Fork is behind upstream.** Aligning with a hard reset to the target commit (deploy-mirror path)." + echo "**Fork is behind upstream.** Applying the target product snapshot while preserving downstream workflows." ;; deploy_mirror_reset) - echo "**Deployment mirror differs from upstream.** Resetting onto the selected target; no Fork code is preserved in the default mode." + echo "**Deployment mirror differs from upstream.** Replacing managed product files with the selected target while preserving downstream workflows." ;; deploy_mirror_ahead) - echo "**Deploy-only fork is ahead of the selected upstream target.** Resetting onto that target." + echo "**Deploy-only fork is ahead of the selected upstream target.** Applying that target's product snapshot while preserving downstream workflows." ;; customized_contains_target) echo "**Opt-in customized fork already contains the upstream target.** Preserving its application changes without another merge." @@ -184,7 +186,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**. The default mirror mode intentionally owns \`main\` and resets it to the selected upstream target." + echo "Prefer this workflow over GitHub **Sync fork**. Mirror mode owns the managed product tree while preserving the Fork's local \`.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}" @@ -201,26 +203,18 @@ jobs: git config user.name "edgeever-updater[bot]" git config user.email "edgeever-updater[bot]@users.noreply.github.com" - case "${ALIGN_MODE}" in - reset) - git reset --hard "${TARGET_COMMIT}" - git clean -fd - echo "Reset main onto ${TARGET_REF} (${TARGET_COMMIT:0:12}) because ${REASON}." - ;; - merge) - if ! git merge --no-edit -m "chore: update EdgeEver from ${TARGET_REF}" "${TARGET_COMMIT}"; then - git merge --abort || true - echo "Merge from ${TARGET_REF} conflicted with local application changes." >&2 - echo "Resolve the customized fork manually, or remove EDGE_EVER_PRESERVE_FORK_CHANGES to return to mirror mode." >&2 - exit 1 - fi - echo "Merged ${TARGET_REF} (${TARGET_COMMIT:0:12}) because ${REASON}." - ;; - *) - echo "Unsupported align mode: ${ALIGN_MODE}" >&2 - exit 1 - ;; - esac + helper_path="${RUNNER_TEMP}/prepare-upstream-sync.mjs" + if git cat-file -e "${TARGET_COMMIT}:scripts/prepare-upstream-sync.mjs" 2>/dev/null; then + git show "${TARGET_COMMIT}:scripts/prepare-upstream-sync.mjs" > "${helper_path}" + else + cp scripts/prepare-upstream-sync.mjs "${helper_path}" + fi + + EDGE_SYNC_ALIGN_MODE="${ALIGN_MODE}" \ + EDGE_SYNC_BASE_COMMIT="HEAD" \ + EDGE_SYNC_TARGET_COMMIT="${TARGET_COMMIT}" \ + node "${helper_path}" prepare + echo "Prepared ${ALIGN_MODE} update from ${TARGET_REF} (${TARGET_COMMIT:0:12}) because ${REASON}." - name: Set up Bun if: steps.upstream.outputs.align_mode == 'merge' @@ -243,6 +237,7 @@ jobs: if: steps.upstream.outputs.update_required == 'true' || steps.upstream.outputs.republish_only == 'true' env: TARGET_REF: ${{ steps.upstream.outputs.target_ref }} + TARGET_COMMIT: ${{ steps.upstream.outputs.target_commit }} UPDATE_REQUIRED: ${{ steps.upstream.outputs.update_required }} REPUBLISH_ONLY: ${{ steps.upstream.outputs.republish_only }} ALIGN_MODE: ${{ steps.upstream.outputs.align_mode }} @@ -260,23 +255,25 @@ jobs: exit 0 fi - if [ "${ALIGN_MODE}" = "reset" ]; then - if [ "${REASON}" = "behind_target" ]; then - git push origin HEAD:main - echo "mode=fast_forward" >> "${GITHUB_OUTPUT}" - echo "Published fast-forward main for ${TARGET_REF}." - else - # Diverged mirrors require a guarded history rewrite. - git push --force-with-lease origin HEAD:main - echo "mode=force_with_lease" >> "${GITHUB_OUTPUT}" - echo "Published reset main for ${TARGET_REF}." - fi - else - git push origin HEAD:main - echo "mode=push" >> "${GITHUB_OUTPUT}" - echo "Published merge for ${TARGET_REF}." + git commit \ + -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 + exit 1 fi + if [ "${ALIGN_MODE}" = "snapshot" ] && \ + ! git diff --quiet "${TARGET_COMMIT}" HEAD -- . ':(exclude).github/workflows/**'; then + echo "Prepared product tree does not match ${TARGET_REF}." >&2 + exit 1 + fi + + git push origin HEAD:main + echo "mode=${ALIGN_MODE}_commit" >> "${GITHUB_OUTPUT}" + echo "Published ${ALIGN_MODE} update for ${TARGET_REF} without changing downstream workflows." + - name: Trigger Cloudflare Deploy Hook if: steps.publish.outcome == 'success' env: diff --git a/docs/agent-deploy-cloudflare.md b/docs/agent-deploy-cloudflare.md index 9da8980a..6285d1e8 100644 --- a/docs/agent-deploy-cloudflare.md +++ b/docs/agent-deploy-cloudflare.md @@ -13,7 +13,8 @@ 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 owns `main` and may reset it to the selected upstream Release before triggering Cloudflare. + - 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. - 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** @@ -48,4 +49,4 @@ This document defines the standard operating specifications and conventions for - Manually trigger **Update deployed EdgeEver** once in the Fork's **Actions** tab. - Open the job **Summary** and confirm it reports Fork mode `mirror`, the upstream target (stable Release or edge `main`), and either an update publish or an explicit *already aligned* result. - Confirm Cloudflare **Deployments** builds the published `main` commit when a push occurred. - - Do not edit deployment files or rely on GitHub **Sync fork** for routine upgrades; the workflow is the only synchronization path needed by a normal deployment Fork. + - Do not edit deployment files or rely on GitHub **Sync fork** for routine upgrades; the workflow is the only synchronization path needed by a normal deployment Fork. Use **Sync fork** once only when an old Fork must receive a newer updater workflow itself, then return to **Update deployed EdgeEver**. diff --git a/docs/agent-deploy-cloudflare.zh-CN.md b/docs/agent-deploy-cloudflare.zh-CN.md index 6a214ffc..c6b3054a 100644 --- a/docs/agent-deploy-cloudflare.zh-CN.md +++ b/docs/agent-deploy-cloudflare.zh-CN.md @@ -13,7 +13,8 @@ 2. **启用工作流** - 在 Fork 仓库的 **Actions** 标签页中,启用 **Update deployed EdgeEver** 自动更新工作流(公共 Fork 上定时任务默认关闭,必须手动启用)。 - - 普通 Fork 默认作为部署镜像,无需配置任何更新变量:工作流负责管理 `main`,可将其重置到选定的上游 Release,再触发 Cloudflare。 + - 普通 Fork 默认作为部署镜像,无需配置任何更新变量:工作流会把所选上游 Release 的产品代码快照应用到 `main`,再触发 Cloudflare。 + - 更新器会完整保留 Fork 自己的 `.github/workflows/**` 目录。官方打包、签名、测试与 Release 工作流继续通过 `tianma-if/edgeever` 仓库门禁限制执行,也不会被下游更新器改写。 - 只有明确维护了应用代码修改的 Fork,才应创建 Actions 仓库变量 `EDGE_EVER_PRESERVE_FORK_CHANGES=true`。启用后改为 merge,并由仓库所有者处理未来冲突;普通部署无需设置。 3. **Cloudflare 项目导入** @@ -48,4 +49,4 @@ - 在 Fork 的 **Actions** 中手动运行一次 **Update deployed EdgeEver**。 - 打开 Job **Summary**,确认 Fork mode 为 `mirror`,并显示上游目标(stable Release 或 edge `main`),以及「已发布更新」或明确的「已对齐」结果。 - 若发生了 push,确认 Cloudflare **Deployments** 构建的是对应的 `main` commit。 - - 普通部署 Fork 不应修改部署文件,日常升级也不要依赖 GitHub **Sync fork**;本工作流就是唯一需要的同步路径。 + - 普通部署 Fork 不应修改部署文件,日常升级也不要依赖 GitHub **Sync fork**;本工作流就是唯一需要的同步路径。只有旧 Fork 必须取得新版更新工作流本身时,才使用一次 **Sync fork**,之后继续使用 **Update deployed EdgeEver**。 diff --git a/docs/cloudflare-workers-builds.md b/docs/cloudflare-workers-builds.md index 6c62c6ea..af961346 100644 --- a/docs/cloudflare-workers-builds.md +++ b/docs/cloudflare-workers-builds.md @@ -18,10 +18,12 @@ Configure `EDGE_EVER_AUTH_PASSWORD` under the Worker's **Settings -> Variables a - **Update deployed EdgeEver** keeps a deployment Fork as an upstream **deploy mirror**: - 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) **reset** onto the target when behind; customized forks **merge** and fail closed on conflicts. + - 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. - 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). - Optional: re-run the workflow with **force_redeploy** to push an empty commit when Git is already current but Cloudflare needs another build. - Build failure: inspect the Worker **Deployments** log and confirm the Deployment commit SHA matches Fork `main`. - Scheduled update never runs: on a public Fork, enable **Update deployed EdgeEver** under **Actions** (scheduled workflows are disabled by default on forks, and may pause after long inactivity). +- Update push is rejected with `without workflows permission`: the Fork still has an older updater. Use GitHub **Sync fork** once with the repository owner's permission, then re-run **Update deployed EdgeEver**. Routine product updates do not require **Sync fork** after that bootstrap. diff --git a/docs/cloudflare-workers-builds.zh-CN.md b/docs/cloudflare-workers-builds.zh-CN.md index 20d36859..ee52319d 100644 --- a/docs/cloudflare-workers-builds.zh-CN.md +++ b/docs/cloudflare-workers-builds.zh-CN.md @@ -18,10 +18,12 @@ - **Update deployed EdgeEver** 把部署用 Fork 当作上游的 **部署镜像** 来维护: - 默认 `stable` 通道跟随最新正式 Release tag。 - 设置 GitHub Repository Variable `EDGE_EVER_UPDATE_CHANNEL=edge` 后跟随上游 `main`。 - - 只读 Fork(未改应用代码)在落后时会 **reset** 到目标版本;有本地业务改动的 Fork 走 **merge**,冲突则失败并保持线上不变。 + - 只读 Fork(未改应用代码)会用一个新的线性提交应用目标版本的产品代码快照;有本地业务改动的 Fork 合并产品代码,冲突则失败并保持线上不变。 + - 下游完整的 `.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 集成偶发未构建时有用)。 - 可选:Git 已是最新但 Cloudflare 需要再构建时,手动运行工作流并勾选 **force_redeploy**(会推送空 commit)。 - 构建失败:查看 Worker **Deployments** 日志,确认部署 commit SHA 与 Fork `main` 一致。 - 定时任务从不运行:公共 Fork 需在 **Actions** 中启用 **Update deployed EdgeEver**(Fork 上 schedule 默认禁用,长期不活跃也可能被暂停)。 +- 更新 push 被 `without workflows permission` 拒绝:说明 Fork 仍在使用旧版更新器。请用仓库所有者权限执行一次 GitHub **Sync fork**,再重新运行 **Update deployed EdgeEver**;完成这次引导后,日常产品更新不再需要 **Sync fork**。 diff --git a/docs/deploy-cloudflare-button.md b/docs/deploy-cloudflare-button.md index d9e274a7..afb77e1e 100644 --- a/docs/deploy-cloudflare-button.md +++ b/docs/deploy-cloudflare-button.md @@ -107,5 +107,6 @@ You can also pick `stable` / `edge` when manually running the workflow. 2. Run it once with **Run workflow**. Open the job **Summary**: it states the upstream target version and whether the fork was updated, already aligned, or failed. 3. A green run with *Already on upstream target* means Git already matches that channel — not a broken skip. If the live site is still old, check Cloudflare **Deployments** commit SHA, or re-run with **force_redeploy**. 4. Prefer this workflow over GitHub **Sync fork** for day-to-day upgrades. + 5. If an old updater fails with `without workflows permission`, use **Sync fork** once as the repository owner, then re-run **Update deployed EdgeEver**. The current updater preserves `.github/workflows/**`, so later product updates do not hit this permission boundary. - **Push succeeded but site unchanged**: Confirm Workers Builds ran for the new `main` SHA. Optionally add repository secret `EDGE_EVER_CLOUDFLARE_DEPLOY_HOOK_URL` so the workflow can call a Deploy Hook after publish. - **Reset or Manual Recovery**: See the [Cloudflare Manual Deployment Guide](manual-deploy.md). diff --git a/docs/deploy-cloudflare-button.zh-CN.md b/docs/deploy-cloudflare-button.zh-CN.md index 6cc0be1e..33cd8ba9 100644 --- a/docs/deploy-cloudflare-button.zh-CN.md +++ b/docs/deploy-cloudflare-button.zh-CN.md @@ -107,5 +107,6 @@ EDGE_EVER_UPDATE_CHANNEL=edge 2. 手动 **Run workflow** 一次,打开 Job **Summary**:会写明上游目标版本,以及本次是「已更新」「已对齐」还是失败。 3. 若绿色成功且 Summary 为 *Already on upstream target* / 已对齐,表示 Git 已是该通道目标版本,不是静默故障。若网站仍旧,请对照 Cloudflare **Deployments** 的 commit SHA,或勾选 **force_redeploy** 再跑一次。 4. 日常升级请优先用本工作流,而不是 GitHub **Sync fork**。 + 5. 若旧版更新器报错 `without workflows permission`,请使用仓库所有者身份执行一次 **Sync fork**,然后重新运行 **Update deployed EdgeEver**。新版更新器会保留 `.github/workflows/**`,后续产品更新不会再触发这项权限限制。 - **Git 已 push 但网站没变**:确认 Workers Builds 是否针对新的 `main` SHA 构建。可选:添加仓库 Secret `EDGE_EVER_CLOUDFLARE_DEPLOY_HOOK_URL`,让工作流在 publish 后调用 Deploy Hook。 - **需要重置或手动恢复部署**:请参阅 [手动部署指南](manual-deploy.zh-CN.md)。 diff --git a/scripts/prepare-upstream-sync.mjs b/scripts/prepare-upstream-sync.mjs new file mode 100644 index 00000000..059aebe6 --- /dev/null +++ b/scripts/prepare-upstream-sync.mjs @@ -0,0 +1,254 @@ +import { execFileSync } from "node:child_process"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const protectedWorkflowPath = ".github/workflows"; + +function runGit(arguments_, { allowFailure = false, cwd = process.cwd() } = {}) { + try { + const stdout = execFileSync("git", arguments_, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return { status: 0, stderr: "", stdout }; + } catch (error) { + const status = Number.isInteger(error.status) ? error.status : 1; + const stderr = String(error.stderr || ""); + const stdout = String(error.stdout || ""); + if (allowFailure) return { status, stderr, stdout }; + + const details = [stdout, stderr] + .map((value) => value.trim()) + .filter(Boolean) + .join("\n"); + throw new Error(`git ${arguments_.join(" ")} failed${details ? `:\n${details}` : ""}`); + } +} + +function resolveCommit(cwd, revision) { + const result = runGit(["rev-parse", "--verify", `${revision}^{commit}`], { cwd }); + const commit = result.stdout.trim(); + if (!commit) { + throw new Error(`git rev-parse returned no commit for ${revision} in ${cwd}`); + } + return commit; +} + +function restoreProtectedWorkflows(cwd, baseCommit) { + const workflowsExist = runGit( + ["cat-file", "-e", `${baseCommit}:${protectedWorkflowPath}`], + { allowFailure: true, cwd }, + ); + + if (workflowsExist.status === 0) { + runGit( + [ + "restore", + `--source=${baseCommit}`, + "--staged", + "--worktree", + "--", + protectedWorkflowPath, + ], + { cwd }, + ); + return; + } + + runGit(["rm", "-r", "-f", "--ignore-unmatch", "--", protectedWorkflowPath], { cwd }); +} + +function requireNoWorkflowChanges(cwd, baseCommit) { + const result = runGit( + ["diff", "--cached", "--quiet", baseCommit, "--", protectedWorkflowPath], + { allowFailure: true, cwd }, + ); + if (result.status === 1) { + throw new Error(`${protectedWorkflowPath} 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})`); + } +} + +function requirePreparedChanges(cwd, baseCommit) { + const result = runGit(["diff", "--cached", "--quiet", baseCommit], { + allowFailure: true, + cwd, + }); + if (result.status === 0) { + throw new Error("The upstream sync plan requested an update, but no managed files changed"); + } + if (result.status !== 1) { + throw new Error(`Could not verify the prepared update (git diff exited ${result.status})`); + } +} + +function prepareSnapshot(cwd, baseCommit, targetCommit) { + runGit(["read-tree", "--reset", "-u", targetCommit], { cwd }); + restoreProtectedWorkflows(cwd, baseCommit); + + const managedTreeMatches = runGit( + [ + "diff", + "--cached", + "--quiet", + targetCommit, + "--", + ".", + ":(exclude).github/workflows/**", + ], + { allowFailure: true, cwd }, + ); + if (managedTreeMatches.status !== 0) { + throw new Error("Prepared deployment tree does not match the selected upstream target"); + } +} + +function resolveCustomizedMergeBase(cwd, baseCommit, targetCommit) { + const message = runGit( + [ + "log", + "-1", + "--grep=^EdgeEver-Upstream-Commit: ", + "--format=%B", + baseCommit, + ], + { cwd }, + ).stdout; + const recordedRevision = message.match(/^EdgeEver-Upstream-Commit:\s*(\S+)\s*$/m)?.[1]; + if (recordedRevision) { + const recordedCommit = runGit( + ["rev-parse", "--verify", `${recordedRevision}^{commit}`], + { allowFailure: true, cwd }, + ); + if (recordedCommit.status === 0 && recordedCommit.stdout.trim()) { + return recordedCommit.stdout.trim(); + } + } + + return runGit(["merge-base", baseCommit, targetCommit], { cwd }).stdout.trim(); +} + +function prepareCustomizedMerge(cwd, baseCommit, targetCommit) { + const mergeBase = resolveCustomizedMergeBase(cwd, baseCommit, targetCommit); + if (!mergeBase) throw new Error("Could not resolve the customized fork's upstream base"); + + const replaceRef = `refs/replace/${baseCommit}`; + if (runGit(["show-ref", "--quiet", "--verify", replaceRef], { + allowFailure: true, + cwd, + }).status === 0) { + throw new Error(`Refusing to overwrite existing Git replacement ${replaceRef}`); + } + + const baseTree = runGit(["rev-parse", `${baseCommit}^{tree}`], { cwd }).stdout.trim(); + const syntheticCommit = runGit( + [ + "commit-tree", + baseTree, + "-p", + mergeBase, + "-m", + "temporary EdgeEver customized sync base", + ], + { cwd }, + ).stdout.trim(); + runGit(["replace", baseCommit, syntheticCommit], { cwd }); + + let mergedTree = ""; + try { + const merge = runGit(["merge", "--no-commit", "--no-ff", targetCommit], { + allowFailure: true, + cwd, + }); + const mergeInProgress = runGit(["rev-parse", "--quiet", "--verify", "MERGE_HEAD"], { + allowFailure: true, + cwd, + }).status === 0; + + if (merge.status !== 0 && !mergeInProgress) { + const details = [merge.stdout, merge.stderr] + .map((value) => value.trim()) + .filter(Boolean) + .join("\n"); + throw new Error( + `Could not start the customized upstream merge${details ? `:\n${details}` : ""}`, + ); + } + + restoreProtectedWorkflows(cwd, baseCommit); + const unresolved = runGit(["diff", "--name-only", "--diff-filter=U"], { cwd }) + .stdout.trim() + .split("\n") + .filter(Boolean); + + if (unresolved.length > 0) { + throw new Error(`Customized fork conflicts with upstream in: ${unresolved.join(", ")}`); + } + if (!mergeInProgress) { + throw new Error("Customized merge did not create a merge result"); + } + + mergedTree = runGit(["write-tree"], { cwd }).stdout.trim(); + } finally { + runGit(["merge", "--abort"], { allowFailure: true, cwd }); + runGit(["replace", "-d", baseCommit], { allowFailure: true, cwd }); + } + + if (!mergedTree) throw new Error("Customized merge did not produce a tree"); + runGit(["read-tree", "--reset", "-u", mergedTree], { cwd }); +} + +export function prepareUpstreamSync({ + alignMode, + baseRevision = "HEAD", + cwd = process.cwd(), + targetRevision, +}) { + if (!targetRevision) throw new Error("targetRevision is required"); + if (!new Set(["merge", "snapshot"]).has(alignMode)) { + throw new Error(`Unsupported upstream sync align mode: ${alignMode}`); + } + + const trackedStatus = runGit(["status", "--porcelain=v1", "--untracked-files=no"], { cwd }) + .stdout.trim(); + if (trackedStatus) { + throw new Error(`Upstream sync requires a clean tracked worktree:\n${trackedStatus}`); + } + + const baseCommit = resolveCommit(cwd, baseRevision); + const targetCommit = resolveCommit(cwd, targetRevision); + + if (alignMode === "snapshot") { + prepareSnapshot(cwd, baseCommit, targetCommit); + } else { + prepareCustomizedMerge(cwd, baseCommit, targetCommit); + } + + requireNoWorkflowChanges(cwd, baseCommit); + requirePreparedChanges(cwd, baseCommit); + + return { alignMode, baseCommit, targetCommit }; +} + +function runCli() { + if (process.argv[2] !== "prepare") { + throw new Error("Usage: node scripts/prepare-upstream-sync.mjs prepare"); + } + + const result = prepareUpstreamSync({ + alignMode: process.env.EDGE_SYNC_ALIGN_MODE, + baseRevision: process.env.EDGE_SYNC_BASE_COMMIT || "HEAD", + 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`, + ); +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : ""; +if (invokedPath === fileURLToPath(import.meta.url)) { + runCli(); +} diff --git a/scripts/upstream-sync-plan.mjs b/scripts/upstream-sync-plan.mjs index 1467c8e0..2c558155 100644 --- a/scripts/upstream-sync-plan.mjs +++ b/scripts/upstream-sync-plan.mjs @@ -29,16 +29,16 @@ export function decideUpstreamSync({ if (!preserveForkChanges) { if (headIsAncestorOfTarget) { - return update("reset", "behind_target"); + return update("snapshot", "behind_target"); } if (targetIsAncestorOfHead) { - return update("reset", "deploy_mirror_ahead"); + return update("snapshot", "deploy_mirror_ahead"); } - return update("reset", "deploy_mirror_reset"); + return update("snapshot", "deploy_mirror_reset"); } if (headIsAncestorOfTarget) { - return update("reset", "behind_target"); + return update("snapshot", "behind_target"); } if (targetIsAncestorOfHead) { return noUpdate("customized_contains_target", forceRedeploy); diff --git a/tests/deployment-entrypoints.test.ts b/tests/deployment-entrypoints.test.ts index 62025653..9d8125d6 100644 --- a/tests/deployment-entrypoints.test.ts +++ b/tests/deployment-entrypoints.test.ts @@ -33,6 +33,51 @@ const extractSection = (document: string, sectionHeading: string) => { }; const normalizeMarkdownCopy = (value: string) => value.replace(/[`*]/g, "").replace(/\s+/g, " ").trim(); +const runFixtureGit = (cwd: string, ...args: string[]) => { + const result = spawnSync("git", args, { cwd, encoding: "utf8" }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error( + `git ${args.join(" ")} failed:\n${result.stdout.trim()}\n${result.stderr.trim()}`, + ); + } +}; +const fixtureGitStatus = (cwd: string, ...args: string[]) => + spawnSync("git", args, { cwd, encoding: "utf8" }).status; +const writeFixtureFile = (cwd: string, path: string, content: string) => { + const absolutePath = resolve(cwd, path); + mkdirSync(resolve(absolutePath, ".."), { recursive: true }); + writeFileSync(absolutePath, content); +}; +const initializeSyncFixture = (cwd: string) => { + runFixtureGit(cwd, "init", "-b", "main"); + runFixtureGit(cwd, "config", "user.name", "EdgeEver Test"); + runFixtureGit(cwd, "config", "user.email", "edgeever-test@example.com"); +}; +const prepareFixtureUpstreamSync = ( + cwd: string, + alignMode: "merge" | "snapshot", + targetRevision: string, +) => { + const result = spawnSync( + "node", + [resolve(repositoryRoot, "scripts/prepare-upstream-sync.mjs"), "prepare"], + { + cwd, + encoding: "utf8", + env: { + ...process.env, + EDGE_SYNC_ALIGN_MODE: alignMode, + EDGE_SYNC_BASE_COMMIT: "HEAD", + EDGE_SYNC_TARGET_COMMIT: targetRevision, + }, + }, + ); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`prepare-upstream-sync failed:\n${result.stdout}\n${result.stderr}`); + } +}; describe("Cloudflare deployment entrypoints", () => { test("all entrypoints converge on the common deployment pipeline", () => { @@ -268,10 +313,11 @@ describe("Cloudflare deployment entrypoints", () => { expect(workflow).toContain(scripts.test); expect(workflow.match(/if: steps\.upstream\.outputs\.align_mode == 'merge'/g)).toHaveLength(2); expect(workflow).toContain("git push origin HEAD:main"); - expect(workflow).toContain("git push --force-with-lease origin HEAD:main"); - expect(workflow).toContain('if [ "${REASON}" = "behind_target" ]'); - expect(workflow).toContain("mode=fast_forward"); - expect(workflow).toContain("git reset --hard"); + expect(workflow).not.toContain("git push --force-with-lease origin HEAD:main"); + 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("content_matches_target"); expect(workflow).toContain("already_on_target"); expect(workflow).toContain("fork_mode=mirror"); @@ -316,14 +362,14 @@ describe("Cloudflare deployment entrypoints", () => { targetIsAncestorOfHead: false, }), ).toEqual({ - alignMode: "reset", + alignMode: "snapshot", reason: "deploy_mirror_reset", republishOnly: false, updateRequired: true, }); }); - test("resets a deploy mirror that moved ahead of the stable release", () => { + test("snapshots a deploy mirror that moved ahead of the stable release", () => { expect( decideUpstreamSync({ contentMatchesTarget: false, @@ -334,7 +380,7 @@ describe("Cloudflare deployment entrypoints", () => { targetIsAncestorOfHead: true, }), ).toEqual({ - alignMode: "reset", + alignMode: "snapshot", reason: "deploy_mirror_ahead", republishOnly: false, updateRequired: true, @@ -377,6 +423,212 @@ describe("Cloudflare deployment entrypoints", () => { }); }); + test("prepares a linear product snapshot without changing downstream workflows", () => { + const workingDirectory = mkdtempSync(resolve(tmpdir(), "edgeever-upstream-snapshot-")); + + try { + initializeSyncFixture(workingDirectory); + writeFixtureFile(workingDirectory, "app.txt", "base\n"); + writeFixtureFile(workingDirectory, "removed.txt", "remove me\n"); + writeFixtureFile( + workingDirectory, + ".github/workflows/sync-edgeever-upstream.yml", + "name: downstream base\n", + ); + runFixtureGit(workingDirectory, "add", "."); + runFixtureGit(workingDirectory, "commit", "-m", "base"); + + runFixtureGit(workingDirectory, "checkout", "-b", "upstream"); + writeFixtureFile(workingDirectory, "app.txt", "upstream target\n"); + writeFixtureFile(workingDirectory, "added.txt", "added upstream\n"); + writeFixtureFile( + workingDirectory, + ".github/workflows/sync-edgeever-upstream.yml", + "name: upstream changed\n", + ); + writeFixtureFile( + workingDirectory, + ".github/workflows/windows-test-signing.yml", + "name: official only\n", + ); + rmSync(resolve(workingDirectory, "removed.txt")); + runFixtureGit(workingDirectory, "add", "-A"); + runFixtureGit(workingDirectory, "commit", "-m", "upstream target"); + + runFixtureGit(workingDirectory, "checkout", "-b", "downstream", "main"); + writeFixtureFile(workingDirectory, "app.txt", "stale downstream product\n"); + writeFixtureFile( + workingDirectory, + ".github/workflows/sync-edgeever-upstream.yml", + "name: downstream updater\n", + ); + runFixtureGit(workingDirectory, "add", "."); + runFixtureGit(workingDirectory, "commit", "-m", "downstream state"); + runFixtureGit(workingDirectory, "tag", "downstream-base"); + + prepareFixtureUpstreamSync(workingDirectory, "snapshot", "upstream"); + + expect(readFileSync(resolve(workingDirectory, "app.txt"), "utf8")).toBe( + "upstream target\n", + ); + expect(readFileSync(resolve(workingDirectory, "added.txt"), "utf8")).toBe( + "added upstream\n", + ); + expect(readFileSync(resolve( + workingDirectory, + ".github/workflows/sync-edgeever-upstream.yml", + ), "utf8")).toBe("name: downstream updater\n"); + expect(readdirSync(resolve(workingDirectory, ".github/workflows"))).toEqual([ + "sync-edgeever-upstream.yml", + ]); + expect(fixtureGitStatus( + workingDirectory, + "diff", + "--cached", + "--quiet", + "--", + ".github/workflows", + )).toBe(0); + + runFixtureGit(workingDirectory, "commit", "-m", "sync upstream product"); + expect(fixtureGitStatus(workingDirectory, "diff", "--quiet", "downstream-base", "HEAD^")) + .toBe(0); + expect(fixtureGitStatus(workingDirectory, "rev-parse", "--verify", "HEAD^2")) + .not.toBe(0); + expect(fixtureGitStatus( + workingDirectory, + "diff", + "--quiet", + "upstream", + "HEAD", + "--", + ".", + ":(exclude).github/workflows/**", + )).toBe(0); + expect(fixtureGitStatus( + workingDirectory, + "diff", + "--quiet", + "downstream-base", + "HEAD", + "--", + ".github/workflows", + )).toBe(0); + } finally { + rmSync(workingDirectory, { force: true, recursive: true }); + } + }); + + test("flattens customized merges and preserves their downstream workflows", () => { + const workingDirectory = mkdtempSync(resolve(tmpdir(), "edgeever-upstream-merge-")); + + try { + initializeSyncFixture(workingDirectory); + writeFixtureFile(workingDirectory, "upstream.txt", "base\n"); + writeFixtureFile(workingDirectory, "custom.txt", "base\n"); + writeFixtureFile( + workingDirectory, + ".github/workflows/sync-edgeever-upstream.yml", + "name: base\n", + ); + runFixtureGit(workingDirectory, "add", "."); + runFixtureGit(workingDirectory, "commit", "-m", "base"); + + runFixtureGit(workingDirectory, "checkout", "-b", "upstream"); + writeFixtureFile(workingDirectory, "upstream.txt", "upstream target\n"); + writeFixtureFile( + workingDirectory, + ".github/workflows/sync-edgeever-upstream.yml", + "name: upstream workflow\n", + ); + runFixtureGit(workingDirectory, "add", "."); + runFixtureGit(workingDirectory, "commit", "-m", "upstream target"); + runFixtureGit(workingDirectory, "tag", "upstream-v1"); + + runFixtureGit(workingDirectory, "checkout", "-b", "downstream", "main"); + writeFixtureFile(workingDirectory, "custom.txt", "downstream customization\n"); + writeFixtureFile( + workingDirectory, + ".github/workflows/sync-edgeever-upstream.yml", + "name: downstream updater\n", + ); + runFixtureGit(workingDirectory, "add", "."); + runFixtureGit(workingDirectory, "commit", "-m", "customized downstream"); + runFixtureGit(workingDirectory, "tag", "downstream-base"); + + prepareFixtureUpstreamSync(workingDirectory, "merge", "upstream-v1"); + + expect(readFileSync(resolve(workingDirectory, "upstream.txt"), "utf8")).toBe( + "upstream target\n", + ); + expect(readFileSync(resolve(workingDirectory, "custom.txt"), "utf8")).toBe( + "downstream customization\n", + ); + expect(readFileSync(resolve( + workingDirectory, + ".github/workflows/sync-edgeever-upstream.yml", + ), "utf8")).toBe("name: downstream updater\n"); + + runFixtureGit( + workingDirectory, + "commit", + "-m", + "sync customized fork", + "-m", + "EdgeEver-Upstream-Commit: upstream-v1", + ); + expect(fixtureGitStatus(workingDirectory, "diff", "--quiet", "downstream-base", "HEAD^")) + .toBe(0); + expect(fixtureGitStatus(workingDirectory, "rev-parse", "--verify", "HEAD^2")) + .not.toBe(0); + expect(fixtureGitStatus( + workingDirectory, + "diff", + "--quiet", + "downstream-base", + "HEAD", + "--", + ".github/workflows", + )).toBe(0); + + runFixtureGit(workingDirectory, "checkout", "upstream"); + writeFixtureFile(workingDirectory, "upstream.txt", "upstream target v2\n"); + writeFixtureFile( + workingDirectory, + ".github/workflows/sync-edgeever-upstream.yml", + "name: upstream workflow v2\n", + ); + runFixtureGit(workingDirectory, "add", "."); + runFixtureGit(workingDirectory, "commit", "-m", "upstream target v2"); + runFixtureGit(workingDirectory, "tag", "upstream-v2"); + runFixtureGit(workingDirectory, "checkout", "downstream"); + prepareFixtureUpstreamSync(workingDirectory, "merge", "upstream-v2"); + + expect(readFileSync(resolve(workingDirectory, "upstream.txt"), "utf8")).toBe( + "upstream target v2\n", + ); + expect(readFileSync(resolve(workingDirectory, "custom.txt"), "utf8")).toBe( + "downstream customization\n", + ); + expect(readFileSync(resolve( + workingDirectory, + ".github/workflows/sync-edgeever-upstream.yml", + ), "utf8")).toBe("name: downstream updater\n"); + runFixtureGit( + workingDirectory, + "commit", + "-m", + "sync customized fork v2", + "-m", + "EdgeEver-Upstream-Commit: upstream-v2", + ); + expect(fixtureGitStatus(workingDirectory, "rev-parse", "--verify", "HEAD^2")) + .not.toBe(0); + } finally { + rmSync(workingDirectory, { force: true, recursive: true }); + } + }); + test("public deployment documentation exposes only Fork and Agent paths", () => { const englishReadme = readRepositoryFile("README.md"); const chineseReadme = readRepositoryFile("README.zh-CN.md");