diff --git a/.github/scripts/check-clawhub-version.mjs b/.github/scripts/check-clawhub-version.mjs new file mode 100644 index 0000000000..50be1155b3 --- /dev/null +++ b/.github/scripts/check-clawhub-version.mjs @@ -0,0 +1,109 @@ +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; + +class UserFacingError extends Error {} + +function fail(message) { + throw new UserFacingError(message); +} + +function loadSemver(packageJson) { + try { + return createRequire(packageJson)('semver'); + } catch { + fail('Unable to load the pinned SemVer dependency.'); + } +} + +function main() { + const packageJson = process.env.SEMVER_PACKAGE_JSON; + const preflightFile = process.env.PREFLIGHT_FILE; + const desired = process.env.PUBLISH_VERSION; + if (!packageJson || !preflightFile || desired === undefined) { + fail('ClawHub version check environment is incomplete.'); + } + + const semver = loadSemver(packageJson); + + let preflight; + try { + preflight = JSON.parse(readFileSync(preflightFile, 'utf8')); + } catch { + fail('Unable to read ClawHub version preflight metadata.'); + } + + function canonicalVersion(value) { + if (typeof value !== 'string') return null; + const parsed = semver.parse(value); + if (!parsed) return null; + const build = parsed.build.length > 0 ? `+${parsed.build.join('.')}` : ''; + return { + full: `${parsed.version}${build}`, + hasBuild: parsed.build.length > 0, + hasPrerelease: parsed.prerelease.length > 0, + }; + } + + const desiredVersion = canonicalVersion(desired); + if (!desiredVersion) { + fail('Requested version is not valid semver.'); + } + if (desiredVersion.hasPrerelease) { + fail('Requested version must be a stable SemVer release.'); + } + if (desiredVersion.hasBuild) { + fail('Requested version must not include build metadata.'); + } + const canonical = desiredVersion.full; + + const isPreflightObject = + preflight !== null && typeof preflight === 'object' && !Array.isArray(preflight); + if ( + !isPreflightObject || + typeof preflight.status !== 'string' || + typeof preflight.version !== 'string' || + typeof preflight.fingerprint !== 'string' || + preflight.fingerprint.length === 0 || + !Object.hasOwn(preflight, 'latestVersion') + ) { + fail('ClawHub returned incomplete version metadata.'); + } + + const preflightVersion = canonicalVersion(preflight.version); + if (!preflightVersion) { + fail('ClawHub returned an invalid preflight version.'); + } + if (preflight.status === 'unchanged' && preflightVersion.full === canonical) { + return `noop\t${canonical}`; + } + if ( + preflight.status === 'unchanged' && + semver.eq(preflightVersion.full, canonical) && + preflightVersion.full !== canonical + ) { + fail( + 'ClawHub has unchanged content at the same SemVer precedence with different build metadata.', + ); + } + + if (preflight.latestVersion !== null) { + const latest = canonicalVersion(preflight.latestVersion); + if (!latest) { + fail('ClawHub returned an invalid latest version.'); + } + if (!semver.gt(canonical, latest.full)) { + fail(`Requested version must be greater than ${latest.full}.`); + } + } + + return `continue\t${canonical}`; +} + +try { + process.stdout.write(`${main()}\n`); +} catch (error) { + const message = + error instanceof UserFacingError ? error.message : 'Unable to check ClawHub version.'; + process.stderr.write(`::error::${message}\n`); + process.exitCode = 1; +} diff --git a/.github/scripts/publish-openmaic-skill.sh b/.github/scripts/publish-openmaic-skill.sh new file mode 100644 index 0000000000..e56386eb92 --- /dev/null +++ b/.github/scripts/publish-openmaic-skill.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash + +set -euo pipefail + +fail() { + echo "::error::$1" >&2 + exit 1 +} + +if [[ "$#" -eq 0 ]]; then + dry_run=false +elif [[ "$#" -eq 1 && "$1" == "--dry-run" ]]; then + dry_run=true +else + fail "Usage: publish-openmaic-skill.sh [--dry-run]" +fi + +[[ -n "${SOURCE_REPO:-}" ]] || fail "SOURCE_REPO is required." +[[ "${PUBLISH_VERSION+x}" == x ]] || fail "PUBLISH_VERSION is required." +[[ -n "${CLAWHUB:-}" ]] || fail "CLAWHUB is required." + +source_commit="$(git rev-parse HEAD)" +publish_args=( + skills/openmaic + --slug openmaic + --name OpenMAIC + --owner wyuc + --source-repo "$SOURCE_REPO" + --source-commit "$source_commit" + --source-path skills/openmaic +) + +if [[ -n "$PUBLISH_VERSION" ]]; then + [[ -n "${RUNNER_TEMP:-}" ]] || fail "RUNNER_TEMP is required for version preflight." + preflight_file="$RUNNER_TEMP/clawhub-version-preflight.json" + "$CLAWHUB" skill publish "${publish_args[@]}" --dry-run --json | tee "$preflight_file" + if ! decision="$(PREFLIGHT_FILE="$preflight_file" node .github/scripts/check-clawhub-version.mjs)"; then + exit 1 + fi + IFS=$'\t' read -r action canonical_version extra <<< "$decision" + if [[ -n "${extra:-}" || -z "$action" || -z "$canonical_version" ]]; then + fail "Invalid version preflight decision." + fi + case "$action" in + noop) + echo "::notice::The requested version already has identical content." + exit 0 + ;; + continue) publish_args+=(--version "$canonical_version") ;; + *) fail "Unknown version preflight action." ;; + esac +fi + +if [[ "$dry_run" == true ]]; then + "$CLAWHUB" skill publish "${publish_args[@]}" --dry-run --json +else + "$CLAWHUB" skill publish "${publish_args[@]}" --json +fi diff --git a/.github/workflows/publish-openmaic-skill.yml b/.github/workflows/publish-openmaic-skill.yml new file mode 100644 index 0000000000..03bd1ad50e --- /dev/null +++ b/.github/workflows/publish-openmaic-skill.yml @@ -0,0 +1,312 @@ +name: Publish OpenMAIC skill + +# Required repository setup: +# - Create a GitHub Environment named `clawhub-release`. +# - Restrict its deployment branches to `main` with a custom policy. +# - Store `CLAWHUB_TOKEN` as an Environment secret. +# - Do not also store `CLAWHUB_TOKEN` as a repository secret. +# This workflow is path-filtered. Do not configure it as a required check for every PR; +# require it only through rules that apply to the paths below. +# PR previews execute the checked-out head scripts, so they intentionally receive no secrets, +# have read-only contents permission, and disable persisted checkout credentials. +# Deleting skills/openmaic does not unpublish or deprecate an existing ClawHub release; +# that registry lifecycle action must be performed manually in ClawHub. + +on: + pull_request: + branches: [main] + paths: + - "skills/openmaic/**" + - ".github/scripts/check-clawhub-version.mjs" + - ".github/scripts/publish-openmaic-skill.sh" + - ".github/workflows/publish-openmaic-skill.yml" + push: + branches: [main] + paths: + - "skills/openmaic/**" + - ".github/scripts/check-clawhub-version.mjs" + - ".github/scripts/publish-openmaic-skill.sh" + - ".github/workflows/publish-openmaic-skill.yml" + workflow_dispatch: + inputs: + dry_run: + description: "Preview the current main branch without publishing" + type: boolean + default: true + version: + description: "Optional stable SemVer version (no prerelease/build); empty means automatic patch" + type: string + default: "" + +jobs: + reject-invalid-dispatch: + name: Reject publish outside main + if: >- + github.event_name == 'workflow_dispatch' && + !inputs.dry_run && github.ref != 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Reject publish outside main + run: | + echo "::error::Publishing is only allowed from the main branch." + exit 1 + + bash-3-compatibility: + name: Verify macOS Bash 3.2 publish compatibility + if: github.event_name == 'pull_request' + runs-on: macos-15 + permissions: + contents: read + concurrency: + group: clawhub-bash3-${{ github.event.pull_request.number }} + cancel-in-progress: true + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - name: Run publish paths with macOS Bash 3.2 + run: | + set -euo pipefail + bash_version="$(/bin/bash -c 'printf "%s.%s" "${BASH_VERSINFO[0]}" "${BASH_VERSINFO[1]}"')" + if [[ "$bash_version" != "3.2" ]]; then + echo "::error::Expected macOS system Bash 3.2, found $bash_version." + exit 1 + fi + + CLAWHUB=/usr/bin/true \ + PUBLISH_VERSION='' \ + SOURCE_REPO='THU-MAIC/OpenMAIC' \ + /bin/bash .github/scripts/publish-openmaic-skill.sh + + compat_dir="$RUNNER_TEMP/clawhub-bash3" + mkdir -p "$compat_dir" + cat > "$compat_dir/clawhub" <<'BASH' + #!/bin/bash + printf '%s\t' "$@" >> "$CLAWHUB_CALLS" + printf '\n' >> "$CLAWHUB_CALLS" + if [[ " $* " != *" --version 0.4.0 "* ]]; then + printf '%s\n' '{"status":"would-publish","version":"0.4.0","latestVersion":"0.3.1","fingerprint":"bash3-fixture"}' + fi + BASH + cat > "$compat_dir/node" <<'BASH' + #!/bin/bash + printf 'continue\t0.4.0\n' + BASH + chmod 700 "$compat_dir/clawhub" "$compat_dir/node" + : > "$compat_dir/calls" + + CLAWHUB="$compat_dir/clawhub" \ + CLAWHUB_CALLS="$compat_dir/calls" \ + PATH="$compat_dir:$PATH" \ + PUBLISH_VERSION='0.4.0' \ + RUNNER_TEMP="$compat_dir" \ + SOURCE_REPO='THU-MAIC/OpenMAIC' \ + /bin/bash .github/scripts/publish-openmaic-skill.sh + if [[ "$(wc -l < "$compat_dir/calls" | tr -d ' ')" != "2" ]] || + ! grep -q -- $'--version\t0.4.0\t' "$compat_dir/calls"; then + echo "::error::Bash 3.2 manual-version path did not reach canonical publish." + exit 1 + fi + + preview: + name: Preview ClawHub publish + if: >- + github.event_name == 'pull_request' || + (github.event_name == 'workflow_dispatch' && inputs.dry_run) + runs-on: ubuntu-latest + permissions: + contents: read + concurrency: + group: clawhub-preview-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }} + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || 'main' }} + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + + - name: Check publish script syntax + run: | + node --check .github/scripts/check-clawhub-version.mjs + bash -n .github/scripts/publish-openmaic-skill.sh + + - name: Install ClawHub CLI + working-directory: ${{ runner.temp }} + env: + NPM_CONFIG_REGISTRY: https://registry.npmjs.org + NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/empty-npmrc + run: | + set -euo pipefail + : > "$NPM_CONFIG_USERCONFIG" + npm install --global --ignore-scripts clawhub@0.23.3 semver@7.8.5 + global_root="$(npm root --global)" + if [[ -z "$global_root" ]]; then + echo "::error::npm returned an empty global package root." + exit 1 + fi + echo "SEMVER_PACKAGE_JSON=$global_root/semver/package.json" >> "$GITHUB_ENV" + + - name: Configure ClawHub registry + run: | + python3 - <<'PY' + import json + import os + from pathlib import Path + path = Path(os.environ["RUNNER_TEMP"]) / "clawhub-preview-config.json" + path.write_text( + json.dumps({"registry": "https://clawhub.ai"}, indent=2) + "\n", + encoding="utf-8", + ) + path.chmod(0o600) + PY + echo "CLAWHUB_CONFIG_PATH=$RUNNER_TEMP/clawhub-preview-config.json" >> "$GITHUB_ENV" + + - name: Preview OpenMAIC skill publish + env: + CLAWHUB: clawhub + PUBLISH_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || '' }} + SOURCE_REPO: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }} + run: | + set -euo pipefail + source_commit="$(git rev-parse HEAD)" + if ! git cat-file -e "HEAD^{tree}:skills/openmaic" 2>/dev/null; then + echo "::notice::Skipping $source_commit because skills/openmaic was deleted." + exit 0 + fi + bash .github/scripts/publish-openmaic-skill.sh --dry-run + + publish: + name: Publish to ClawHub + if: >- + github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && + github.ref == 'refs/heads/main' && !inputs.dry_run) + runs-on: ubuntu-latest + environment: clawhub-release + permissions: + contents: read + concurrency: + group: publish-openmaic-skill + cancel-in-progress: false + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + + - name: Check publish script syntax + run: | + node --check .github/scripts/check-clawhub-version.mjs + bash -n .github/scripts/publish-openmaic-skill.sh + + - name: Install ClawHub CLI + working-directory: ${{ runner.temp }} + env: + NPM_CONFIG_REGISTRY: https://registry.npmjs.org + NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/empty-npmrc + run: | + set -euo pipefail + : > "$NPM_CONFIG_USERCONFIG" + npm install --global --ignore-scripts clawhub@0.23.3 semver@7.8.5 + global_root="$(npm root --global)" + if [[ -z "$global_root" ]]; then + echo "::error::npm returned an empty global package root." + exit 1 + fi + echo "SEMVER_PACKAGE_JSON=$global_root/semver/package.json" >> "$GITHUB_ENV" + + - name: Verify ClawHub token is configured + env: + CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }} + run: | + if [[ -z "$CLAWHUB_TOKEN" ]]; then + echo "::error::CLAWHUB_TOKEN is not configured in the clawhub-release environment." + exit 1 + fi + + - name: Write ClawHub config + env: + CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }} + run: | + set -euo pipefail + python3 - <<'PY' + import json + import os + import sys + from pathlib import Path + + path = Path(os.environ["RUNNER_TEMP"]) / "clawhub-config.json" + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + try: + fd = os.open(path, flags, 0o600) + except FileExistsError: + print("::error::ClawHub config already exists in RUNNER_TEMP.", file=sys.stderr) + sys.exit(1) + with os.fdopen(fd, "w", encoding="utf-8") as config: + json.dump( + {"registry": "https://clawhub.ai", "token": os.environ["CLAWHUB_TOKEN"]}, + config, + indent=2, + ) + config.write("\n") + PY + echo "CLAWHUB_CONFIG_PATH=$RUNNER_TEMP/clawhub-config.json" >> "$GITHUB_ENV" + + - name: Verify ClawHub authentication + run: clawhub whoami + + - name: Publish OpenMAIC skill + env: + CLAWHUB: clawhub + EVENT_NAME: ${{ github.event_name }} + PUBLISH_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || '' }} + SOURCE_REPO: ${{ github.repository }} + run: | + set -euo pipefail + source_commit="$(git rev-parse HEAD)" + handle_divergence() { + reason="$1" + case "$EVENT_NAME" in + workflow_dispatch) + echo "::error::Refusing manual publish because $reason." + exit 1 + ;; + push) + echo "::notice::Skipping $source_commit because $reason." + exit 0 + ;; + *) + echo "::error::Unexpected publish event: $EVENT_NAME." + exit 1 + ;; + esac + } + if ! git rev-parse --verify --quiet "refs/remotes/origin/main^{commit}" >/dev/null; then + echo "::error::Unable to resolve the checked-out origin/main commit." + exit 1 + fi + if ! git cat-file -e "HEAD^{tree}:skills/openmaic" 2>/dev/null; then + handle_divergence "skills/openmaic was deleted" + fi + if ! git cat-file -e "origin/main^{tree}:skills/openmaic" 2>/dev/null; then + handle_divergence "skills/openmaic was removed from main" + fi + source_tree="$(git rev-parse HEAD:skills/openmaic)" + main_tree="$(git rev-parse origin/main:skills/openmaic)" + if [[ "$source_tree" != "$main_tree" ]]; then + handle_divergence "skills/openmaic changed on main" + fi + bash .github/scripts/publish-openmaic-skill.sh diff --git a/package.json b/package.json index 511e7f22ae..9e794dcaf3 100644 --- a/package.json +++ b/package.json @@ -166,6 +166,7 @@ "prettier": "3.8.1", "rollup": "^4.35.0", "rollup-plugin-typescript2": "^0.36.0", + "semver": "7.8.5", "tailwindcss": "^4", "tslib": "^2.8.0", "tsx": "^4.21.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 347dafdda5..2ccacc93f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -414,6 +414,9 @@ importers: rollup-plugin-typescript2: specifier: ^0.36.0 version: 0.36.0(rollup@4.59.0)(typescript@5.9.3) + semver: + specifier: 7.8.5 + version: 7.8.5 tailwindcss: specifier: ^4 version: 4.2.1 @@ -11831,6 +11834,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.2: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} @@ -26378,6 +26386,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.5: {} + send@0.19.2: dependencies: debug: 2.6.9 diff --git a/tests/ci/check-clawhub-version.test.ts b/tests/ci/check-clawhub-version.test.ts new file mode 100644 index 0000000000..b7a26212ff --- /dev/null +++ b/tests/ci/check-clawhub-version.test.ts @@ -0,0 +1,428 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, describe, expect, it } from 'vitest'; + +const repositoryRoot = fileURLToPath(new URL('../..', import.meta.url)); +const scriptPath = resolve(repositoryRoot, '.github/scripts/check-clawhub-version.mjs'); +const publishScriptPath = resolve(repositoryRoot, '.github/scripts/publish-openmaic-skill.sh'); +const workflowPath = resolve(repositoryRoot, '.github/workflows/publish-openmaic-skill.yml'); +const packageJsonPath = resolve(repositoryRoot, 'package.json'); +const requireFromRoot = createRequire(packageJsonPath); +const semverPackageJsonPath = requireFromRoot.resolve('semver/package.json'); +const fixtureRoot = mkdtempSync(resolve(tmpdir(), 'clawhub-version-test-')); +let fixtureIndex = 0; + +function workflowJob(workflow: string, name: string) { + const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const markedWorkflow = `${workflow}\n __end__:\n`; + const job = markedWorkflow.match( + new RegExp(`^ ${escapedName}:\\n([\\s\\S]*?)(?=^ [A-Za-z0-9_-]+:\\n)`, 'm'), + )?.[1]; + expect(job, `workflow job ${name}`).toBeDefined(); + return job ?? ''; +} + +afterAll(() => { + rmSync(fixtureRoot, { recursive: true, force: true }); +}); + +type ScriptEnvironment = 'SEMVER_PACKAGE_JSON' | 'PREFLIGHT_FILE' | 'PUBLISH_VERSION'; + +type FixtureInput = + | { kind: 'json'; value: unknown } + | { kind: 'missing' } + | { kind: 'raw'; content: string }; + +type RunOptions = { + environmentOverrides?: Partial>; + omittedEnvironment?: ScriptEnvironment; +}; + +const jsonFixture = (value: unknown): FixtureInput => ({ kind: 'json', value }); +const missingFixture: FixtureInput = { kind: 'missing' }; +const rawFixture = (content: string): FixtureInput => ({ kind: 'raw', content }); + +function runCheck(desired: string, fixture: FixtureInput, options: RunOptions = {}) { + const fixturePath = resolve(fixtureRoot, `${fixtureIndex++}.json`); + if (fixture.kind === 'json') { + writeFileSync(fixturePath, `${JSON.stringify(fixture.value)}\n`, 'utf8'); + } else if (fixture.kind === 'raw') { + writeFileSync(fixturePath, fixture.content, 'utf8'); + } + const env = Object.create(null) as NodeJS.ProcessEnv; + for (const name of ['PATH', 'HOME', 'TMPDIR', 'TMP', 'TEMP', 'SystemRoot', 'WINDIR']) { + if (process.env[name] !== undefined) env[name] = process.env[name]; + } + Object.assign(env, { + SEMVER_PACKAGE_JSON: semverPackageJsonPath, + PREFLIGHT_FILE: fixturePath, + PUBLISH_VERSION: desired, + }); + Object.assign(env, options.environmentOverrides); + if (options.omittedEnvironment) delete env[options.omittedEnvironment]; + return spawnSync(process.execPath, [scriptPath], { + cwd: repositoryRoot, + encoding: 'utf8', + env, + }); +} + +function expectFailure(result: ReturnType, message: string) { + expect(result.error).toBeUndefined(); + expect(result.signal).toBeNull(); + expect(result.status).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe(`::error::${message}\n`); +} + +function expectSuccess(result: ReturnType, stdout: string) { + expect(result.error).toBeUndefined(); + expect(result.signal).toBeNull(); + expect(result.status).toBe(0); + expect(result.stdout).toBe(stdout); + expect(result.stderr).toBe(''); +} + +const validPreflight = { + status: 'would-publish', + version: '0.3.2', + latestVersion: '0.3.1', + fingerprint: 'fixture-fingerprint', +}; + +describe('check-clawhub-version', () => { + it('pins the ClawHub CLI and its independent SemVer runtime in both jobs', () => { + const semverPackage = JSON.parse(readFileSync(semverPackageJsonPath, 'utf8')) as { + version: string; + }; + const workflow = readFileSync(workflowPath, 'utf8'); + const installPins = [ + ...workflow.matchAll(/npm install --global --ignore-scripts clawhub@(\S+) semver@(\S+)/g), + ].map((match) => match.slice(1)); + + expect(installPins).toEqual([ + ['0.23.3', '7.8.5'], + ['0.23.3', '7.8.5'], + ]); + expect(workflow.match(/SEMVER_PACKAGE_JSON=/g)).toHaveLength(2); + expect(workflow.match(/global_root="\$\(npm root --global\)"/g)).toHaveLength(2); + expect(workflow.match(/if \[\[ -z "\$global_root" \]\]; then/g)).toHaveLength(2); + expect(workflow.match(/set -euo pipefail\n\s+: > "\$NPM_CONFIG_USERCONFIG"/g)).toHaveLength(2); + expect(workflow).not.toContain('CLAWHUB_PACKAGE_JSON'); + expect(semverPackage.version).toBe('7.8.5'); + }); + + it('routes preview and publish through the same shared script', () => { + const workflow = readFileSync(workflowPath, 'utf8'); + const publishScript = readFileSync(publishScriptPath, 'utf8'); + + expect( + workflow.match(/bash \.github\/scripts\/publish-openmaic-skill\.sh --dry-run/g), + ).toHaveLength(1); + expect( + workflow.match(/^\s+bash \.github\/scripts\/publish-openmaic-skill\.sh$/gm), + ).toHaveLength(1); + expect(workflow.match(/bash -n \.github\/scripts\/publish-openmaic-skill\.sh/g)).toHaveLength( + 2, + ); + expect(workflow.match(/- "\.github\/scripts\/publish-openmaic-skill\.sh"/g)).toHaveLength(2); + expect(publishScript).toContain('set -euo pipefail'); + expect(publishScript).toContain('source_commit="$(git rev-parse HEAD)"'); + }); + + it('runs automatic and manual paths in a no-secret macOS Bash 3.2 job', () => { + const workflow = readFileSync(workflowPath, 'utf8'); + const compatibilityJob = workflowJob(workflow, 'bash-3-compatibility'); + + expect(compatibilityJob).toContain("if: github.event_name == 'pull_request'"); + expect(compatibilityJob).toContain('runs-on: macos-15'); + expect(compatibilityJob).toContain('permissions:\n contents: read'); + expect(compatibilityJob).toContain( + 'group: clawhub-bash3-${{ github.event.pull_request.number }}', + ); + expect(compatibilityJob).toContain('cancel-in-progress: true'); + expect(compatibilityJob).toContain('persist-credentials: false'); + expect(compatibilityJob).toContain('CLAWHUB=/usr/bin/true'); + expect(compatibilityJob).toContain("PUBLISH_VERSION=''"); + expect(compatibilityJob).toContain('BASH_VERSINFO[0]'); + expect(compatibilityJob).toContain('if [[ "$bash_version" != "3.2" ]]'); + expect( + compatibilityJob.match(/\/bin\/bash \.github\/scripts\/publish-openmaic-skill\.sh/g), + ).toHaveLength(2); + expect(compatibilityJob).toContain("PUBLISH_VERSION='0.4.0'"); + expect(compatibilityJob).toContain('"status":"would-publish"'); + expect(compatibilityJob).toContain("printf 'continue\\t0.4.0\\n'"); + expect(compatibilityJob).toContain("grep -q -- $'--version\\t0.4.0\\t'"); + expect(compatibilityJob).not.toContain('secrets.'); + expect(compatibilityJob).not.toContain('environment:'); + expect(compatibilityJob).not.toContain('setup-node'); + expect(compatibilityJob).not.toContain('npm install'); + }); + + it('keeps the preview job isolated from secrets and pinned to the fork head', () => { + const workflow = readFileSync(workflowPath, 'utf8'); + const previewJob = workflowJob(workflow, 'preview'); + + expect(previewJob).toContain("github.event_name == 'pull_request'"); + expect(previewJob).toContain('permissions:\n contents: read'); + expect(previewJob).toContain( + "repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}", + ); + expect(previewJob).toContain( + "ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || 'main' }}", + ); + expect(previewJob).toContain('persist-credentials: false'); + expect(previewJob).not.toMatch(/^\s+environment:/m); + expect(previewJob).not.toContain('secrets.'); + }); + + it('gates publish on main and preserves checkout and stale-tree safeguards', () => { + const workflow = readFileSync(workflowPath, 'utf8'); + const publishJob = workflowJob(workflow, 'publish'); + + expect(workflow).toContain('push:\n branches: [main]'); + expect(publishJob).toContain("github.event_name == 'push'"); + expect(publishJob).toContain("github.ref == 'refs/heads/main'"); + expect(publishJob).toContain('!inputs.dry_run'); + expect(publishJob).toContain('environment: clawhub-release'); + expect(publishJob).toContain('permissions:\n contents: read'); + expect(publishJob).toContain('ref: ${{ github.sha }}'); + expect(publishJob).toContain('fetch-depth: 0'); + expect(publishJob).toContain('persist-credentials: false'); + expect(publishJob).not.toContain('git fetch'); + expect(publishJob).toContain('EVENT_NAME: ${{ github.event_name }}'); + expect(publishJob).toMatch( + /handle_divergence\(\) \{[\s\S]*?workflow_dispatch\)[\s\S]*?::error::Refusing manual publish because \$reason\.[\s\S]*?exit 1[\s\S]*?push\)[\s\S]*?::notice::Skipping \$source_commit because \$reason\.[\s\S]*?exit 0[\s\S]*?Unexpected publish event:[\s\S]*?exit 1[\s\S]*?\n\s+\}/, + ); + const publishSequence = [ + String.raw`if ! git rev-parse --verify --quiet "refs/remotes/origin/main\^\{commit\}" >/dev/null; then`, + String.raw`echo "::error::Unable to resolve the checked-out origin/main commit\."`, + String.raw`exit 1`, + String.raw`fi`, + String.raw`if ! git cat-file -e "HEAD\^\{tree\}:skills/openmaic" 2>/dev/null; then`, + String.raw`handle_divergence "skills/openmaic was deleted"`, + String.raw`fi`, + String.raw`if ! git cat-file -e "origin/main\^\{tree\}:skills/openmaic" 2>/dev/null; then`, + String.raw`handle_divergence "skills/openmaic was removed from main"`, + String.raw`fi`, + String.raw`source_tree="\$\(git rev-parse HEAD:skills/openmaic\)"`, + String.raw`main_tree="\$\(git rev-parse origin/main:skills/openmaic\)"`, + String.raw`if \[\[ "\$source_tree" != "\$main_tree" \]\]; then`, + String.raw`handle_divergence "skills/openmaic changed on main"`, + String.raw`fi`, + String.raw`bash \.github/scripts/publish-openmaic-skill\.sh`, + ].join(String.raw`\s+`); + expect(publishJob).toMatch(new RegExp(publishSequence)); + expect(publishJob.match(/publish-openmaic-skill\.sh/g)).toHaveLength(2); + expect(publishJob.match(/\bhandle_divergence\b/g)).toHaveLength(4); + }); + + it('lets Node drain output without immediate process exits', () => { + const checker = readFileSync(scriptPath, 'utf8'); + + expect(checker).not.toMatch(/process\.exit\s*\(/); + expect(checker.match(/process\.stdout\.write/g)).toHaveLength(1); + expect(checker).toContain('process.exitCode = 1'); + }); + + it('does not inherit Node control variables from the test runner', () => { + const previousOptions = process.env.NODE_OPTIONS; + const previousDebug = process.env.NODE_DEBUG; + process.env.NODE_OPTIONS = '--require=/definitely/missing/clawhub-test-module'; + process.env.NODE_DEBUG = 'module'; + try { + expectSuccess(runCheck('0.4.0', jsonFixture(validPreflight)), 'continue\t0.4.0\n'); + } finally { + if (previousOptions === undefined) delete process.env.NODE_OPTIONS; + else process.env.NODE_OPTIONS = previousOptions; + if (previousDebug === undefined) delete process.env.NODE_DEBUG; + else process.env.NODE_DEBUG = previousDebug; + } + }); + + it.each([ + ['null', null], + ['array', []], + ['primitive', 'value'], + ['empty object', {}], + [ + 'missing latestVersion', + { status: 'would-publish', version: '0.3.2', fingerprint: 'fixture-fingerprint' }, + ], + ])('rejects incomplete %s metadata without a stack trace', (_name, fixture) => { + expectFailure( + runCheck('0.4.0', jsonFixture(fixture)), + 'ClawHub returned incomplete version metadata.', + ); + }); + + it.each([ + ['status', { ...validPreflight, status: 42 }], + ['version', { ...validPreflight, version: 42 }], + ['fingerprint', { ...validPreflight, fingerprint: 123 }], + ])('rejects a non-string %s as incomplete metadata', (_name, fixture) => { + expectFailure( + runCheck('0.4.0', jsonFixture(fixture)), + 'ClawHub returned incomplete version metadata.', + ); + }); + + it.each([ + ['malformed JSON', rawFixture('{not-json')], + ['a nonexistent file', missingFixture], + ])('rejects %s without a stack trace', (_name, fixture) => { + expectFailure(runCheck('0.4.0', fixture), 'Unable to read ClawHub version preflight metadata.'); + }); + + it('rejects an invalid preflight version', () => { + expectFailure( + runCheck('0.4.0', jsonFixture({ ...validPreflight, version: 'invalid' })), + 'ClawHub returned an invalid preflight version.', + ); + }); + + it('rejects an invalid non-null latest version', () => { + expectFailure( + runCheck('0.4.0', jsonFixture({ ...validPreflight, latestVersion: 'invalid' })), + 'ClawHub returned an invalid latest version.', + ); + }); + + it('rejects a numeric latest version', () => { + expectFailure( + runCheck('0.4.0', jsonFixture({ ...validPreflight, latestVersion: 42 })), + 'ClawHub returned an invalid latest version.', + ); + }); + + it('rejects an empty fingerprint', () => { + expectFailure( + runCheck('0.4.0', jsonFixture({ ...validPreflight, fingerprint: '' })), + 'ClawHub returned incomplete version metadata.', + ); + }); + + it('returns only a noop decision for unchanged identical content', () => { + expectSuccess( + runCheck( + ' v0.3.1 ', + jsonFixture({ + status: 'unchanged', + version: '0.3.1', + latestVersion: '0.3.1', + fingerprint: 'fixture-fingerprint', + }), + ), + 'noop\t0.3.1\n', + ); + }); + + it.each([ + ['greater version', ' v0.4.0 ', validPreflight, 'continue\t0.4.0\n'], + [ + 'null latest version', + '1.0.0', + { ...validPreflight, version: '1.0.0', latestVersion: null }, + 'continue\t1.0.0\n', + ], + ])('returns only a continue decision for a %s', (_name, desired, fixture, stdout) => { + expectSuccess(runCheck(desired, jsonFixture(fixture)), stdout); + }); + + it('continues for an unknown status instead of treating it as unchanged', () => { + expectSuccess( + runCheck( + '0.4.0', + jsonFixture({ + status: 'blocked', + version: '0.4.0', + latestVersion: '0.3.1', + fingerprint: 'fixture-fingerprint', + }), + ), + 'continue\t0.4.0\n', + ); + }); + + it('rejects manual build metadata', () => { + expectFailure( + runCheck('0.4.0+ci.1', jsonFixture(validPreflight)), + 'Requested version must not include build metadata.', + ); + }); + + it('rejects a manual prerelease version', () => { + expectFailure( + runCheck('0.4.0-rc.1', jsonFixture(validPreflight)), + 'Requested version must be a stable SemVer release.', + ); + }); + + it('rejects unchanged registry content with conflicting build metadata', () => { + expectFailure( + runCheck( + '1.2.3', + jsonFixture({ + status: 'unchanged', + version: '1.2.3+old', + latestVersion: '1.2.3+old', + fingerprint: 'fixture-fingerprint', + }), + ), + 'ClawHub has unchanged content at the same SemVer precedence with different build metadata.', + ); + }); + + it.each(['0.3.0', '0.3.1'])( + 'rejects non-increasing version %s for different content', + (desired) => { + expectFailure( + runCheck(desired, jsonFixture(validPreflight)), + 'Requested version must be greater than 0.3.1.', + ); + }, + ); + + it('rejects invalid semver', () => { + expectFailure( + runCheck('invalid', jsonFixture(validPreflight)), + 'Requested version is not valid semver.', + ); + }); + + it.each(['SEMVER_PACKAGE_JSON', 'PREFLIGHT_FILE', 'PUBLISH_VERSION'] as const)( + 'rejects missing %s environment', + (name) => { + expectFailure( + runCheck('0.4.0', jsonFixture(validPreflight), { omittedEnvironment: name }), + 'ClawHub version check environment is incomplete.', + ); + }, + ); + + it.each([ + ['SEMVER_PACKAGE_JSON', 'ClawHub version check environment is incomplete.'], + ['PREFLIGHT_FILE', 'ClawHub version check environment is incomplete.'], + ['PUBLISH_VERSION', 'Requested version is not valid semver.'], + ] as const)('rejects empty %s environment', (name, message) => { + expectFailure( + runCheck('0.4.0', jsonFixture(validPreflight), { + environmentOverrides: { [name]: '' }, + }), + message, + ); + }); + + it('reports a missing SemVer runtime without a stack trace', () => { + expectFailure( + runCheck('0.4.0', jsonFixture(validPreflight), { + environmentOverrides: { SEMVER_PACKAGE_JSON: '/definitely/missing/semver/package.json' }, + }), + 'Unable to load the pinned SemVer dependency.', + ); + }); +}); diff --git a/tests/ci/publish-openmaic-skill.test.ts b/tests/ci/publish-openmaic-skill.test.ts new file mode 100644 index 0000000000..6a523b1cd3 --- /dev/null +++ b/tests/ci/publish-openmaic-skill.test.ts @@ -0,0 +1,254 @@ +import { spawnSync } from 'node:child_process'; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, describe, expect, it } from 'vitest'; + +const repositoryRoot = fileURLToPath(new URL('../..', import.meta.url)); +const publishScript = resolve(repositoryRoot, '.github/scripts/publish-openmaic-skill.sh'); +const packageJsonPath = resolve(repositoryRoot, 'package.json'); +const semverPackageJsonPath = createRequire(packageJsonPath).resolve('semver/package.json'); +const fixtureRoot = mkdtempSync(resolve(tmpdir(), 'clawhub-publish-test-')); +const stubClawhub = resolve(fixtureRoot, 'clawhub-stub.sh'); +let runIndex = 0; + +writeFileSync( + stubClawhub, + `#!/bin/bash +set -euo pipefail +printf '%s\\t' "$@" >> "$STUB_CALLS" +printf '\\n' >> "$STUB_CALLS" +if [[ -n "\${STUB_PREFLIGHT_JSON:-}" && " $* " != *" --version "* ]]; then + printf '%s\\n' "$STUB_PREFLIGHT_JSON" +else + printf '%s\\n' '{"published":true}' +fi +`, + 'utf8', +); +chmodSync(stubClawhub, 0o755); + +afterAll(() => { + rmSync(fixtureRoot, { recursive: true, force: true }); +}); + +type RunOptions = { + args?: string[]; + clawhub?: string; + omittedEnvironment?: 'SOURCE_REPO' | 'PUBLISH_VERSION' | 'CLAWHUB' | 'RUNNER_TEMP'; + pathPrefix?: string; + preflight?: unknown; + publishVersion?: string; + sourceRepo?: string; +}; + +function runPublish(options: RunOptions = {}) { + const callsPath = resolve(fixtureRoot, `calls-${runIndex++}.txt`); + const env = Object.create(null) as NodeJS.ProcessEnv; + for (const name of ['PATH', 'HOME', 'TMPDIR', 'TMP', 'TEMP', 'SystemRoot', 'WINDIR']) { + if (process.env[name] !== undefined) env[name] = process.env[name]; + } + Object.assign(env, { + CLAWHUB: options.clawhub ?? stubClawhub, + PUBLISH_VERSION: options.publishVersion ?? '', + RUNNER_TEMP: fixtureRoot, + SEMVER_PACKAGE_JSON: semverPackageJsonPath, + SOURCE_REPO: options.sourceRepo ?? 'THU-MAIC/OpenMAIC', + STUB_CALLS: callsPath, + }); + if (options.preflight !== undefined) { + env.STUB_PREFLIGHT_JSON = JSON.stringify(options.preflight); + } + if (options.pathPrefix) env.PATH = `${options.pathPrefix}:${env.PATH ?? ''}`; + if (options.omittedEnvironment) delete env[options.omittedEnvironment]; + + const result = spawnSync('/bin/bash', [publishScript, ...(options.args ?? [])], { + cwd: repositoryRoot, + encoding: 'utf8', + env, + }); + const calls = (() => { + try { + return readFileSync(callsPath, 'utf8') + .trimEnd() + .split('\n') + .map((line) => line.split('\t')); + } catch { + return []; + } + })(); + return { calls, result }; +} + +function expectSuccess(result: ReturnType['result'], stdout: string) { + expect(result.error).toBeUndefined(); + expect(result.signal).toBeNull(); + expect(result.status).toBe(0); + expect(result.stdout).toBe(stdout); + expect(result.stderr).toBe(''); +} + +function expectFailure(result: ReturnType['result'], message: string) { + expect(result.error).toBeUndefined(); + expect(result.signal).toBeNull(); + expect(result.status).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe(`::error::${message}\n`); +} + +describe('publish-openmaic-skill shell contract', () => { + it('performs a real publish with no positional argument under macOS Bash 3.2', () => { + const { calls, result } = runPublish(); + + expectSuccess(result, '{"published":true}\n'); + expect(calls).toHaveLength(1); + expect(calls[0]).toContain('publish'); + expect(calls[0]).not.toContain('--dry-run'); + }); + + it('adds dry-run only for a preview publish', () => { + const { calls, result } = runPublish({ args: ['--dry-run'] }); + + expectSuccess(result, '{"published":true}\n'); + expect(calls).toHaveLength(1); + expect(calls[0]).toContain('--dry-run'); + }); + + it('preflights a manual version and publishes the canonical stable version', () => { + const preflight = { + status: 'would-publish', + version: '0.4.0', + latestVersion: '0.3.1', + fingerprint: 'fixture-fingerprint', + }; + const { calls, result } = runPublish({ + preflight, + publishVersion: ' v0.4.0 ', + }); + + expectSuccess(result, `${JSON.stringify(preflight)}\n{"published":true}\n`); + expect(calls).toHaveLength(2); + expect(calls[0]).toContain('--dry-run'); + expect(calls[0]).not.toContain('--version'); + expect(calls[1]).not.toContain('--dry-run'); + expect(calls[1]).toContain('--version'); + expect(calls[1][calls[1].indexOf('--version') + 1]).toBe('0.4.0'); + }); + + it('keeps a manual version preview in dry-run mode after preflight', () => { + const preflight = { + status: 'would-publish', + version: '0.4.0', + latestVersion: '0.3.1', + fingerprint: 'fixture-fingerprint', + }; + const { calls, result } = runPublish({ + args: ['--dry-run'], + preflight, + publishVersion: ' v0.4.0 ', + }); + + expectSuccess(result, `${JSON.stringify(preflight)}\n{"published":true}\n`); + expect(calls).toHaveLength(2); + expect(calls[1]).toContain('--dry-run'); + expect(calls[1]).toContain('--version'); + expect(calls[1][calls[1].indexOf('--version') + 1]).toBe('0.4.0'); + }); + + it('stops after a noop preflight', () => { + const preflight = { + status: 'unchanged', + version: '0.3.1', + latestVersion: '0.3.1', + fingerprint: 'fixture-fingerprint', + }; + const { calls, result } = runPublish({ preflight, publishVersion: '0.3.1' }); + + expectSuccess( + result, + `${JSON.stringify(preflight)}\n::notice::The requested version already has identical content.\n`, + ); + expect(calls).toHaveLength(1); + expect(calls[0]).toContain('--dry-run'); + }); + + it('rejects unsupported arguments', () => { + const usage = runPublish({ args: ['--publish'] }); + expectFailure(usage.result, 'Usage: publish-openmaic-skill.sh [--dry-run]'); + expect(usage.calls).toEqual([]); + }); + + it.each([ + ['an empty argument before dry-run', ['', '--dry-run']], + ['a trailing argument after dry-run', ['--dry-run', '--anything']], + ])('rejects %s without invoking ClawHub', (_name, args) => { + const { calls, result } = runPublish({ args }); + + expectFailure(result, 'Usage: publish-openmaic-skill.sh [--dry-run]'); + expect(calls).toEqual([]); + }); + + it.each([ + ['SOURCE_REPO', 'SOURCE_REPO is required.'], + ['PUBLISH_VERSION', 'PUBLISH_VERSION is required.'], + ['CLAWHUB', 'CLAWHUB is required.'], + ] as const)('rejects an unset %s environment variable', (name, message) => { + const { calls, result } = runPublish({ omittedEnvironment: name }); + + expectFailure(result, message); + expect(calls).toEqual([]); + }); + + it.each([ + ['SOURCE_REPO', { sourceRepo: '' }, 'SOURCE_REPO is required.'], + ['CLAWHUB', { clawhub: '' }, 'CLAWHUB is required.'], + ] as const)('rejects an empty %s environment variable', (_name, options, message) => { + const { calls, result } = runPublish(options); + + expectFailure(result, message); + expect(calls).toEqual([]); + }); + + it('requires RUNNER_TEMP when an explicit version reaches preflight', () => { + const { calls, result } = runPublish({ + omittedEnvironment: 'RUNNER_TEMP', + publishVersion: '0.4.0', + }); + + expectFailure(result, 'RUNNER_TEMP is required for version preflight.'); + expect(calls).toEqual([]); + }); + + it('accepts an explicitly empty PUBLISH_VERSION for automatic versioning', () => { + const { calls, result } = runPublish({ publishVersion: '' }); + + expectSuccess(result, '{"published":true}\n'); + expect(calls).toHaveLength(1); + expect(calls[0]).not.toContain('--version'); + }); + + it('rejects a malformed checker decision', () => { + const fakeBin = resolve(fixtureRoot, `fake-bin-${runIndex++}`); + const fakeNode = resolve(fakeBin, 'node'); + mkdirSync(fakeBin); + writeFileSync(fakeNode, '#!/bin/bash\nprintf "malformed-decision\\n"\n', 'utf8'); + chmodSync(fakeNode, 0o755); + const preflight = { + status: 'would-publish', + version: '0.4.0', + latestVersion: '0.3.1', + fingerprint: 'fixture-fingerprint', + }; + const { calls, result } = runPublish({ + pathPrefix: fakeBin, + preflight, + publishVersion: '0.4.0', + }); + + expect(result.status).toBe(1); + expect(result.stderr).toBe('::error::Invalid version preflight decision.\n'); + expect(calls).toHaveLength(1); + }); +});