Skip to content
Merged
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
24 changes: 13 additions & 11 deletions .github/scripts/wait-for-daily-ci.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,13 @@ async function waitForDailyCI({
const expectedFullName = `${owner}/${repo}`;
const expectedSha = context?.sha;
const deploymentRunId = context?.runId;
const deploymentEvent = context?.eventName;

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

const startedAt = now();
Expand Down Expand Up @@ -127,25 +128,26 @@ async function waitForDailyCI({

if (
!deploymentRun
|| deploymentRun.event !== 'schedule'
|| deploymentRun.event !== deploymentEvent
|| deploymentRun.head_branch !== 'main'
|| deploymentRun.head_sha !== expectedSha
|| !repositoryMatches(deploymentRun, expectedFullName)
) {
throw new Error('Could not verify this scheduled deploy run belongs to main in the current repository');
throw new Error(`Could not verify this ${deploymentEvent} deploy run belongs to main in the current repository`);
}

const scheduledDate = detroitCalendarDate(deploymentRun.created_at);
if (!scheduledDate) {
throw new Error('Scheduled deploy run has no valid created_at timestamp');
throw new Error('Deploy run has no valid created_at timestamp');
}
const ciRunKind = deploymentEvent === 'schedule' ? 'scheduled' : 'manually dispatched';

while (true) {
const response = await readGitHub('scheduled CI runs', () => github.rest.actions.listWorkflowRuns({
const response = await readGitHub(`${ciRunKind} CI runs`, () => github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: 'ci.yml',
event: 'schedule',
event: deploymentEvent,
branch: 'main',
per_page: 100,
}));
Expand All @@ -155,7 +157,7 @@ async function waitForDailyCI({
}

const matchingRuns = workflowRuns.filter((run) => (
run?.event === 'schedule'
run?.event === deploymentEvent
&& run.head_branch === 'main'
&& run.head_sha === expectedSha
&& repositoryMatches(run, expectedFullName)
Expand All @@ -167,15 +169,15 @@ async function waitForDailyCI({
));
if (unsuccessfulRun) {
throw new Error(
`Scheduled CI run ${unsuccessfulRun.id} completed with conclusion ${unsuccessfulRun.conclusion ?? 'unknown'}; refusing deployment`,
`${ciRunKind[0].toUpperCase()}${ciRunKind.slice(1)} CI run ${unsuccessfulRun.id} completed with conclusion ${unsuccessfulRun.conclusion ?? 'unknown'}; refusing deployment`,
);
}

const successfulRun = matchingRuns.find((run) => (
run.status === 'completed' && run.conclusion === 'success'
));
if (successfulRun) {
core.info(`Verified scheduled CI run ${successfulRun.id} for ${scheduledDate}.`);
core.info(`Verified ${ciRunKind} CI run ${successfulRun.id} for ${scheduledDate}.`);
return {
runId: successfulRun.id,
scheduledDate,
Expand All @@ -185,7 +187,7 @@ async function waitForDailyCI({
const remainingMs = timeoutMs - (now() - startedAt);
if (remainingMs <= 0) {
throw new Error(
`Timed out after ${timeoutMs}ms waiting for scheduled CI on ${scheduledDate}; refusing deployment`,
`Timed out after ${timeoutMs}ms waiting for ${ciRunKind} CI on ${scheduledDate}; refusing deployment`,
);
}

Expand Down
49 changes: 48 additions & 1 deletion .github/scripts/wait-for-daily-ci.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ function makeHarness(runResponses, { deployRun, deployErrors = [], context = mak
assert.deepEqual(args, { owner: OWNER, repo: REPO, run_id: context.runId });
if (deployErrors.length) throw deployErrors.shift();
return {
data: deployRun ?? makeWorkflowRun({ id: context.runId }),
data: deployRun ?? makeWorkflowRun({ id: context.runId, event: context.eventName }),
};
},
listWorkflowRuns: async (args) => {
Expand Down Expand Up @@ -122,6 +122,53 @@ test('fails closed when the matching scheduled CI run completed unsuccessfully',
assert.deepEqual(harness.infos, []);
});

test('validates a manual deployment against same-day manually dispatched CI for the same commit', async () => {
const manualContext = makeContext({ eventName: 'workflow_dispatch' });
const harness = makeHarness(
[[makeWorkflowRun({ event: 'workflow_dispatch' })]],
{ context: manualContext },
);

const result = await waitForDailyCI(harness.args);

assert.deepEqual(result, { runId: 41, scheduledDate: DETROIT_DAY });
assert.equal(harness.listCalls[0].event, 'workflow_dispatch');
assert.deepEqual(harness.infos, [`Verified manually dispatched CI run 41 for ${DETROIT_DAY}.`]);
});

test('rejects manual deployment event, branch, and CI-run identity mismatches', async () => {
const manualContext = makeContext({ eventName: 'workflow_dispatch' });
const wrongDeployEvent = makeHarness([], {
context: manualContext,
deployRun: makeWorkflowRun({ event: 'schedule' }),
});

await assert.rejects(
waitForDailyCI(wrongDeployEvent.args),
/Could not verify this workflow_dispatch deploy run belongs to main in the current repository/,
);
assert.equal(wrongDeployEvent.listCalls.length, 0);

const wrongBranch = makeHarness([], {
context: makeContext({ eventName: 'workflow_dispatch', ref: 'refs/heads/release' }),
});
await assert.rejects(
waitForDailyCI(wrongBranch.args),
/CI wait is only valid for a scheduled or manually dispatched run on main/,
);
assert.equal(wrongBranch.listCalls.length, 0);

const wrongCiEvent = makeHarness(
[[makeWorkflowRun({ event: 'schedule' })]],
{ context: manualContext },
);
await assert.rejects(
waitForDailyCI({ ...wrongCiEvent.args, timeoutMs: 0 }),
/Timed out after 0ms waiting for manually dispatched CI on 2026-10-04; refusing deployment/,
);
assert.equal(wrongCiEvent.listCalls[0].event, 'workflow_dispatch');
});

test('does not accept a different SHA, event, head repository, or Detroit calendar day', async () => {
const mismatches = [
makeWorkflowRun({ head_sha: 'b'.repeat(40) }),
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
run: ci-storage-efficiency start
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0

- name: Test scheduled publication CI guard
- name: Test deployment CI identity and transient retry guard
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
Expand Down
9 changes: 6 additions & 3 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
schedule:
- cron: '5 4 * * *'
timezone: America/Detroit
workflow_dispatch: {}
workflow_run:
workflows: ["CI Pipeline"]
types: [completed]
Expand All @@ -21,7 +22,9 @@ jobs:
name: Build and Push Docker Image
if: >-
(github.event_name == 'schedule' && github.ref == 'refs/heads/main') ||
(github.event.workflow_run.conclusion == 'success' &&
(github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') ||
(github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.head_repository.full_name == github.repository)
Expand All @@ -44,8 +47,8 @@ jobs:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
fetch-depth: 1

- name: Require successful daily CI for scheduled publication
if: github.event_name == 'schedule'
- name: Require successful matching CI before publication
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

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.

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.
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.

## Requirements

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

## CI and containers

[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.
[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.

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.

Expand Down
19 changes: 19 additions & 0 deletions docs/daily-deploy-gate-repair-2026-10-08.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Daily deployment gate repair — October 8, 2026

## Failure

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.

## Repair

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.

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.

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.

## Validation

- Local focused test command: `node --test .github/scripts/wait-for-daily-ci.test.cjs` — 11 tests passed after adding manual-event identity coverage.
- Workflow checks and a fresh Actions run at the repaired source: recorded after remote validation.
- Publication or remaining blocker: recorded after the repaired-source deploy validation.
Loading