diff --git a/.github/scripts/wait-for-daily-ci.cjs b/.github/scripts/wait-for-daily-ci.cjs index 374bafd..4af687c 100644 --- a/.github/scripts/wait-for-daily-ci.cjs +++ b/.github/scripts/wait-for-daily-ci.cjs @@ -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(); @@ -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, })); @@ -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) @@ -167,7 +169,7 @@ 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`, ); } @@ -175,7 +177,7 @@ async function waitForDailyCI({ 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, @@ -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`, ); } diff --git a/.github/scripts/wait-for-daily-ci.test.cjs b/.github/scripts/wait-for-daily-ci.test.cjs index 05dce6c..f2bb40e 100644 --- a/.github/scripts/wait-for-daily-ci.test.cjs +++ b/.github/scripts/wait-for-daily-ci.test.cjs @@ -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) => { @@ -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) }), diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 523ab9c..3493fb4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: | diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index da7d893..842e55a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,6 +4,7 @@ on: schedule: - cron: '5 4 * * *' timezone: America/Detroit + workflow_dispatch: {} workflow_run: workflows: ["CI Pipeline"] types: [completed] @@ -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) @@ -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: | diff --git a/README.md b/README.md index 31ccbcd..ec81471 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/docs/daily-deploy-gate-repair-2026-10-08.md b/docs/daily-deploy-gate-repair-2026-10-08.md new file mode 100644 index 0000000..10899d5 --- /dev/null +++ b/docs/daily-deploy-gate-repair-2026-10-08.md @@ -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.