diff --git a/.github/workflows/live-smoke.yml b/.github/workflows/live-smoke.yml index 0a0742c..144f414 100644 --- a/.github/workflows/live-smoke.yml +++ b/.github/workflows/live-smoke.yml @@ -19,8 +19,8 @@ jobs: vars.LIVE_SMOKE_ENABLED == 'true' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest - # Required repository configuration: protect this environment, its reviewers, - # COMETAPI_KEY secret, and the optional model variable. + # Required repository configuration: protect this environment without required + # reviewers, add COMETAPI_KEY, and optionally set the model variable. environment: live-smoke timeout-minutes: 5 steps: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 17bae3d..21a3a80 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -195,6 +195,11 @@ jobs: contents: read id-token: write steps: + - name: Check out the verified release commit + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + ref: ${{ needs.verify.outputs.release-commit }} - name: Set up Node.js 24 uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: @@ -213,46 +218,7 @@ jobs: 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 - exit 1 - fi - local_integrity="$(node -e 'const {createHash}=require("node:crypto");const {readFileSync}=require("node:fs");process.stdout.write("sha512-"+createHash("sha512").update(readFileSync(process.argv[1])).digest("base64"))' "${tarballs[0]}")" - - view_error="$(mktemp)" - set +e - existing_dist="$(npm view "cometapi@${VERSION}" dist --json 2>"$view_error")" - view_status=$? - set -e - if [[ "$view_status" -eq 0 && -n "$existing_dist" ]]; then - EXISTING_DIST="$existing_dist" LOCAL_INTEGRITY="$local_integrity" node <<'EOF' - const dist = JSON.parse(process.env.EXISTING_DIST); - if (dist.integrity !== process.env.LOCAL_INTEGRITY) { - throw new Error("The existing registry version has different integrity."); - } - 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 - sed -n '1,20p' "$view_error" >&2 - exit 1 - fi + run: bash scripts/publish-artifact.sh - name: Verify the public registry artifact env: DIST_TAG: ${{ needs.verify.outputs.dist-tag }} diff --git a/RELEASING.md b/RELEASING.md index d363f0d..50b5c0c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -66,8 +66,9 @@ artifacts. Authorized maintainers must supply or approve: - Changes to the canonical identity and contact values listed above - Repository creation and visibility, branch/tag protections, environments, - secrets, and required reviewers -- npm package ownership and Trusted Publisher configuration + secrets, and environment approval policies +- npm package ownership for the maintainer-confirmed `cometapi-team` account + and Trusted Publisher configuration - A `COMETAPI_KEY`, request budget, and explicit authorization for live smoke tests - Immutable public tags, GitHub releases, environment approvals, and npm @@ -76,6 +77,12 @@ artifacts. Authorized maintainers must supply or approve: Missing identity, credentials, ownership, or authorization blocks the corresponding release gate. Do not invent it or replace it with a mock. +The `cometapi` package does not exist in the public registry yet, so npm cannot +verify its owner before the first publication. Registry Alpha owner evidence is +complete only when `npm owner ls cometapi` lists the maintainer-confirmed +`cometapi-team` account after bootstrap; until then this remains a Registry +Alpha prerequisite, not a Public Preview blocker. + For the current milestone, authorized external actions stop at creating the empty private repository, pushing its sanitized first history, and observing credential-free CI. Visibility changes and every subsequent external action diff --git a/scripts/check-public-preview.mjs b/scripts/check-public-preview.mjs index ea30237..a638652 100644 --- a/scripts/check-public-preview.mjs +++ b/scripts/check-public-preview.mjs @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { ROOT } from "./lib.mjs"; import { @@ -8,64 +9,79 @@ import { } from "./release-validation.mjs"; import { collectStandaloneContentViolations } from "./standalone-content.mjs"; -const inputViolations = []; -const read = (name) => { - try { - return readFileSync(join(ROOT, name), "utf8"); - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - inputViolations.push(`${name} could not be read: ${detail}`); - return undefined; +export function collectPublicPreviewGateViolations(root = ROOT) { + const inputViolations = []; + const read = (name) => { + try { + return readFileSync(join(root, name), "utf8"); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + inputViolations.push(`${name} could not be read: ${detail}`); + return undefined; + } + }; + + let sourceManifest; + const packageText = read("package.json"); + if (packageText !== undefined) { + try { + sourceManifest = JSON.parse(packageText); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + inputViolations.push(`package.json could not be parsed: ${detail}`); + } } -}; -let sourceManifest; -const packageText = read("package.json"); -if (packageText !== undefined) { + const contentViolations = collectPublicPreviewViolations({ + documents: { + agents: read("AGENTS.md"), + architecture: read("ARCHITECTURE.md"), + changelog: read("CHANGELOG.md"), + compatibility: read("COMPATIBILITY.md"), + conduct: read("CODE_OF_CONDUCT.md"), + contributing: read("CONTRIBUTING.md"), + license: read("LICENSE"), + readme: read("README.md"), + releasing: read("RELEASING.md"), + roadmap: read("ROADMAP.md"), + security: read("SECURITY.md"), + support: read("SUPPORT.md"), + }, + sourceManifest, + }); + + const standaloneContentViolations = []; try { - sourceManifest = JSON.parse(packageText); + standaloneContentViolations.push( + ...collectStandaloneContentViolations(root), + ); } catch (error) { const detail = error instanceof Error ? error.message : String(error); - inputViolations.push(`package.json could not be parsed: ${detail}`); + standaloneContentViolations.push( + `standalone content could not be checked: ${detail}`, + ); } -} -const violations = collectPublicPreviewViolations({ - documents: { - agents: read("AGENTS.md"), - architecture: read("ARCHITECTURE.md"), - changelog: read("CHANGELOG.md"), - compatibility: read("COMPATIBILITY.md"), - conduct: read("CODE_OF_CONDUCT.md"), - contributing: read("CONTRIBUTING.md"), - license: read("LICENSE"), - readme: read("README.md"), - releasing: read("RELEASING.md"), - roadmap: read("ROADMAP.md"), - security: read("SECURITY.md"), - support: read("SUPPORT.md"), - }, - sourceManifest, -}); + return [ + ...inputViolations, + ...contentViolations, + ...standaloneContentViolations, + ]; +} -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}`, - ); +function main() { + const violations = collectPublicPreviewGateViolations(); + if (violations.length > 0) { + console.error(formatPublicPreviewViolations(violations)); + process.exitCode = 1; + } else { + console.log("Public Preview content gate passed."); + } } -const allViolations = [ - ...inputViolations, - ...violations, - ...standaloneContentViolations, -]; -if (allViolations.length > 0) { - console.error(formatPublicPreviewViolations(allViolations)); - process.exitCode = 1; -} else { - console.log("Public Preview content gate passed."); +if ( + process.argv[1] !== undefined && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + main(); } diff --git a/scripts/publish-artifact.sh b/scripts/publish-artifact.sh new file mode 100644 index 0000000..4ce4b75 --- /dev/null +++ b/scripts/publish-artifact.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash + +set -euo pipefail + +: "${DIST_TAG:?DIST_TAG is required}" +: "${VERSION:?VERSION is required}" + +artifact_directory="${ARTIFACT_DIRECTORY:-release-artifacts}" +bootstrap_enabled="${ALPHA1_BOOTSTRAP_ENABLED:-}" + +if [[ "$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 + +shopt -s nullglob +tarballs=("$artifact_directory"/*.tgz) +if [[ "${#tarballs[@]}" -ne 1 ]]; then + echo "Expected exactly one downloaded artifact, found ${#tarballs[@]}." >&2 + exit 1 +fi + +local_integrity="$(node -e 'const {createHash}=require("node:crypto");const {readFileSync}=require("node:fs");process.stdout.write("sha512-"+createHash("sha512").update(readFileSync(process.argv[1])).digest("base64"))' "${tarballs[0]}")" +view_error="$(mktemp)" +trap 'rm -f "$view_error"' EXIT + +set +e +existing_dist="$(npm view "cometapi@${VERSION}" dist --json 2>"$view_error")" +view_status=$? +set -e + +if [[ "$view_status" -eq 0 && -n "$existing_dist" ]]; then + EXISTING_DIST="$existing_dist" LOCAL_INTEGRITY="$local_integrity" node <<'EOF' +const dist = JSON.parse(process.env.EXISTING_DIST); +if (dist.integrity !== process.env.LOCAL_INTEGRITY) { + throw new Error("The existing registry version has different integrity."); +} +EOF + echo "cometapi@${VERSION} already matches the verified artifact; resuming checks." +elif grep -q "E404" "$view_error"; then + if [[ "$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 + sed -n '1,20p' "$view_error" >&2 + exit 1 +fi diff --git a/tests/publish-artifact.test.mjs b/tests/publish-artifact.test.mjs new file mode 100644 index 0000000..4003e56 --- /dev/null +++ b/tests/publish-artifact.test.mjs @@ -0,0 +1,113 @@ +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath, URL } from "node:url"; + +import { afterEach, describe, expect, it } from "vitest"; + +const script = fileURLToPath( + new URL("../scripts/publish-artifact.sh", import.meta.url), +); +const temporaryDirectories = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { force: true, recursive: true }); + } +}); + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "cometapi-publish-test-")); + temporaryDirectories.push(root); + const artifacts = join(root, "artifacts"); + const bin = join(root, "bin"); + const log = join(root, "npm-call.log"); + mkdirSync(artifacts); + mkdirSync(bin); + writeFileSync(join(artifacts, "cometapi.tgz"), "artifact\n"); + const npm = join(bin, "npm"); + writeFileSync( + npm, + [ + "#!/usr/bin/env bash", + 'if [[ "$1" == "view" ]]; then', + ' echo "npm error code E404" >&2', + " exit 1", + "fi", + 'if [[ "$1" == "publish" ]]; then', + ' printf "%s\\n" "${NODE_AUTH_TOKEN:+token-present}" > "$NPM_CALL_LOG"', + ' printf "%s\\n" "$*" >> "$NPM_CALL_LOG"', + " exit 0", + "fi", + 'echo "unexpected npm command: $*" >&2', + "exit 2", + "", + ].join("\n"), + ); + chmodSync(npm, 0o755); + return { artifacts, bin, log }; +} + +function runPublish({ + bootstrapEnabled = "", + distTag = "next", + token = "", + version = "0.1.0-alpha.1", +} = {}) { + const { artifacts, bin, log } = fixture(); + const result = spawnSync("bash", [script], { + encoding: "utf8", + env: { + ...process.env, + ALPHA1_BOOTSTRAP_ENABLED: bootstrapEnabled, + ARTIFACT_DIRECTORY: artifacts, + DIST_TAG: distTag, + NODE_AUTH_TOKEN: token, + NPM_CALL_LOG: log, + PATH: `${bin}${delimiter}${process.env.PATH ?? ""}`, + VERSION: version, + }, + }); + return { + log: existsSync(log) ? readFileSync(log, "utf8") : "", + result, + }; +} + +describe("publish artifact authentication", () => { + it("uses Trusted Publishing without injecting a registry token by default", () => { + const { log, result } = runPublish({ version: "0.1.0-alpha.2" }); + expect(result.status, result.stderr).toBe(0); + expect(log).toMatch(/^\npublish .* --provenance --tag next\n$/); + }); + + it("allows the protected token bootstrap for alpha.1 on next", () => { + const { log, result } = runPublish({ + bootstrapEnabled: "true", + token: "opaque", + }); + expect(result.status, result.stderr).toBe(0); + expect(log).toMatch( + /^token-present\npublish .* --provenance --tag next\n$/, + ); + }); + + it.each([ + ["another version", { bootstrapEnabled: "true", version: "0.1.0-alpha.2" }], + ["another dist-tag", { bootstrapEnabled: "true", distTag: "latest" }], + ["a missing token", { bootstrapEnabled: "true" }], + ])("rejects bootstrap mode for %s", (_name, options) => { + const { log, result } = runPublish(options); + expect(result.status).not.toBe(0); + expect(log).toBe(""); + }); +}); diff --git a/tests/standalone-content.test.mjs b/tests/standalone-content.test.mjs index f35e7fc..df958a5 100644 --- a/tests/standalone-content.test.mjs +++ b/tests/standalone-content.test.mjs @@ -1,4 +1,5 @@ import { + cpSync, mkdirSync, mkdtempSync, readFileSync, @@ -7,12 +8,16 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { URL } from "node:url"; +import { basename, join } from "node:path"; import { describe, expect, it } from "vitest"; -import { collectStandaloneContentViolations } from "../scripts/standalone-content.mjs"; +import { collectPublicPreviewGateViolations } from "../scripts/check-public-preview.mjs"; +import { ROOT } from "../scripts/lib.mjs"; +import { + collectStandaloneContentViolations, + STANDALONE_CONTENT_EXCLUSIONS, +} from "../scripts/standalone-content.mjs"; function withTemporaryDirectory(callback) { const directory = mkdtempSync(join(tmpdir(), "cometapi-content-test-")); @@ -58,6 +63,32 @@ describe("standalone content", () => { }); }); + it("reports missing symbolic-link targets", () => { + withTemporaryDirectory((root) => { + symlinkSync(join(root, "missing.txt"), join(root, "broken.txt")); + + expect(collectStandaloneContentViolations(root)).toEqual([ + expect.stringMatching(/symbolic link target is missing/), + ]); + }); + }); + + it("reports private artifacts and sibling workspaces", () => { + withTemporaryDirectory((root) => { + const privateArtifact = ["SDK", "PRD.md"].join("_"); + const siblingWorkspace = `${["cometapi", "python"].join("-")}/README.md`; + writeFileSync( + join(root, "notes.md"), + `See ${privateArtifact} and ${siblingWorkspace}.\n`, + ); + + const violations = collectStandaloneContentViolations(root); + expect(violations).toHaveLength(2); + expect(violations.join("\n")).toMatch(/private material/); + expect(violations.join("\n")).toMatch(/sibling repository/); + }); + }); + it("ignores generated and dependency directories", () => { withTemporaryDirectory((root) => { const dependencyDirectory = join(root, "node_modules", "fixture"); @@ -69,12 +100,28 @@ describe("standalone content", () => { }); }); - 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"); + it("aggregates content and standalone violations in one Public Preview run", () => { + withTemporaryDirectory((parent) => { + const root = join(parent, "repository"); + cpSync(ROOT, root, { + filter: (source) => + !STANDALONE_CONTENT_EXCLUSIONS.has(basename(source)), + recursive: true, + }); + + const manifestPath = join(root, "package.json"); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + manifest.author = "Different Author"; + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + + const absoluteReference = ["", "Users", "example", "notes.md"].join("/"); + writeFileSync(join(root, "outside.md"), `${absoluteReference}\n`); + + const violations = collectPublicPreviewGateViolations(root); + expect(violations.join("\n")).toMatch(/package\.json author/); + expect(violations.join("\n")).toMatch( + /outside\.md: absolute machine-local path/, + ); + }); }); }); diff --git a/tests/workflow-contract.test.mjs b/tests/workflow-contract.test.mjs index 97c99a4..24d9666 100644 --- a/tests/workflow-contract.test.mjs +++ b/tests/workflow-contract.test.mjs @@ -108,11 +108,10 @@ describe("GitHub Actions workflow contract", () => { 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"', + "ref: ${{ needs.verify.outputs.release-commit }}", ); + expect(publish).toContain("run: bash scripts/publish-artifact.sh"); expect( matches(publishWorkflow, /secrets\.NPM_ALPHA1_BOOTSTRAP_TOKEN/g), ).toHaveLength(1);