Sync upstream release #8
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
| --- | |
| # Requires: | |
| # * Settings -> Actions -> General -> | |
| # "Allow GitHub Actions to create and approve pull requests". | |
| # * A repo secret SYNC_TOKEN holding a PAT with the `workflow` scope | |
| name: Sync upstream release | |
| on: # yamllint disable-line rule:truthy | |
| schedule: | |
| # Mondays 06:00 UTC | |
| - cron: '0 6 * * 1' | |
| workflow_dispatch: | |
| env: | |
| UPSTREAM_REPO: drakkan/sftpgo | |
| TARGET_BRANCH: main | |
| # The only workflows this fork runs. Everything else upstream ships (their | |
| # CI, Windows/arm64 builds, their docker and release pipelines) is deleted | |
| # on every sync, so an upstream workflow can never start running here | |
| # unnoticed. Extended regex, anchored to .github/workflows/<name>. | |
| KEEP_WORKFLOWS: 'build-image\.yaml|sync-upstream\.yaml' | |
| jobs: | |
| sync: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| ref: ${{ env.TARGET_BRANCH }} | |
| fetch-depth: 0 | |
| # PAT with `workflow` scope so the push may include upstream | |
| # changes to .github/workflows/*. Persisted for the git push step. | |
| token: ${{ secrets.SYNC_TOKEN }} | |
| - name: Configure git identity | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| - name: Determine latest upstream release | |
| id: upstream | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| TAG=$(gh release view --repo "${UPSTREAM_REPO}" --json tagName --jq .tagName) | |
| echo "tag=${TAG}" >> "$GITHUB_OUTPUT" | |
| echo "branch=upstream-sync/${TAG}" >> "$GITHUB_OUTPUT" | |
| echo "Latest upstream release: ${TAG}" | |
| - name: Merge upstream release onto sync branch | |
| id: merge | |
| env: | |
| UPSTREAM_TAG: ${{ steps.upstream.outputs.tag }} | |
| SYNC_BRANCH: ${{ steps.upstream.outputs.branch }} | |
| run: | | |
| set -euo pipefail | |
| git remote add upstream "https://github.com/${UPSTREAM_REPO}.git" | |
| git fetch upstream "refs/tags/${UPSTREAM_TAG}:refs/tags/${UPSTREAM_TAG}" | |
| # Nothing to do if the default branch already contains this release. | |
| # `--is-ancestor` exits 1 for "no" but 128 for a bad revision, so the | |
| # two cases are told apart instead of both meaning "not up to date". | |
| ANCESTOR_RC=0 | |
| git merge-base --is-ancestor "refs/tags/${UPSTREAM_TAG}" "origin/${TARGET_BRANCH}" || ANCESTOR_RC=$? | |
| if [ "${ANCESTOR_RC}" -eq 0 ]; then | |
| echo "up_to_date=true" >> "$GITHUB_OUTPUT" | |
| echo "Default branch already contains ${UPSTREAM_TAG}." | |
| exit 0 | |
| elif [ "${ANCESTOR_RC}" -ne 1 ]; then | |
| echo "::error::git merge-base failed (rc=${ANCESTOR_RC})" | |
| exit "${ANCESTOR_RC}" | |
| fi | |
| git checkout -B "${SYNC_BRANCH}" "origin/${TARGET_BRANCH}" | |
| MERGE_RC=0 | |
| git merge --no-edit "refs/tags/${UPSTREAM_TAG}" || MERGE_RC=$? | |
| if [ "${MERGE_RC}" -ne 0 ] && [ -z "$(git ls-files --unmerged)" ]; then | |
| # Merge failed for a reason other than conflicts (bad ref, dirty | |
| # tree, ...). Fail loudly rather than push a bogus branch. | |
| echo "::error::git merge failed without conflicts (rc=${MERGE_RC})" | |
| exit "${MERGE_RC}" | |
| fi | |
| # Delete every workflow upstream ships; this fork runs only its own. | |
| # Done before the conflict report so conflicts in workflows we drop | |
| # anyway are not raised to the reviewer. `sort -u` because during a | |
| # conflicted merge ls-files lists a path once per stage. | |
| DROPPED=$(git ls-files -- '.github/workflows/*.yml' '.github/workflows/*.yaml' \ | |
| | sort -u | grep -Ev "^\.github/workflows/(${KEEP_WORKFLOWS})$" || true) | |
| if [ -n "${DROPPED}" ]; then | |
| echo "Dropping upstream workflows:" | |
| echo "${DROPPED}" | |
| # -f is required to remove paths that are unmerged or modified. | |
| while IFS= read -r wf; do | |
| git rm -q -f -- "${wf}" | |
| done <<< "${DROPPED}" | |
| fi | |
| CONFLICTED=$(git ls-files --unmerged | awk '{print $4}' | sort -u | sed 's/^/- /') | |
| if [ -n "${CONFLICTED}" ]; then | |
| # Conflicted merge: commit the markers so the reviewer has a branch to fix. | |
| echo "conflicts=true" >> "$GITHUB_OUTPUT" | |
| { | |
| echo "conflicted_files<<EOF" | |
| echo "${CONFLICTED}" | |
| echo "EOF" | |
| } >> "$GITHUB_OUTPUT" | |
| else | |
| echo "conflicts=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| if [ "${MERGE_RC}" -ne 0 ]; then | |
| # `add -u`, not `add -A`: restricts staging to tracked and unmerged | |
| # paths, so untracked files in the runner's working tree can never | |
| # end up in the merge commit. | |
| git add -u | |
| git commit --no-edit | |
| elif [ -n "${DROPPED}" ]; then | |
| git commit -q -m "Drop upstream workflows not used by this fork" | |
| fi | |
| echo "up_to_date=false" >> "$GITHUB_OUTPUT" | |
| - name: Push sync branch | |
| if: steps.merge.outputs.up_to_date == 'false' | |
| env: | |
| # Via env, not a `${{ }}` inline expansion: the branch name is derived | |
| # from an upstream tag and must not be pasted into the shell. | |
| SYNC_BRANCH: ${{ steps.upstream.outputs.branch }} | |
| run: git push --force-with-lease origin "${SYNC_BRANCH}" | |
| - name: Open or update pull request | |
| if: steps.merge.outputs.up_to_date == 'false' | |
| env: | |
| # PAT (not GITHUB_TOKEN) so the PR triggers build-image's | |
| # pull_request check to confirm the merged upstream still builds. | |
| GH_TOKEN: ${{ secrets.SYNC_TOKEN }} | |
| UPSTREAM_TAG: ${{ steps.upstream.outputs.tag }} | |
| SYNC_BRANCH: ${{ steps.upstream.outputs.branch }} | |
| CONFLICTS: ${{ steps.merge.outputs.conflicts }} | |
| CONFLICTED_FILES: ${{ steps.merge.outputs.conflicted_files }} | |
| run: | | |
| set -euo pipefail | |
| { | |
| printf '%s\n' "Merges upstream release ${UPSTREAM_TAG} (${UPSTREAM_REPO}) into ${TARGET_BRANCH}." | |
| printf '%s\n' "" | |
| printf '%s\n' "Workflows upstream ships are deleted by the sync job; this fork runs only \`build-image.yaml\` and \`sync-upstream.yaml\`." | |
| printf '%s\n' "" | |
| if [ "${CONFLICTS}" = "true" ]; then | |
| printf '%s\n' "**This branch contains conflict markers and does NOT build.** Resolve it locally:" | |
| printf '%s\n' "" | |
| printf '%s\n' '```sh' | |
| printf '%s\n' "git fetch origin" | |
| printf '%s\n' "git checkout ${SYNC_BRANCH}" | |
| printf '%s\n' "git status # find conflicts, fix the <<<<<<< markers" | |
| printf '%s\n' "git add -u # -u, not -A: do not sweep in untracked files" | |
| printf '%s\n' "git commit" | |
| printf '%s\n' "git push" | |
| printf '%s\n' '```' | |
| printf '%s\n' "" | |
| printf '%s\n' "Conflicted files:" | |
| printf '%s\n' "${CONFLICTED_FILES}" | |
| printf '%s\n' "" | |
| printf '%s\n' "Keep this fork's custom changes intact while taking the upstream changes." | |
| else | |
| printf '%s\n' "No conflicts. Review the upstream changes, then merge." | |
| printf '%s\n' "After merge, publish a new Release to build the image." | |
| fi | |
| } > pr-body.md | |
| # Conflicted branches are opened as drafts: they carry conflict | |
| # markers and do not build, so they must not look mergeable. | |
| EXTRA_ARGS=() | |
| if [ "${CONFLICTS}" = "true" ]; then | |
| TITLE="Merge upstream release ${UPSTREAM_TAG} — conflicts, manual resolution needed" | |
| EXTRA_ARGS+=(--draft) | |
| else | |
| TITLE="Merge upstream release ${UPSTREAM_TAG}" | |
| fi | |
| # --repo pins gh to this repo (detached mode) so --head is treated as a | |
| # branch in swisscom/sftpgo, not a cross-fork ref. Without it, gh infers | |
| # the head from the PAT user and the fork relationship, producing | |
| # "Head ref must be a branch / Head sha can't be blank". | |
| OPEN=$(gh pr list --repo "${GITHUB_REPOSITORY}" --head "${SYNC_BRANCH}" --state open --json number --jq 'length') | |
| if [ "${OPEN}" != "0" ]; then | |
| # PR already open — the force-push above updated it; just refresh title/body. | |
| gh pr edit "${SYNC_BRANCH}" --repo "${GITHUB_REPOSITORY}" --title "${TITLE}" --body-file pr-body.md | |
| else | |
| gh pr create \ | |
| --repo "${GITHUB_REPOSITORY}" \ | |
| --base "${TARGET_BRANCH}" \ | |
| --head "${SYNC_BRANCH}" \ | |
| --title "${TITLE}" \ | |
| --body-file pr-body.md \ | |
| "${EXTRA_ARGS[@]}" | |
| fi |