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
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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`.
82 changes: 82 additions & 0 deletions test/ci-workflow.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}>;
"timeout-minutes": number;
};
};
on: { pull_request: unknown; push: { branches: string[] } };
permissions: Record<string, string>;
};

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);
}
});
1 change: 1 addition & 0 deletions test/documentation-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 18 additions & 1 deletion test/oci-live-matrix-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}

Expand Down Expand Up @@ -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);
Expand All @@ -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(
Expand All @@ -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 });
}
Expand Down
1 change: 1 addition & 0 deletions test/scripts/lib/oci-v081-acceptance-matrix.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down