From df464ee462989fcda3544390caa65bf51c189d9c Mon Sep 17 00:00:00 2001 From: TensorNull Date: Tue, 21 Jul 2026 14:25:52 +0800 Subject: [PATCH] harden Public Preview and alpha bootstrap gates --- .github/workflows/publish.yml | 25 +++-- AGENTS.md | 1 + ARCHITECTURE.md | 3 + CHANGELOG.md | 2 + RELEASING.md | 16 +-- ROADMAP.md | 15 +-- SECURITY.md | 3 +- package.json | 1 + scripts/check-public-preview.mjs | 17 +++- scripts/check-self-contained.mjs | 141 ++------------------------- scripts/check-standalone-content.mjs | 13 +++ scripts/standalone-content.mjs | 137 ++++++++++++++++++++++++++ tests/standalone-content.test.mjs | 80 +++++++++++++++ tests/workflow-contract.test.mjs | 30 ++++++ 14 files changed, 329 insertions(+), 155 deletions(-) create mode 100644 scripts/check-standalone-content.mjs create mode 100644 scripts/standalone-content.mjs create mode 100644 tests/standalone-content.test.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 610863c..17bae3d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -150,8 +150,8 @@ jobs: cancel-in-progress: false runs-on: ubuntu-latest timeout-minutes: 5 - # Required repository configuration: configure the protected live-smoke environment, - # required reviewers, and COMETAPI_KEY before publishing a release. + # Required repository configuration: configure the protected live-smoke environment + # without required reviewers and add COMETAPI_KEY before publishing a release. environment: live-smoke steps: - name: Check out the verified release tag @@ -180,14 +180,14 @@ jobs: run: npm run test:live publish: - name: Publish with npm Trusted Publishing + name: Publish with npm Trusted Publishing or alpha.1 bootstrap needs: - live-smoke - verify runs-on: ubuntu-latest timeout-minutes: 15 - # Required repository configuration: configure the protected npm environment, required - # reviewers, package ownership, and this workflow as the Trusted Publisher. + # Required repository configuration: configure the protected npm environment with + # approval by the current release approver and self-review allowed, plus package ownership. environment: name: npm url: https://www.npmjs.com/package/cometapi/v/${{ needs.verify.outputs.version }} @@ -209,11 +209,19 @@ jobs: path: release-artifacts - name: Publish the exact artifact with provenance env: + ALPHA1_BOOTSTRAP_ENABLED: ${{ vars.NPM_ALPHA1_BOOTSTRAP_ENABLED }} DIST_TAG: ${{ needs.verify.outputs.dist-tag }} + NODE_AUTH_TOKEN: ${{ vars.NPM_ALPHA1_BOOTSTRAP_ENABLED == 'true' && needs.verify.outputs.version == '0.1.0-alpha.1' && secrets.NPM_ALPHA1_BOOTSTRAP_TOKEN || '' }} VERSION: ${{ needs.verify.outputs.version }} shell: bash run: | set -euo pipefail + if [[ "$ALPHA1_BOOTSTRAP_ENABLED" == "true" && \ + ( "$VERSION" != "0.1.0-alpha.1" || "$DIST_TAG" != "next" ) ]]; then + echo "The token bootstrap is restricted to cometapi@0.1.0-alpha.1 on the next dist-tag." >&2 + exit 1 + fi + mapfile -t tarballs < <(find release-artifacts -maxdepth 1 -type f -name '*.tgz' -print) if [[ "${#tarballs[@]}" -ne 1 ]]; then echo "Expected exactly one downloaded artifact, found ${#tarballs[@]}." >&2 @@ -235,6 +243,10 @@ jobs: EOF echo "cometapi@${VERSION} already matches the verified artifact; resuming checks." elif grep -q "E404" "$view_error"; then + if [[ "$ALPHA1_BOOTSTRAP_ENABLED" == "true" && -z "$NODE_AUTH_TOKEN" ]]; then + echo "NPM_ALPHA1_BOOTSTRAP_TOKEN is required when the alpha.1 bootstrap is enabled." >&2 + exit 1 + fi npm publish "${tarballs[0]}" --access public --provenance --tag "$DIST_TAG" else echo "Unable to determine whether cometapi@${VERSION} already exists." >&2 @@ -363,6 +375,3 @@ jobs: process.exitCode = 1; }); EOF - - # The conditional first-publication token bootstrap is intentionally - # not encoded here. It is an owner-run, one-time exception only. diff --git a/AGENTS.md b/AGENTS.md index 453ca15..caec729 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -176,6 +176,7 @@ npm run format:check npm run test:package npm run test:fixtures npm run test:compat +npm run check:standalone-content npm run check:self-contained npm run actionlint npm run verify diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ef7c362..0cf5354 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -87,6 +87,9 @@ tag and GitHub release agreement. The publish workflow is the sole source of npm dist-tag selection: prereleases use `next`, stable versions use `latest`. The package manifest must not carry a static dist-tag because that would make stable and prerelease policy diverge. +Trusted Publishing remains the default authentication path. The only token +path is an explicitly enabled protected-environment fallback that rejects every +version except `0.1.0-alpha.1` and every dist-tag except `next`. ## Testing layers diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a6b239..80fb474 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ No user-visible changes are currently recorded beyond the initial alpha scope. - Made Public Preview and publication validation fail closed on canonical identity, public-safe standalone content, and release-ready status at the applicable gate. +- Added standalone-content scanning to the aggregated Public Preview gate and + encoded the protected, opt-in npm token bootstrap for `0.1.0-alpha.1` only. - Made the release workflow the sole npm dist-tag source: prereleases use `next`, stable releases use `latest`, and the package manifest has no static dist-tag. diff --git a/RELEASING.md b/RELEASING.md index 5e43a09..d363f0d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -96,6 +96,7 @@ npm run test:package npm run test:live-contract npm run test:fixtures npm run test:compat +npm run check:standalone-content npm run check:self-contained npm run actionlint npm run verify @@ -290,14 +291,17 @@ Maintainers must first attempt normal Trusted Publisher configuration. If npm does not permit it before the package exists, only `0.1.0-alpha.1` may use this one-time exception: -1. A maintainer creates a short-lived granular read/write token with minimum scope - and non-interactive 2FA bypass from an account protected by 2FA. -2. The token is exposed only through a protected GitHub Environment to one - reviewed immutable-tag run on a GitHub-hosted runner. +1. A maintainer creates a short-lived granular read/write token with minimum + scope and non-interactive 2FA bypass from an account protected by 2FA. +2. In the protected `npm` environment, set + `NPM_ALPHA1_BOOTSTRAP_ENABLED=true` and expose the token only as + `NPM_ALPHA1_BOOTSTRAP_TOKEN` to one reviewed immutable-tag run. The workflow + rejects the bootstrap for every version except `0.1.0-alpha.1`, requires the + `next` dist-tag, and fails if the token is absent when publication is needed. 3. That run verifies and publishes the exact artifact with public access and provenance, then verifies registry installation. -4. A maintainer immediately configures OIDC, removes the environment secret, - revokes the token, and restricts token-based publishing. +4. A maintainer immediately configures OIDC, removes the environment variable + and secret, revokes the token, and restricts token-based publishing. 5. The project immediately prepares and publishes `0.1.0-alpha.2` through OIDC, verifies its provenance and public installation, and confirms that `next` resolves to `0.1.0-alpha.2`. diff --git a/ROADMAP.md b/ROADMAP.md index 84b5568..0cdd0af 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -320,15 +320,18 @@ Maintainers must first attempt to configure the Trusted Publisher normally. If npm does not allow configuration before the first package publication, only `0.1.0-alpha.1` may use a one-time bootstrap: -1. A maintainer creates a short-lived granular read/write publish token with the - minimum available scope and non-interactive 2FA bypass from an account +1. A maintainer creates a short-lived granular read/write publish token with + the minimum available scope and non-interactive 2FA bypass from an account protected by 2FA. -2. The token is exposed only through a protected GitHub Environment to one - reviewed immutable-tag run on a GitHub-hosted runner. +2. In the protected `npm` environment, set + `NPM_ALPHA1_BOOTSTRAP_ENABLED=true` and expose the token only as + `NPM_ALPHA1_BOOTSTRAP_TOKEN` to one reviewed immutable-tag run. The workflow + rejects that mode unless the version is exactly `0.1.0-alpha.1` and the + dist-tag is `next`. 3. That run builds and verifies the tag artifact, publishes it with public access and provenance, and verifies registry installation. -4. A maintainer immediately configures Trusted Publishing, removes the secret, - revokes the token, and restricts token-based publishing. +4. A maintainer immediately configures Trusted Publishing, removes the variable + and secret, revokes the token, and restricts token-based publishing. 5. The project immediately publishes `0.1.0-alpha.2` through OIDC, verifies its provenance and installation, and confirms that `next` resolves to `0.1.0-alpha.2`. diff --git a/SECURITY.md b/SECURITY.md index a4bb3d9..f21d374 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -59,4 +59,5 @@ environment, provenance, an immutable reviewed tag, and post-publication installation verification. Long-lived registry tokens are forbidden. The sole conditional bootstrap for the first alpha is documented in [RELEASING.md](./RELEASING.md) and is owner-controlled, one-time, and immediately -revoked. +revoked. The workflow keeps this fallback disabled by default and rejects it +for every version other than `0.1.0-alpha.1`. diff --git a/package.json b/package.json index 601a499..fd29f15 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "test:fixtures": "node scripts/test-fixtures.mjs", "test:compat": "node scripts/test-compat.mjs", "test:secrets": "node scripts/check-secrets.mjs", + "check:standalone-content": "node scripts/check-standalone-content.mjs", "check:self-contained": "node scripts/check-self-contained.mjs", "check:public-preview": "node scripts/check-public-preview.mjs", "actionlint": "node scripts/run-actionlint.mjs", diff --git a/scripts/check-public-preview.mjs b/scripts/check-public-preview.mjs index 0c81079..ea30237 100644 --- a/scripts/check-public-preview.mjs +++ b/scripts/check-public-preview.mjs @@ -6,6 +6,7 @@ import { collectPublicPreviewViolations, formatPublicPreviewViolations, } from "./release-validation.mjs"; +import { collectStandaloneContentViolations } from "./standalone-content.mjs"; const inputViolations = []; const read = (name) => { @@ -47,7 +48,21 @@ const violations = collectPublicPreviewViolations({ sourceManifest, }); -const allViolations = [...inputViolations, ...violations]; +const standaloneContentViolations = []; +try { + standaloneContentViolations.push(...collectStandaloneContentViolations(ROOT)); +} catch (error) { + const detail = error instanceof Error ? error.message : String(error); + standaloneContentViolations.push( + `standalone content could not be checked: ${detail}`, + ); +} + +const allViolations = [ + ...inputViolations, + ...violations, + ...standaloneContentViolations, +]; if (allViolations.length > 0) { console.error(formatPublicPreviewViolations(allViolations)); process.exitCode = 1; diff --git a/scripts/check-self-contained.mjs b/scripts/check-self-contained.mjs index e776580..f528c8d 100644 --- a/scripts/check-self-contained.mjs +++ b/scripts/check-self-contained.mjs @@ -1,21 +1,5 @@ -import { isUtf8 } from "node:buffer"; -import { - cpSync, - existsSync, - readFileSync, - readlinkSync, - readdirSync, - statSync, -} from "node:fs"; -import { - basename, - dirname, - isAbsolute, - join, - relative, - resolve, - sep, -} from "node:path"; +import { cpSync } from "node:fs"; +import { basename, join } from "node:path"; import { ROOT, @@ -23,121 +7,13 @@ import { removeTemporaryDirectory, run, } from "./lib.mjs"; - -const excluded = new Set([ - ".artifacts", - ".cache", - ".DS_Store", - ".git", - "coverage", - "dist", - "node_modules", -]); -const parentReferencePattern = - /(?:^|[\s`"'(=:[{])((?:\.\.[\\/])+[A-Za-z0-9@%_+.,~\\/-]+)/gm; -const absoluteLocalPathPatterns = [ - /(?:^|[\s`"'(=:[{])((?:file:\/\/\/(?:Users|home|private|root|Volumes|workspaces?)|\/(?:Users|home|private|root|Volumes|workspaces?))\/[A-Za-z0-9@%_+.,~/-]+)/gm, - /(?:^|[\s`"'(=:[{])((?:~\/|\$HOME\/|\$\{HOME\}\/)[A-Za-z0-9@%_+.,~/-]+)/gm, - /(?:^|[\s`"'(=:[{])([A-Za-z]:[\\/][^\s`"')\]}>;,]+)/gm, - /(?:^|[\s`"'(=:[{])(\\\\[A-Za-z0-9._-]+[\\/][^\s`"')\]}>;,]+)/gm, -]; -const privateArtifactPattern = /\b(?:SDK_PRD\.md|references\/)/g; -const privateWorkspacePathPattern = - /\b(?:cometapi-worksapce|cometapi-(?:python|go|cli)|comet-api-(?:backend|frontend|next))[\\/][A-Za-z0-9@%_+.~/-]{2,}/g; -const privateReferencesDirectory = ["references", ""].join("/"); +import { + collectStandaloneContentViolations, + STANDALONE_CONTENT_EXCLUSIONS, +} from "./standalone-content.mjs"; function shouldCopy(source) { - return !excluded.has(basename(source)); -} - -function isInside(root, path) { - const pathFromRoot = relative(root, path); - return ( - pathFromRoot === "" || - (!isAbsolute(pathFromRoot) && - pathFromRoot !== ".." && - !pathFromRoot.startsWith(`..${sep}`)) - ); -} - -function scanTextFile(path, candidateRoot, violations) { - const bytes = readFileSync(path); - if (bytes.includes(0) || !isUtf8(bytes)) return; - - const contents = bytes.toString("utf8"); - const displayPath = relative(candidateRoot, path); - - for (const match of contents.matchAll(parentReferencePattern)) { - const reference = match[1]; - const resolvedReference = resolve( - dirname(path), - reference.replaceAll("\\", "/"), - ); - if (!isInside(candidateRoot, resolvedReference)) { - violations.push( - `${displayPath}: parent-relative path escapes the repository (${reference})`, - ); - } - } - - for (const pattern of absoluteLocalPathPatterns) { - for (const match of contents.matchAll(pattern)) { - violations.push( - `${displayPath}: absolute machine-local path is not standalone (${match[1]})`, - ); - } - } - - for (const match of contents.matchAll(privateArtifactPattern)) { - const reference = match[0]; - const rootEntry = reference.startsWith(privateReferencesDirectory) - ? "references" - : reference; - if (!existsSync(join(candidateRoot, rootEntry))) { - violations.push( - `${displayPath}: references non-repository private material (${reference})`, - ); - } - } - - for (const match of contents.matchAll(privateWorkspacePathPattern)) { - const reference = match[0]; - const rootEntry = reference.split(/[\\/]/, 1)[0]; - if (!existsSync(join(candidateRoot, rootEntry))) { - violations.push( - `${displayPath}: references a private workspace or sibling repository (${reference})`, - ); - } - } -} - -function scan(directory, candidateRoot, violations) { - for (const entry of readdirSync(directory, { withFileTypes: true })) { - if (entry.isDirectory() && excluded.has(entry.name)) continue; - const path = join(directory, entry.name); - if (entry.isDirectory()) { - scan(path, candidateRoot, violations); - continue; - } - if (entry.isSymbolicLink()) { - const target = readlinkSync(path); - const resolvedTarget = resolve(dirname(path), target); - if (!isInside(candidateRoot, resolvedTarget)) { - violations.push( - `${relative(candidateRoot, path)}: symbolic link escapes the repository (${target})`, - ); - continue; - } - if (!existsSync(resolvedTarget)) { - violations.push( - `${relative(candidateRoot, path)}: symbolic link target is missing (${target})`, - ); - continue; - } - if (statSync(path).isDirectory()) continue; - } - scanTextFile(path, candidateRoot, violations); - } + return !STANDALONE_CONTENT_EXCLUSIONS.has(basename(source)); } const temporaryParent = makeTemporaryDirectory("cometapi-standalone-"); @@ -145,8 +21,7 @@ const candidateRoot = join(temporaryParent, "cometapi-node"); try { cpSync(ROOT, candidateRoot, { filter: shouldCopy, recursive: true }); - const violations = []; - scan(candidateRoot, candidateRoot, violations); + const violations = collectStandaloneContentViolations(candidateRoot); if (violations.length > 0) { throw new Error( `Standalone repository scan found ${String(violations.length)} outside-root reference(s):\n- ${violations.join("\n- ")}`, diff --git a/scripts/check-standalone-content.mjs b/scripts/check-standalone-content.mjs new file mode 100644 index 0000000..1db2af0 --- /dev/null +++ b/scripts/check-standalone-content.mjs @@ -0,0 +1,13 @@ +import { ROOT } from "./lib.mjs"; +import { + collectStandaloneContentViolations, + formatStandaloneContentViolations, +} from "./standalone-content.mjs"; + +const violations = collectStandaloneContentViolations(ROOT); +if (violations.length > 0) { + console.error(formatStandaloneContentViolations(violations)); + process.exitCode = 1; +} else { + console.log("Standalone content gate passed."); +} diff --git a/scripts/standalone-content.mjs b/scripts/standalone-content.mjs new file mode 100644 index 0000000..c89d102 --- /dev/null +++ b/scripts/standalone-content.mjs @@ -0,0 +1,137 @@ +import { isUtf8 } from "node:buffer"; +import { + existsSync, + readFileSync, + readlinkSync, + readdirSync, + statSync, +} from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; + +export const STANDALONE_CONTENT_EXCLUSIONS = new Set([ + ".artifacts", + ".cache", + ".DS_Store", + ".git", + "coverage", + "dist", + "node_modules", +]); + +const parentReferencePattern = + /(?:^|[\s`"'(=:[{])((?:\.\.[\\/])+[A-Za-z0-9@%_+.,~\\/-]+)/gm; +const absoluteLocalPathPatterns = [ + /(?:^|[\s`"'(=:[{])((?:file:\/\/\/(?:Users|home|private|root|Volumes|workspaces?)|\/(?:Users|home|private|root|Volumes|workspaces?))\/[A-Za-z0-9@%_+.,~/-]+)/gm, + /(?:^|[\s`"'(=:[{])((?:~\/|\$HOME\/|\$\{HOME\}\/)[A-Za-z0-9@%_+.,~/-]+)/gm, + /(?:^|[\s`"'(=:[{])([A-Za-z]:[\\/][^\s`"')\]}>;,]+)/gm, + /(?:^|[\s`"'(=:[{])(\\\\[A-Za-z0-9._-]+[\\/][^\s`"')\]}>;,]+)/gm, +]; +const privateArtifactPattern = /\b(?:SDK_PRD\.md|references\/)/g; +const privateWorkspacePathPattern = + /\b(?:cometapi-worksapce|cometapi-(?:python|go|cli)|comet-api-(?:backend|frontend|next))[\\/][A-Za-z0-9@%_+.~/-]{2,}/g; +const privateReferencesDirectory = ["references", ""].join("/"); + +function isInside(root, path) { + const pathFromRoot = relative(root, path); + return ( + pathFromRoot === "" || + (!isAbsolute(pathFromRoot) && + pathFromRoot !== ".." && + !pathFromRoot.startsWith(`..${sep}`)) + ); +} + +function scanTextFile(path, candidateRoot, violations) { + const bytes = readFileSync(path); + if (bytes.includes(0) || !isUtf8(bytes)) return; + + const contents = bytes.toString("utf8"); + const displayPath = relative(candidateRoot, path); + + for (const match of contents.matchAll(parentReferencePattern)) { + const reference = match[1]; + const resolvedReference = resolve( + dirname(path), + reference.replaceAll("\\", "/"), + ); + if (!isInside(candidateRoot, resolvedReference)) { + violations.push( + `${displayPath}: parent-relative path escapes the repository (${reference})`, + ); + } + } + + for (const pattern of absoluteLocalPathPatterns) { + for (const match of contents.matchAll(pattern)) { + violations.push( + `${displayPath}: absolute machine-local path is not standalone (${match[1]})`, + ); + } + } + + for (const match of contents.matchAll(privateArtifactPattern)) { + const reference = match[0]; + const rootEntry = reference.startsWith(privateReferencesDirectory) + ? "references" + : reference; + if (!existsSync(join(candidateRoot, rootEntry))) { + violations.push( + `${displayPath}: references non-repository private material (${reference})`, + ); + } + } + + for (const match of contents.matchAll(privateWorkspacePathPattern)) { + const reference = match[0]; + const rootEntry = reference.split(/[\\/]/, 1)[0]; + if (!existsSync(join(candidateRoot, rootEntry))) { + violations.push( + `${displayPath}: references a private workspace or sibling repository (${reference})`, + ); + } + } +} + +function scan(directory, candidateRoot, violations) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.isDirectory() && STANDALONE_CONTENT_EXCLUSIONS.has(entry.name)) { + continue; + } + const path = join(directory, entry.name); + if (entry.isDirectory()) { + scan(path, candidateRoot, violations); + continue; + } + if (entry.isSymbolicLink()) { + const target = readlinkSync(path); + const resolvedTarget = resolve(dirname(path), target); + if (!isInside(candidateRoot, resolvedTarget)) { + violations.push( + `${relative(candidateRoot, path)}: symbolic link escapes the repository (${target})`, + ); + continue; + } + if (!existsSync(resolvedTarget)) { + violations.push( + `${relative(candidateRoot, path)}: symbolic link target is missing (${target})`, + ); + continue; + } + if (statSync(path).isDirectory()) continue; + } + scanTextFile(path, candidateRoot, violations); + } +} + +export function collectStandaloneContentViolations(candidateRoot) { + const violations = []; + scan(candidateRoot, candidateRoot, violations); + return violations; +} + +export function formatStandaloneContentViolations(violations) { + return [ + `Standalone content gate found ${String(violations.length)} violation(s):`, + ...violations.map((violation) => `- ${violation}`), + ].join("\n"); +} diff --git a/tests/standalone-content.test.mjs b/tests/standalone-content.test.mjs new file mode 100644 index 0000000..f35e7fc --- /dev/null +++ b/tests/standalone-content.test.mjs @@ -0,0 +1,80 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { URL } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { collectStandaloneContentViolations } from "../scripts/standalone-content.mjs"; + +function withTemporaryDirectory(callback) { + const directory = mkdtempSync(join(tmpdir(), "cometapi-content-test-")); + try { + return callback(directory); + } finally { + rmSync(directory, { force: true, recursive: true }); + } +} + +describe("standalone content", () => { + it("collects independent outside-root references", () => { + withTemporaryDirectory((root) => { + const parentReference = ["..", "private-notes.md"].join("/"); + const absoluteReference = [ + "", + "Users", + "example", + "private-notes.md", + ].join("/"); + writeFileSync( + join(root, "README.md"), + `See ${parentReference} and ${absoluteReference}.\n`, + ); + + const violations = collectStandaloneContentViolations(root); + expect(violations).toHaveLength(2); + expect(violations.join("\n")).toMatch(/parent-relative path escapes/); + expect(violations.join("\n")).toMatch(/absolute machine-local path/); + }); + }); + + it("reports escaping symbolic links", () => { + withTemporaryDirectory((parent) => { + const root = join(parent, "repository"); + mkdirSync(root); + writeFileSync(join(parent, "outside.txt"), "private\n"); + symlinkSync(join(parent, "outside.txt"), join(root, "outside.txt")); + + expect(collectStandaloneContentViolations(root)).toEqual([ + expect.stringMatching(/symbolic link escapes the repository/), + ]); + }); + }); + + it("ignores generated and dependency directories", () => { + withTemporaryDirectory((root) => { + const dependencyDirectory = join(root, "node_modules", "fixture"); + mkdirSync(dependencyDirectory, { recursive: true }); + const absoluteReference = ["", "Users", "example", "secret"].join("/"); + writeFileSync(join(dependencyDirectory, "README.md"), absoluteReference); + + expect(collectStandaloneContentViolations(root)).toEqual([]); + }); + }); + + it("is included in the Public Preview aggregate gate", () => { + const gate = readFileSync( + new URL("../scripts/check-public-preview.mjs", import.meta.url), + "utf8", + ); + expect(gate).toContain("collectStandaloneContentViolations(ROOT)"); + expect(gate).toContain("...standaloneContentViolations"); + }); +}); diff --git a/tests/workflow-contract.test.mjs b/tests/workflow-contract.test.mjs index aaee181..97c99a4 100644 --- a/tests/workflow-contract.test.mjs +++ b/tests/workflow-contract.test.mjs @@ -88,6 +88,36 @@ describe("GitHub Actions workflow contract", () => { ); }); + it("matches the required environment reviewer configuration", () => { + const publishWorkflow = workflow("publish.yml"); + expect(job(publishWorkflow, "live-smoke")).toContain( + "without required reviewers", + ); + expect(job(publishWorkflow, "publish")).toContain( + "approval by the current release approver and self-review allowed", + ); + }); + + it("keeps the token bootstrap opt-in and alpha.1-only", () => { + const publishWorkflow = workflow("publish.yml"); + const publish = job(publishWorkflow, "publish"); + expect(publish).toContain( + "ALPHA1_BOOTSTRAP_ENABLED: ${{ vars.NPM_ALPHA1_BOOTSTRAP_ENABLED }}", + ); + expect(publish).toContain("secrets.NPM_ALPHA1_BOOTSTRAP_TOKEN"); + expect(publish).toContain( + "needs.verify.outputs.version == '0.1.0-alpha.1'", + ); + expect(publish).toContain('"$VERSION" != "0.1.0-alpha.1"'); + expect(publish).toContain('"$DIST_TAG" != "next"'); + expect(publish).toContain( + '"$ALPHA1_BOOTSTRAP_ENABLED" == "true" && -z "$NODE_AUTH_TOKEN"', + ); + expect( + matches(publishWorkflow, /secrets\.NPM_ALPHA1_BOOTSTRAP_TOKEN/g), + ).toHaveLength(1); + }); + it("pins third-party actions and disables checkout credential persistence", () => { for (const name of workflowNames) { const contents = workflow(name);