Repository navigation
Upstream Stable Sync #154
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Upstream Stable Sync | |
| on: | |
| schedule: | |
| - cron: '17 * * * *' | |
| workflow_dispatch: | |
| inputs: | |
| upstream_tag: | |
| description: 'Optional exact official stable tag; empty resolves latest stable' | |
| required: false | |
| default: '' | |
| type: string | |
| auto_promote: | |
| description: 'For safe candidates only: merge checked PR, release and queue production approval' | |
| required: true | |
| default: true | |
| type: boolean | |
| permissions: | |
| actions: write | |
| contents: write | |
| pull-requests: write | |
| issues: write | |
| concurrency: | |
| group: upstream-stable-sync | |
| cancel-in-progress: false | |
| jobs: | |
| sync: | |
| runs-on: ubuntu-latest | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| UPSTREAM_REPOSITORY: Wei-Shaw/sub2api | |
| steps: | |
| - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 | |
| with: | |
| fetch-depth: 0 | |
| ref: main | |
| - name: Test the trusted risk classifier | |
| run: | | |
| python3 .github/scripts/test_upstream_risk.py | |
| python3 .github/scripts/test_upstream_automation_contract.py | |
| - name: Resolve latest stable release | |
| id: release | |
| env: | |
| REQUESTED_TAG: ${{ inputs.upstream_tag }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if [[ -n "$REQUESTED_TAG" ]]; then | |
| [[ "$REQUESTED_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] | |
| payload=$(gh api "repos/${UPSTREAM_REPOSITORY}/releases/tags/${REQUESTED_TAG}") | |
| else | |
| payload=$(gh api "repos/${UPSTREAM_REPOSITORY}/releases/latest") | |
| fi | |
| tag=$(jq -er 'select(.draft == false and .prerelease == false) | .tag_name' <<<"$payload") | |
| [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] | |
| current=$(tr -d '\r\n' < .downstream/upstream-base) | |
| [[ "$current" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] | |
| [[ "$(printf '%s\n%s\n' "$current" "$tag" | sort -V | head -n1)" == "$current" ]] | |
| echo "tag=$tag" >> "$GITHUB_OUTPUT" | |
| echo "base=$current" >> "$GITHUB_OUTPUT" | |
| [[ "$tag" == "$current" ]] && echo 'changed=false' >> "$GITHUB_OUTPUT" || echo 'changed=true' >> "$GITHUB_OUTPUT" | |
| - name: Analyze and merge release into candidate branch | |
| if: steps.release.outputs.changed == 'true' | |
| id: candidate | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| tag='${{ steps.release.outputs.tag }}' | |
| base='${{ steps.release.outputs.base }}' | |
| branch="sync/upstream-${tag#v}" | |
| git config user.name 'github-actions[bot]' | |
| git config user.email '41898282+github-actions[bot]@users.noreply.github.com' | |
| git remote add upstream "https://github.com/${UPSTREAM_REPOSITORY}.git" 2>/dev/null || true | |
| git fetch upstream "refs/tags/${base}:refs/tags/${base}" "refs/tags/${tag}:refs/tags/${tag}" --force | |
| python3 .github/scripts/upstream_risk.py \ | |
| --repo . --base "$base" --tag "$tag" \ | |
| --output .downstream/upstream-risk.json --github-output "$GITHUB_OUTPUT" | |
| old_remote=$(git ls-remote --heads origin "refs/heads/${branch}" | awk '{print $1}') | |
| if [[ -n "$old_remote" ]]; then | |
| echo "Candidate $branch already exists; preserve manual resolutions and require Upstream Reviewed Promotion." >&2 | |
| printf '## Upstream %s candidate already exists\\n\\n' "$tag" > /tmp/upstream-conflict.md | |
| printf 'The sync workflow did not overwrite the existing candidate branch.\\n\\n' >> /tmp/upstream-conflict.md | |
| printf 'Inspect and resolve the existing candidate PR, then run Upstream Reviewed Promotion.\\n' >> /tmp/upstream-conflict.md | |
| python3 -c 'import json; d=json.load(open(".downstream/upstream-risk.json")); d["merge_conflicts"]=["existing_candidate_branch"]; print(json.dumps(d,indent=2))' > /tmp/upstream-conflict.json | |
| echo 'conflict=true' >> "$GITHUB_OUTPUT" | |
| echo "branch=$branch" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| git switch -C "$branch" origin/main | |
| set +e | |
| git merge --no-ff --no-commit "$tag" | |
| merge_rc=$? | |
| set -e | |
| if ((merge_rc != 0)); then | |
| mapfile -t conflict_files < <(git diff --name-only --diff-filter=U | LC_ALL=C sort -u) | |
| git merge --abort || true | |
| { | |
| printf '## Upstream %s requires manual conflict resolution\n\n' "$tag" | |
| printf 'The hourly sync stopped without changing `main`.\n\n### Conflicted paths\n\n' | |
| if ((${#conflict_files[@]} == 0)); then | |
| printf -- '- Merge failed before Git reported conflicted paths.\n' | |
| else | |
| printf -- '- `%s`\n' "${conflict_files[@]}" | |
| fi | |
| } > /tmp/upstream-conflict.md | |
| python3 -c 'import json,sys; d=json.load(open(".downstream/upstream-risk.json")); d["merge_conflicts"]=sys.argv[1:]; print(json.dumps(d,indent=2))' "${conflict_files[@]}" > /tmp/upstream-conflict.json | |
| echo 'conflict=true' >> "$GITHUB_OUTPUT" | |
| echo "branch=$branch" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| printf '%s\n' "$tag" > .downstream/upstream-base | |
| git add .downstream/upstream-base .downstream/upstream-risk.json | |
| git commit -m "chore: merge upstream ${tag}" | |
| if [[ -n "$old_remote" ]]; then | |
| git push --force-with-lease="refs/heads/${branch}:${old_remote}" origin "HEAD:refs/heads/${branch}" | |
| else | |
| git push origin "HEAD:refs/heads/${branch}" | |
| fi | |
| echo 'conflict=false' >> "$GITHUB_OUTPUT" | |
| echo "branch=$branch" >> "$GITHUB_OUTPUT" | |
| - name: Create or update conflict issue | |
| if: steps.release.outputs.changed == 'true' && steps.candidate.outputs.conflict == 'true' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| tag='${{ steps.release.outputs.tag }}' | |
| title="Upstream ${tag} merge conflicts" | |
| gh label create --repo "$GITHUB_REPOSITORY" upstream-review-required --color B60205 --description 'Manual upstream review is required' --force | |
| issue=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "${title} in:title" --json number,title --jq ".[] | select(.title == \"${title}\") | .number" | head -n1) | |
| if [[ -n "$issue" ]]; then | |
| gh issue edit "$issue" --repo "$GITHUB_REPOSITORY" --body-file /tmp/upstream-conflict.md --add-label upstream-review-required | |
| else | |
| gh issue create --repo "$GITHUB_REPOSITORY" --title "$title" --body-file /tmp/upstream-conflict.md --label upstream-review-required | |
| fi | |
| - name: Create or update candidate pull request | |
| if: steps.release.outputs.changed == 'true' && steps.candidate.outputs.conflict == 'false' | |
| env: | |
| AUTO_PROMOTE: ${{ github.event_name == 'schedule' || inputs.auto_promote }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| branch='${{ steps.candidate.outputs.branch }}' | |
| tag='${{ steps.release.outputs.tag }}' | |
| risk='${{ steps.candidate.outputs.risk_class }}' | |
| overlap='${{ steps.candidate.outputs.overlap_file_count }}' | |
| critical='${{ steps.candidate.outputs.critical_file_count }}' | |
| gh label create --repo "$GITHUB_REPOSITORY" upstream-safe-candidate --color 0E8A16 --description 'Eligible for checked auto-merge' --force | |
| gh label create --repo "$GITHUB_REPOSITORY" upstream-review-required --color B60205 --description 'Manual upstream review is required' --force | |
| gh label create --repo "$GITHUB_REPOSITORY" upstream-reviewed --color 5319E7 --description 'Human upstream overlap review completed' --force | |
| body=$(mktemp) | |
| cat >"$body" <<EOF | |
| Stable upstream candidate for **${tag}**. | |
| - Risk class: \`${risk}\` | |
| - Downstream overlap files: \`${overlap}\` | |
| - Critical upstream paths: \`${critical}\` | |
| - Full machine-readable report: [\`.downstream/upstream-risk.json\`](https://github.com/${GITHUB_REPOSITORY}/blob/${branch}/.downstream/upstream-risk.json) | |
| Production remains protected by the \`production\` Environment. This PR cannot deploy directly. | |
| EOF | |
| pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --head "$branch" --json number --jq '.[0].number // empty') | |
| if [[ -z "$pr" ]]; then | |
| gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$branch" --title "chore: merge upstream ${tag}" --body-file "$body" | |
| pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --head "$branch" --json number --jq '.[0].number') | |
| else | |
| gh pr edit "$pr" --repo "$GITHUB_REPOSITORY" --title "chore: merge upstream ${tag}" --body-file "$body" | |
| fi | |
| gh pr edit "$pr" --repo "$GITHUB_REPOSITORY" --remove-label upstream-safe-candidate --remove-label upstream-review-required 2>/dev/null || true | |
| if [[ "$risk" == safe ]]; then | |
| gh pr edit "$pr" --repo "$GITHUB_REPOSITORY" --add-label upstream-safe-candidate | |
| gh pr merge "$pr" --repo "$GITHUB_REPOSITORY" --auto --merge | |
| else | |
| gh pr merge "$pr" --repo "$GITHUB_REPOSITORY" --disable-auto 2>/dev/null || true | |
| gh pr edit "$pr" --repo "$GITHUB_REPOSITORY" --add-label upstream-review-required | |
| fi | |
| # Bot-created pushes cannot be relied on to trigger downstream workflows. | |
| for workflow in backend-ci.yml security-scan.yml downstream-verify.yml; do | |
| gh workflow run "$workflow" --repo "$GITHUB_REPOSITORY" --ref "$branch" | |
| done | |
| gh workflow run upstream-risk-gate.yml --repo "$GITHUB_REPOSITORY" --ref "$branch" -f pr_number="$pr" | |
| if [[ "$risk" == safe && "$AUTO_PROMOTE" == true ]]; then | |
| gh workflow run upstream-promote.yml --repo "$GITHUB_REPOSITORY" --ref main \ | |
| -f pr_number="$pr" -f confirmation=PROMOTE | |
| else | |
| echo "Candidate PR #$pr requires review; use Upstream Reviewed Promotion after resolution." | |
| fi | |
| - name: Upload conflict report | |
| if: always() && steps.candidate.outputs.conflict == 'true' | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| with: | |
| name: upstream-${{ steps.release.outputs.tag }}-merge-conflicts | |
| path: | | |
| /tmp/upstream-conflict.md | |
| /tmp/upstream-conflict.json | |
| if-no-files-found: error | |
| retention-days: 30 |