@@ -228,7 +228,7 @@ runs:
228228 '',
229229 ...planSteps,
230230 '',
231- 'This item has been pre-approved (Workflow Status set directly to `Approved`) under the',
231+ 'This item has been pre-approved (its status on the Workflow board set directly to `Approved`) under the',
232232 'narrow policy for composite-action pin-bump issues raised by this workflow - see',
233233 '`.github/actions/check-composite-action-versions/action.yml`.',
234234 ].join('\n');
@@ -247,6 +247,15 @@ runs:
247247 // why that gate normally must never be set by automation. Best-effort: the "Workflow"
248248 // project is optional (see same doc), so any failure here just leaves the issue at
249249 // "Not Started" for a human/the orchestrator's normal fallback to pick up.
250+ const OVERWRITE_SETTLE_DELAY_MS = 5 * 60 * 1000;
251+ // The board's status field is either the built-in "Status" (once the board is converted)
252+ // or the older custom "Workflow Status". Only a single-select field that actually offers
253+ // an "Approved" option qualifies, so an unconverted board's built-in "Status"
254+ // (Todo/In Progress/Done) is passed over; when both qualify mid-conversion, "Status" wins.
255+ const selectStatusField = fields => {
256+ const approvable = fields.filter(f => f?.options?.some(o => o.name === 'Approved'));
257+ return approvable.find(f => f.name === 'Status') ?? approvable.find(f => f.name === 'Workflow Status');
258+ };
250259 try {
251260 const projectQuery = `
252261 query($owner: String!, $repo: String!) {
@@ -277,11 +286,15 @@ runs:
277286 if (!project) {
278287 core.warning('⚠️ No "Workflow" project linked to this repo - skipping pre-approval');
279288 } else {
280- const statusField = (project.fields?.nodes ?? []).find(f => f?.name === 'Workflow Status');
281- const approvedOption = statusField?.options?.find(o => o.name === 'Approved');
282- if (!statusField || !approvedOption) {
283- core.warning('⚠️ "Workflow Status" field or "Approved" option not found - skipping pre-approval');
289+ const statusField = selectStatusField(project.fields?.nodes ?? []);
290+ if (!statusField) {
291+ core.warning(
292+ '⚠️ No "Status" or "Workflow Status" single-select field with an "Approved" option found ' +
293+ '- skipping pre-approval'
294+ );
284295 } else {
296+ const approvedOption = statusField.options.find(o => o.name === 'Approved');
297+ core.info(`ℹ️ Using the "${statusField.name}" field for pre-approval`);
285298 const addResult = await github.graphql(
286299 `mutation($p: ID!, $c: ID!) {
287300 addProjectV2ItemById(input: { projectId: $p, contentId: $c }) { item { id } }
@@ -290,41 +303,106 @@ runs:
290303 );
291304 const itemId = addResult.addProjectV2ItemById.item.id;
292305 const readBackQuery = `
293- query($i: ID!) {
306+ query($i: ID!, $n: String! ) {
294307 node(id: $i) {
295308 ... on ProjectV2Item {
296- fieldValueByName(name: "Workflow Status" ) {
309+ fieldValueByName(name: $n ) {
297310 ... on ProjectV2ItemFieldSingleSelectValue { optionId }
298311 }
299312 }
300313 }
301314 }`;
302-
303- // The item was just added to the project, so the field write below can be
304- // accepted (no GraphQL error) without actually persisting yet - a known
305- // eventual-consistency race in the Projects v2 API on freshly-added items.
306- // Verify with a read-back and retry rather than trusting the mutation response.
307- let verified = false;
308- for (let attempt = 1; attempt <= 3 && !verified; attempt++) {
309- await github.graphql(
310- `mutation($p: ID!, $i: ID!, $f: ID!, $v: String!) {
311- updateProjectV2ItemFieldValue(
312- input: { projectId: $p, itemId: $i, fieldId: $f, value: { singleSelectOptionId: $v } }
313- ) { projectV2Item { id } }
314- }`,
315- { p: project.id, i: itemId, f: statusField.id, v: approvedOption.id }
316- );
317- await new Promise(resolve => setTimeout(resolve, attempt * 1000));
318- const readBack = await github.graphql(readBackQuery, { i: itemId });
319- verified = readBack.node?.fieldValueByName?.optionId === approvedOption.id;
320- if (!verified) {
321- core.info(`⚠️ Pre-approval write not yet visible on attempt ${attempt} - retrying`);
315+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
316+ const applyApprovedStatus = () => github.graphql(
317+ `mutation($p: ID!, $i: ID!, $f: ID!, $v: String!) {
318+ updateProjectV2ItemFieldValue(
319+ input: { projectId: $p, itemId: $i, fieldId: $f, value: { singleSelectOptionId: $v } }
320+ ) { projectV2Item { id } }
321+ }`,
322+ { p: project.id, i: itemId, f: statusField.id, v: approvedOption.id }
323+ );
324+ const isApprovedNow = async () =>
325+ (await github.graphql(readBackQuery, { i: itemId, n: statusField.name })).node?.fieldValueByName?.optionId === approvedOption.id;
326+ // Every write to this field is followed by a read-back rather than trusting the
327+ // mutation response, because a freshly-added item's field write can be accepted
328+ // (no GraphQL error) without actually persisting yet - a known eventual-consistency
329+ // race in the Projects v2 API. This retry+verify covers only that short
330+ // write-propagation delay - it does not and cannot detect the project's own
331+ // built-in "Item added to project" automation later overwriting this same field;
332+ // see the settle-and-recheck step below for that.
333+ const applyAndVerifyApproved = async (maxAttempts, onRetry) => {
334+ let ok = false;
335+ for (let attempt = 1; attempt <= maxAttempts && !ok; attempt++) {
336+ await applyApprovedStatus();
337+ await sleep(attempt * 1000);
338+ ok = await isApprovedNow();
339+ if (!ok && onRetry) {
340+ onRetry(attempt);
341+ }
322342 }
323- }
343+ return ok;
344+ };
345+
346+ const verified = await applyAndVerifyApproved(3, attempt =>
347+ core.info(`⚠️ Pre-approval write not yet visible on attempt ${attempt} - retrying`)
348+ );
324349
325350 if (verified) {
326351 core.info('✅ Pre-approved on the Workflow board');
327352 core.notice(`Pre-approved issue #${issue.number} on the Workflow board`);
353+
354+ // The "Workflow" project has an enabled built-in "Item added to project"
355+ // automation that sets a default Status on any newly-added item. It fires
356+ // asynchronously off the same "item added" event as the write above, with
357+ // unbounded latency, so it can silently overwrite Approved with its own
358+ // default afterwards - see
359+ // https://github.com/funfair-tech/funfair-server-template/issues/1004. The
360+ // retry loop above cannot catch this: it only outraces Projects v2
361+ // write-propagation delay, on the order of seconds, not this automation, which
362+ // can fire minutes later. Settle for much longer than that, then re-check and
363+ // re-apply once if needed - not an unbounded loop, since the automation only
364+ // fires once per item.
365+ // Scoped to its own try/catch, separate from the outer one around the whole
366+ // pre-approval block: that block's core.notice above has already reported
367+ // pre-approval as successful, so a failure here must be reported as its own,
368+ // more specific warning rather than falling through to the generic
369+ // "Failed to pre-approve" message the outer catch would otherwise log.
370+ try {
371+ await sleep(OVERWRITE_SETTLE_DELAY_MS);
372+ if (!(await isApprovedNow())) {
373+ // A non-Approved read here isn't necessarily the automation's overwrite -
374+ // the issue may have been legitimately closed in the meantime. Check state
375+ // before re-applying, so a closed issue isn't force-reopened-in-spirit by
376+ // having Approved reapplied to it.
377+ const { data: currentIssue } = await github.rest.issues.get({
378+ owner: context.repo.owner,
379+ repo: context.repo.repo,
380+ issue_number: issue.number,
381+ });
382+ if (currentIssue.state !== 'open') {
383+ core.info(`ℹ️ Issue #${issue.number} is no longer open - skipping re-apply`);
384+ } else {
385+ core.warning(
386+ `⚠️ ${statusField.name} for issue #${issue.number} was reset after pre-approval ` +
387+ `(likely by the project's built-in "Item added to project" automation) - re-applying Approved`
388+ );
389+ if (await applyAndVerifyApproved(3)) {
390+ core.info(`✅ Re-applied Approved status for issue #${issue.number} after overwrite`);
391+ } else {
392+ core.warning(
393+ `⚠️ Attempted to re-apply Approved status for issue #${issue.number} but it ` +
394+ `still does not read back as Approved after 3 attempts - issue left for ` +
395+ `manual/orchestrator review`
396+ );
397+ }
398+ }
399+ }
400+ } catch (e) {
401+ core.warning(
402+ `⚠️ Pre-approval succeeded but the settle-and-recheck step failed for issue ` +
403+ `#${issue.number}: ${e.message} - issue left for manual/orchestrator review`
404+ );
405+ }
328406 } else {
329407 core.warning(
330408 `⚠️ Pre-approval mutation returned no error but did not persist after 3 attempts ` +
0 commit comments