Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions .github/workflows/keep-pr-current.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
name: Keep Pull Request Current

# Replaces the Mergify `update` rule that used to merge `develop` into open
# pull requests (see `.github/mergify.yml` and #3574).
#
# Why this is a workflow and not a Mergify rule: Mergify reports a `failure`
# check whenever it cannot perform the update, with no option to change that.
# The schema accepts only `bot_account` for the action
# (https://docs.mergify.com/mergify-configuration-schema.json, `$defs.UpdateActionModel`,
# `additionalProperties: false`), so the red check could only be avoided by
# never attempting an update. That outcome is not gated — the attempt itself is
# what produced the failures. This workflow performs the same merge through
# GitHub's `update-branch` API and reports a conflict as a comment, so the
# check is green whether or not the merge was possible.
#
# The up-to-date requirement is unchanged: `develop-branch-ruleset` still sets
# `strict_required_status_checks_policy`, so a branch that is behind `develop`
# and cannot be updated is still blocked from merging. Nothing here relaxes the
# ruleset, and this workflow is deliberately not a required check.
on:
push:
branches:
- develop
pull_request:
types:
- opened
- reopened
- ready_for_review
workflow_dispatch:

permissions:
contents: write
pull-requests: write

# One run per repository state. Queued behind any run already in flight so a
# burst of pushes to `develop` cannot have two runs merging the same branch.
concurrency:
group: keep-pr-current
cancel-in-progress: false

jobs:
keep-current:
name: Keep pull request current
runs-on: ubuntu-latest
timeout-minutes: 10

steps:
# Sparse checkout: the decision logic in scripts/ is all this needs, and
# no pull request code is ever checked out.
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 1
sparse-checkout: |
scripts/automation

- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: '.nvmrc'

- name: Merge develop into open pull requests
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
// Relative specifier, matching the other workflows here
// (ai-feedback-validation.yml, orchestrate-phase-progression.yml).
const {
CONFLICT_COMMENT_MARKER,
buildConflictComment,
classifyUpdateResult,
normaliseCommentBody,
shouldAttemptUpdate,
} = require('./scripts/automation/keep-pr-current.cjs');

const { owner, repo } = context.repo;
const log = [];

// A pull_request event carries its own number; a push to develop has
// to enumerate what is open against it.
let numbers = [];
if (context.eventName === 'pull_request') {
numbers = [context.payload.pull_request.number];
} else {
const open = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
base: 'develop',
per_page: 100,
});
numbers = open.map((item) => item.number);
}

core.info(`Considering ${numbers.length} pull request(s) against develop.`);

for (const number of numbers) {
let pullRequest;
try {
({ data: pullRequest } = await github.rest.pulls.get({
owner,
repo,
pull_number: number,
}));
} catch (error) {
// A pull request merged or deleted between listing and reading.
core.info(`#${number}: skipped, could not read (${error.status}).`);
continue;
}

const decision = shouldAttemptUpdate(pullRequest);
if (!decision.attempt) {
core.info(`#${number}: skipped (${decision.reason}).`);
continue;
}

let status;
let message = '';
try {
// expected_head_sha guards against merging into a branch that
// moved while this run was in flight.
const { status: responseStatus } = await github.rest.pulls.updateBranch({
owner,
repo,
pull_number: number,
expected_head_sha: pullRequest.head.sha,
});
status = responseStatus;
} catch (error) {
status = error.status;
// The API's own message is what distinguishes the two 422
// responses, so prefer it over Octokit's wrapper text.
message = error.response?.data?.message || error.message || '';
}

const outcome = classifyUpdateResult({ status, message });
core.info(`#${number}: ${outcome} (HTTP ${status}${message ? `, ${message}` : ''}).`);

if (outcome === 'conflict') {
log.push(`#${number} HTTP ${status}${message ? `: ${message}` : ''}`);

const body = buildConflictComment({
pullNumber: number,
mergeableState: pullRequest.mergeable_state || 'unknown',
log,
});

// Upsert rather than append, so a pull request that stays
// conflicting across several pushes to develop carries one
// comment instead of one per run.
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: number,
per_page: 100,
});
const existing = comments.find((comment) =>
comment.user.type === 'Bot' &&
comment.body.includes(CONFLICT_COMMENT_MARKER)
);

if (existing && normaliseCommentBody(existing.body) === normaliseCommentBody(body)) {
core.info(`#${number}: conflict comment already up to date.`);
continue;
}
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: number, body });
}
}

// Nothing below this point reports a failure. A conflict is
// written as a comment and every other outcome is logged, so no
// outcome of this workflow turns a check red. See #3574.
}

core.info(`Processed ${numbers.length} pull request(s).`);
Loading
Loading