Files
edgeever/.github/workflows/sync-edgeever-upstream.yml
Dariusz Koryto 6f50d1a80a ci: retry bun install across remaining workflows (#565)
Mirror the retry loop already used in desktop-build.yml for every remaining one-shot 'bun install --frozen-lockfile' step so transient registry or network failures (e.g. a failed mermaid tarball download) no longer fail the whole run. deploy-tools.yml gains shell: bash because its matrix includes windows-latest; sync-edgeever-upstream.yml uses break on success so the following verification commands still run.
2026-10-14 23:59:50 +08:00

422 lines
20 KiB
YAML

name: Update deployed EdgeEver
# Keeps a deployment Fork aligned with upstream EdgeEver.
# Deployment Forks mirror product code by default while preserving a small
# updater layer so GITHUB_TOKEN never needs permission to rewrite Actions.
# The rare customized Fork must explicitly opt in to merge preservation.
on:
schedule:
- cron: "17 2 * * *"
workflow_dispatch:
inputs:
channel:
description: Update channel for this run (stable = latest formal Release, edge = upstream main)
required: true
default: stable
type: choice
options:
- stable
- edge
permissions:
contents: write
issues: write
concurrency:
group: edgeever-upstream-update
cancel-in-progress: false
jobs:
update:
if: github.repository != 'tianma-if/edgeever'
name: Sync Fork and trigger deployment
runs-on: ubuntu-latest
timeout-minutes: 20
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UPSTREAM_REPOSITORY: tianma-if/edgeever
UPDATE_CHANNEL: ${{ inputs.channel || vars.EDGE_EVER_UPDATE_CHANNEL || 'stable' }}
# A manual run is an explicit request to publish the selected version.
# Scheduled checks remain no-op when the Fork is already aligned.
FORCE_REDEPLOY: ${{ github.event_name == 'workflow_dispatch' }}
PRESERVE_FORK_CHANGES: ${{ vars.EDGE_EVER_PRESERVE_FORK_CHANGES || 'false' }}
steps:
- name: Checkout deployed repository
uses: actions/checkout@v5
with:
ref: main
fetch-depth: 0
- name: Require a GitHub Fork
shell: bash
run: |
set -euo pipefail
is_fork="$(gh api "repos/${GITHUB_REPOSITORY}" --jq '.fork')"
if [ "${is_fork}" != "true" ]; then
echo "This deployment repository must be a GitHub Fork of ${UPSTREAM_REPOSITORY}." >&2
echo "Create the repository with the EdgeEver Fork flow before enabling upstream updates." >&2
exit 1
fi
- name: Resolve upstream version
id: upstream
shell: bash
run: |
set -euo pipefail
git remote add upstream "https://github.com/${UPSTREAM_REPOSITORY}.git"
git fetch upstream main --tags
case "${UPDATE_CHANNEL}" in
stable)
target_ref="$(gh api "repos/${UPSTREAM_REPOSITORY}/releases/latest" --jq '.tag_name')"
;;
edge)
target_ref="upstream/main"
;;
*)
echo "Unsupported update channel: ${UPDATE_CHANNEL}" >&2
exit 1
;;
esac
target_commit="$(git rev-parse "${target_ref}^{commit}")"
head_commit="$(git rev-parse HEAD)"
target_version="$(git show "${target_commit}:package.json" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("version",""))' 2>/dev/null || true)"
if [ -z "${target_version}" ]; then
target_version="unknown"
fi
head_version="$(python3 -c 'import json; print(json.load(open("package.json")).get("version",""))' 2>/dev/null || true)"
if [ -z "${head_version}" ]; then
head_version="unknown"
fi
# GitHub blocks GITHUB_TOKEN pushes that add, modify, or remove Actions
# workflows. Deployment forks therefore compare and update only the
# managed product tree; the small downstream updater layer stays local.
content_diff="$(git diff --name-only "${target_commit}" HEAD -- . \
':(exclude).github/workflows/**' \
':(exclude)scripts/prepare-upstream-sync.mjs' \
':(exclude)scripts/upstream-sync-plan.mjs' || true)"
content_matches_target=false
if [ -z "${content_diff}" ]; then
content_matches_target=true
fi
fork_mode=mirror
if [ "${PRESERVE_FORK_CHANGES}" = "true" ]; then
fork_mode=customized
fi
head_equals_target=false
if [ "${head_commit}" = "${target_commit}" ]; then
head_equals_target=true
fi
head_is_ancestor_of_target=false
if git merge-base --is-ancestor HEAD "${target_commit}" 2>/dev/null; then
head_is_ancestor_of_target=true
fi
target_is_ancestor_of_head=false
if git merge-base --is-ancestor "${target_commit}" HEAD 2>/dev/null; then
target_is_ancestor_of_head=true
fi
planner_path="${RUNNER_TEMP}/upstream-sync-plan.mjs"
if git cat-file -e "${target_commit}:scripts/upstream-sync-plan.mjs" 2>/dev/null; then
git show "${target_commit}:scripts/upstream-sync-plan.mjs" > "${planner_path}"
else
cp scripts/upstream-sync-plan.mjs "${planner_path}"
fi
IFS=$'\t' read -r update_required align_mode reason republish_only < <(
EDGE_SYNC_CONTENT_MATCHES_TARGET="${content_matches_target}" \
EDGE_SYNC_FORCE_REDEPLOY="${FORCE_REDEPLOY}" \
EDGE_SYNC_HEAD_EQUALS_TARGET="${head_equals_target}" \
EDGE_SYNC_HEAD_IS_ANCESTOR_OF_TARGET="${head_is_ancestor_of_target}" \
EDGE_SYNC_PRESERVE_FORK_CHANGES="${PRESERVE_FORK_CHANGES}" \
EDGE_SYNC_TARGET_IS_ANCESTOR_OF_HEAD="${target_is_ancestor_of_head}" \
node "${planner_path}"
)
# Releases created before the snapshot updater called this mode reset.
# Normalize that legacy plan without executing a history rewrite.
if [ "${align_mode}" = "reset" ]; then
align_mode=snapshot
fi
{
echo "target_ref=${target_ref}"
echo "target_commit=${target_commit}"
echo "target_version=${target_version}"
echo "head_commit=${head_commit}"
echo "head_version=${head_version}"
echo "update_required=${update_required}"
echo "align_mode=${align_mode}"
echo "reason=${reason}"
echo "republish_only=${republish_only}"
} >> "${GITHUB_OUTPUT}"
{
echo "### EdgeEver update / EdgeEver 更新"
echo ""
echo "| Field / 字段 | Value / 值 |"
echo "| --- | --- |"
echo "| Channel / 更新通道 | \`${UPDATE_CHANNEL}\` |"
echo "| Fork mode / Fork 模式 | \`${fork_mode}\` |"
echo "| Upstream target / 上游目标 | \`${target_ref}\` (\`${target_commit:0:12}\`, v${target_version}) |"
echo "| Fork main before / 更新前 | \`${head_commit:0:12}\` (v${head_version}) |"
echo "| Decision / 判定 | \`${reason}\` / align=\`${align_mode}\` / update=\`${update_required}\` / republish=\`${republish_only}\` |"
echo ""
case "${reason}" in
already_on_target)
echo "**Already on upstream target.** No git update is needed. This is success for a read-only deployment fork — not a skipped failure."
;;
content_matches_target)
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.** Applying the target product snapshot while preserving downstream workflows."
;;
deploy_mirror_reset)
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.** 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."
;;
customized_merge)
echo "**Opt-in customized fork.** Merging upstream target and leaving production unchanged if verification fails."
;;
*)
echo "Decision reason: \`${reason}\`."
;;
esac
if [ "${republish_only}" = "true" ]; then
echo ""
echo "**Manual redeploy / 手动重新部署:** the Fork is already aligned, so this run will push an empty commit and request a fresh Cloudflare build."
fi
echo ""
echo "Prefer this workflow over GitHub **Sync fork**. Mirror mode owns the managed product tree while preserving the Fork's local updater layer and \`.github/workflows/**\`."
} >> "${GITHUB_STEP_SUMMARY}"
if [ "${republish_only}" = "true" ]; then
echo "::notice title=Manual redeploy / 手动重新部署::Fork code is already v${target_version}. This run will push an empty commit and request a fresh Cloudflare build."
elif [ "${update_required}" = "true" ]; then
echo "::notice title=EdgeEver update / EdgeEver 更新::Fork main v${head_version} will be synchronized to v${target_version}, then a Cloudflare build will be requested."
else
echo "::notice title=Scheduled check / 定时检查::Fork code is already v${target_version}. No Git push or Cloudflare deployment was requested by this run."
fi
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}"
- name: Align to upstream
if: steps.upstream.outputs.update_required == 'true'
env:
TARGET_COMMIT: ${{ steps.upstream.outputs.target_commit }}
TARGET_REF: ${{ steps.upstream.outputs.target_ref }}
ALIGN_MODE: ${{ steps.upstream.outputs.align_mode }}
REASON: ${{ steps.upstream.outputs.reason }}
run: |
set -euo pipefail
git config user.name "edgeever-updater[bot]"
git config user.email "edgeever-updater[bot]@users.noreply.github.com"
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'
uses: oven-sh/setup-bun@v2
- name: Verify customized merge
if: steps.upstream.outputs.align_mode == 'merge'
run: |
set -euo pipefail
for attempt in 1 2 3; do
if bun install --frozen-lockfile; then
break
fi
if [[ "$attempt" = "3" ]]; then
exit 1
fi
sleep "$((15 * attempt))"
done
bun run db:migrate:local
bun run test
bun run typecheck
bun run build
- name: Publish update
id: publish
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 }}
REASON: ${{ steps.upstream.outputs.reason }}
run: |
set -euo pipefail
git config user.name "edgeever-updater[bot]"
git config user.email "edgeever-updater[bot]@users.noreply.github.com"
if [ "${REPUBLISH_ONLY}" = "true" ] && [ "${UPDATE_REQUIRED}" != "true" ]; then
git commit --allow-empty -m "chore: retrigger EdgeEver deployment for ${TARGET_REF}"
git push origin HEAD:main
echo "mode=empty_commit" >> "${GITHUB_OUTPUT}"
echo "Pushed empty commit to retrigger Cloudflare Workers Builds."
exit 0
fi
git commit \
-m "chore: update EdgeEver from ${TARGET_REF}" \
-m "EdgeEver-Upstream-Commit: ${TARGET_COMMIT}"
if ! git diff --quiet HEAD^ HEAD -- \
.github/workflows \
scripts/prepare-upstream-sync.mjs \
scripts/upstream-sync-plan.mjs; then
echo "Refusing to publish an update that changes the downstream updater layer." >&2
exit 1
fi
if [ "${ALIGN_MODE}" = "snapshot" ] && \
! git diff --quiet "${TARGET_COMMIT}" HEAD -- . \
':(exclude).github/workflows/**' \
':(exclude)scripts/prepare-upstream-sync.mjs' \
':(exclude)scripts/upstream-sync-plan.mjs'; then
echo "Prepared product tree does not match ${TARGET_REF}." >&2
exit 1
fi
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: Request Cloudflare deployment
id: deploy
if: steps.publish.outcome == 'success'
env:
DEPLOY_HOOK_URL: ${{ secrets.EDGE_EVER_CLOUDFLARE_DEPLOY_HOOK_URL }}
run: |
set -euo pipefail
if [ -z "${DEPLOY_HOOK_URL:-}" ]; then
echo "No EDGE_EVER_CLOUDFLARE_DEPLOY_HOOK_URL secret — relying on Cloudflare Git integration for main pushes."
echo "Optional: add a Workers/Pages Deploy Hook secret if pushes do not start builds." >> "${GITHUB_STEP_SUMMARY}"
echo "method=git_push" >> "${GITHUB_OUTPUT}"
exit 0
fi
curl -fsS -X POST "${DEPLOY_HOOK_URL}" >/dev/null
echo "Triggered Cloudflare Deploy Hook."
echo "method=git_push_and_deploy_hook" >> "${GITHUB_OUTPUT}"
echo "" >> "${GITHUB_STEP_SUMMARY}"
echo "Triggered optional Cloudflare Deploy Hook after publish." >> "${GITHUB_STEP_SUMMARY}"
- name: Report result / 输出结果
if: always() && steps.upstream.outcome == 'success'
env:
TARGET_REF: ${{ steps.upstream.outputs.target_ref }}
TARGET_COMMIT: ${{ steps.upstream.outputs.target_commit }}
TARGET_VERSION: ${{ steps.upstream.outputs.target_version }}
UPDATE_REQUIRED: ${{ steps.upstream.outputs.update_required }}
REPUBLISH_ONLY: ${{ steps.upstream.outputs.republish_only }}
REASON: ${{ steps.upstream.outputs.reason }}
ALIGN_MODE: ${{ steps.upstream.outputs.align_mode }}
PUBLISH_OUTCOME: ${{ steps.publish.outcome }}
PUBLISH_MODE: ${{ steps.publish.outputs.mode }}
DEPLOY_TRIGGER_OUTCOME: ${{ steps.deploy.outcome }}
DEPLOY_TRIGGER_METHOD: ${{ steps.deploy.outputs.method }}
run: |
set -euo pipefail
final_commit="$(git rev-parse HEAD)"
git_result="Not requested / 未请求"
deploy_result="Not requested / 未请求"
live_result="Not changed by this run / 本次运行未改变线上部署"
live_status="unchanged"
notice_level="notice"
notice_title="Scheduled check / 定时检查"
notice_body="Fork code is already v${TARGET_VERSION}. No Git push or Cloudflare deployment was requested."
if [ "${UPDATE_REQUIRED}" = "true" ] || [ "${REPUBLISH_ONLY}" = "true" ]; then
live_result="Not verified by this workflow / 本工作流未验证"
live_status="not_verified"
if [ "${PUBLISH_OUTCOME}" != "success" ]; then
git_result="Failed (\`${PUBLISH_OUTCOME}\`) / 失败"
deploy_result="Not requested because Git publish failed / 因 Git 发布失败而未请求"
notice_level="error"
notice_title="Git publish failed / Git 发布失败"
notice_body="The Fork could not publish v${TARGET_VERSION}; no Cloudflare deployment was requested."
else
case "${PUBLISH_MODE}" in
empty_commit)
git_result="Redeploy commit pushed / 已推送重部署 commit"
;;
*)
git_result="Version update pushed (\`${ALIGN_MODE}\`) / 已推送版本更新"
;;
esac
if [ "${DEPLOY_TRIGGER_OUTCOME}" = "success" ]; then
case "${DEPLOY_TRIGGER_METHOD}" in
git_push_and_deploy_hook)
deploy_result="Requested by Git push and Deploy Hook / 已通过 Git push 和 Deploy Hook 请求"
;;
*)
deploy_result="Requested by Git push / 已通过 Git push 请求"
;;
esac
notice_title="Deployment requested / 已请求部署"
notice_body="Fork main is ${final_commit:0:12} (v${TARGET_VERSION}). Confirm Cloudflare Deployments builds this commit; the live version is not verified here."
else
deploy_result="Trigger failed (\`${DEPLOY_TRIGGER_OUTCOME}\`) / 触发失败"
notice_level="error"
notice_title="Deployment trigger failed / 部署触发失败"
notice_body="Fork main is ${final_commit:0:12} (v${TARGET_VERSION}), but the Cloudflare deployment request failed."
fi
fi
fi
{
echo ""
echo "### Result / 结果"
echo ""
echo "| Field / 字段 | Value / 值 |"
echo "| --- | --- |"
echo "| Target version / 目标版本 | \`${TARGET_REF}\` / v${TARGET_VERSION} |"
echo "| Final main / 最终 main | \`${final_commit:0:12}\` |"
echo "| Git publish / Git 发布 | ${git_result} |"
echo "| Deployment trigger / 部署触发 | ${deploy_result} |"
echo "| Live deployment / 线上部署 | ${live_result} |"
echo ""
echo "When a deployment was requested, confirm Cloudflare **Deployments** builds commit \`${final_commit:0:12}\`. / 若已请求部署,请在 Cloudflare **Deployments** 中确认构建 commit 为 \`${final_commit:0:12}\`。"
} >> "${GITHUB_STEP_SUMMARY}"
echo "::${notice_level} title=${notice_title}::${notice_body}"
echo "result target=v${TARGET_VERSION} final=${final_commit:0:12} git_publish=${PUBLISH_OUTCOME} publish_mode=${PUBLISH_MODE:-none} deploy_trigger=${DEPLOY_TRIGGER_OUTCOME} deploy_method=${DEPLOY_TRIGGER_METHOD:-none} live=${live_status}"
- name: Report an update failure
if: failure()
continue-on-error: true
env:
TARGET_REF: ${{ steps.upstream.outputs.target_ref || 'unknown' }}
run: |
set -euo pipefail
title="EdgeEver automatic update requires attention"
existing="$(gh issue list --state open --search "${title} in:title" --json number --jq '.[0].number // empty')"
if [ -z "${existing}" ]; then
gh issue create \
--title "${title}" \
--body "The daily update from \`${UPSTREAM_REPOSITORY}\` (${TARGET_REF}) could not be aligned or verified. The current production deployment was left unchanged. Open the failed **Update deployed EdgeEver** workflow run for details (see the job summary for channel/target/reason), then retry it manually after resolving the problem."
fi