diff --git a/.github/scripts/verify-release-artifacts.sh b/.github/scripts/verify-release-artifacts.sh index b35a74651..5d891302e 100644 --- a/.github/scripts/verify-release-artifacts.sh +++ b/.github/scripts/verify-release-artifacts.sh @@ -33,7 +33,7 @@ require_exactly_one_file() { expected_dirs=( "release-disksage-ubuntu-22.04-${run_attempt}" - "release-disksage-windows-latest-${run_attempt}" + "release-disksage-windows-2022-${run_attempt}" "release-disksage-macos-latest-${run_attempt}" ) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b35c94808..f6d7c1df9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,6 +30,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' && github.run_attempt == 1 }} + jobs: test: runs-on: ubuntu-latest diff --git a/docs/doctoring/release-artifact-provenance.md b/docs/doctoring/release-artifact-provenance.md index 296a0246d..8f479ee40 100644 --- a/docs/doctoring/release-artifact-provenance.md +++ b/docs/doctoring/release-artifact-provenance.md @@ -19,7 +19,7 @@ The authoritative implementation is `.github/workflows/release.yml`. The release contract requires all of the following: - checkout binds every platform build to `github.event.pull_request.head.sha` for pull requests and `github.sha` for tags or manual runs, rather than silently treating a generated pull-request merge ref as exact-head evidence; -- release concurrency uses `github.run_attempt == 1`, so a fresh first attempt supersedes stale work while explicit rerun attempts do not cancel themselves inside the same concurrency group; +- release concurrency uses `github.event_name == 'pull_request'`: a newer run for the same repository PR cancels its superseded pull-request build, while tag and manual release runs use unique run IDs and are never cancelled by PR activity; - the three platform builds upload the exact bundle and operational CLI paths that later jobs consume; - release workflow artifacts use the `release-disksage-*` namespace, which excludes concurrently uploaded `disksage-gpu-*` diagnostic bundles; - attestation and publication downloads preserve each workflow artifact in its own directory instead of flattening archives, so duplicate basenames remain observable and last-writer-wins extraction cannot erase evidence before admission; @@ -61,7 +61,7 @@ Retain the artifact, the downloaded bundle, the release tag, the source commit S The pipeline fails closed when an expected platform bundle, operational CLI, checksum file, or source-bound SBOM is absent or duplicated. Artifact namespaces remain separate during download, so the same required filename contributed by two platform archives remains two filesystem entries and is rejected rather than silently overwritten. It validates checksum-record semantics and digests first so an invalid or redirected record receives the specific actionable diagnostic, then rejects any nineteenth regular file and every non-regular filesystem entry before attestation or publication. This exact-set rule prevents a build step from silently adding an unreviewed diagnostic archive, crash dump, log, secret-bearing output, or unrelated executable to the release. Path-scoped checks distinguish the Windows NSIS installer from the two separately shipped Windows operational CLI executables. The pipeline also fails when a checksum record names a file other than its adjacent operational CLI, contains additional fields or records, or presents a malformed digest. A checksum mismatch or malformed/private-path SBOM stops the attestation job. A failed, cancelled, skipped, neutral, missing, or stale-head attestation job cannot satisfy the publication dependency. -Concurrency cancellation applies only to a first workflow attempt. A newer first attempt may cancel stale work for the same ref, but an explicit rerun has `github.run_attempt > 1` and therefore cannot cancel itself. A rerun remains non-authoritative until every required exact-head job in that attempt completes successfully. +Release concurrency cancels an existing run only when the incoming event is a first-attempt pull request (`github.event_name == 'pull_request' && github.run_attempt == 1`) in the same workflow, repository, and PR-number group. Tag and manual runs use `github.run_id`, so they cannot cancel each other or be cancelled by PR activity. Partial reruns are unsupported because platform artifacts are attempt-scoped; use **Re-run all jobs** so every required platform artifact is rebuilt under one attempt. Each attempt remains non-authoritative until every required exact-head job for that run completes successfully. Attestations bind artifact digests, not mutable filenames. Rebuilding the same version produces different bytes and therefore requires new exact-build attestations. Evidence from an earlier workflow run or commit must never authorize publication of a later head. diff --git a/package.json b/package.json index efb3e6b47..51bdc3756 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "test": "svelte-kit sync && vitest run", + "test": "svelte-kit sync && vitest run && node --test scripts/ci/workflow-concurrency-contract.test.mjs", "coverage": "svelte-kit sync && vitest run --coverage", "prepare": "svelte-kit sync || echo ''", "tauri": "tauri" diff --git a/scripts/ci/workflow-concurrency-contract.test.mjs b/scripts/ci/workflow-concurrency-contract.test.mjs new file mode 100644 index 000000000..dc652d976 --- /dev/null +++ b/scripts/ci/workflow-concurrency-contract.test.mjs @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const workflow = (name) => readFileSync(new URL(`../../.github/workflows/${name}`, import.meta.url), "utf8"); + +test("PR validation cancels only superseded first-attempt heads from the same repository PR", () => { + const source = workflow("test.yml"); + assert.match(source, /group: \$\{\{ github\.workflow \}\}-\$\{\{ github\.repository \}\}-\$\{\{ github\.event\.pull_request\.number \|\| github\.run_id \}\}/); + assert.match(source, /cancel-in-progress: \$\{\{ github\.event_name == 'pull_request' && github\.run_attempt == 1 \}\}/); +}); + +test("release validation cancels only superseded first-attempt runs from the same pull request", () => { + const source = workflow("release.yml"); + assert.match(source, /group: \$\{\{ github\.workflow \}\}-\$\{\{ github\.repository \}\}-\$\{\{ github\.event\.pull_request\.number \|\| github\.run_id \}\}/); + assert.match(source, /cancel-in-progress: \$\{\{ github\.event_name == 'pull_request' && github\.run_attempt == 1 \}\}/); +}); diff --git a/src/lib/releaseWorkflowRetryContract.test.ts b/src/lib/releaseWorkflowRetryContract.test.ts index db1ef0a31..fc365d6fb 100644 --- a/src/lib/releaseWorkflowRetryContract.test.ts +++ b/src/lib/releaseWorkflowRetryContract.test.ts @@ -11,27 +11,32 @@ function readRepositoryFile(relativePath: string): string { } describe('release workflow retry contract', () => { - it('cancels stale first attempts without self-cancelling explicit reruns', () => { + it('cancels superseded PR builds while preserving tag and manual releases', () => { const workflow = readRepositoryFile('.github/workflows/release.yml'); - expect(workflow).toContain("cancel-in-progress: ${{ github.run_attempt == 1 }}"); - expect(workflow).not.toContain('cancel-in-progress: true'); + expect(workflow).toContain( + "cancel-in-progress: ${{ github.event_name == 'pull_request' && github.run_attempt == 1 }}", + ); }); it('binds upload and download artifact names to the current rerun attempt', () => { const workflow = readRepositoryFile('.github/workflows/release.yml'); + const verifier = readRepositoryFile('.github/scripts/verify-release-artifacts.sh'); expect(workflow).toContain( 'name: release-disksage-${{ matrix.os }}-${{ github.run_attempt }}', ); expect( workflow.split('pattern: release-disksage-*-${{ github.run_attempt }}').length - 1, ).toBe(3); + expect(verifier).toContain('release-disksage-windows-2022-${run_attempt}'); + expect(verifier).not.toContain('release-disksage-windows-latest-${run_attempt}'); }); - it('documents retry-safe concurrency in authoritative evidence', () => { + it('documents trigger-aware release concurrency in authoritative evidence', () => { const doctoring = readRepositoryFile('docs/doctoring/release-artifact-provenance.md'); const changelog = readRepositoryFile('CHANGELOG.md'); - expect(doctoring).toContain('explicit rerun attempts do not cancel themselves'); - expect(doctoring).toContain('github.run_attempt == 1'); + expect(doctoring).toContain('superseded pull-request build'); + expect(doctoring).toContain("github.event_name == 'pull_request' && github.run_attempt == 1"); + expect(doctoring).toContain('Re-run all jobs'); expect(changelog).toContain('retry-safe release concurrency'); }); -}); \ No newline at end of file +}); diff --git a/src/lib/vitestWorkerBudgetContract.test.ts b/src/lib/vitestWorkerBudgetContract.test.ts new file mode 100644 index 000000000..69e8cf9dc --- /dev/null +++ b/src/lib/vitestWorkerBudgetContract.test.ts @@ -0,0 +1,23 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + +/** Read the Vitest configuration with stable line endings for worker-budget assertions. */ +function readVitestConfig(): string { + return readFileSync(resolve(repositoryRoot, 'vitest.config.ts'), 'utf8').replace(/\r\n?/g, '\n'); +} + +describe('Vitest hosted-runner worker budget', () => { + it('bounds CI worker concurrency without disabling file isolation or test files', () => { + const config = readVitestConfig(); + + expect(config).toContain('maxWorkers: process.env.CI ? 2 : undefined'); + expect(config).toContain('include: ["src/**/*.test.ts"]'); + expect(config).not.toContain('fileParallelism: false'); + expect(config).not.toContain('pool: "threads"'); + expect(config).not.toContain("pool: 'threads'"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 198e3dcb8..31c4f5d89 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,10 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { include: ["src/**/*.test.ts"], + // GitHub-hosted Ubuntu runners can expose enough logical CPUs for Vitest's + // default forks pool to exceed the job's memory budget. Keep file isolation + // and the complete test set; only bound concurrent worker processes in CI. + maxWorkers: process.env.CI ? 2 : undefined, coverage: { provider: "v8", // ponytail: 커버리지는 헤드리스로 검증 가능한 순수 로직과 mockable Tauri API 래퍼만 측정.