diff --git a/.github/workflows/develop-sync.yml b/.github/workflows/develop-sync.yml new file mode 100644 index 0000000000000..29fca2b4d616c --- /dev/null +++ b/.github/workflows/develop-sync.yml @@ -0,0 +1,43 @@ +name: Sync develop with master + +# Runs after a final release is published to master and opens a PR syncing +# develop with master. Version-bump conflicts are resolved in develop's favour +# and the PR is set to auto-merge; changelog/code conflicts open a review PR. +on: + workflow_run: + workflows: ['Publish Final Release'] + types: [completed] + +concurrency: ${{ github.workflow }} + +env: + HUSKY: 0 + +jobs: + sync: + name: Sync develop + if: ${{ github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-24.04 + steps: + - name: Checkout Repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + token: ${{ secrets.CI_PAT }} + + - name: Setup NodeJS + uses: ./.github/actions/setup-node + with: + cache-modules: true + install: true + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Build release action + run: yarn workspace @rocket.chat/release-action build + + - name: Sync develop with master + uses: ./packages/release-action + with: + action: sync-develop + env: + GITHUB_TOKEN: ${{ secrets.CI_PAT }} diff --git a/packages/release-action/action.yml b/packages/release-action/action.yml index 23d7382aab6d8..f0282ce6e757c 100644 --- a/packages/release-action/action.yml +++ b/packages/release-action/action.yml @@ -3,7 +3,7 @@ description: Action to cut and publish releases using changesets inputs: action: - description: "The main action to perform: publish, publish-final, bump or patch" + description: "The main action to perform: publish, publish-final, bump, patch or sync-develop" required: true base-ref: description: "Base ref to use for the release" diff --git a/packages/release-action/src/gitUtils.ts b/packages/release-action/src/gitUtils.ts index 702e5d0060283..ab7ec0f9f43ea 100644 --- a/packages/release-action/src/gitUtils.ts +++ b/packages/release-action/src/gitUtils.ts @@ -37,6 +37,68 @@ export async function pushChanges() { await exec('git', ['push', '--follow-tags']); } +export async function fetchRefs(refs: string[]) { + await exec('git', ['fetch', 'origin', ...refs]); +} + +// reset/create a local branch pointing at the given ref (e.g. origin/develop) +export async function resetBranchTo(branch: string, ref: string) { + await exec('git', ['checkout', '-B', branch, ref]); +} + +// start a merge but leave it uncommitted so conflicts can be inspected/resolved. +// returns true when the merge applied cleanly, false when there are conflicts. +export async function mergeNoCommit(ref: string): Promise { + const code = await exec('git', ['merge', '--no-commit', '--no-ff', ref], { ignoreReturnCode: true }); + return code === 0; +} + +export async function abortMerge() { + await exec('git', ['merge', '--abort']); +} + +export async function getConflictedFiles(): Promise { + const { stdout } = await getExecOutput('git', ['diff', '--name-only', '--diff-filter=U']); + + return stdout + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); +} + +// resolve a conflicted file by keeping our side (the branch we merge into) +export async function checkoutOurs(file: string) { + await exec('git', ['checkout', '--ours', '--', file]); +} + +export async function addFiles(files: string[]) { + if (files.length === 0) { + return; + } + + await exec('git', ['add', '--', ...files]); +} + +// true when `ancestor` is reachable from `descendant` (nothing to sync) +export async function isAncestor(ancestor: string, descendant: string): Promise { + const code = await exec('git', ['merge-base', '--is-ancestor', ancestor, descendant], { ignoreReturnCode: true }); + + return code === 0; +} + +export async function getShortSha(ref: string): Promise { + const { stdout } = await getExecOutput('git', ['rev-parse', '--short', ref]); + + return stdout.trim(); +} + +// contents of a file at a given ref (e.g. origin/master:package.json) or merge stage (e.g. :2:CHANGELOG.md) +export async function showFile(ref: string): Promise { + const { stdout } = await getExecOutput('git', ['show', ref]); + + return stdout; +} + export async function pushNewBranch(newBranch: string, force = false) { const params = ['push']; diff --git a/packages/release-action/src/index.ts b/packages/release-action/src/index.ts index 53f82641b0ba6..21824b66a0134 100644 --- a/packages/release-action/src/index.ts +++ b/packages/release-action/src/index.ts @@ -7,6 +7,7 @@ import { bumpNextVersion } from './bumpNextVersion'; import { setupGitUser } from './gitUtils'; import { publishRelease } from './publishRelease'; import { startPatchRelease } from './startPatchRelease'; +import { syncDevelopWithMaster } from './syncDevelop'; import { updatePRDescription } from './updatePRDescription'; // const getOptionalInput = (name: string) => core.getInput(name) || undefined; @@ -48,6 +49,8 @@ import { updatePRDescription } from './updatePRDescription'; await startPatchRelease({ baseRef, githubToken, mainPackagePath }); } else if (action === 'update-pr-description') { await updatePRDescription({ githubToken, mainPackagePath }); + } else if (action === 'sync-develop') { + await syncDevelopWithMaster({ githubToken }); } })().catch((err) => { core.error(err); diff --git a/packages/release-action/src/syncDevelop.ts b/packages/release-action/src/syncDevelop.ts new file mode 100644 index 0000000000000..6c3020380e7d7 --- /dev/null +++ b/packages/release-action/src/syncDevelop.ts @@ -0,0 +1,240 @@ +import { writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import * as core from '@actions/core'; +import { getExecOutput } from '@actions/exec'; +import * as github from '@actions/github'; + +import { + abortMerge, + addFiles, + checkoutOurs, + commitChanges, + fetchRefs, + getConflictedFiles, + getShortSha, + isAncestor, + mergeNoCommit, + pushNewBranch, + resetBranchTo, + showFile, +} from './gitUtils'; +import { setupOctokit } from './setupOctokit'; +import { getUpdateFilesList } from './utils'; + +// Every sync PR title starts with this conventional prefix so it satisfies the +// repo's PR title rules (see .github/PULL_REQUEST_TEMPLATE.md). A branch sync is +// housekeeping with no end-user changelog impact, hence `chore:`. +const TITLE_PREFIX = 'chore: sync develop with master'; + +const SOURCE = 'master'; +const TARGET = 'develop'; + +type Classification = { + versionFiles: string[]; + changelogs: string[]; + other: string[]; +}; + +function classifyConflicts(conflicts: string[], extraVersionFiles: string[]): Classification { + const versionFiles: string[] = []; + const changelogs: string[] = []; + const other: string[] = []; + + for (const file of conflicts) { + const base = path.basename(file); + + if (base === 'package.json' || extraVersionFiles.includes(file)) { + versionFiles.push(file); + } else if (base === 'CHANGELOG.md') { + changelogs.push(file); + } else { + other.push(file); + } + } + + return { versionFiles, changelogs, other }; +} + +// Resolve a conflicted CHANGELOG.md with a lossless union of both sides so no +// release notes are dropped. Ordering still needs a human eye, which is why the +// changelog tier always opens a review PR rather than auto-merging. +async function unionMergeChangelog(file: string) { + const stage = async (n: number) => { + const tmp = path.join(os.tmpdir(), `changelog-${n}-${path.basename(file)}`); + await writeFile(tmp, await showFile(`:${n}:${file}`), 'utf8'); + return tmp; + }; + + const [base, ours, theirs] = await Promise.all([stage(1), stage(2), stage(3)]); + + // `git merge-file -p --union` prints the union of ours+theirs (relative to base) to stdout + const { stdout } = await getExecOutput('git', ['merge-file', '-p', '--union', ours, base, theirs]); + + await writeFile(file, stdout, 'utf8'); +} + +export async function syncDevelopWithMaster({ githubToken, cwd = process.cwd() }: { githubToken: string; cwd?: string }) { + const octokit = setupOctokit(githubToken); + + await fetchRefs([SOURCE, TARGET]); + + if (await isAncestor(`origin/${SOURCE}`, `origin/${TARGET}`)) { + core.info(`origin/${TARGET} already contains origin/${SOURCE}; nothing to sync.`); + return; + } + + // the released version, read from master's root package.json — used for titles + const { version } = JSON.parse(await showFile(`origin/${SOURCE}:package.json`)); + const shortSha = await getShortSha(`origin/${SOURCE}`); + const syncBranch = `sync/master-to-develop-${shortSha}`; + + // the non-package.json version files (e.g. apps/meteor/app/utils/rocketchat.info) + const extraVersionFiles = (await getUpdateFilesList(cwd)).filter((file) => path.basename(file) !== 'package.json'); + + // branch off develop, then merge master into it + await resetBranchTo(syncBranch, `origin/${TARGET}`); + await mergeNoCommit(`origin/${SOURCE}`); + + const conflicts = await getConflictedFiles(); + const { versionFiles, changelogs, other } = classifyConflicts(conflicts, extraVersionFiles); + + // Tier 3: real code conflicts — we can't safely resolve. Open a manual PR with + // master as-is so a human resolves everything in the PR. + if (other.length > 0) { + core.warning(`Unresolvable conflicts require manual review: ${other.join(', ')}`); + + await abortMerge(); + await resetBranchTo(syncBranch, `origin/${SOURCE}`); + await pushNewBranch(syncBranch, true); + + await ensurePullRequest({ + octokit, + head: syncBranch, + title: `${TITLE_PREFIX} (${version}) — manual resolution required`, + body: manualBody(version, other), + autoMerge: false, + }); + return; + } + + // resolve the predictable conflicts: version lines keep develop's side + for (const file of versionFiles) { + await checkoutOurs(file); + } + for (const file of changelogs) { + await unionMergeChangelog(file); + } + await addFiles([...versionFiles, ...changelogs]); + + await commitChanges(`${TITLE_PREFIX} (${version})`); + await pushNewBranch(syncBranch, true); + + // Tier 2: changelog conflicts were auto-resolved but ordering needs review → no auto-merge. + if (changelogs.length > 0) { + await ensurePullRequest({ + octokit, + head: syncBranch, + title: `${TITLE_PREFIX} (${version}) — review changelog`, + body: changelogBody(version, changelogs), + autoMerge: false, + }); + return; + } + + // Tier 1: only version lines conflicted (or a clean merge) → safe to auto-merge. + await ensurePullRequest({ + octokit, + head: syncBranch, + title: `${TITLE_PREFIX} (${version})`, + body: cleanBody(version), + autoMerge: true, + }); +} + +async function ensurePullRequest({ + octokit, + head, + title, + body, + autoMerge, +}: { + octokit: ReturnType; + head: string; + title: string; + body: string; + autoMerge: boolean; +}) { + const { owner, repo } = github.context.repo; + + const { data: existing } = await octokit.rest.pulls.list({ + owner, + repo, + state: 'open', + base: TARGET, + head: `${owner}:${head}`, + }); + + let nodeId: string; + + if (existing[0]) { + core.info(`Updating existing sync PR #${existing[0].number}`); + await octokit.rest.pulls.update({ owner, repo, pull_number: existing[0].number, title, body }); + nodeId = existing[0].node_id; + } else { + core.info('Creating sync PR'); + const { data } = await octokit.rest.pulls.create({ owner, repo, base: TARGET, head, title, body }); + nodeId = data.node_id; + } + + if (autoMerge) { + await enableAutoMerge(octokit, nodeId); + } +} + +async function enableAutoMerge(octokit: ReturnType, pullRequestId: string) { + try { + await octokit.graphql( + `mutation($id: ID!) { + enablePullRequestAutoMerge(input: { pullRequestId: $id, mergeMethod: MERGE }) { + pullRequest { number } + } + }`, + { id: pullRequestId }, + ); + } catch (err) { + // auto-merge may be disabled on the repo or the PR may already be mergeable; don't fail the sync + core.warning(`Could not enable auto-merge: ${(err as Error).message}`); + } +} + +function cleanBody(version: string) { + return [ + `Automated sync of \`${TARGET}\` with \`${SOURCE}\` after releasing **${version}**.`, + '', + 'Only version-bump conflicts were found; they were resolved in favour of `develop`. Auto-merge is enabled.', + ].join('\n'); +} + +function changelogBody(version: string, changelogs: string[]) { + return [ + `Automated sync of \`${TARGET}\` with \`${SOURCE}\` after releasing **${version}**.`, + '', + 'Version-bump conflicts were resolved in favour of `develop`. The following changelog files conflicted and were', + 'union-merged automatically — **please review the ordering of entries before merging**:', + '', + ...changelogs.map((file) => `- \`${file}\``), + ].join('\n'); +} + +function manualBody(version: string, other: string[]) { + return [ + `Automated sync of \`${TARGET}\` with \`${SOURCE}\` after releasing **${version}**.`, + '', + 'This sync could not be resolved automatically because of conflicts in files beyond the expected version bumps.', + '**Resolve the conflicts manually before merging.** Conflicting files:', + '', + ...other.map((file) => `- \`${file}\``), + ].join('\n'); +} diff --git a/packages/release-action/src/utils.ts b/packages/release-action/src/utils.ts index 271fb729494b9..9c0cdfac0dc27 100644 --- a/packages/release-action/src/utils.ts +++ b/packages/release-action/src/utils.ts @@ -66,7 +66,7 @@ export async function readPackageJson(cwd: string) { return JSON.parse(await readFile(filePath, 'utf-8')); } -async function getUpdateFilesList(cwd: string): Promise { +export async function getUpdateFilesList(cwd: string): Promise { const file = await readPackageJson(cwd); if (!file.houston) { return [];