fix(deploy): preserve fork workflows during updates
This commit is contained in:
1 parent
12f6fff108
commit
06297ff11f
10 files changed
+571
-60
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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**.
|
||||
@@ -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**。
|
||||
@@ -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.
|
||||
@@ -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**。
|
||||
@@ -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).
|
||||
@@ -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)。
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user