fix(deploy): preserve fork workflows during updates

This commit is contained in:
tianma-if committed 2026-08-13 20:49:04 +08:00
1 parent 12f6fff108
commit 06297ff11f
10 files changed
+571 -60

No files matched your search

+40 -43
View File
@@ -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:
+3 -2
View File
@@ -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**.
+3 -2
View File
@@ -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**。
+3 -1
View File
@@ -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.
+3 -1
View File
@@ -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**。
+1
View File
@@ -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).
+1
View File
@@ -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)。
+254
View File
@@ -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();
}
+4 -4
View File
@@ -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);
+259 -7
View File
@@ -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");