diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 614712c..eecb909 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,8 +9,8 @@ permissions: contents: read concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true + group: ci-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: check: diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index b47e886..fbed519 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -6,6 +6,13 @@ on: tag: required: true type: string + mode: + description: >- + canonical when the calling Release run verified this exact tag with the + complete source check; mirror for a current-main retry, which reruns it. + required: false + default: mirror + type: string permissions: contents: read @@ -63,8 +70,10 @@ jobs: id: identity env: REQUESTED_TAG: ${{ inputs.tag }} + RELEASE_MODE: ${{ inputs.mode }} run: | set -euo pipefail + [[ "$RELEASE_MODE" == canonical || "$RELEASE_MODE" == mirror ]] [[ "$GITHUB_REPOSITORY" == hraness/direct && "$GITHUB_REPOSITORY_ID" == 1306913032 && "$GITHUB_ACTOR_ID" == 894119 ]] [[ "$REQUESTED_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] git fetch --no-tags --unshallow origin "refs/heads/main:refs/remotes/origin/main" @@ -79,6 +88,12 @@ jobs: EXPECTED_VERSION="${REQUESTED_TAG#v}" EXPECTED_WORKFLOW_SHA="$workflow_sha" \ node "$GITHUB_WORKSPACE/scripts/github-release.ts" mirror "$canonical_directory" [[ "$(node -p 'JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).sourceSha' "$canonical_directory/release-manifest.json")" == "$source_sha" ]] + if [[ "$RELEASE_MODE" == canonical ]]; then + # The complete source check below is reused only from this exact Release + # run: its Verify job checked the same tag commit before attestation. + [[ "$GITHUB_EVENT_NAME" == push && "$GITHUB_REF" == "refs/tags/$REQUESTED_TAG" && "$GITHUB_SHA" == "$source_sha" ]] + [[ "$(node -p 'String(JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).runId)' "$canonical_directory/release-manifest.json")" == "$GITHUB_RUN_ID" ]] + fi source_tree="$RUNNER_TEMP/direct-mirror-source" git worktree add --detach "$source_tree" "$source_sha" printf 'source_sha=%s\nworkflow_sha=%s\nsource_tree=%s\ncanonical_directory=%s\n' \ @@ -118,7 +133,9 @@ jobs: printf 'already_public=%s\n' "$already_public" >> "$GITHUB_OUTPUT" - run: bun install --frozen-lockfile --ignore-scripts working-directory: ${{ steps.identity.outputs.source_tree }} - - run: bun run check + - name: Complete source check (mirror retries only) + if: inputs.mode != 'canonical' + run: bun run check working-directory: ${{ steps.identity.outputs.source_tree }} - name: Verify generated tree working-directory: ${{ steps.identity.outputs.source_tree }} @@ -756,13 +773,32 @@ jobs: EXPECTED_VERSION="$package_version" EXPECTED_WORKFLOW_SHA="$WORKFLOW_SHA" \ node "$GITHUB_WORKSPACE/scripts/github-release.ts" mirror-verify "$source_directory" mkdir -p "$registry_directory" + # A fresh version can take a minute to reach every registry replica, and + # npm caches packuments, so poll with --prefer-online for up to 5 minutes. + # Replace this poll with the shared npm-visible action once it exists. + registry_poll_err="$RUNNER_TEMP/registry-poll.err" + for attempt in $(seq 1 20); do + if [[ "$(npm view "$package_spec" version --prefer-online \ + --registry=https://registry.npmjs.org 2>"$registry_poll_err")" == "$package_version" ]]; then + break + fi + if [[ "$attempt" == 20 ]]; then + echo "::error::$package_spec did not become visible on the npm registry" + echo "Last npm view stderr:" + cat "$registry_poll_err" || true + exit 1 + fi + sleep 15 + done npm pack "$package_spec" \ + --prefer-online \ --ignore-scripts \ --json \ --pack-destination "$registry_directory" \ --registry=https://registry.npmjs.org \ > "$registry_pack_json" npm view "$package_spec" name version dist \ + --prefer-online \ --json \ --registry=https://registry.npmjs.org \ > "$registry_view_json" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2bb786..11daf07 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -318,6 +318,7 @@ jobs: uses: ./.github/workflows/npm-publish.yml with: tag: ${{ needs.authorize.outputs.tag }} + mode: ${{ needs.authorize.outputs.mode }} legacy: name: Recover historical GitHub Release diff --git a/AGENTS.md b/AGENTS.md index 090aeab..af63c94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ - State proof limits precisely. Fixture evidence does not prove the live adapter, service, host, operating system, or device behavior that the composition replaces. In public copy, state each limit once, beside the claim it limits, in the reader's terms (`STYLE.md`). - Use focused local checks and independent impact review, then require complete CI on the exact current head, authoritative current `main`, and checked integration tree as the final source aggregate. CI must run the complete root `bun run check`, preserve committed-output cleanliness, and pass both `check` and `Required` in the same run attempt. Compare workflow, command, discovery, deadline, and platform changes against the prior required coverage; keep the coverage contracts passing. Record source, lockfile, toolchain, and run identity. Keep `bun run check` available as the complete local fallback when coverage or equivalence is uncertain, and diagnose known failures before requalification. This source receipt does not replace explicit local, native, browser, install, release, npm-mirror, or provider acceptance. See `CONTRIBUTING.md` for the final receipt. Run the todo example's production build and marker scan when changing the example or package boundaries. - Run the React Native example's iOS, Android, and web export gate when changing mobile integration or production boundaries. -- Follow `docs/publishing.md` for canonical GitHub releases and optional exact-archive npm mirrors. Treat one protected lightweight `v*` tag on `main` as the complete release request. Restrict version-tag creation to organization administrators and block updates/deletion with no bypass actors. Keep the complete source gate and isolated installation before checkout-free four-subject attestation and immutable five-asset publication. Bind verified certificate, source, current helper closure, original run/attempt, and actual remote bytes; preserve draft IDs and reconcile uncertain writes before retrying. npm runs only after all required canonical jobs succeed. Within the npm workflow, only its minimal dependent publication job may request OIDC; rebind the downloaded exact artifact, every remote stable tag, and current `main` immediately before publication. Preserve the full mirror source gate and registry identity/install readback. Current-main mirror retries retain the original canonical source and provenance. Historical asset-free recovery is restricted to versions at or below 0.7.20: bind current helpers to reviewed Git blobs and invoke them against exact tagged source with config/environment loading disabled and `npm pack --ignore-scripts`, so recovery never runs a historical `prepack`. Never move a tag or replace a package version or immutable artifact. Record canonical and mirror delivery outcomes separately. +- Follow `docs/publishing.md` for canonical GitHub releases and optional exact-archive npm mirrors. Treat one protected lightweight `v*` tag on `main` as the complete release request. Restrict version-tag creation to organization administrators and block updates/deletion with no bypass actors. Keep the complete source gate and isolated installation before checkout-free four-subject attestation and immutable five-asset publication. Bind verified certificate, source, current helper closure, original run/attempt, and actual remote bytes; preserve draft IDs and reconcile uncertain writes before retrying. npm runs only after all required canonical jobs succeed. Within the npm workflow, only its minimal dependent publication job may request OIDC; rebind the downloaded exact artifact, every remote stable tag, and current `main` immediately before publication. Preserve the full mirror source gate on current-main mirror retries; a canonical tag run may reuse only its own Verify job's complete check of the identical tag commit. Preserve registry identity/install readback. Current-main mirror retries retain the original canonical source and provenance. Historical asset-free recovery is restricted to versions at or below 0.7.20: bind current helpers to reviewed Git blobs and invoke them against exact tagged source with config/environment loading disabled and `npm pack --ignore-scripts`, so recovery never runs a historical `prepack`. Never move a tag or replace a package version or immutable artifact. Record canonical and mirror delivery outcomes separately. - Public copy (websites, READMEs, docs, package and GitHub descriptions, CLI help, `llms.txt`, generated pages) follows `STYLE.md`, synced from hraness/.github. Text a model writes for publication also follows `GENERATION_STYLE.md`. diff --git a/docs/publishing.md b/docs/publishing.md index 71b28c5..7a06a9d 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -145,9 +145,12 @@ job failed remains usable only when all four canonical jobs succeeded. The read-only mirror job verifies the immutable GitHub record, all five assets, actual bytes, and cryptographic provenance. It keeps the canonical source `C` separate from protected current workflow `W`, checks out `C` in an isolated tree, -and retains the frozen install, complete `bun run check`, generated cleanliness, -and exact-archive isolated installation. Current helpers are bound to `W` before -running against `C`. It copies exactly the tarball, `npm-pack.json`, and +and retains the frozen install, generated cleanliness, and exact-archive isolated +installation. A current-main mirror retry also reruns the complete `bun run check` +on `C`. A canonical tag run instead reuses the complete check its own **Verify** +job ran on the same tag commit; the mirror job requires that the release manifest +names this run and that `C` is the pushed tag commit before it skips the rerun. +Current helpers are bound to `W` before running against `C`. It copies exactly the tarball, `npm-pack.json`, and `npm-package.sha256` into the privileged handoff; it never repacks the mirror. Within the npm workflow, the only job with OIDC authority checks out no source @@ -159,8 +162,9 @@ The candidate must remain the newest remote stable tag and be newer than every published stable npm version. Fresh main/tag reads immediately precede the exact `npm publish --ignore-scripts --provenance` to `https://registry.npmjs.org`. -The read-only registry job compares the canonical archive with the public npm -package by complete extracted safe path, entry type, mode, size, and regular-file +The read-only registry job first waits up to five minutes, reading past the npm +metadata cache, for the new version to become visible. It then compares the +canonical archive with the public npm package by complete extracted safe path, entry type, mode, size, and regular-file SHA-256/SHA-512. Each transport must independently match its npm SHA-1/SHA-512 and registry metadata, since registry compression can differ. It then installs that registry archive in clean Bun/npm consumers. An existing npm version is left diff --git a/examples/todos/README.md b/examples/todos/README.md index 81478c6..a6dc522 100644 --- a/examples/todos/README.md +++ b/examples/todos/README.md @@ -22,7 +22,7 @@ bun run example:dev bun run example:direct ``` -Use Node 24 and the frozen repository dependencies. Each command compiles one immutable StyleX generation with the pinned Vite 8.2.1/Rolldown 1.2.8 toolchain, then serves it on `127.0.0.1:5173`. Run only one preview at a time. Production opens at `/`; the workbench opens at `/direct/`. After editing a recipe or component, stop the command, rebuild/restart it, and refresh the browser. This compiled preview does not claim HMR or React-plugin support. Ctrl-C closes the owned Vite preview server. +Use Node 24 and the frozen repository dependencies. Each command compiles one immutable StyleX generation with the pinned Vite 8.2.1/Rolldown 1.2.8 toolchain, then serves it on `127.0.0.1:5173`. Set `DIRECT_EXAMPLE_PORT` to serve on another port, or to `0` for a free one; the command prints the address it bound. Run only one preview per port. Production opens at `/`; the workbench opens at `/direct/`. After editing a recipe or component, stop the command, rebuild/restart it, and refresh the browser. This compiled preview does not claim HMR or React-plugin support. Ctrl-C closes the owned Vite preview server. The workbench provides three stable scenarios: diff --git a/examples/todos/preview.ts b/examples/todos/preview.ts index 17efa53..b7a814d 100644 --- a/examples/todos/preview.ts +++ b/examples/todos/preview.ts @@ -2,15 +2,29 @@ import { preview } from "vite"; import { buildTodo } from "./build.ts"; import { parseTodoBuildTarget } from "./build-contract.ts"; +const defaultPort = 5173; + +// DIRECT_EXAMPLE_PORT lets parallel worktrees serve side by side; 0 picks a free port. +function parsePreviewPort(value: string | undefined): number { + if (value === undefined || value === "") return defaultPort; + if (!/^(0|[1-9][0-9]{0,4})$/u.test(value) || Number(value) > 65_535) { + throw new Error(`DIRECT_EXAMPLE_PORT must be an integer from 0 to 65535, got ${JSON.stringify(value)}`); + } + return Number(value); +} + const target = parseTodoBuildTarget(process.argv[2]); +const port = parsePreviewPort(process.env.DIRECT_EXAMPLE_PORT); const directory = await buildTodo(target); const server = await preview({ configFile: false, root: directory, build: { outDir: directory }, - preview: { host: "127.0.0.1", port: 5173, strictPort: true, open: false }, + preview: { host: "127.0.0.1", port, strictPort: true, open: false }, }); -console.log(`Compiled Todo preview: http://127.0.0.1:5173/${target === "direct" ? "direct/" : ""}`); +const address = server.httpServer.address(); +const boundPort = typeof address === "object" && address !== null ? address.port : port; +console.log(`Compiled Todo preview: http://127.0.0.1:${String(boundPort)}/${target === "direct" ? "direct/" : ""}`); console.log(`Generation: ${directory}\nAfter edits, stop this command, rebuild/restart, then refresh the browser. HMR is disabled.`); let closing = false; function close(): void { diff --git a/scripts/ci-source-coverage.test.ts b/scripts/ci-source-coverage.test.ts index f4a0d43..aa48d65 100644 --- a/scripts/ci-source-coverage.test.ts +++ b/scripts/ci-source-coverage.test.ts @@ -20,6 +20,8 @@ const releaseWorkflow = record(Bun.YAML.parse(readFileSync(new URL("../.github/w const canonicalPackScript = 'set -euo pipefail\npackage_directory="$(mktemp -d "$RUNNER_TEMP/direct-canonical-ci.XXXXXX")"\nbun --no-env-file --config=/dev/null run ./scripts/prepare-npm-package.ts "$package_directory"\ncat "$package_directory/npm-pack.json"\n'; const canonicalPackStep = " - name: Verify canonical npm archive\n run: |\n" + canonicalPackScript.trimEnd().split("\n").map((line) => ` ${line}\n`).join(""); +const perCommitConcurrency = "concurrency:\n group: ci-${{ github.event_name == 'pull_request' && github.ref || github.sha }}\n cancel-in-progress: ${{ github.event_name == 'pull_request' }}\n"; +const priorConcurrency = "concurrency:\n group: ci-${{ github.ref }}\n cancel-in-progress: true\n"; const phases = [ "typecheck", "check:effect", "test:npm-release", "build", "test:package", "test", "lint", "example:test", "example:typecheck", "example:verify", "example:react-native:test", @@ -101,8 +103,11 @@ function assertSourceCoverage(packageValue: RecordValue, workflowValue: RecordVa test("complete CI retains the root aggregate, release-contract discovery, and committed-output checks", () => { const current = readFileSync(new URL("../.github/workflows/ci.yml", import.meta.url), "utf8"); - // Removing only this additive pack gate and its Node patch pin must recover all prior CI bytes. - const prior = current.replace(canonicalPackStep, "").replace('node-version: "24.18.1"', 'node-version: "24"'); + // Removing only this additive pack gate, its Node patch pin, and the per-commit main + // concurrency (PR runs still cancel superseded heads) must recover all prior CI bytes. + expect(current).toContain(perCommitConcurrency); + const prior = current.replace(canonicalPackStep, "").replace('node-version: "24.18.1"', 'node-version: "24"') + .replace(perCommitConcurrency, priorConcurrency); expect(createHash("sha256").update(prior).digest("hex")).toBe("b47f2d0ead76414025eddf15dedbd940ba8114a53fc93b9efecc78554015aaa0"); assertSourceCoverage(manifest, workflow); }); diff --git a/scripts/npm-publish-workflow.test.ts b/scripts/npm-publish-workflow.test.ts index 54595e2..edb9bfc 100644 --- a/scripts/npm-publish-workflow.test.ts +++ b/scripts/npm-publish-workflow.test.ts @@ -105,6 +105,27 @@ const historicalRecoverySources = [ }, ] as const; +// Each historical recovery test is an isolated mkdtemp tree whose cost is mostly +// single-threaded child processes (bun add, tsc, npm pack). Run them concurrently, +// but cap in-flight work so a 4 vCPU runner is not oversubscribed. The per-test +// timeout covers the queue wait as well as the recovery itself. +const historicalRecoveryConcurrency = 4; +const historicalRecoveryTimeoutMs = 600_000; +let historicalRecoverySlots = historicalRecoveryConcurrency; +const historicalRecoveryWaiters: Array<() => void> = []; + +async function withHistoricalRecoverySlot(work: () => Promise): Promise { + if (historicalRecoverySlots > 0) historicalRecoverySlots -= 1; + else await new Promise((resolve) => { historicalRecoveryWaiters.push(resolve); }); + try { + await work(); + } finally { + const next = historicalRecoveryWaiters.shift(); + if (next === undefined) historicalRecoverySlots += 1; + else next(); + } +} + function workflowStepScript(workflow: string, name: string): string { const stepMarker = ` - name: ${name}\n`; const stepStart = workflow.indexOf(stepMarker); @@ -440,6 +461,33 @@ import { isUtf8ByteLengthAtMost } from "./utf8-byte-boundary.js"; expect(readme).not.toMatch(/source candidate/iu); }); + test("reuses the canonical source check only from the same Release run and waits for registry propagation", async () => { + const [workflow, releaseWorkflow] = await Promise.all([ + readFile(publishWorkflowUrl, "utf8"), + readFile(releaseWorkflowUrl, "utf8"), + ]); + const verifyJob = workflow.slice(workflow.indexOf("\n verify:\n"), workflow.indexOf("\n publish:\n")); + const registryJob = workflow.slice(workflow.indexOf("\n registry:\n")); + + expect(workflow).toContain(" mode:\n"); + expect(workflow).toContain(" default: mirror\n"); + expect(releaseWorkflow).toContain(" tag: ${{ needs.authorize.outputs.tag }}\n mode: ${{ needs.authorize.outputs.mode }}\n"); + expect(verifyJob).toContain('[[ "$RELEASE_MODE" == canonical || "$RELEASE_MODE" == mirror ]]'); + expect(verifyJob).toContain('[[ "$GITHUB_EVENT_NAME" == push && "$GITHUB_REF" == "refs/tags/$REQUESTED_TAG" && "$GITHUB_SHA" == "$source_sha" ]]'); + expect(verifyJob).toContain('.runId)\' "$canonical_directory/release-manifest.json")" == "$GITHUB_RUN_ID" ]]'); + const guard = verifyJob.indexOf('if [[ "$RELEASE_MODE" == canonical ]]; then'); + expect(guard).toBeGreaterThan(verifyJob.indexOf("github-release.ts\" mirror")); + expect(verifyJob.match(/^ {8}if: .*$/gmu) ?? []).toEqual([" if: inputs.mode != 'canonical'"]); + expect(verifyJob).toContain(" if: inputs.mode != 'canonical'\n run: bun run check\n"); + + const poll = registryJob.indexOf('npm view "$package_spec" version --prefer-online'); + expect(poll).toBeGreaterThan(registryJob.indexOf("mirror-verify")); + expect(poll).toBeLessThan(registryJob.indexOf('npm pack "$package_spec"')); + expect(registryJob).toContain('npm pack "$package_spec" \\\n --prefer-online'); + expect(registryJob).toContain('npm view "$package_spec" name version dist \\\n --prefer-online'); + expect(registryJob).toContain('if [[ "$attempt" == 20 ]]; then'); + }); + test("separates read-only verification from the exact terminal OIDC publish", async () => { const [workflow, releaseWorkflow] = await Promise.all([ readFile(publishWorkflowUrl, "utf8"), @@ -1093,9 +1141,9 @@ describe("canonical npm package identity", () => { } }); - for (const release of historicalRecoverySources) test( + for (const release of historicalRecoverySources) test.concurrent( `current tools prepare and smoke exact v${release.version} source without tagged helpers`, - async () => { + () => withHistoricalRecoverySlot(async () => { const work = await mkdtemp(join(tmpdir(), "direct-release-recovery-test-")); try { const sourceArchive = join(work, `v${release.version}-source.tar`); @@ -1168,7 +1216,7 @@ describe("canonical npm package identity", () => { } finally { await rm(work, { force: true, recursive: true }); } - }, - 180_000, + }), + historicalRecoveryTimeoutMs, ); });