@@ -50,36 +50,144 @@ runs:
5050
5151 // Escape special regex characters in delimiter strings
5252 const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
53-
54- const comments = await github.paginate(
55- github.rest.issues.listComments,
56- { ...context.repo, issue_number: prNumber }
53+ const sectionRegex = new RegExp(
54+ `${escapeRegex(sectionStart)}[\\s\\S]*?${escapeRegex(sectionEnd)}`
5755 )
5856
59- const existing = comments.find(
60- (c) =>
61- c.user?.login === 'github-actions[bot]' &&
62- c.body?.includes(commentMarker)
63- )
57+ // Every section-start marker in a body, used to detect whether a
58+ // concurrent writer's section was lost across our write.
59+ const sectionStartsIn = (body) =>
60+ body.match(/<!-- section:[a-z0-9-]+:start -->/g) ?? []
6461
65- if (!existing) {
66- return github.rest.issues.createComment({
67- ...context.repo,
68- issue_number: prNumber,
69- body: `${commentMarker}\n${sectionBlock}`
70- })
62+ const findComment = async () => {
63+ const comments = await github.paginate(
64+ github.rest.issues.listComments,
65+ { ...context.repo, issue_number: prNumber }
66+ )
67+ return comments.find(
68+ (c) =>
69+ c.user?.login === 'github-actions[bot]' &&
70+ c.body?.includes(commentMarker)
71+ )
7172 }
7273
73- const body = existing.body ?? ''
74- const sectionRegex = new RegExp(
75- `${escapeRegex(sectionStart)}[\\s\\S]*?${escapeRegex(sectionEnd)}`
76- )
77- const updated = sectionRegex.test(body)
78- ? body.replace(sectionRegex, sectionBlock)
79- : body.trimEnd() + '\n\n' + sectionBlock
74+ // Several workflows share one comment via read-modify-write, which the
75+ // REST API can't do atomically. Re-read immediately before each write
76+ // and verify our section plus every previously-present section
77+ // survived; on a detected clobber, re-read and retry so writers
78+ // converge instead of silently dropping each other's sections.
79+ const maxAttempts = 5
80+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
81+ const existing = await findComment()
82+
83+ if (!existing) {
84+ try {
85+ await github.rest.issues.createComment({
86+ ...context.repo,
87+ issue_number: prNumber,
88+ body: `${commentMarker}\n${sectionBlock}`
89+ })
90+ // Concurrent writers may have also created a comment; merge all
91+ // sections from every duplicate into the lowest-id copy, verify,
92+ // then delete the rest so no report content is lost.
93+ const allComments = await github.paginate(
94+ github.rest.issues.listComments,
95+ { ...context.repo, issue_number: prNumber }
96+ )
97+ const markers = allComments.filter(
98+ (c) =>
99+ c.user?.login === 'github-actions[bot]' &&
100+ c.body?.includes(commentMarker)
101+ )
102+ if (markers.length > 1) {
103+ markers.sort((a, b) => a.id - b.id)
104+ const canonical = markers[0]
105+ // Merge sections from all duplicates into the canonical body
106+ let mergedBody = canonical.body ?? ''
107+ for (const dup of markers.slice(1)) {
108+ const dupSections = (dup.body ?? '').match(
109+ /<!-- section:[a-z0-9-]+:start -->[\s\S]*?<!-- section:[a-z0-9-]+:end -->/g
110+ ) ?? []
111+ for (const block of dupSections) {
112+ const nameMatch = block.match(/<!-- section:([a-z0-9-]+):start -->/)
113+ if (!nameMatch) continue
114+ const name = nameMatch[1]
115+ const startTag = `<!-- section:${name}:start -->`
116+ const endTag = `<!-- section:${name}:end -->`
117+ const existingRegex = new RegExp(
118+ `${escapeRegex(startTag)}[\\s\\S]*?${escapeRegex(endTag)}`
119+ )
120+ mergedBody = existingRegex.test(mergedBody)
121+ ? mergedBody.replace(existingRegex, block)
122+ : mergedBody.trimEnd() + '\n\n' + block
123+ }
124+ }
125+ try {
126+ await github.rest.issues.updateComment({
127+ ...context.repo,
128+ comment_id: canonical.id,
129+ body: mergedBody
130+ })
131+ // Verify canonical has all sections before deleting duplicates
132+ const verified = (await github.rest.issues.getComment({
133+ ...context.repo,
134+ comment_id: canonical.id
135+ })).data.body ?? ''
136+ const allSections = markers.flatMap(
137+ (m) => sectionStartsIn(m.body ?? '')
138+ )
139+ const allPresent = allSections.every((s) => verified.includes(s))
140+ if (allPresent) {
141+ for (const dup of markers.slice(1)) {
142+ try {
143+ await github.rest.issues.deleteComment({
144+ ...context.repo,
145+ comment_id: dup.id
146+ })
147+ } catch (_) {}
148+ }
149+ }
150+ } catch (_) {
151+ // Leave duplicates rather than risk losing content
152+ }
153+ }
154+ return
155+ } catch (err) {
156+ if (attempt === maxAttempts) throw err
157+ continue // another writer likely created it first; retry updates
158+ }
159+ }
160+
161+ const body = existing.body ?? ''
162+ const expectedSections = sectionStartsIn(body)
163+ const updated = sectionRegex.test(body)
164+ ? body.replace(sectionRegex, sectionBlock)
165+ : body.trimEnd() + '\n\n' + sectionBlock
80166
81- return github.rest.issues.updateComment({
82- ...context.repo,
83- comment_id: existing.id,
84- body: updated
85- })
167+ try {
168+ await github.rest.issues.updateComment({
169+ ...context.repo,
170+ comment_id: existing.id,
171+ body: updated
172+ })
173+ } catch (err) {
174+ if (attempt === maxAttempts) throw err
175+ continue // transient failure; retry
176+ }
177+
178+ const current = (await findComment())?.body ?? ''
179+ const survived =
180+ current.includes(sectionBlock) &&
181+ expectedSections.every((s) => current.includes(s))
182+ if (survived) return
183+
184+ if (attempt === maxAttempts) {
185+ core.warning(
186+ `upsert-comment-section: section "${sectionName}" may have been clobbered by a concurrent writer after ${maxAttempts} attempts`
187+ )
188+ return
189+ }
190+ await new Promise((resolve) =>
191+ setTimeout(resolve, 250 * attempt + Math.floor(Math.random() * 250))
192+ )
193+ }
0 commit comments