@@ -18,15 +18,22 @@ jobs:
1818 if : github.repository == 'github/docs-internal'
1919 runs-on : ubuntu-latest
2020 timeout-minutes : 180 # 3 hours for external checks
21+ # Serialize publishing so two overlapping runs can't both create a
22+ # "rolling" issue, or write their results out of order.
23+ concurrency :
24+ group : broken-external-links-report
25+ cancel-in-progress : false
2126 steps :
2227 - name : Checkout
2328 uses : actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
29+
2430 - uses : ./.github/actions/node-npm-setup
2531
2632 - name : Install dependencies
2733 run : npm ci
2834
2935 - name : Check external links
36+ id : check
3037 env :
3138 ACTION_RUN_URL : ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
3239 CACHE_MAX_AGE_DAYS : ' 7'
@@ -57,15 +64,158 @@ jobs:
5764 echo "No broken link report generated - all links valid!"
5865 fi
5966
60- - name : Create issue if broken links found
61- if : always() && steps.check_report.outputs.has_report == 'true'
62- uses : peter-evans/create-issue-from-file@65115121ba9a3573cbaded4dc66b90ba1f9b69dc
67+ - name : Create or update the rolling report issue
68+ if : |
69+ always()
70+ && steps.check.outcome == 'success'
71+ && steps.check_report.outputs.has_report == 'true'
72+ && !inputs.max_urls
73+ uses : actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3
6374 with :
64- token : ${{ secrets.DOCS_BOT_PAT_BASE }}
65- repository : github/docs-content
66- title : ' 🌐 Broken External Links Report'
67- content-filepath : artifacts/external-link-report.md
68- labels : broken link report
75+ github-token : ${{ secrets.DOCS_BOT_PAT_BASE }}
76+ script : |
77+ const fs = require('fs')
78+ const title = '🌐 Broken External Links Report'
79+ const owner = 'github'
80+ const repo = 'docs-content'
81+ const label = 'broken link report'
82+
83+ // GitHub rejects issue bodies over 65536 characters with a 422.
84+ // Truncate and point at the run artifact for the full contents.
85+ const MAX_BODY_SIZE = 60000
86+ const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
87+ let body = fs.readFileSync('artifacts/external-link-report.md', 'utf8')
88+ if (body.length > MAX_BODY_SIZE) {
89+ const notice = `\n\n---\n\n*Report truncated. Download the full report from the [workflow run artifacts](${runUrl}).*`
90+ body = body.slice(0, MAX_BODY_SIZE - notice.length) + notice
91+ core.warning(`Report exceeded ${MAX_BODY_SIZE} characters, so it was truncated.`)
92+ }
93+
94+ // Reuse a single rolling issue instead of opening a new one every
95+ // week, which floods the first responders' board. Find the open
96+ // report issues (newest first).
97+ const open = await github.paginate(github.rest.issues.listForRepo, {
98+ owner,
99+ repo,
100+ state: 'open',
101+ labels: label,
102+ per_page: 100,
103+ })
104+ const reportIssues = open
105+ .filter((issue) => !issue.pull_request && issue.title === title)
106+ .sort((a, b) => b.number - a.number)
107+
108+ if (reportIssues.length === 0) {
109+ const created = await github.rest.issues.create({
110+ owner,
111+ repo,
112+ title,
113+ body,
114+ labels: [label],
115+ })
116+ core.info(`Created rolling report issue: ${created.data.html_url}`)
117+ return
118+ }
119+
120+ // Refresh the newest open report in place and close any older
121+ // duplicates so exactly one canonical issue remains.
122+ const [canonical, ...superseded] = reportIssues
123+ await github.rest.issues.update({
124+ owner,
125+ repo,
126+ issue_number: canonical.number,
127+ title,
128+ body,
129+ })
130+ core.info(`Updated rolling report issue: ${canonical.html_url}`)
131+
132+ // Attempt every duplicate even if one fails, so a single transient
133+ // API error doesn't leave the rest open.
134+ const results = await Promise.allSettled(
135+ superseded.map(async (issue) => {
136+ await github.rest.issues.createComment({
137+ owner,
138+ repo,
139+ issue_number: issue.number,
140+ body: `Superseded by the current rolling report: #${canonical.number}.`,
141+ })
142+ await github.rest.issues.update({
143+ owner,
144+ repo,
145+ issue_number: issue.number,
146+ state: 'closed',
147+ state_reason: 'not_planned',
148+ })
149+ core.info(`Closed superseded report issue #${issue.number}`)
150+ }),
151+ )
152+ const failures = results.filter((result) => result.status === 'rejected')
153+ if (failures.length > 0) {
154+ throw new AggregateError(
155+ failures.map((failure) => failure.reason),
156+ `Failed to close ${failures.length} superseded report issue(s).`,
157+ )
158+ }
159+
160+ - name : Close the rolling report issue when all links are valid
161+ if : |
162+ always()
163+ && steps.check.outcome == 'success'
164+ && steps.check_report.outputs.has_report == 'false'
165+ && !inputs.max_urls
166+ uses : actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3
167+ with :
168+ github-token : ${{ secrets.DOCS_BOT_PAT_BASE }}
169+ script : |
170+ const title = '🌐 Broken External Links Report'
171+ const owner = 'github'
172+ const repo = 'docs-content'
173+ const label = 'broken link report'
174+
175+ // A clean run means the open report is stale. Leaving it open would
176+ // keep fixed failures on the first responders' board.
177+ const open = await github.paginate(github.rest.issues.listForRepo, {
178+ owner,
179+ repo,
180+ state: 'open',
181+ labels: label,
182+ per_page: 100,
183+ })
184+ const reportIssues = open.filter(
185+ (issue) => !issue.pull_request && issue.title === title,
186+ )
187+
188+ if (reportIssues.length === 0) {
189+ core.info('No open report issue to close.')
190+ return
191+ }
192+
193+ const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
194+ const results = await Promise.allSettled(
195+ reportIssues.map(async (issue) => {
196+ await github.rest.issues.createComment({
197+ owner,
198+ repo,
199+ issue_number: issue.number,
200+ body: `All external links are valid as of the [latest run](${runUrl}). Closing this report. A new one opens if links break again.`,
201+ })
202+ await github.rest.issues.update({
203+ owner,
204+ repo,
205+ issue_number: issue.number,
206+ state: 'closed',
207+ state_reason: 'completed',
208+ })
209+ core.info(`Closed resolved report issue #${issue.number}`)
210+ }),
211+ )
212+ const failures = results.filter((result) => result.status === 'rejected')
213+ if (failures.length > 0) {
214+ throw new AggregateError(
215+ failures.map((failure) => failure.reason),
216+ `Failed to close ${failures.length} resolved report issue(s).`,
217+ )
218+ }
69219
70220 - uses : ./.github/actions/create-workflow-failure-issue
71221 id : create-failure-issue
0 commit comments