Files
forge/.hanzo/workflows/sync-from-github.yml
zeekayandHanzo Dev 9899b5f3b8 ci: sync tags too, not just main
cicd.yml builds a release on `tags: ['v*']`, and the sync only ever moved
refs/heads/main. Tags are created on GitHub, so they never arrived here, the
trigger never fired, and no semver image was ever built: the forge sat at
v1.26.24 while GitHub had v1.26.26. A version number that names no artifact is
worse than no tag at all, because it reads as a release.

Tags are immutable, so fast-forward-only means a tag we do not have is pushed,
and a tag we do have that points somewhere else is a rewritten release — which
is reported and refused, never silently adopted and never silently skipped.

Both sides are read from their own ref namespace. A draft that compared against
refs/tags/ was wrong in a way worth recording: `git fetch` auto-follows tags
pointing at fetched objects even when given an explicit refspec, so the GitHub
tags landed in refs/tags/ as a side effect, every new tag compared equal to the
copy just followed, and nothing was ever pushed — this fix silently undoing
itself. --no-tags plus an explicit namespace per side removes both that and any
dependence on what checkout leaves behind.

Verified against a pair of local repositories holding a shared tag, a rewritten
tag and a new one: unchanged is left alone, the rewrite is refused and reported,
the new tag is pushed.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-30 17:20:29 -07:00

111 lines
5.4 KiB
YAML

name: Sync from GitHub
# git.hanzo.ai is canonical for this fork (the forge reports mirror=false — it is
# not a pull mirror of GitHub, it owns main); development also lands on
# github.com/hanzoai/git. ONE deterministic direction: an in-cluster PULL. The
# runner reaches both ends (GitHub outbound, this instance via the URL
# actions/checkout already uses), so the sync has no ingress dependency.
#
# Fast-forward ONLY: a divergence fails loudly here instead of force-pushing
# either side.
on:
schedule:
- cron: '*/10 * * * *'
workflow_dispatch: {}
concurrency:
group: sync-from-github
cancel-in-progress: false
jobs:
ff-main:
runs-on: [hanzo-build-linux-amd64]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: true
- name: Fast-forward main from github.com/hanzoai/git
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
git fetch --quiet "https://x-access-token:${GH_PAT}@github.com/hanzoai/git.git" main
LOCAL="$(git rev-parse HEAD)"; REMOTE="$(git rev-parse FETCH_HEAD)"
if [ "$LOCAL" = "$REMOTE" ]; then echo "in sync at $LOCAL"; exit 0; fi
if git merge-base --is-ancestor "$LOCAL" "$REMOTE"; then
echo "fast-forwarding $LOCAL -> $REMOTE"
git push origin "$REMOTE:refs/heads/main"
# A push made with the workflow token does not trigger workflows, so
# synced commits would never build. Dispatch the build explicitly.
curl -fsS --max-time 20 -X POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
"${{ github.server_url }}/v1/repos/${{ github.repository }}/actions/workflows/cicd.yml/dispatches" \
-d '{"ref":"main"}' \
&& echo "CI/CD dispatched" || echo "CI/CD dispatch failed (non-fatal — next direct push builds)"
elif git merge-base --is-ancestor "$REMOTE" "$LOCAL"; then
echo "canonical is AHEAD of GitHub — nothing to pull (never force from here)."
else
echo "::error::main DIVERGED between GitHub ($REMOTE) and canonical ($LOCAL) — refusing to force. Reconcile manually."
exit 1
fi
# Tags were not synced, only main. cicd.yml triggers a release build on
# `tags: ['v*']`, and that tag is created on GitHub — so it never arrived
# here, the trigger never fired, and no semver image was ever built. The
# forge sat at v1.26.24 while GitHub had v1.26.26: tagged, and not built.
# A version number that names no artifact is worse than no tag, because it
# reads as a release.
#
# Tags are immutable, so "fast-forward only" means: a tag we do not have is
# pushed, and a tag we do have that points somewhere else is a rewritten
# release — which must be loud, never silently adopted or silently ignored.
- name: Sync tags from github.com/hanzoai/git
env:
GH_PAT: ${{ secrets.GH_PAT }}
run: |
set -euo pipefail
# Each side is fetched into its own namespace rather than over
# refs/tags/*. Two reasons, both of which broke a draft of this:
#
# * A plain `fetch --tags` refuses to move a tag that already exists
# locally and fails the step — turning the very case this step must
# REPORT into a fetch error.
#
# * `git fetch` auto-follows tags that point at fetched objects even
# when given an explicit refspec, so refs/tags/ picks up GitHub's
# tags as a side effect. Comparing against refs/tags/ then finds the
# tag we just followed, calls a genuinely new tag unchanged, and
# never pushes it — the exact failure this step exists to fix,
# silently reintroduced. --no-tags turns that off, and reading the
# canonical side from its own namespace means the comparison does
# not depend on what checkout happened to leave behind either.
git fetch --quiet --prune --no-tags "https://x-access-token:${GH_PAT}@github.com/hanzoai/git.git" \
'+refs/tags/*:refs/gh-tags/*'
git fetch --quiet --prune --no-tags origin \
'+refs/tags/*:refs/canon-tags/*'
new=0 conflict=0
for ref in $(git for-each-ref --format='%(refname)' refs/gh-tags/); do
tag="${ref#refs/gh-tags/}"
# Compared unpeeled: for an annotated tag this is the tag object on
# both sides, so a tag is equal to itself and only a genuine rewrite
# differs.
theirs="$(git rev-parse "$ref")"
mine="$(git rev-parse -q --verify "refs/canon-tags/${tag}" || true)"
if [ -z "$mine" ]; then
echo "new tag ${tag} -> ${theirs}"
git push origin "${ref}:refs/tags/${tag}"
new=$((new+1))
elif [ "$mine" != "$theirs" ]; then
echo "::error::tag ${tag} is ${mine} here and ${theirs} on GitHub — a released tag was rewritten. Refusing to move it."
conflict=$((conflict+1))
fi
done
echo "${new} tag(s) pushed"
if [ "$conflict" -ne 0 ]; then
echo "::error::${conflict} tag(s) disagree between GitHub and canonical — reconcile manually."
exit 1
fi