Skip to content

Commit 505790e

Browse files
authored
fix(ci): retry transient deploy gate requests (#7)
1 parent 96c571c commit 505790e

6 files changed

Lines changed: 89 additions & 18 deletions

File tree

‎.github/scripts/wait-for-daily-ci.cjs‎

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,13 @@ async function waitForDailyCI({
8080
const expectedFullName = `${owner}/${repo}`;
8181
const expectedSha = context?.sha;
8282
const deploymentRunId = context?.runId;
83+
const deploymentEvent = context?.eventName;
8384

8485
if (!owner || !repo || !expectedSha || !deploymentRunId) {
8586
throw new TypeError('GitHub repository, SHA, and workflow run context are required');
8687
}
87-
if (context.eventName !== 'schedule' || context.ref !== 'refs/heads/main') {
88-
throw new Error('Daily CI wait is only valid for a scheduled run on main');
88+
if (!['schedule', 'workflow_dispatch'].includes(deploymentEvent) || context.ref !== 'refs/heads/main') {
89+
throw new Error('CI wait is only valid for a scheduled or manually dispatched run on main');
8990
}
9091

9192
const startedAt = now();
@@ -127,25 +128,26 @@ async function waitForDailyCI({
127128

128129
if (
129130
!deploymentRun
130-
|| deploymentRun.event !== 'schedule'
131+
|| deploymentRun.event !== deploymentEvent
131132
|| deploymentRun.head_branch !== 'main'
132133
|| deploymentRun.head_sha !== expectedSha
133134
|| !repositoryMatches(deploymentRun, expectedFullName)
134135
) {
135-
throw new Error('Could not verify this scheduled deploy run belongs to main in the current repository');
136+
throw new Error(`Could not verify this ${deploymentEvent} deploy run belongs to main in the current repository`);
136137
}
137138

138139
const scheduledDate = detroitCalendarDate(deploymentRun.created_at);
139140
if (!scheduledDate) {
140-
throw new Error('Scheduled deploy run has no valid created_at timestamp');
141+
throw new Error('Deploy run has no valid created_at timestamp');
141142
}
143+
const ciRunKind = deploymentEvent === 'schedule' ? 'scheduled' : 'manually dispatched';
142144

143145
while (true) {
144-
const response = await readGitHub('scheduled CI runs', () => github.rest.actions.listWorkflowRuns({
146+
const response = await readGitHub(`${ciRunKind} CI runs`, () => github.rest.actions.listWorkflowRuns({
145147
owner,
146148
repo,
147149
workflow_id: 'ci.yml',
148-
event: 'schedule',
150+
event: deploymentEvent,
149151
branch: 'main',
150152
per_page: 100,
151153
}));
@@ -155,7 +157,7 @@ async function waitForDailyCI({
155157
}
156158

157159
const matchingRuns = workflowRuns.filter((run) => (
158-
run?.event === 'schedule'
160+
run?.event === deploymentEvent
159161
&& run.head_branch === 'main'
160162
&& run.head_sha === expectedSha
161163
&& repositoryMatches(run, expectedFullName)
@@ -167,15 +169,15 @@ async function waitForDailyCI({
167169
));
168170
if (unsuccessfulRun) {
169171
throw new Error(
170-
`Scheduled CI run ${unsuccessfulRun.id} completed with conclusion ${unsuccessfulRun.conclusion ?? 'unknown'}; refusing deployment`,
172+
`${ciRunKind[0].toUpperCase()}${ciRunKind.slice(1)} CI run ${unsuccessfulRun.id} completed with conclusion ${unsuccessfulRun.conclusion ?? 'unknown'}; refusing deployment`,
171173
);
172174
}
173175

174176
const successfulRun = matchingRuns.find((run) => (
175177
run.status === 'completed' && run.conclusion === 'success'
176178
));
177179
if (successfulRun) {
178-
core.info(`Verified scheduled CI run ${successfulRun.id} for ${scheduledDate}.`);
180+
core.info(`Verified ${ciRunKind} CI run ${successfulRun.id} for ${scheduledDate}.`);
179181
return {
180182
runId: successfulRun.id,
181183
scheduledDate,
@@ -185,7 +187,7 @@ async function waitForDailyCI({
185187
const remainingMs = timeoutMs - (now() - startedAt);
186188
if (remainingMs <= 0) {
187189
throw new Error(
188-
`Timed out after ${timeoutMs}ms waiting for scheduled CI on ${scheduledDate}; refusing deployment`,
190+
`Timed out after ${timeoutMs}ms waiting for ${ciRunKind} CI on ${scheduledDate}; refusing deployment`,
189191
);
190192
}
191193

‎.github/scripts/wait-for-daily-ci.test.cjs‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ function makeHarness(runResponses, { deployRun, deployErrors = [], context = mak
4949
assert.deepEqual(args, { owner: OWNER, repo: REPO, run_id: context.runId });
5050
if (deployErrors.length) throw deployErrors.shift();
5151
return {
52-
data: deployRun ?? makeWorkflowRun({ id: context.runId }),
52+
data: deployRun ?? makeWorkflowRun({ id: context.runId, event: context.eventName }),
5353
};
5454
},
5555
listWorkflowRuns: async (args) => {
@@ -122,6 +122,53 @@ test('fails closed when the matching scheduled CI run completed unsuccessfully',
122122
assert.deepEqual(harness.infos, []);
123123
});
124124

125+
test('validates a manual deployment against same-day manually dispatched CI for the same commit', async () => {
126+
const manualContext = makeContext({ eventName: 'workflow_dispatch' });
127+
const harness = makeHarness(
128+
[[makeWorkflowRun({ event: 'workflow_dispatch' })]],
129+
{ context: manualContext },
130+
);
131+
132+
const result = await waitForDailyCI(harness.args);
133+
134+
assert.deepEqual(result, { runId: 41, scheduledDate: DETROIT_DAY });
135+
assert.equal(harness.listCalls[0].event, 'workflow_dispatch');
136+
assert.deepEqual(harness.infos, [`Verified manually dispatched CI run 41 for ${DETROIT_DAY}.`]);
137+
});
138+
139+
test('rejects manual deployment event, branch, and CI-run identity mismatches', async () => {
140+
const manualContext = makeContext({ eventName: 'workflow_dispatch' });
141+
const wrongDeployEvent = makeHarness([], {
142+
context: manualContext,
143+
deployRun: makeWorkflowRun({ event: 'schedule' }),
144+
});
145+
146+
await assert.rejects(
147+
waitForDailyCI(wrongDeployEvent.args),
148+
/Could not verify this workflow_dispatch deploy run belongs to main in the current repository/,
149+
);
150+
assert.equal(wrongDeployEvent.listCalls.length, 0);
151+
152+
const wrongBranch = makeHarness([], {
153+
context: makeContext({ eventName: 'workflow_dispatch', ref: 'refs/heads/release' }),
154+
});
155+
await assert.rejects(
156+
waitForDailyCI(wrongBranch.args),
157+
/CI wait is only valid for a scheduled or manually dispatched run on main/,
158+
);
159+
assert.equal(wrongBranch.listCalls.length, 0);
160+
161+
const wrongCiEvent = makeHarness(
162+
[[makeWorkflowRun({ event: 'schedule' })]],
163+
{ context: manualContext },
164+
);
165+
await assert.rejects(
166+
waitForDailyCI({ ...wrongCiEvent.args, timeoutMs: 0 }),
167+
/Timed out after 0ms waiting for manually dispatched CI on 2026-10-04; refusing deployment/,
168+
);
169+
assert.equal(wrongCiEvent.listCalls[0].event, 'workflow_dispatch');
170+
});
171+
125172
test('does not accept a different SHA, event, head repository, or Detroit calendar day', async () => {
126173
const mismatches = [
127174
makeWorkflowRun({ head_sha: 'b'.repeat(40) }),

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ jobs:
2424
run: ci-storage-efficiency start
2525
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
2626

27-
- name: Test scheduled publication CI guard
27+
- name: Test deployment CI identity and transient retry guard
2828
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
2929
with:
3030
script: |

‎.github/workflows/deploy.yml‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ on:
44
schedule:
55
- cron: '5 4 * * *'
66
timezone: America/Detroit
7+
workflow_dispatch: {}
78
workflow_run:
89
workflows: ["CI Pipeline"]
910
types: [completed]
@@ -21,7 +22,9 @@ jobs:
2122
name: Build and Push Docker Image
2223
if: >-
2324
(github.event_name == 'schedule' && github.ref == 'refs/heads/main') ||
24-
(github.event.workflow_run.conclusion == 'success' &&
25+
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') ||
26+
(github.event_name == 'workflow_run' &&
27+
github.event.workflow_run.conclusion == 'success' &&
2528
github.event.workflow_run.event == 'push' &&
2629
github.event.workflow_run.head_branch == 'main' &&
2730
github.event.workflow_run.head_repository.full_name == github.repository)
@@ -44,8 +47,8 @@ jobs:
4447
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
4548
fetch-depth: 1
4649

47-
- name: Require successful daily CI for scheduled publication
48-
if: github.event_name == 'schedule'
50+
- name: Require successful matching CI before publication
51+
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
4952
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
5053
with:
5154
script: |

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
A responsive Linux-terminal-themed portfolio for Joshua Terk, Senior Software Engineer. The site presents his work, writing, technology interests, and automotive projects through a custom React terminal backed by Spring Boot APIs.
44

5-
The [maintenance audit](docs/maintenance-audit-2026-09-28.md) records the code review, dependency updates, and verification evidence. The [October 8 checkpoint](docs/maintenance-checkpoint-2026-10-08.md) records current release verification and the scheduled-publication retry fix. Current source navigation and build instructions follow.
5+
The [maintenance audit](docs/maintenance-audit-2026-09-28.md) records the code review and dependency updates. The [October 8 checkpoint](docs/maintenance-checkpoint-2026-10-08.md) records current release verification, and the [daily deploy gate repair](docs/daily-deploy-gate-repair-2026-10-08.md) describes transient API retries and the manual validation route. Current source navigation and build instructions follow.
66

77
## Requirements
88

@@ -43,7 +43,7 @@ Vite serves the development UI on port 3000. `npm run build` runs TypeScript che
4343

4444
## CI and containers
4545

46-
[CI](.github/workflows/ci.yml) and [deployment configuration](.github/workflows/deploy.yml) contain the automated build and image-publication rules. See the [maintenance audit](docs/maintenance-audit-2026-09-28.md) for the private-runner contract, validation evidence, and activation boundaries.
46+
[CI](.github/workflows/ci.yml) and [deployment configuration](.github/workflows/deploy.yml) contain the automated build and image-publication rules. See the [maintenance audit](docs/maintenance-audit-2026-09-28.md) for the private-runner contract, validation evidence, and activation boundaries, and the [daily deploy gate repair](docs/daily-deploy-gate-repair-2026-10-08.md) for transient API retry and manual validation behavior.
4747

4848
The [Dockerfile](Dockerfile) tests and builds with Java 25 and Node 26, then uses `jdeps` and `jlink` to create the Java runtime. The final Alpine image contains the application and linked runtime, runs as UID/GID 1001, and excludes Maven, Node, Java compiler tools, and source resources outside the JAR. CI boots that image, exercises PDF/content/UI routes, and scans its filesystem with Trivy before image publication. Any reported OS or Java-library vulnerability fails the scan; the scan uses current advisory data rather than guaranteeing future vulnerability absence.
4949

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Daily deployment gate repair — October 8, 2026
2+
3+
## Failure
4+
5+
Scheduled production deployment run [37801492760](https://github.com/javaDevJT/website/actions/runs/37801492760) at `4e71d679a5e8cd62d41ddb1725f259002bf5bb76` failed in “Require successful daily CI for scheduled publication.” GitHub's Actions API request raised a transient DNS error (`getaddrinfo EAI_AGAIN api.github.com`). The deployment correctly stopped before build and publication. The same-commit scheduled CI run [37801518895](https://github.com/javaDevJT/website/actions/runs/37801518895), attempt 2, subsequently passed; it did not turn the already failed deployment into a publication.
6+
7+
## Repair
8+
9+
The deployment gate retries temporary DNS/socket errors and HTTP 500, 502, 503, and 504 responses within the existing 20-minute CI wait deadline, using bounded 20-second waits. It propagates permanent errors immediately. It still rejects failed matching CI and requires the deploy run and CI run to share the same repository, `main` branch, full commit SHA, and Detroit calendar day.
10+
11+
The deployment workflow also has a manual validation route on `main`. A manual deploy must find a successful same-day `workflow_dispatch` CI run for the identical repository, branch, and SHA. The workflow keeps both current-main checks and the vulnerability scan before publishing release tags. Manual validation does not emulate a schedule event.
12+
13+
The CI workflow runs the helper's Node tests using its already pinned `actions/github-script` runtime. The test cases cover transient recovery and exhaustion, immediate failure for permanent errors, same-commit identity, failed CI, Detroit-day boundaries, and manual-event identity and branch checks.
14+
15+
## Validation
16+
17+
- Local focused test command: `node --test .github/scripts/wait-for-daily-ci.test.cjs` — 11 tests passed after adding manual-event identity coverage.
18+
- Workflow checks and a fresh Actions run at the repaired source: recorded after remote validation.
19+
- Publication or remaining blocker: recorded after the repaired-source deploy validation.

0 commit comments

Comments
 (0)