diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3795fb5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: ci + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Install pinned Node.js runtime + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24.15.0 + cache: yarn + cache-dependency-path: yarn.lock + + - name: Install frozen project dependencies + run: yarn install --frozen-lockfile + + - name: Run direct non-root project tests + run: | + set -euo pipefail + if [[ $(id -u) == 0 ]]; then + printf 'Direct project tests require an ordinary non-root runner\n' >&2 + exit 1 + fi + env -u OCI_V081_MATRIX_TEMP_ROOT npm test + + - name: Reject generated or tracked-file drift + run: git diff --exit-code diff --git a/tasks/completed/2026-08-06-1023_FIX_NON_ROOT_TEST_TEMP_ROOT_AND_ADD_DIRECT_CI.md b/tasks/completed/2026-08-06-1023_FIX_NON_ROOT_TEST_TEMP_ROOT_AND_ADD_DIRECT_CI.md new file mode 100644 index 0000000..1e4aacb --- /dev/null +++ b/tasks/completed/2026-08-06-1023_FIX_NON_ROOT_TEST_TEMP_ROOT_AND_ADD_DIRECT_CI.md @@ -0,0 +1,125 @@ +# Fix Non-Root Test Temp Root And Add Direct CI + +Status: completed. + +Baseline: `v0.9.1` release commit +`db35bb8eefef173aa052b1264b973bdb0794912d`; working baseline +`2e65be81527dbb4e4fa99c3775715b7c1be7aa72`. + +This is a repository QA and test-harness maintenance change. It does not alter +the Rush Delivery module, Action, metadata, schema, or consumer configuration +contract, and it must not retarget or republish `v0.9.1`. + +## Context + +A clean direct `npm test` run as an ordinary non-root user exposes one harness +failure in the v0.8.1 OCI compatibility archive-helper test. The test sources +[`../../test/scripts/lib/oci-v081-acceptance-matrix.sh`](../../test/scripts/lib/oci-v081-acceptance-matrix.sh) +without the matrix runner that normally initializes +`OCI_V081_MATRIX_TEMP_ROOT`. The unset value produces root-level +`/rush-delivery-v081-*` `mktemp` templates, which a root Dagger test container +can create but an ordinary user correctly cannot. + +Setting the private variable to `/tmp` is a diagnostic workaround, not an +acceptable repository or CI requirement. The helper library must own a safe +standard fallback, and CI must exercise the unconfigured non-root path directly. + +## Decisions And Constraints + +- [x] Preserve the production matrix runner's existing `TMPDIR` then `/tmp` + default and its cleanup-bound temporary namespace. +- [x] Make direct library use resolve the same default without requiring a + caller-specific environment workaround. +- [x] Do not expose `OCI_V081_MATRIX_TEMP_ROOT` as a Rush Delivery consumer + setting or add it to project documentation and examples. +- [x] Keep archive member/link validation, exact-file cleanup, and failure + behavior unchanged. +- [x] Add automatic pull-request and `main` push CI with `contents: read` only, + immutable third-party Action pins, the project-pinned Node runtime, and the + existing frozen Yarn install. +- [x] Run direct `npm test` with `OCI_V081_MATRIX_TEMP_ROOT` explicitly absent; + never configure the workaround in CI. +- [x] Keep this work release-neutral: no version, schema snapshot, provenance, + site-version, release, or tag changes. + +## Phase 1: Repair And Prove Temp-Root Ownership + +- [x] Initialize the library's task-specific temp root from an existing explicit + value, otherwise standard `TMPDIR`, otherwise `/tmp`. +- [x] Update the archive-helper regression to remove the task-specific variable, + provide an isolated standard temp directory, and prove scratch files are + cleaned. +- [x] Reproduce the old failure as a non-root user before the fix and prove the + corrected focused suite succeeds without the private override. + +### Phase 1 Exit Gate + +- [x] Direct archive-helper use is portable for non-root callers and retains the + existing bounded cleanup contract. + +## Phase 2: Add Direct Repository CI + +- [x] Add a dedicated CI workflow for pull requests and pushes to `main`. +- [x] Install with `yarn install --frozen-lockfile`, run direct `npm test` with + the private override absent, and fail on generated or tracked-file drift. + Keep clean-checkout type-checking in the Dagger self-check, which injects + the generated, ignored `sdk` directory before running `yarn typecheck`. +- [x] Add workflow contract tests for triggers, least privilege, immutable Action + pins, pinned Node version, direct command path, unset override, and clean + tree verification. +- [x] Keep credentialed/mutating OCI acceptance manual and separate from this + ordinary non-secret CI job. + +### Phase 2 Exit Gate + +- [x] An ordinary GitHub-hosted runner will catch the original regression on + every proposed change without registry credentials or package-write + permission. + +## Phase 3: Validation And Handoff + +- [x] Run the focused matrix and CI workflow contract tests. +- [x] Run a clean archived `npm test` as a non-root user with both + `OCI_V081_MATRIX_TEMP_ROOT` and `TMPDIR` initially absent. +- [x] Run the complete direct test suite, `yarn typecheck`, workflow/shell lint, + `git diff --check`, and the clean Dagger self-check. +- [x] Review the final diff for unrelated changes, mutable pins, secret access, + excessive permissions, and release/version drift. +- [x] Commit and push reviewable changes, then open a draft pull request through + the normal repository flow. +- [x] Record validation and pull-request evidence, mark every item complete, and + move this file to `tasks/completed` without changing a released tag. + +## Completion Criteria + +- [x] A clean non-root direct `npm test` passes without the private matrix temp + override. +- [x] Direct CI runs automatically and cannot silently reintroduce the override. +- [x] Production acceptance behavior and all consumer contracts remain + unchanged. +- [x] `v0.9.1` remains immutable. + +## Validation Evidence + +- Before the repair, a clean direct test run as `nobody`, with the task-specific + override absent, reproduced the single archive-helper failure: 446 passed and + 1 failed out of 447 tests. +- After the repair, the same clean non-root path, with both the task-specific + override and `TMPDIR` initially absent, passed all 448 tests. +- The focused OCI matrix, CI workflow, and documentation contract suites passed + all 30 tests. +- `yarn install --frozen-lockfile`, `yarn typecheck`, repository-wide Trunk + checks, shell syntax validation, and `git diff --check` passed. +- The first GitHub-hosted run proved that direct `npm test` passes as the runner + user. Its subsequent standalone `yarn typecheck` failed because a clean Git + checkout correctly excludes the generated `sdk` directory. CI was aligned + with project architecture by leaving that generated-SDK-aware check in the + clean Dagger self-check instead of inventing a second SDK bootstrap path. +- The corrected clean Dagger self-check passed all 448 tests and its generated + SDK-aware `yarn typecheck` on commit + `29b155a0f66d329c689555d1e776eaae666b2b1e`. +- The corrected GitHub-hosted pull-request CI passed in + [run 31093830711](https://github.com/BootstrapLaboratory/rush-delivery/actions/runs/31093830711), + and the review handoff is [pull request #7](https://github.com/BootstrapLaboratory/rush-delivery/pull/7). +- The immutable `v0.9.1` tag still resolves to release commit + `db35bb8eefef173aa052b1264b973bdb0794912d`. diff --git a/test/ci-workflow.test.ts b/test/ci-workflow.test.ts new file mode 100644 index 0000000..4983121 --- /dev/null +++ b/test/ci-workflow.test.ts @@ -0,0 +1,82 @@ +import * as assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import * as path from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { parse as parseYaml } from "yaml"; + +const testDirectory = path.dirname(fileURLToPath(import.meta.url)); +const workflowPath = path.join(testDirectory, "../.github/workflows/ci.yml"); + +test("direct CI runs the unconfigured non-root project test path", async () => { + const source = await readFile(workflowPath, "utf8"); + const workflow = parseYaml(source) as { + concurrency: { "cancel-in-progress": boolean; group: string }; + jobs: { + test: { + "runs-on": string; + steps: Array<{ + name: string; + run?: string; + uses?: string; + with?: Record; + }>; + "timeout-minutes": number; + }; + }; + on: { pull_request: unknown; push: { branches: string[] } }; + permissions: Record; + }; + + assert.ok(Object.hasOwn(workflow.on, "pull_request")); + assert.deepEqual(workflow.on.push.branches, ["main"]); + assert.deepEqual(workflow.permissions, { contents: "read" }); + assert.deepEqual(workflow.concurrency, { + "cancel-in-progress": true, + group: "ci-${{ github.workflow }}-${{ github.ref }}", + }); + assert.equal(workflow.jobs.test["runs-on"], "ubuntu-latest"); + assert.equal(workflow.jobs.test["timeout-minutes"], 30); + + const steps = workflow.jobs.test.steps; + assert.ok( + steps.some( + (step) => + step.uses === + "actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803", + ), + ); + assert.ok( + steps.some( + (step) => + step.uses === + "actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38" && + step.with?.["node-version"] === "24.15.0" && + step.with.cache === "yarn" && + step.with["cache-dependency-path"] === "yarn.lock", + ), + ); + + const commands = steps.flatMap((step) => (step.run ? [step.run] : [])); + assert.ok(commands.includes("yarn install --frozen-lockfile")); + assert.ok(commands.includes("git diff --exit-code")); + assert.ok( + commands.some( + (command) => + command.includes("[[ $(id -u) == 0 ]]") && + command.includes("env -u OCI_V081_MATRIX_TEMP_ROOT npm test"), + ), + ); + + assert.doesNotMatch(source, /OCI_V081_MATRIX_TEMP_ROOT:\s*\S+/u); + assert.doesNotMatch(source, /packages:\s+write/u); + assert.doesNotMatch(source, /secrets\./u); + + const actionReferences = [ + ...source.matchAll(/^\s*uses:\s+\S+@([^\s#]+)(?:\s+#.*)?$/gmu), + ]; + assert.equal(actionReferences.length, 2); + for (const reference of actionReferences) { + assert.match(reference[1], /^[a-f0-9]{40}$/u); + } +}); diff --git a/test/documentation-contract.test.ts b/test/documentation-contract.test.ts index 550a1e8..d84ed39 100644 --- a/test/documentation-contract.test.ts +++ b/test/documentation-contract.test.ts @@ -571,6 +571,7 @@ test("generic onboarding snippets remain OCI-credential-free and provider-off", test("current production snippets pin third-party actions to reviewed commits", async () => { const productionFiles = [ + ".github/workflows/ci.yml", ".github/workflows/oci-acceptance.yml", ".github/workflows/pages.yml", ".github/workflows/release-smoke.yml", diff --git a/test/oci-live-matrix-contract.test.ts b/test/oci-live-matrix-contract.test.ts index a48f975..f157190 100644 --- a/test/oci-live-matrix-contract.test.ts +++ b/test/oci-live-matrix-contract.test.ts @@ -54,9 +54,14 @@ const realFaultHookPath = path.join( ); const gitSha = "0123456789abcdef0123456789abcdef01234567"; -async function runBash(source: string, args: string[] = []) { +async function runBash( + source: string, + args: string[] = [], + environment: NodeJS.ProcessEnv = process.env, +) { return execFileAsync("bash", ["-c", source, "matrix-test", ...args], { encoding: "utf8", + env: environment, }); } @@ -305,6 +310,11 @@ test("archive helpers verify external checksum and preserve bytes, modes, and sy const checksum = path.join(temporaryRoot, "protected", "bundle.sha256"); const sourceRecord = path.join(temporaryRoot, "protected", "bundle.git-sha"); const restored = path.join(temporaryRoot, "restored", "bundle"); + const environment: NodeJS.ProcessEnv = { + ...process.env, + TMPDIR: temporaryRoot, + }; + delete environment.OCI_V081_MATRIX_TEMP_ROOT; try { await buildFixture("oci-isolation", fixture); @@ -318,6 +328,7 @@ test("archive helpers verify external checksum and preserve bytes, modes, and sy 'oci_v081_matrix_restore_archive "$3" "$4" "$7"', ].join("; "), [libraryPath, fixture, archive, checksum, sourceRecord, gitSha, restored], + environment, ); assert.deepEqual( await readFile( @@ -335,6 +346,12 @@ test("archive helpers verify external checksum and preserve bytes, modes, and sy true, ); assert.equal(await readFile(sourceRecord, "utf8"), `${gitSha}\n`); + assert.deepEqual( + (await readdir(temporaryRoot)).filter((entry) => + entry.startsWith("rush-delivery-v081-archive-"), + ), + [], + ); } finally { await rm(temporaryRoot, { force: true, recursive: true }); } diff --git a/test/scripts/lib/oci-v081-acceptance-matrix.sh b/test/scripts/lib/oci-v081-acceptance-matrix.sh index b2d6aa6..471db61 100644 --- a/test/scripts/lib/oci-v081-acceptance-matrix.sh +++ b/test/scripts/lib/oci-v081-acceptance-matrix.sh @@ -9,6 +9,7 @@ OCI_V081_MATRIX_DAGGER_TIMEOUT_SECONDS=900 OCI_V081_MATRIX_LIVE_MUTATION_TIMEOUT_SECONDS=1200 OCI_V081_MATRIX_INVENTORY_TIMEOUT_SECONDS=300 OCI_V081_MATRIX_PROTECTED_SCAN_TIMEOUT_SECONDS=300 +OCI_V081_MATRIX_TEMP_ROOT="${OCI_V081_MATRIX_TEMP_ROOT:-${TMPDIR:-/tmp}}" oci_v081_matrix_require_commands() { local command_name