diff --git a/.github/workflows/cli-package-validation.yml b/.github/workflows/cli-package-validation.yml index 800a39fafe..1bd0b62743 100644 --- a/.github/workflows/cli-package-validation.yml +++ b/.github/workflows/cli-package-validation.yml @@ -41,6 +41,9 @@ on: - 'packages/storage/src/native-file-lock.ts' - 'scripts/generate-runtime-host-peer-*' - 'scripts/release-cli-package.mjs' + - 'scripts/qualify-released-cli-state-root.mjs' + - 'scripts/qualify-released-cli-state-root.test.mjs' + - 'scripts/released-cli-state-root-fixture.mjs' - 'scripts/smoke-release-cli-package.mjs' workflow_call: inputs: @@ -61,6 +64,15 @@ on: release_candidate_run_attempt: description: Workflow attempt that built the immutable artifact value: ${{ jobs.build.outputs.release_candidate_run_attempt }} + release_predecessor_version: + description: Exact npm Nightly version qualified against this candidate + value: ${{ jobs.release-predecessor.outputs.version }} + release_predecessor_tarball_url: + description: Exact npm Nightly tarball qualified against this candidate + value: ${{ jobs.release-predecessor.outputs.tarball_url }} + release_predecessor_integrity: + description: npm SHA-512 integrity of the Nightly tarball qualified against this candidate + value: ${{ jobs.release-predecessor.outputs.integrity }} workflow_dispatch: permissions: @@ -70,6 +82,26 @@ concurrency: group: cli-package-validation-${{ github.workflow }}-${{ github.ref }} jobs: + release-predecessor: + name: Resolve immutable release predecessor + runs-on: ubuntu-24.04 + timeout-minutes: 45 + outputs: + version: ${{ steps.predecessor.outputs.version }} + tarball_url: ${{ steps.predecessor.outputs.tarball_url }} + integrity: ${{ steps.predecessor.outputs.integrity }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.source_commit || github.sha }} + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + - name: Resolve the current npm Nightly as immutable evidence + id: predecessor + run: node scripts/release-cli-publication.mjs resolve-nightly-predecessor "$GITHUB_OUTPUT" + peer-native: name: Build direct-peer addon (${{ matrix.target }}) runs-on: ${{ matrix.runner }} @@ -276,6 +308,133 @@ jobs: - name: Validate the installed tarball run: node scripts/smoke-release-cli-package.mjs + state-root-qualification: + name: Qualify released State Root (${{ matrix.name }}) + needs: [build, release-predecessor] + runs-on: ubuntu-24.04 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - name: cross epoch 74 to 76 + source_url: https://registry.npmjs.org/maka-agent/-/maka-agent-0.2.0-dev.3.20260830.tgz + source_sha256: 66b1ce9307c9d5c06eaa7a6cbf533d4747d02caf71c1776c69c7dbfa12c3f414 + target_kind: published + target_url: https://registry.npmjs.org/maka-agent/-/maka-agent-0.2.0-dev.4.20260830.tgz + target_sha256: b7d48adb466e16be7ffefbda3a0fcd833cc4108ea502b27778d0f4da680e1fc0 + epoch_relation: different + - name: same epoch 76 + source_url: https://registry.npmjs.org/maka-agent/-/maka-agent-0.2.0-dev.4.20260830.tgz + source_sha256: b7d48adb466e16be7ffefbda3a0fcd833cc4108ea502b27778d0f4da680e1fc0 + target_kind: published + target_url: https://registry.npmjs.org/maka-agent/-/maka-agent-0.2.0-dev.5.20260830.tgz + target_sha256: e7a682157c6899fc7f1be86a2d7b0bd0696195a5771d8cc97bd1389a5b74989f + epoch_relation: same + - name: current Nightly predecessor to candidate + source_url: ${{ needs.release-predecessor.outputs.tarball_url }} + source_sha256: '' + source_integrity: ${{ needs.release-predecessor.outputs.integrity }} + target_kind: candidate + target_url: '' + target_sha256: '' + epoch_relation: any + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.source_commit || github.sha }} + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + - name: Select the release npm toolchain + run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')" + - name: Require the account-isolation sandbox + run: | + sudo apt-get update + sudo apt-get install --yes bubblewrap + bwrap --version + - name: Download the release candidate + if: matrix.target_kind == 'candidate' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ needs.build.outputs.release_candidate_artifact_id }} + path: packages/cli/release + - name: Prepare exact source and target artifacts + env: + SOURCE_URL: ${{ matrix.source_url }} + SOURCE_SHA256: ${{ matrix.source_sha256 }} + SOURCE_INTEGRITY: ${{ matrix.source_integrity }} + TARGET_KIND: ${{ matrix.target_kind }} + TARGET_URL: ${{ matrix.target_url }} + TARGET_SHA256: ${{ matrix.target_sha256 }} + run: | + set -euo pipefail + evidence_root="$RUNNER_TEMP/released-state-root" + mkdir -p "$evidence_root" + source_path="$evidence_root/source.tgz" + curl --fail --location --max-filesize 67108864 --proto '=https' --tlsv1.2 "$SOURCE_URL" --output "$source_path" + source_sha256="$SOURCE_SHA256" + if [[ -n "$SOURCE_INTEGRITY" ]]; then + node - "$source_path" "$SOURCE_INTEGRITY" <<'NODE' + const { createHash } = require('node:crypto'); + const { readFileSync } = require('node:fs'); + const bytes = readFileSync(process.argv[2]); + const actual = `sha512-${createHash('sha512').update(bytes).digest('base64')}`; + if (actual !== process.argv[3]) throw new Error('Source tarball integrity mismatch'); + NODE + source_sha256="$(sha256sum "$source_path" | cut -d ' ' -f 1)" + else + test -n "$source_sha256" + fi + if [[ "$TARGET_KIND" == 'published' ]]; then + target_path="$evidence_root/target.tgz" + curl --fail --location --max-filesize 67108864 --proto '=https' --tlsv1.2 "$TARGET_URL" --output "$target_path" + target_sha256="$TARGET_SHA256" + else + target_path="$(find packages/cli/release -maxdepth 1 -name '*.tgz' -print -quit)" + test -n "$target_path" + target_path="$(realpath "$target_path")" + target_sha256="$(sha256sum "$target_path" | cut -d ' ' -f 1)" + fi + { + echo "SOURCE_PATH=$source_path" + echo "SOURCE_SHA256=$source_sha256" + echo "TARGET_PATH=$target_path" + echo "TARGET_SHA256=$target_sha256" + } >> "$GITHUB_ENV" + - name: Qualify the released State Root transition + env: + EXPECTED_EPOCH_RELATION: ${{ matrix.epoch_relation }} + MAKA_QUALIFICATION_BWRAP_USE_SUDO: '1' + run: | + set -o pipefail + npm run --silent release:cli:qualify-state-root -- \ + --source "$SOURCE_PATH" \ + --source-sha256 "$SOURCE_SHA256" \ + --target "$TARGET_PATH" \ + --target-sha256 "$TARGET_SHA256" \ + --expect-epoch-relation "$EXPECTED_EPOCH_RELATION" \ + | tee "$RUNNER_TEMP/released-state-root-report.json" + - name: Preserve the qualification report + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: released-state-root-${{ strategy.job-index }} + path: ${{ runner.temp }}/released-state-root-report.json + if-no-files-found: error + retention-days: 7 + - name: Require the qualified Nightly predecessor to remain current + if: matrix.target_kind == 'candidate' + env: + PREDECESSOR_VERSION: ${{ needs.release-predecessor.outputs.version }} + PREDECESSOR_TARBALL_URL: ${{ needs.release-predecessor.outputs.tarball_url }} + PREDECESSOR_INTEGRITY: ${{ needs.release-predecessor.outputs.integrity }} + run: | + node scripts/release-cli-publication.mjs assert-nightly-predecessor \ + "$PREDECESSOR_VERSION" \ + "$PREDECESSOR_TARBALL_URL" \ + "$PREDECESSOR_INTEGRITY" + eval: name: Validate installed CLI Eval if: github.event_name != 'pull_request' diff --git a/.github/workflows/npm-publication.yml b/.github/workflows/npm-publication.yml index 909f58e60a..1fc32f99a1 100644 --- a/.github/workflows/npm-publication.yml +++ b/.github/workflows/npm-publication.yml @@ -144,10 +144,17 @@ jobs: "$NIGHTLY_VERSION" \ "$GITHUB_OUTPUT" - - name: Require the Nightly channel to advance + - name: Require the qualified predecessor and Nightly channel advance env: NIGHTLY_VERSION: ${{ steps.npm-nightly.outputs.version }} + PREDECESSOR_VERSION: ${{ needs.cli.outputs.release_predecessor_version }} + PREDECESSOR_TARBALL_URL: ${{ needs.cli.outputs.release_predecessor_tarball_url }} + PREDECESSOR_INTEGRITY: ${{ needs.cli.outputs.release_predecessor_integrity }} run: | + node scripts/release-cli-publication.mjs assert-nightly-predecessor \ + "$PREDECESSOR_VERSION" \ + "$PREDECESSOR_TARBALL_URL" \ + "$PREDECESSOR_INTEGRITY" current="$(npm view maka-agent dist-tags.nightly --registry https://registry.npmjs.org/)" node scripts/product-nightly.mjs assert-channel-advance "$NIGHTLY_VERSION" "$current" diff --git a/.github/workflows/release-cli-stage.yml b/.github/workflows/release-cli-stage.yml index 92cb75da66..165bdad170 100644 --- a/.github/workflows/release-cli-stage.yml +++ b/.github/workflows/release-cli-stage.yml @@ -175,11 +175,19 @@ jobs: - name: Submit the candidate to npm staging env: GH_TOKEN: ${{ github.token }} + PREDECESSOR_VERSION: ${{ needs.validate.outputs.release_predecessor_version }} + PREDECESSOR_TARBALL_URL: ${{ needs.validate.outputs.release_predecessor_tarball_url }} + PREDECESSOR_INTEGRITY: ${{ needs.validate.outputs.release_predecessor_integrity }} PRODUCT_SOURCE_COMMIT: ${{ needs.authorize.outputs.source_commit }} PRODUCT_TAG: ${{ needs.authorize.outputs.product_tag }} RELEASE_DIST_TAG: ${{ steps.release.outputs.dist_tag }} RELEASE_TARBALL: ${{ steps.release.outputs.tarball }} run: | + node scripts/release-cli-publication.mjs assert-nightly-predecessor \ + "$PREDECESSOR_VERSION" \ + "$PREDECESSOR_TARBALL_URL" \ + "$PREDECESSOR_INTEGRITY" + node scripts/product-release-authority.mjs verify-draft \ "$PRODUCT_TAG" "$PRODUCT_SOURCE_COMMIT" "$GITHUB_REPOSITORY" diff --git a/package.json b/package.json index f16041e72c..ce07b14b71 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "check:cli-third-party-notices": "node scripts/generate-third-party-notices.mjs --target cli --check", "release:cli:pack": "node scripts/release-cli-package.mjs", "release:cli:smoke": "node scripts/smoke-release-cli-package.mjs", + "release:cli:qualify-state-root": "node scripts/qualify-released-cli-state-root.mjs", "release:cli:eval": "node scripts/release-cli-eval-package.mjs", "check:app-shell-hooks": "node scripts/check-app-shell-hooks.mjs", "check:asf-headers": "node scripts/asf-license-headers.mjs check", @@ -74,7 +75,7 @@ "check:runtime-host-peer-dependencies": "node scripts/generate-runtime-host-peer-dependencies.mjs --check", "generate:runtime-host-peer-notices": "node scripts/generate-runtime-host-peer-notices.mjs", "check:runtime-host-peer-notices": "node scripts/generate-runtime-host-peer-notices.mjs --check", - "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test scripts/product-nightly.test.mjs scripts/desktop-nightly.test.mjs scripts/desktop-nightly-stage.test.mjs scripts/desktop-nightly-release.test.mjs scripts/desktop-nightly-workflow-policy.test.mjs scripts/product-release.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs scripts/windows-package-source-closure.test.mjs", + "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test scripts/product-nightly.test.mjs scripts/desktop-nightly.test.mjs scripts/desktop-nightly-stage.test.mjs scripts/desktop-nightly-release.test.mjs scripts/desktop-nightly-workflow-policy.test.mjs scripts/product-release.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/qualify-released-cli-state-root.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs scripts/windows-package-source-closure.test.mjs", "package:macos-arm64": "node scripts/package-macos-arm64.mjs", "verify:macos-arm64": "node scripts/verify-macos-arm64-dmg.mjs", "package:macos-autoupdate-next": "node scripts/package-macos-autoupdate-next.mjs", diff --git a/scripts/qualify-released-cli-state-root.mjs b/scripts/qualify-released-cli-state-root.mjs new file mode 100644 index 0000000000..5a4faf250a --- /dev/null +++ b/scripts/qualify-released-cli-state-root.mjs @@ -0,0 +1,600 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + cpSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { createServer } from 'node:net'; +import { tmpdir, userInfo } from 'node:os'; +import { isAbsolute, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { npmSpawnOptions } from './npm-spawn.mjs'; + +const PROCESS_TIMEOUT_MS = 90_000; +const MAX_OUTPUT_BYTES = 16 * 1024 * 1024; +const MAX_TARBALL_BYTES = 64 * 1024 * 1024; +const fixturePath = fileURLToPath( + new URL('./released-cli-state-root-fixture.mjs', import.meta.url), +); +const qualificationPath = fileURLToPath(import.meta.url); +const SUDO_BWRAP_ENV = 'MAKA_QUALIFICATION_BWRAP_USE_SUDO'; + +export function parseQualificationArgs(argv) { + const allowedNames = new Set([ + '--source', + '--target', + '--source-sha256', + '--target-sha256', + '--expect-epoch-relation', + ]); + const values = new Map(); + for (let index = 0; index < argv.length; index += 2) { + const name = argv[index]; + const value = argv[index + 1]; + if (!name?.startsWith('--') || value === undefined) { + throw new Error('Qualification arguments must be --name value pairs'); + } + if (!allowedNames.has(name)) throw new Error(`Unknown qualification argument: ${name}`); + if (values.has(name)) throw new Error(`Duplicate qualification argument: ${name}`); + values.set(name, value); + } + const source = requireAbsolutePath(values, '--source'); + const target = requireAbsolutePath(values, '--target'); + const sourceSha256 = requireSha256(values, '--source-sha256'); + const targetSha256 = requireSha256(values, '--target-sha256'); + const expectedEpochRelation = values.get('--expect-epoch-relation') ?? 'any'; + if (!['same', 'different', 'any'].includes(expectedEpochRelation)) { + throw new Error('Expected epoch relation must be same, different, or any'); + } + return { source, target, sourceSha256, targetSha256, expectedEpochRelation }; +} + +export function assertExpectedEpochRelation(sourceEpoch, targetEpoch, expected) { + if (!Number.isSafeInteger(sourceEpoch) || !Number.isSafeInteger(targetEpoch)) { + throw new Error('Release compatibility epochs must be safe integers'); + } + const actual = sourceEpoch === targetEpoch ? 'same' : 'different'; + if (expected !== 'any' && expected !== actual) { + throw new Error(`Expected ${expected} compatibility epochs, found ${actual}`); + } + return actual; +} + +export function sha256File(path) { + return createHash('sha256').update(readFileSync(path)).digest('hex'); +} + +export function qualificationSandboxInvocation({ args, account, useSudo }) { + if (!useSudo) return { command: 'bwrap', args }; + const separator = args.indexOf('--'); + if (separator === -1) throw new Error('Qualification sandbox command is missing'); + return { + command: 'sudo', + args: [ + '--non-interactive', + '--', + '/usr/bin/bwrap', + ...args.slice(0, separator), + '--cap-add', + 'CAP_SETUID', + '--cap-add', + 'CAP_SETGID', + '--cap-add', + 'CAP_SETPCAP', + '--', + '/usr/bin/setpriv', + '--regid', + String(account.gid), + '--reuid', + String(account.uid), + '--clear-groups', + '--inh-caps=-all', + '--ambient-caps=-all', + '--bounding-set=-all', + '--no-new-privs', + ...args.slice(separator + 1), + ], + }; +} + +export async function qualifyReleasedCliStateRoot(input) { + if (process.platform !== 'linux') { + throw new Error('Released State Root qualification currently requires Linux'); + } + assertCommandAvailable('bwrap'); + assertCommandAvailable('timeout'); + const useSudo = parseSudoBwrapEnvironment(process.env[SUDO_BWRAP_ENV]); + if (useSudo) { + assertCommandAvailable('sudo'); + assertCommandAvailable('/usr/bin/setpriv'); + } + assertTarballDigest(input.source, input.sourceSha256, 'source'); + assertTarballDigest(input.target, input.targetSha256, 'target'); + const scope = mkdtempSync(join(tmpdir(), 'maka-released-state-root-')); + try { + const sandbox = prepareSandbox(scope); + const source = installArtifact({ + role: 'source', + tarball: input.source, + scope, + sandbox, + }); + const target = installArtifact({ + role: 'target', + tarball: input.target, + scope, + sandbox, + }); + const epochRelation = assertExpectedEpochRelation( + source.compatibilityEpoch, + target.compatibilityEpoch, + input.expectedEpochRelation, + ); + const innerInputPath = join(scope, 'qualification-input.json'); + writeFileSync( + innerInputPath, + `${JSON.stringify({ + scope, + source, + target, + sourceSha256: input.sourceSha256, + targetSha256: input.targetSha256, + epochRelation, + })}\n`, + { mode: 0o600 }, + ); + return runQualificationSandbox({ innerInputPath, sandbox, scope, useSudo }); + } finally { + rmSync(scope, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } +} + +async function qualifyInstalledArtifacts(input) { + const { scope, source, target } = input; + const rootPath = join(scope, 'state-root'); + const goldenPath = join(scope, 'golden-root'); + mkdirSync(rootPath, { recursive: true }); + const seeded = runFixture({ action: 'seed', artifact: source, rootPath, scope }); + assertFacts(seeded); + cpSync(rootPath, goldenPath, { recursive: true, force: true }); + + const sourceReady = await runInstalledRuntimeHost({ artifact: source, rootPath, scope }); + const sourceFacts = runFixture({ action: 'inspect', artifact: source, rootPath, scope }); + assertSameFacts(seeded, sourceFacts, 'source self-reopen'); + + restoreGolden(rootPath, goldenPath); + const writerFence = await proveWriterFence({ source, target, rootPath, scope }); + + restoreGolden(rootPath, goldenPath); + const targetReady = await runInstalledRuntimeHost({ artifact: target, rootPath, scope }); + const targetFacts = runFixture({ action: 'inspect', artifact: target, rootPath, scope }); + assertSameFacts(seeded, targetFacts, 'target transition'); + + return { + schemaVersion: 1, + source: artifactEvidence(source, input.sourceSha256), + target: artifactEvidence(target, input.targetSha256), + epochRelation: input.epochRelation, + facts: seeded, + checks: { + sourceSelfReopen: { kind: 'passed', host: sourceReady }, + concurrentWriterFence: writerFence, + targetTransition: { kind: 'passed', host: targetReady }, + }, + claims: { + rollback: 'not_claimed', + downgrade: 'unsupported', + externalNpmReconciliation: 'not_qualified_by_this_harness', + }, + }; +} + +function requireAbsolutePath(values, name) { + const value = values.get(name); + if (!value || !isAbsolute(value)) throw new Error(`${name} must be an absolute path`); + return resolve(value); +} + +function requireSha256(values, name) { + const value = values.get(name); + if (!value || !/^[a-f0-9]{64}$/u.test(value)) { + throw new Error(`${name} must be a lowercase SHA-256`); + } + return value; +} + +function assertTarballDigest(path, expected, role) { + const stat = statSync(path); + if (!stat.isFile() || stat.size > MAX_TARBALL_BYTES) { + throw new Error(`The ${role} release tarball exceeds the qualification boundary`); + } + const actual = sha256File(path); + if (actual !== expected) { + throw new Error(`The ${role} release tarball SHA-256 does not match`); + } +} + +function assertCommandAvailable(command) { + const result = spawnSync(command, ['--version'], { encoding: 'utf8' }); + if (result.error?.code === 'ENOENT') throw new Error(`${command} is required`); + if (result.status !== 0) throw new Error(`${command} is unavailable`); +} + +function prepareSandbox(scope) { + const account = userInfo(); + const home = join(scope, 'home'); + const temp = join(scope, 'tmp'); + const etc = join(scope, 'etc'); + for (const path of [home, temp, etc]) mkdirSync(path, { recursive: true }); + const passwd = join(etc, 'passwd'); + const group = join(etc, 'group'); + writeFileSync( + passwd, + `maka-qualification:x:${account.uid}:${account.gid}:Maka qualification:${home}:/bin/sh\n`, + ); + writeFileSync(group, `maka-qualification:x:${account.gid}:\n`); + return { + account, + home, + temp, + passwd, + group, + environment: { + ...process.env, + HOME: home, + XDG_CACHE_HOME: join(home, '.cache'), + XDG_CONFIG_HOME: join(home, '.config'), + XDG_DATA_HOME: join(home, '.local/share'), + TMPDIR: temp, + }, + }; +} + +function parseSudoBwrapEnvironment(value) { + if (value === undefined || value === '') return false; + if (value === '1') return true; + throw new Error(`${SUDO_BWRAP_ENV} must be 1 when set`); +} + +function installArtifact({ role, tarball, scope, sandbox }) { + const prefix = join(scope, `${role}-prefix`); + const cache = join(scope, `${role}-npm-cache`); + const result = spawnSync( + 'npm', + [ + 'install', + '--global', + '--prefix', + prefix, + '--cache', + cache, + '--offline', + '--no-audit', + '--no-fund', + tarball, + ], + npmSpawnOptions({ + cwd: scope, + env: { + ...sandbox.environment, + npm_config_registry: 'http://127.0.0.1:9/', + }, + encoding: 'utf8', + }), + ); + if (result.status !== 0) { + throw new Error(`Unable to install the ${role} release tarball: ${result.stderr}`); + } + const packageRoot = join(prefix, 'lib/node_modules/maka-agent'); + const manifest = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')); + const protocolPath = join(packageRoot, 'node_modules/@maka/runtime-host/dist/protocol/index.js'); + const protocol = readFileSync(protocolPath, 'utf8'); + const epoch = protocol.match(/RUNTIME_HOST_COMPATIBILITY_EPOCH\s*=\s*(\d+)/u)?.[1]; + if (!epoch) throw new Error(`The ${role} release has no compatibility epoch`); + const cliPath = join(packageRoot, 'dist/cli.js'); + const versionResult = spawnSync(process.execPath, [cliPath, '--version'], { + cwd: scope, + env: sandbox.environment, + encoding: 'utf8', + maxBuffer: MAX_OUTPUT_BYTES, + timeout: PROCESS_TIMEOUT_MS, + }); + if (versionResult.status !== 0) { + throw new Error(`The ${role} CLI version check failed: ${versionResult.stderr}`); + } + const version = versionResult.stdout.trim(); + if (version !== manifest.version) { + throw new Error(`The ${role} CLI reports ${version}; expected ${manifest.version}`); + } + return { role, prefix, packageRoot, cliPath, version, compatibilityEpoch: Number(epoch) }; +} + +export function qualificationSandboxArgs({ innerInputPath, sandbox, scope }) { + return [ + '--die-with-parent', + '--ro-bind', + '/', + '/', + '--tmpfs', + '/tmp', + '--chmod', + '1777', + '/tmp', + '--bind', + scope, + scope, + '--ro-bind', + sandbox.passwd, + '/etc/passwd', + '--ro-bind', + sandbox.group, + '/etc/group', + '--setenv', + 'HOME', + sandbox.home, + '--setenv', + 'XDG_CACHE_HOME', + sandbox.environment.XDG_CACHE_HOME, + '--setenv', + 'XDG_CONFIG_HOME', + sandbox.environment.XDG_CONFIG_HOME, + '--setenv', + 'XDG_DATA_HOME', + sandbox.environment.XDG_DATA_HOME, + '--setenv', + 'TMPDIR', + sandbox.temp, + '--chdir', + scope, + '--', + process.execPath, + qualificationPath, + '--inner', + innerInputPath, + ]; +} + +function runQualificationSandbox({ innerInputPath, sandbox, scope, useSudo }) { + const invocation = qualificationSandboxInvocation({ + args: qualificationSandboxArgs({ innerInputPath, sandbox, scope }), + account: sandbox.account, + useSudo, + }); + const result = spawnSync(invocation.command, invocation.args, { + cwd: scope, + env: sandbox.environment, + encoding: 'utf8', + maxBuffer: MAX_OUTPUT_BYTES, + timeout: PROCESS_TIMEOUT_MS * 4, + }); + if (result.status !== 0) { + throw new Error(`Qualification sandbox failed: ${result.stderr || result.stdout}`); + } + return parseLastJsonLine(result.stdout); +} + +function runFixture({ action, artifact, rootPath, scope }) { + const result = spawnSync( + process.execPath, + [fixturePath, '--action', action, '--package-root', artifact.packageRoot, '--root', rootPath], + { + cwd: scope, + env: process.env, + encoding: 'utf8', + maxBuffer: MAX_OUTPUT_BYTES, + timeout: PROCESS_TIMEOUT_MS, + }, + ); + if (result.status !== 0) { + throw new Error(`Released fixture failed: ${result.stderr || result.stdout}`); + } + return parseLastJsonLine(result.stdout); +} + +async function proveWriterFence({ source, target, rootPath, scope }) { + const result = spawnSync( + process.execPath, + [ + fixturePath, + '--action', + 'fence', + '--package-root', + source.packageRoot, + '--root', + rootPath, + '--target-package-root', + target.packageRoot, + ], + { + cwd: scope, + env: process.env, + encoding: 'utf8', + maxBuffer: MAX_OUTPUT_BYTES, + timeout: PROCESS_TIMEOUT_MS, + }, + ); + if (result.status !== 0) { + throw new Error(`Released writer-fence fixture failed: ${result.stderr || result.stdout}`); + } + const attempted = parseLastJsonLine(result.stdout); + if (attempted.kind !== 'writer_fenced') { + throw new Error('The target release acquired a concurrently held State Root writer'); + } + return { kind: 'passed', rootId: attempted.rootId }; +} + +async function runInstalledRuntimeHost({ artifact, rootPath, scope }) { + const configPath = join(scope, `${artifact.role}-runtime-host-service.json`); + writeFileSync( + configPath, + `${JSON.stringify({ + schemaVersion: 2, + rootPath, + projectDirectoryRoots: [{ label: 'qualification', path: scope }], + websocket: { + host: '127.0.0.1', + port: await allocateLoopbackPort(), + path: '/runtime-host', + }, + launch: { nodePath: process.execPath, cliPath: artifact.cliPath }, + })}\n`, + { mode: 0o600 }, + ); + const result = spawnSync( + 'timeout', + [ + '--signal=INT', + '--kill-after=15s', + '--preserve-status', + '10s', + process.execPath, + artifact.cliPath, + 'runtime-host', + 'serve', + '--managed-service-config', + configPath, + '--json', + ], + { + cwd: scope, + env: process.env, + encoding: 'utf8', + maxBuffer: MAX_OUTPUT_BYTES, + timeout: PROCESS_TIMEOUT_MS, + }, + ); + if (result.status !== 0) { + throw new Error(`Released Runtime Host failed: ${result.stderr || result.stdout}`); + } + const ready = findJsonLine(result.stdout, (value) => value.event === 'runtime_host_ready'); + if (!ready?.rootId || !ready.hostEpoch) { + throw new Error(`The released Runtime Host did not publish Ready: ${result.stderr}`); + } + return { rootId: ready.rootId, hostEpoch: ready.hostEpoch }; +} + +function parseLastJsonLine(output) { + const lines = output.trim().split(/\r?\n/u); + for (let index = lines.length - 1; index >= 0; index -= 1) { + try { + return JSON.parse(lines[index]); + } catch { + // Earlier lines may contain runtime diagnostics. + } + } + throw new Error('Released fixture produced no JSON evidence'); +} + +function findJsonLine(output, predicate) { + for (const line of output.split(/\r?\n/u)) { + if (!line.trim().startsWith('{')) continue; + try { + const value = JSON.parse(line); + if (predicate(value)) return value; + } catch { + // Non-JSON diagnostics remain outside the evidence channel. + } + } + return undefined; +} + +function assertFacts(value) { + if ( + value.kind !== 'facts' || + !value.rootId || + !value.session?.id || + !value.session?.message?.id || + !value.scheduledTask?.id + ) { + throw new Error('Released fixture evidence is incomplete'); + } +} + +function assertSameFacts(expected, actual, stage) { + assertFacts(actual); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`Durable facts changed during ${stage}`); + } +} + +function restoreGolden(rootPath, goldenPath) { + for (const entry of readdirSync(rootPath)) { + rmSync(join(rootPath, entry), { recursive: true, force: true }); + } + for (const entry of readdirSync(goldenPath)) { + cpSync(join(goldenPath, entry), join(rootPath, entry), { + recursive: true, + force: true, + }); + } +} + +function artifactEvidence(artifact, sha256) { + return { + version: artifact.version, + compatibilityEpoch: artifact.compatibilityEpoch, + sha256, + }; +} + +async function allocateLoopbackPort() { + const server = createServer(); + await new Promise((resolvePromise, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolvePromise); + }); + const address = server.address(); + await new Promise((resolvePromise, reject) => + server.close((error) => (error ? reject(error) : resolvePromise())), + ); + if (!address || typeof address === 'string') throw new Error('Unable to allocate a port'); + return address.port; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + if (process.argv[2] === '--inner') { + const innerInputPath = process.argv[3]; + if (!innerInputPath || !isAbsolute(innerInputPath) || process.argv.length !== 4) { + throw new Error('Qualification sandbox requires one absolute input path'); + } + const report = await qualifyInstalledArtifacts( + JSON.parse(readFileSync(innerInputPath, 'utf8')), + ); + process.stdout.write(`${JSON.stringify(report)}\n`); + } else { + const report = await qualifyReleasedCliStateRoot( + parseQualificationArgs(process.argv.slice(2)), + ); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + } + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/qualify-released-cli-state-root.test.mjs b/scripts/qualify-released-cli-state-root.test.mjs new file mode 100644 index 0000000000..87c3bd8822 --- /dev/null +++ b/scripts/qualify-released-cli-state-root.test.mjs @@ -0,0 +1,192 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import test from 'node:test'; +import { + assertExpectedEpochRelation, + parseQualificationArgs, + qualificationSandboxArgs, + qualificationSandboxInvocation, + sha256File, +} from './qualify-released-cli-state-root.mjs'; + +const SHA_A = 'a'.repeat(64); +const SHA_B = 'b'.repeat(64); + +test('parses two exact artifacts and an epoch relation', () => { + const source = resolve(tmpdir(), 'source.tgz'); + const target = resolve(tmpdir(), 'target.tgz'); + assert.deepEqual( + parseQualificationArgs([ + '--source', + source, + '--source-sha256', + SHA_A, + '--target', + target, + '--target-sha256', + SHA_B, + '--expect-epoch-relation', + 'same', + ]), + { + source, + sourceSha256: SHA_A, + target, + targetSha256: SHA_B, + expectedEpochRelation: 'same', + }, + ); +}); + +test('rejects ambiguous artifact identity and unknown arguments', () => { + const target = resolve(tmpdir(), 'target.tgz'); + assert.throws( + () => + parseQualificationArgs([ + '--source', + 'source.tgz', + '--source-sha256', + SHA_A, + '--target', + target, + '--target-sha256', + SHA_B, + '--expect-epoch-relation', + 'any', + ]), + /source must be an absolute path/u, + ); + assert.throws( + () => + parseQualificationArgs([ + '--source', + resolve(tmpdir(), 'source.tgz'), + '--source-sha256', + SHA_A, + '--target', + target, + '--target-sha256', + SHA_B, + '--extra', + 'value', + ]), + /Unknown qualification argument/u, + ); +}); + +test('uses privilege only for mount setup and drops every privilege before Node', () => { + const args = ['--die-with-parent', '--', '/usr/bin/node']; + assert.deepEqual( + qualificationSandboxInvocation({ args, account: { uid: 1001, gid: 1002 }, useSudo: false }), + { command: 'bwrap', args }, + ); + assert.deepEqual( + qualificationSandboxInvocation({ args, account: { uid: 1001, gid: 1002 }, useSudo: true }), + { + command: 'sudo', + args: [ + '--non-interactive', + '--', + '/usr/bin/bwrap', + '--die-with-parent', + '--cap-add', + 'CAP_SETUID', + '--cap-add', + 'CAP_SETGID', + '--cap-add', + 'CAP_SETPCAP', + '--', + '/usr/bin/setpriv', + '--regid', + '1002', + '--reuid', + '1001', + '--clear-groups', + '--inh-caps=-all', + '--ambient-caps=-all', + '--bounding-set=-all', + '--no-new-privs', + '/usr/bin/node', + ], + }, + ); + assert.throws( + () => + qualificationSandboxInvocation({ args: ['--die-with-parent'], account: {}, useSudo: true }), + /sandbox command is missing/u, + ); +}); + +test('creates a private Host IPC temp root before mounting a scope that may live below it', () => { + const args = qualificationSandboxArgs({ + innerInputPath: '/qualification/input.json', + scope: '/qualification', + sandbox: { + home: '/qualification/home', + temp: '/qualification/tmp', + passwd: '/qualification/etc/passwd', + group: '/qualification/etc/group', + environment: { + XDG_CACHE_HOME: '/qualification/home/.cache', + XDG_CONFIG_HOME: '/qualification/home/.config', + XDG_DATA_HOME: '/qualification/home/.local/share', + }, + }, + }); + const tmpfsIndex = args.indexOf('--tmpfs'); + assert.deepEqual(args.slice(tmpfsIndex, tmpfsIndex + 8), [ + '--tmpfs', + '/tmp', + '--chmod', + '1777', + '/tmp', + '--bind', + '/qualification', + '/qualification', + ]); +}); + +test('classifies and fences the expected epoch relationship', () => { + assert.equal(assertExpectedEpochRelation(74, 76, 'different'), 'different'); + assert.equal(assertExpectedEpochRelation(76, 76, 'same'), 'same'); + assert.equal(assertExpectedEpochRelation(76, 78, 'any'), 'different'); + assert.throws( + () => assertExpectedEpochRelation(76, 78, 'same'), + /Expected same compatibility epochs/u, + ); +}); + +test('computes the exact artifact SHA-256', () => { + const root = mkdtempSync(join(tmpdir(), 'maka-release-digest-')); + try { + const path = join(root, 'artifact.tgz'); + writeFileSync(path, 'released bytes'); + assert.equal( + sha256File(path), + '2f9e0acbd320f87ceff2b9d259c99ec87830fc87d99bf914cef87394294a6682', + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/release-cli-publication.mjs b/scripts/release-cli-publication.mjs index 82ba186aa8..0065ce9540 100644 --- a/scripts/release-cli-publication.mjs +++ b/scripts/release-cli-publication.mjs @@ -22,12 +22,17 @@ import { basename, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createHash } from 'node:crypto'; import { CLI_RELEASE_ARTIFACT_LIMITS } from './release-cli-artifact-policy.mjs'; -import { assertProductNightlyVersion, parseProductReleaseVersion } from './release-version.mjs'; +import { + assertProductNightlyVersion, + parseProductNightlyVersion, + parseProductReleaseVersion, +} from './release-version.mjs'; const PACKAGE_NAME = 'maka-agent'; const REGISTRY_ORIGIN = 'https://registry.npmjs.org'; const REPOSITORY = 'apache/maka'; const PUBLICATION_WORKFLOW_PATH = '.github/workflows/npm-publication.yml'; +const REGISTRY_REQUEST_TIMEOUT_MS = 30_000; const RELEASE_RECORD_KEYS = [ 'schemaVersion', 'packageName', @@ -213,6 +218,54 @@ export async function fetchRegistryRelease({ return { ...record, tarballPath, sha256 }; } +export async function resolveRegistryNightlyPredecessor({ fetchImpl = fetch } = {}) { + const packageMetadata = await fetchJson( + fetchImpl, + `${REGISTRY_ORIGIN}/${PACKAGE_NAME}`, + 'package metadata', + ); + const version = packageMetadata?.['dist-tags']?.nightly; + parseProductNightlyVersion(version); + + const versionMetadata = await fetchJson( + fetchImpl, + `${REGISTRY_ORIGIN}/${PACKAGE_NAME}/${encodeURIComponent(version)}`, + 'package version metadata', + 'application/json', + ); + if (versionMetadata.name !== PACKAGE_NAME || versionMetadata.version !== version) { + throw new Error('Registry Nightly identity does not match its dist-tag'); + } + + const tarball = `${PACKAGE_NAME}-${version}.tgz`; + const tarballUrl = parseRegistryTarballUrl(versionMetadata.dist?.tarball, tarball); + const integrity = parseSha512Integrity(versionMetadata.dist?.integrity); + return { + version, + tarballUrl, + integrity, + }; +} + +export async function assertRegistryNightlyPredecessor({ + expectedVersion, + expectedTarballUrl, + expectedIntegrity, + fetchImpl = fetch, +}) { + const current = await resolveRegistryNightlyPredecessor({ fetchImpl }); + if ( + current.version !== expectedVersion || + current.tarballUrl !== expectedTarballUrl || + current.integrity !== expectedIntegrity + ) { + throw new Error( + `Qualified npm Nightly predecessor ${expectedVersion} is no longer current; found ${current.version}`, + ); + } + return current; +} + export function validateSignatureAudit({ releaseDirectory, audit }) { const record = loadReleaseRecord(releaseDirectory); if (!Array.isArray(audit?.invalid) || !Array.isArray(audit?.missing)) { @@ -380,6 +433,7 @@ async function fetchJson(fetchImpl, url, label, accept = 'application/vnd.npm.in const response = await fetchImpl(url, { headers: { accept }, redirect: 'error', + signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT_MS), }); if (!response.ok) throw new Error(`Registry ${label} request failed with status ${response.status}`); @@ -438,6 +492,18 @@ function parseRegistryTarballUrl(value, expectedName) { return url.href; } +function parseSha512Integrity(value) { + if (typeof value !== 'string' || !value.startsWith('sha512-')) { + throw new Error('Registry package metadata has no valid SHA-512 integrity'); + } + const encoded = value.slice('sha512-'.length); + const bytes = Buffer.from(encoded, 'base64'); + if (bytes.byteLength !== 64 || bytes.toString('base64') !== encoded) { + throw new Error('Registry package metadata has no valid SHA-512 integrity'); + } + return value; +} + function exactKeys(value, keys, label) { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error(`${label} must be an object`); @@ -555,6 +621,25 @@ async function main() { }); return; } + if (command === 'resolve-nightly-predecessor' && args.length === 1) { + const [output] = args; + const predecessor = await resolveRegistryNightlyPredecessor(); + appendOutputs(output, { + version: predecessor.version, + tarball_url: predecessor.tarballUrl, + integrity: predecessor.integrity, + }); + return; + } + if (command === 'assert-nightly-predecessor' && args.length === 3) { + const [expectedVersion, expectedTarballUrl, expectedIntegrity] = args; + await assertRegistryNightlyPredecessor({ + expectedVersion, + expectedTarballUrl, + expectedIntegrity, + }); + return; + } if (command === 'validate-audit' && args.length === 2) { const [releaseDirectory, auditPath] = args; validateSignatureAudit({ @@ -564,7 +649,7 @@ async function main() { return; } throw new Error( - `Usage: release-cli-publication.mjs ...`, + `Usage: release-cli-publication.mjs ...`, ); } diff --git a/scripts/release-cli-publication.test.mjs b/scripts/release-cli-publication.test.mjs index cef61dba5c..28cc5d13fa 100644 --- a/scripts/release-cli-publication.test.mjs +++ b/scripts/release-cli-publication.test.mjs @@ -26,12 +26,14 @@ import { join, resolve } from 'node:path'; import test from 'node:test'; import { CLI_RELEASE_ARTIFACT_LIMITS } from './release-cli-artifact-policy.mjs'; import { + assertRegistryNightlyPredecessor, fetchRegistryRelease, parseCliNightlyVersion, parseCliReleaseVersion, prepareNightlyRelease, prepareSignatureAuditTree, prepareStageRelease, + resolveRegistryNightlyPredecessor, validateRegistryChannels, validateSignatureAudit, validateStageRun, @@ -286,6 +288,50 @@ test('registry downloads stop reading as soon as the tarball exceeds its bound', assert.ok(pulls < offeredChunks, `expected an early bounded read, consumed ${pulls} chunks`); }); +test('release qualification binds the current Nightly tag to immutable registry bytes', async () => { + const fixture = createCandidate('0.2.0-dev.42.20260829', '0.2.0'); + const predecessor = await resolveRegistryNightlyPredecessor({ + fetchImpl: registryFetch({ fixture }), + }); + + assert.deepEqual(predecessor, { + version: fixture.version, + tarballUrl: `https://registry.npmjs.org/maka-agent/-/${fixture.tarball}`, + integrity: `sha512-${digest('sha512', fixture.bytes, 'base64')}`, + }); + await assert.doesNotReject( + assertRegistryNightlyPredecessor({ + expectedVersion: predecessor.version, + expectedTarballUrl: predecessor.tarballUrl, + expectedIntegrity: predecessor.integrity, + fetchImpl: registryFetch({ fixture }), + }), + ); +}); + +test('the release predecessor may come from the previous product version', async () => { + const fixture = createCandidate('0.1.0-dev.41.20260828', '0.1.0'); + const predecessor = await resolveRegistryNightlyPredecessor({ + fetchImpl: registryFetch({ fixture }), + }); + assert.equal(predecessor.version, fixture.version); +}); + +test('a newer Nightly invalidates previously qualified predecessor evidence', async () => { + const previous = createCandidate('0.2.0-dev.42.20260829', '0.2.0'); + const current = createCandidate('0.2.0-dev.43.20260830', '0.2.0'); + + await assert.rejects( + assertRegistryNightlyPredecessor({ + expectedVersion: previous.version, + expectedTarballUrl: `https://registry.npmjs.org/maka-agent/-/${previous.tarball}`, + expectedIntegrity: `sha512-${digest('sha512', previous.bytes, 'base64')}`, + fetchImpl: registryFetch({ fixture: current }), + }), + /is no longer current; found 0\.2\.0-dev\.43\.20260830/u, + ); +}); + test('signature audit must contain Maka provenance for the finalized version', () => { const fixture = createPreparedCandidate(); const verified = { @@ -574,7 +620,7 @@ function registryFetch({ fixture, bytes = fixture.bytes }) { } if (url === 'https://registry.npmjs.org/maka-agent') { assert.equal(options.headers?.accept, 'application/vnd.npm.install-v1+json'); - return Response.json({ 'dist-tags': { latest: fixture.version } }); + return Response.json({ 'dist-tags': { latest: fixture.version, nightly: fixture.version } }); } if (url === tarballUrl) return new Response(bytes); return new Response('not found', { status: 404 }); diff --git a/scripts/release-cli-workflow-policy.test.mjs b/scripts/release-cli-workflow-policy.test.mjs index bad2c92acf..4abe6ef970 100644 --- a/scripts/release-cli-workflow-policy.test.mjs +++ b/scripts/release-cli-workflow-policy.test.mjs @@ -46,6 +46,75 @@ test('validation consumers download the artifact produced by the build job', () } }); +test('CLI validation qualifies exact published State Roots without weakening artifact identity', () => { + const workflow = readWorkflow('cli-package-validation.yml'); + assert.match( + workflow, + /release_predecessor_version:[\s\S]*?value: \$\{\{ jobs\.release-predecessor\.outputs\.version \}\}/u, + ); + assert.match( + workflow, + /release-predecessor:[\s\S]*?resolve-nightly-predecessor "\$GITHUB_OUTPUT"/u, + ); + assert.match( + workflow, + /release_predecessor_integrity:[\s\S]*?jobs\.release-predecessor\.outputs\.integrity/u, + ); + assert.match( + workflow, + /state-root-qualification:\n[\s\S]*?needs: \[build, release-predecessor\]/u, + ); + assert.match(workflow, /source_sha256: [a-f0-9]{64}/u); + assert.match(workflow, /target_sha256: [a-f0-9]{64}/u); + assert.match(workflow, /epoch_relation: different/u); + assert.match(workflow, /epoch_relation: same/u); + const steps = workflowSteps(workflow); + const sandbox = namedStep(steps, 'Require the account-isolation sandbox'); + assert.match(sandbox, /apt-get install --yes bubblewrap/u); + const qualify = namedStep(steps, 'Qualify the released State Root transition'); + assert.match(qualify, /release:cli:qualify-state-root/u); + assert.match(qualify, /MAKA_QUALIFICATION_BWRAP_USE_SUDO:\s*'1'/u); + assert.match(qualify, /--source-sha256/u); + assert.match(qualify, /--target-sha256/u); + assert.match(qualify, /--expect-epoch-relation/u); + assert.match(qualify, /set -o pipefail/u); + assert.match(qualify, /npm run --silent/u); + const prepare = namedStep(steps, 'Prepare exact source and target artifacts'); + assert.match(prepare, /--max-filesize 67108864/gu); + assert.match(prepare, /SOURCE_INTEGRITY/u); + assert.match(prepare, /createHash\('sha512'\)/u); + assert.match(prepare, /source_sha256="\$\(sha256sum/u); + const preserve = namedStep(steps, 'Preserve the qualification report'); + assert.match(preserve, /if-no-files-found: error/u); + const freshness = namedStep(steps, 'Require the qualified Nightly predecessor to remain current'); + assert.match(freshness, /assert-nightly-predecessor/u); + assert.match(freshness, /needs\.release-predecessor\.outputs\.version/u); + assert.ok(steps.indexOf(freshness) > steps.indexOf(preserve)); + assert.match( + workflow, + /source_url: \$\{\{ needs\.release-predecessor\.outputs\.tarball_url \}\}/u, + ); +}); + +test('npm mutations revalidate the exact qualified Nightly predecessor', () => { + const nightly = readWorkflow('npm-publication.yml'); + const nightlyFence = namedStep( + workflowSteps(nightly), + 'Require the qualified predecessor and Nightly channel advance', + ); + assert.match(nightlyFence, /needs\.cli\.outputs\.release_predecessor_version/u); + assert.match(nightlyFence, /needs\.cli\.outputs\.release_predecessor_integrity/u); + assert.match(nightlyFence, /assert-nightly-predecessor/u); + assert.ok(nightly.indexOf(nightlyFence) < nightly.indexOf('npm publish')); + + const stage = readWorkflow('release-cli-stage.yml'); + const submit = namedStep(workflowSteps(stage), 'Submit the candidate to npm staging'); + assert.match(submit, /needs\.validate\.outputs\.release_predecessor_version/u); + assert.match(submit, /needs\.validate\.outputs\.release_predecessor_integrity/u); + assert.match(submit, /assert-nightly-predecessor/u); + assert.ok(submit.indexOf('assert-nightly-predecessor') < submit.indexOf('npm stage publish')); +}); + test('stage consumes the validated artifact and makes provenance staging the final step', () => { const workflow = readWorkflow('release-cli-stage.yml'); assert.match(workflow, /environment:\n\s+name: npm-publication/u); diff --git a/scripts/released-cli-state-root-fixture.mjs b/scripts/released-cli-state-root-fixture.mjs new file mode 100644 index 0000000000..b67f374057 --- /dev/null +++ b/scripts/released-cli-state-root-fixture.mjs @@ -0,0 +1,223 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { isAbsolute, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const SESSION_NAME = 'Released State Root qualification'; +const MESSAGE_ID = 'released-state-root-message'; +const TASK_TITLE = 'Released State Root durable task'; +const FUTURE_FIRE_DELAY_MS = 24 * 60 * 60 * 1_000; + +const input = parseFixtureArgs(process.argv.slice(2)); +const storageRootAuthority = await loadInstalled( + input.packageRoot, + 'node_modules/@maka/storage/dist/root-authority.js', +); +const capability = await storageRootAuthority.resolveStorageRoot({ + path: input.rootPath, + kind: 'interactive', +}); +const owner = await storageRootAuthority.tryAcquireInteractiveRootOwner(capability); + +if (!owner) { + writeResult({ kind: 'writer_busy' }); + process.exit(0); +} + +try { + if (input.action === 'seed') { + writeResult(await seedFixture(input.packageRoot, input.rootPath, owner, capability.rootId)); + } else if (input.action === 'inspect') { + writeResult(await inspectFixture(input.packageRoot, input.rootPath, owner, capability.rootId)); + } else { + writeResult(await probeWriterFence(input.targetPackageRoot, input.rootPath, capability.rootId)); + } +} finally { + await owner.close(); +} + +async function seedFixture(packageRoot, rootPath, rootOwner, rootId) { + const sessionsModule = await loadInstalled( + packageRoot, + 'node_modules/@maka/storage/dist/session-store.js', + ); + const scheduledTasksModule = await loadInstalled( + packageRoot, + 'node_modules/@maka/storage/dist/scheduled-task-store.js', + ); + const sessions = sessionsModule.createSessionStore(rootPath); + const scheduledTasks = await scheduledTasksModule.openInteractiveScheduledTaskStoreForWrite( + rootOwner.lease, + ); + try { + const session = await sessions.create({ + cwd: rootPath, + backend: 'ai-sdk', + llmConnectionSlug: 'released-state-root-qualification', + model: 'qualification-model', + permissionMode: 'ask', + name: SESSION_NAME, + labels: ['release-qualification'], + }); + await sessions.appendMessage(session.id, { + type: 'user', + id: MESSAGE_ID, + turnId: 'released-state-root-turn', + ts: Date.now(), + text: 'Preserve this released State Root fact.', + }); + const now = Date.now(); + const task = await scheduledTasks.create( + { + title: TASK_TITLE, + intentBody: '', + schedule: { kind: 'once', runAt: now + FUTURE_FIRE_DELAY_MS }, + effect: { kind: 'notify', channel: 'local' }, + createdBy: { kind: 'user' }, + }, + now, + ); + return { + kind: 'facts', + rootId, + session: { + id: session.id, + name: session.name, + message: { + id: MESSAGE_ID, + type: 'user', + text: 'Preserve this released State Root fact.', + }, + }, + scheduledTask: { + id: task.id, + title: task.title, + status: task.status, + schedule: task.schedule, + effect: task.effect, + }, + }; + } finally { + scheduledTasks.close(); + await sessions.close?.(); + } +} + +async function inspectFixture(packageRoot, rootPath, rootOwner, rootId) { + const sessionsModule = await loadInstalled( + packageRoot, + 'node_modules/@maka/storage/dist/session-store.js', + ); + const scheduledTasksModule = await loadInstalled( + packageRoot, + 'node_modules/@maka/storage/dist/scheduled-task-store.js', + ); + const sessions = sessionsModule.createSessionStore(rootPath); + const scheduledTasks = await scheduledTasksModule.openInteractiveScheduledTaskStoreForWrite( + rootOwner.lease, + ); + try { + const session = (await sessions.listHeaders()).find( + (candidate) => candidate.name === SESSION_NAME, + ); + if (!session) throw new Error('The released Session fact is missing'); + const messages = await sessions.readMessages(session.id); + const message = messages.find((candidate) => candidate.id === MESSAGE_ID); + if (!message) throw new Error('The released Session message is missing'); + const task = (await scheduledTasks.list()).find((candidate) => candidate.title === TASK_TITLE); + if (!task) throw new Error('The released Scheduled Task fact is missing'); + return { + kind: 'facts', + rootId, + session: { + id: session.id, + name: session.name, + message: { id: message.id, type: message.type, text: message.text }, + }, + scheduledTask: { + id: task.id, + title: task.title, + status: task.status, + schedule: task.schedule, + effect: task.effect, + }, + }; + } finally { + scheduledTasks.close(); + await sessions.close?.(); + } +} + +function parseFixtureArgs(argv) { + const values = new Map(); + for (let index = 0; index < argv.length; index += 2) { + const name = argv[index]; + const value = argv[index + 1]; + if (!name?.startsWith('--') || value === undefined) { + throw new Error('Fixture arguments must be --name value pairs'); + } + if (values.has(name)) throw new Error(`Duplicate fixture argument: ${name}`); + values.set(name, value); + } + const action = values.get('--action'); + const packageRoot = values.get('--package-root'); + const rootPath = values.get('--root'); + if (!['seed', 'inspect', 'fence'].includes(action)) { + throw new Error('Fixture action must be seed, inspect, or fence'); + } + if (!packageRoot || !isAbsolute(packageRoot)) { + throw new Error('Fixture package root must be absolute'); + } + if (!rootPath || !isAbsolute(rootPath)) { + throw new Error('Fixture State Root must be absolute'); + } + const targetPackageRoot = values.get('--target-package-root'); + if (action === 'fence' && (!targetPackageRoot || !isAbsolute(targetPackageRoot))) { + throw new Error('Fence fixture target package root must be absolute'); + } + const expectedNames = action === 'fence' ? 4 : 3; + if (values.size !== expectedNames) throw new Error('Unknown fixture argument'); + return { action, packageRoot, rootPath, targetPackageRoot }; +} + +async function loadInstalled(packageRoot, relativePath) { + return import(pathToFileURL(join(packageRoot, relativePath)).href); +} + +function writeResult(result) { + process.stdout.write(`${JSON.stringify(result)}\n`); +} + +async function probeWriterFence(targetPackageRoot, rootPath, rootId) { + const targetAuthority = await loadInstalled( + targetPackageRoot, + 'node_modules/@maka/storage/dist/root-authority.js', + ); + const targetCapability = await targetAuthority.resolveStorageRoot({ + path: rootPath, + kind: 'interactive', + }); + const targetOwner = await targetAuthority.tryAcquireInteractiveRootOwner(targetCapability); + if (targetOwner) { + await targetOwner.close(); + return { kind: 'writer_acquired', rootId }; + } + return { kind: 'writer_fenced', rootId }; +}