From ffe77cdba9a0a3511525416ac5d3ce8fbd8a0792 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 28 Oct 2026 13:12:22 +0100 Subject: [PATCH] feat(ci): mirror the source repository's main into a fork on every push bf25eb2fc7 removed the sync-main job because the fork had no token that may push .github/workflows/**: GITHUB_TOKEN cannot be granted the workflows permission, and the bot App is installed on infinito-nexus/core only. Since then a fork's main only moves when its owner syncs it by hand, and the fork's main has fallen 368 commits behind. The job is back with a token of its own: CI_SYNC_MAIN_TOKEN, a fine-grained personal access token limited to the fork, with Contents and Workflows write. main_from_repository.sh resolves the source through baseline_remote.sh, so the normalization and the off switch exist once: in infinito-nexus/core itself and with CI_SYNC_MAIN_SOURCE_REPOSITORY disabled the job ends green without reading the secret. Everywhere else a missing secret fails the job before any fetch or push and names the page that explains how to create it. The token reaches git as an HTTP header through GIT_CONFIG_* variables, never through argv or .git/config; checkout runs without persisted credentials so its own header does not compete. The job runs beside run-policy, not before it: CI keeps fetching its diff baseline from the source repository read-only, so a failed sync turns the run red without holding CI back. fix/ci-mirror-token tried the bot App instead (019fb572a7, 756b180f1d). That needs the App installed on every fork and granted Workflows write, which secrets.md rules out for the App, so the token here is a per-fork PAT. Checked: the script skips as infinito-nexus/core and with the sync disabled, and fails before fetching as a fork without the secret. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/entry-push-latest.yml | 21 ++++++++++++ .../tools/github/actions/secrets.md | 21 ++++++++++++ scripts/github/README.md | 1 + scripts/github/sync/README.md | 5 +++ scripts/github/sync/main_from_repository.sh | 34 +++++++++++++++++++ 5 files changed, 82 insertions(+) create mode 100644 scripts/github/sync/README.md create mode 100755 scripts/github/sync/main_from_repository.sh diff --git a/.github/workflows/entry-push-latest.yml b/.github/workflows/entry-push-latest.yml index 59070de57b..72c1ca9e52 100644 --- a/.github/workflows/entry-push-latest.yml +++ b/.github/workflows/entry-push-latest.yml @@ -19,6 +19,27 @@ permissions: contents: read jobs: + sync-main: + name: 🔄 Sync main + runs-on: ubuntu-26.04 + permissions: + contents: read + steps: + - name: ⬇️ Checkout repository + uses: actions/checkout@v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: 🔄 Sync main from source repository + shell: bash + env: + CI_SYNC_MAIN_TOKEN: ${{ secrets.CI_SYNC_MAIN_TOKEN }} + CI_SYNC_MAIN_SOURCE_REPOSITORY_DEFAULT: infinito-nexus/core + CI_SYNC_MAIN_SOURCE_REPOSITORY: ${{ vars.CI_SYNC_MAIN_SOURCE_REPOSITORY }} + CI_SYNC_MAIN_SOURCE_REPOSITORY_IS_SET: ${{ contains(toJSON(vars), '"CI_SYNC_MAIN_SOURCE_REPOSITORY"') }} + run: bash scripts/github/sync/main_from_repository.sh + run-policy: name: 🧭 Decide CI policy runs-on: ubuntu-26.04 diff --git a/docs/contributing/tools/github/actions/secrets.md b/docs/contributing/tools/github/actions/secrets.md index 54cefb8974..25a0d0398a 100644 --- a/docs/contributing/tools/github/actions/secrets.md +++ b/docs/contributing/tools/github/actions/secrets.md @@ -15,6 +15,27 @@ Repository secrets MUST be set under **Settings → Secrets and variables → Ac | `API_OPENROUTER_KEY` | [call-test-deploy.yml](../../../../../.github/workflows/call-test-deploy.yml) | Publishes `openrouter/auto`. | Never. | | `DOCKERHUB_USERNAME` | [cron-images-mirror-all.yml](../../../../../.github/workflows/cron-images-mirror-all.yml), [call-images-mirror-missing.yml](../../../../../.github/workflows/call-images-mirror-missing.yml) | Authenticates the mirror job against Docker Hub, which raises the anonymous pull-rate limit the mirror would otherwise hit. | Never. Both workflows declare the secret `required: false` and gate the login step on both values being non-empty. | | `DOCKERHUB_TOKEN` | [cron-images-mirror-all.yml](../../../../../.github/workflows/cron-images-mirror-all.yml), [call-images-mirror-missing.yml](../../../../../.github/workflows/call-images-mirror-missing.yml) | The access token paired with `DOCKERHUB_USERNAME`. | Never, and only together with the username. | +| `CI_SYNC_MAIN_TOKEN` | [entry-push-latest.yml](../../../../../.github/workflows/entry-push-latest.yml) | Fine-grained token the `🔄 Sync main` job pushes the source repository's `main` with. | In every repository whose `CI_SYNC_MAIN_SOURCE_REPOSITORY` names another repository, which a fork does by default. | + +## `CI_SYNC_MAIN_TOKEN` 🔄 + +### How to create the token 🛠️ + +1. Open **Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new token** on the account that owns the fork. +2. Set **Resource owner** to that account and **Repository access** to **Only select repositories**, selecting the fork alone. +3. Grant these repository permissions and nothing else: + - `Contents`: **Read and write** (force-push of `main`). + - `Workflows`: **Read and write** (the pushed `main` carries `.github/workflows/**`). + - `Metadata`: **Read** (default, cannot be removed). +4. Choose an expiration date and note it; an expired token fails the job like a missing one. +5. In the fork, add it under **Settings → Secrets and variables → Actions → Secrets → New repository secret** as `CI_SYNC_MAIN_TOKEN`. + +### Failure mode when the secret is missing 💥 + +| Repository | Behaviour | +|---|---| +| `infinito-nexus/core`, or a repository with the sync disabled through `CI_SYNC_MAIN_SOURCE_REPOSITORY` | The job prints that the sync is skipped and succeeds. The secret is not read. | +| Any other repository | [main_from_repository.sh](../../../../../scripts/github/sync/main_from_repository.sh) fails before any fetch or push and names the secret. The run is marked failed; the other jobs of the run are unaffected. | ## `API_OPENAI_KEY`, `API_ANTHROPIC_KEY`, `API_OPENROUTER_KEY` 🤖 diff --git a/scripts/github/README.md b/scripts/github/README.md index d3e2d3eb9b..8812ce88cf 100644 --- a/scripts/github/README.md +++ b/scripts/github/README.md @@ -12,6 +12,7 @@ This directory holds the shell helpers that GitHub Actions workflows under [`.gi | [resolve/](resolve/) | Derivation of structured workflow inputs and outputs from repository state. | | [update/](update/) | The scheduled `🔄 Update versions` pipeline (version bumps and PR open/refresh). | | [release/](release/) | Release-time gates that decide whether a tagged commit may be released. | +| [sync/](sync/) | Mirroring the source repository's `main` into a fork on every push. | A new helper MUST live in the subfolder that matches its responsibility. New responsibilities MAY motivate a new subfolder, but MUST NOT flatten back into the top level. diff --git a/scripts/github/sync/README.md b/scripts/github/sync/README.md new file mode 100644 index 0000000000..81f7e8bc51 --- /dev/null +++ b/scripts/github/sync/README.md @@ -0,0 +1,5 @@ +# GitHub Sync Helpers 🔄 + +[main_from_repository.sh](main_from_repository.sh) force-pushes the source repository's `main` onto the current repository's `main`. The `🔄 Sync main` job of [entry-push-latest.yml](../../../.github/workflows/entry-push-latest.yml) runs it on every push. + +The push needs the secret `CI_SYNC_MAIN_TOKEN`, see [secrets.md](../../../docs/contributing/tools/github/actions/secrets.md). The source repository and the switch to disable the sync are the repository variable `CI_SYNC_MAIN_SOURCE_REPOSITORY`, see [configuration.md](../../../docs/contributing/tools/github/actions/configuration.md). diff --git a/scripts/github/sync/main_from_repository.sh b/scripts/github/sync/main_from_repository.sh new file mode 100755 index 0000000000..b282776870 --- /dev/null +++ b/scripts/github/sync/main_from_repository.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# +# Env: +# GITHUB_REPOSITORY - owner/repo of the running job, whose main is overwritten +# CI_SYNC_MAIN_TOKEN - fine-grained token for GITHUB_REPOSITORY only, with Contents and Workflows write +# CI_SYNC_MAIN_SOURCE_REPOSITORY, CI_SYNC_MAIN_SOURCE_REPOSITORY_IS_SET, +# CI_SYNC_MAIN_SOURCE_REPOSITORY_DEFAULT - the source of main, see scripts/meta/resolve/baseline_remote.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" + +: "${GITHUB_REPOSITORY:?Missing GITHUB_REPOSITORY}" + +source_remote="$(bash "${REPO_ROOT}/scripts/meta/resolve/baseline_remote.sh")" +if [[ "${source_remote}" == "origin" ]]; then + echo "Main sync skipped: no source repository other than ${GITHUB_REPOSITORY} is configured." + exit 0 +fi + +if [[ -z "${CI_SYNC_MAIN_TOKEN:-}" ]]; then + echo "ERROR: secret CI_SYNC_MAIN_TOKEN is not set, so ${GITHUB_REPOSITORY}:main cannot be synced from ${source_remote}." >&2 + echo "Create it as described in docs/contributing/tools/github/actions/secrets.md, or disable the sync through the repository variable CI_SYNC_MAIN_SOURCE_REPOSITORY." >&2 + exit 1 +fi + +echo "Syncing ${GITHUB_REPOSITORY}:main from ${source_remote}." +git fetch "${source_remote}" main:refs/remotes/main-sync-source/main --force + +auth="$(printf 'x-access-token:%s' "${CI_SYNC_MAIN_TOKEN}" | base64 -w0)" +GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0="http.https://github.com/.extraheader" \ + GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${auth}" \ + git push "https://github.com/${GITHUB_REPOSITORY}.git" refs/remotes/main-sync-source/main:refs/heads/main --force