diff --git a/.github/main-sync.json b/.github/main-sync.json new file mode 100644 index 000000000..c238fb031 --- /dev/null +++ b/.github/main-sync.json @@ -0,0 +1,12 @@ +{ + "schemaVersion": 1, + "repository": "MemTensor/memmy-agent", + "targets": [ + "v1.2.5", + "v1.2.6", + "v1.2.7" + ], + "maxRounds": 6, + "maxNoProgress": 2, + "maxDurationMinutes": 180 +} diff --git a/.github/workflows/github-draft-release-v2.yml b/.github/workflows/github-draft-release-v2.yml index 0b94dc2b8..038e5d773 100644 --- a/.github/workflows/github-draft-release-v2.yml +++ b/.github/workflows/github-draft-release-v2.yml @@ -4,8 +4,8 @@ on: # Use the base repository's trusted workflow and admit only its canonical # release branches in the job guard below. pull_request_target: - types: [closed] - branches: [main] + types: [ closed ] + branches: [ main ] workflow_dispatch: inputs: version: @@ -37,8 +37,7 @@ permissions: jobs: release: if: >- - github.event_name == 'workflow_dispatch' || - (github.event.pull_request.merged == true && + github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository && startsWith(github.event.pull_request.head.ref, 'release/v')) concurrency: @@ -937,6 +936,31 @@ jobs: exit 1 fi trap - EXIT + - name: Verify completed draft for synchronization + id: sync-source + if: ${{ steps.release.outputs.create_draft == 'true' }} + env: + SYNC_TAG: ${{ steps.release.outputs.tag }} + SYNC_SOURCE_SHA: ${{ steps.release.outputs.target_sha }} + SYNC_CONTROLLER_SHA: ${{ github.workflow_sha }} + SYNC_ASSET_DIR: release-assets + MAIN_SYNC_REQUEST_PATH: ${{ runner.temp }}/memmy-main-sync-request/main-sync-request.json + SYNC_RELEASE_PR_NUMBER: ${{ github.event.pull_request.number || '' }} + run: |- + set -euo pipefail + # Separate from upload cleanup: sync failure never deletes a complete Draft. + controller="$RUNNER_TEMP/main-sync-controller" + mkdir -p "$controller" + git archive "$SYNC_CONTROLLER_SHA" scripts/main-sync .github/main-sync.json | tar -x -C "$controller" + node "$controller/scripts/main-sync/cli.mjs" signal + - name: Publish CodingAgent synchronization request + if: ${{ steps.sync-source.outputs.sync_ready == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: memmy-main-sync-request-${{ github.run_attempt }} + path: ${{ runner.temp }}/memmy-main-sync-request/main-sync-request.json + if-no-files-found: error + retention-days: 30 - name: Record the manual publish boundary if: ${{ steps.release.outputs.create_draft == 'true' }} @@ -975,3 +999,7 @@ jobs: fi echo "- Set create_draft=true only when intentionally creating a Draft Release." } >> "$GITHUB_STEP_SUMMARY" + outputs: + sync_ready: ${{ steps.sync-source.outputs.sync_ready }} + sync_release_id: ${{ steps.sync-source.outputs.release_id }} + sync_source_sha: ${{ steps.sync-source.outputs.source_sha }} diff --git a/.github/workflows/main-sync-validation.yml b/.github/workflows/main-sync-validation.yml new file mode 100644 index 000000000..ecac95f8c --- /dev/null +++ b/.github/workflows/main-sync-validation.yml @@ -0,0 +1,143 @@ +name: Validate managed main synchronization +run-name: Main sync validation ${{ inputs.source_sha }} ${{ inputs.validation_key }} +on: + workflow_dispatch: + inputs: + pr_number: + description: Managed synchronization PR number + required: true + type: string + source_sha: + description: Exact current PR head + required: true + type: string + base_sha: + description: Exact maintained target head + required: true + type: string + validation_key: + description: Scheduler-owned snapshot/generation identity + required: true + type: string +permissions: + contents: read + actions: read +jobs: + source: + name: Main sync source + if: ${{ github.repository == 'MemTensor/memmy-agent' && github.ref == 'refs/heads/main' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + head: ${{ steps.source.outputs.head }} + base: ${{ steps.source.outputs.base }} + pr: ${{ steps.source.outputs.pr }} + validation_key: ${{ steps.source.outputs.validation_key }} + head_repository: ${{ steps.source.outputs.head_repository }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 22 + - id: source + name: Verify authoritative current candidate + env: + GH_TOKEN: ${{ github.token }} + SYNC_CANDIDATE_SHA: ${{ inputs.source_sha }} + SYNC_BASE_SHA: ${{ inputs.base_sha }} + SYNC_PR_NUMBER: ${{ inputs.pr_number }} + SYNC_VALIDATION_KEY: ${{ inputs.validation_key }} + run: node scripts/main-sync/validate-source.mjs + linux: + name: Main sync Linux + needs: source + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + MEMMY_LEGAL_CN_BASE_URL: https://memmy.cn + MEMMY_LEGAL_INTL_BASE_URL: https://memmy.bot + steps: + - uses: actions/checkout@v4 + with: + repository: ${{ needs.source.outputs.head_repository }} + ref: ${{ needs.source.outputs.head }} + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - name: Install declared workspace and Agent dependencies + run: |- + npm ci + npm ci --prefix App/memmy-agent + - name: Build declared workspaces and Agent + run: |- + npm run build + npm --prefix App/memmy-agent run build + - name: Run repository lint and type checks + run: |- + npm run version:check + npm run lint + npm run typecheck + npm --prefix App/memmy-agent run typecheck + - name: Run full repository and standalone Agent suites + run: |- + npm test + npm --prefix App/memmy-agent test + - name: Run public critical smoke plans + run: |- + npm run smoke:memory-layer:test + npm run smoke:local-agent-memory:test + - name: Require unchanged verified source + env: + EXPECTED_SOURCE: ${{ needs.source.outputs.head }} + run: |- + test "$(git rev-parse HEAD)" = "$EXPECTED_SOURCE" + test -z "$(git status --porcelain --untracked-files=all)" + windows: + name: Main sync Windows + needs: source + uses: MemTensor/memmy-agent/.github/workflows/windows-computer-history-validation.yml@main + permissions: + contents: read + with: + source_sha: ${{ needs.source.outputs.head }} + source_repository: ${{ needs.source.outputs.head_repository }} + record: + name: Main sync record + needs: + - source + - linux + - windows + if: ${{ always() && needs.source.result == 'success' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Record provider outcomes for the exact snapshot + shell: node {0} + env: + SOURCE_SHA: ${{ needs.source.outputs.head }} + BASE_SHA: ${{ needs.source.outputs.base }} + PR_NUMBER: ${{ needs.source.outputs.pr }} + VALIDATION_KEY: ${{ needs.source.outputs.validation_key }} + CONTROLLER_SHA: ${{ github.workflow_sha }} + PROVIDER_RUN_ID: ${{ github.run_id }} + PROVIDER_ATTEMPT: ${{ github.run_attempt }} + JOB_RESULTS: ${{ toJSON(needs) }} + run: |- + const fs=require('node:fs');const path=require('node:path'); + const results=JSON.parse(process.env.JOB_RESULTS); + const jobs=Object.fromEntries(['source','linux','windows'].map(name=>[name,results[name].result])); + const proof={schemaVersion:1,headSha:process.env.SOURCE_SHA,baseSha:process.env.BASE_SHA,prNumber:Number(process.env.PR_NUMBER),validationKey:process.env.VALIDATION_KEY,controllerSha:process.env.CONTROLLER_SHA,runId:process.env.PROVIDER_RUN_ID,runAttempt:Number(process.env.PROVIDER_ATTEMPT),jobs,passed:Object.values(jobs).every(value=>value==='success'),diagnostics:JSON.stringify(jobs)}; + const directory=path.join(process.env.RUNNER_TEMP,'main-sync-validation');fs.mkdirSync(directory,{recursive:true}); + fs.writeFileSync(path.join(directory,'main-sync-validation.json'),JSON.stringify(proof,null,2)+'\n'); + - uses: actions/upload-artifact@v4 + with: + name: memmy-main-sync-validation-${{ inputs.validation_key }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/main-sync-validation/main-sync-validation.json + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/main-sync.yml b/.github/workflows/main-sync.yml new file mode 100644 index 000000000..c0b997544 --- /dev/null +++ b/.github/workflows/main-sync.yml @@ -0,0 +1,41 @@ +name: Recover CodingAgent main synchronization request +on: + workflow_dispatch: + inputs: + release_id: + description: Existing stable Release database ID + required: true + type: string + source_sha: + description: Exact main commit recorded by that Release + required: true + type: string +permissions: + contents: read + actions: read +jobs: + request: + if: ${{ github.repository == 'MemTensor/memmy-agent' && github.ref == 'refs/heads/main' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Read authoritative release facts and seal a recovery request + env: + GH_TOKEN: ${{ github.token }} + SYNC_RELEASE_ID: ${{ inputs.release_id }} + SYNC_SOURCE_SHA: ${{ inputs.source_sha }} + MAIN_SYNC_REQUEST_PATH: ${{ runner.temp }}/memmy-main-sync-request/main-sync-request.json + run: node scripts/main-sync/cli.mjs recover + - uses: actions/upload-artifact@v4 + with: + name: memmy-main-sync-request-${{ github.run_attempt }} + path: ${{ runner.temp }}/memmy-main-sync-request/main-sync-request.json + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/windows-computer-history-validation.yml b/.github/workflows/windows-computer-history-validation.yml index 724d19fc8..71fb1f47b 100644 --- a/.github/workflows/windows-computer-history-validation.yml +++ b/.github/workflows/windows-computer-history-validation.yml @@ -4,6 +4,7 @@ on: pull_request: paths: - ".github/workflows/windows-computer-history-validation.yml" + - "tests/windows-memory-workflow.test.ts" - "App/native-computer-history/**" - "App/memmy-agent/src/tools/computer-history/**" - "App/memmy-agent/tests/tools/computer-history/**" @@ -16,6 +17,16 @@ on: - "package.json" - "package-lock.json" workflow_dispatch: + workflow_call: + inputs: + source_sha: + description: Exact candidate commit to validate with the trusted main workflow + required: true + type: string + source_repository: + description: Authoritative PR head repository selected by the trusted main source job + required: false + type: string permissions: contents: read @@ -28,9 +39,65 @@ jobs: run: shell: pwsh steps: + # github.sha belongs to the caller for reusable runs. Resolve the candidate + # before checkout, using only trusted workflow code and expression env values. + - name: Bind exact validation source + id: source + shell: node {0} + env: + INPUT_SOURCE_SHA: ${{ inputs.source_sha || '' }} + INPUT_SOURCE_REPOSITORY: ${{ inputs.source_repository || '' }} + EVENT_NAME: ${{ github.event_name }} + EVENT_SHA: ${{ github.sha }} + SOURCE_REPOSITORY: ${{ github.repository }} + CONTROLLER_WORKFLOW_REF: ${{ github.workflow_ref }} + CONTROLLER_WORKFLOW_SHA: ${{ github.workflow_sha }} + run: | + const fs = require('node:fs'); + const env = process.env; + const isSha = value => typeof value === 'string' && value.length === 40 && /^[a-fA-F0-9]{40}$/.test(value); + const trustedCallers = new Set([ + 'MemTensor/memmy-agent/.github/workflows/github-draft-release-v2.yml@refs/heads/main', + 'MemTensor/memmy-agent/.github/workflows/main-sync.yml@refs/heads/main', + 'MemTensor/memmy-agent/.github/workflows/main-sync-validation.yml@refs/heads/main', + ]); + let candidate; + let mode; + const directWorkflow = `${env.SOURCE_REPOSITORY}/.github/workflows/windows-computer-history-validation.yml@`; + if (env.INPUT_SOURCE_SHA || !env.CONTROLLER_WORKFLOW_REF?.startsWith(directWorkflow)) { + if (env.SOURCE_REPOSITORY !== 'MemTensor/memmy-agent' || + !trustedCallers.has(env.CONTROLLER_WORKFLOW_REF) || + !isSha(env.CONTROLLER_WORKFLOW_SHA)) { + throw new Error('Reusable validation requires the trusted main controller'); + } + candidate = env.INPUT_SOURCE_SHA; + mode = 'workflow_call'; + } else { + if (!['pull_request', 'workflow_dispatch'].includes(env.EVENT_NAME)) { + throw new Error('An exact source_sha is required for reusable validation'); + } + // Preserve PR merge-ref and manual selected-ref validation semantics. + candidate = env.EVENT_SHA; + mode = env.EVENT_NAME; + } + if (!isSha(candidate)) throw new Error('Expected a full 40-hex source SHA'); + const repository = mode === 'workflow_call' ? (env.INPUT_SOURCE_REPOSITORY || env.SOURCE_REPOSITORY) : env.SOURCE_REPOSITORY; + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) throw new Error('Invalid candidate repository'); + fs.appendFileSync(env.GITHUB_OUTPUT, `sha=${candidate.toLowerCase()}\nmode=${mode}\nrepository=${repository}\n`); - uses: actions/checkout@v4 with: + repository: ${{ steps.source.outputs.repository }} + ref: ${{ steps.source.outputs.sha }} persist-credentials: false + - name: Verify exact checked out source + id: exact-head + shell: node {0} + env: + EXPECTED_HEAD_SHA: ${{ steps.source.outputs.sha }} + run: | + const { execFileSync } = require('node:child_process'); + const actual = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + if (actual !== process.env.EXPECTED_HEAD_SHA) throw new Error('Checked out HEAD differs from the candidate'); - uses: actions/setup-node@v4 with: node-version: 22 @@ -41,16 +108,24 @@ jobs: go-version: "1.22.12" cache-dependency-path: App/native-computer-history/apps/OpenComputerHistoryWindows/go.mod - name: Require Windows 11 + id: windows-os run: | $ErrorActionPreference = 'Stop' $build = [Environment]::OSVersion.Version.Build if ($build -lt 22000) { throw "Windows 11 build 22000 or later required; found $build" } Write-Host "Windows build=$build; session=$((Get-Process -Id $PID).SessionId)" - name: Install workspace dependencies - run: npm ci + id: workspace-dependencies + run: | + npm ci + if ($LASTEXITCODE -ne 0) { throw 'Workspace dependency installation failed' } - name: Install standalone agent dependencies - run: npm ci --prefix App/memmy-agent + id: agent-dependencies + run: | + npm ci --prefix App/memmy-agent + if ($LASTEXITCODE -ne 0) { throw 'Agent dependency installation failed' } - name: Validate native protocol and lifecycle without desktop access + id: native-go run: | $ErrorActionPreference = 'Stop' Push-Location App/native-computer-history/apps/OpenComputerHistoryWindows @@ -62,9 +137,24 @@ jobs: go vet -unsafeptr=false ./... if ($LASTEXITCODE -ne 0) { throw 'Native recorder vet failed' } } finally { Pop-Location } + - name: Validate native ComputerUse protocol without desktop access + id: computer-use-go + run: |- + $ErrorActionPreference = 'Stop' + Push-Location App/native-computer-use/apps/OpenComputerUseWindows + try { + go test ./... + if ($LASTEXITCODE -ne 0) { throw 'Native ComputerUse Go tests failed' } + go vet ./... + if ($LASTEXITCODE -ne 0) { throw 'Native ComputerUse vet failed' } + } finally { Pop-Location } - name: Build agent and native History recorder from source - run: npm --prefix App/memmy-agent run build + id: source-build + run: | + npm --prefix App/memmy-agent run build + if ($LASTEXITCODE -ne 0) { throw 'Source build failed' } - name: Validate packaged app icons with Windows PowerShell 5.1 + id: app-icons run: | $ErrorActionPreference = 'Stop' $source = 'App/memmy-agent/src/tools/computer-history/win/app-icon.ps1' @@ -93,6 +183,7 @@ jobs: } } - name: Validate native recorder identity, pure self-test and installed app catalog + id: native-runtime run: | $ErrorActionPreference = 'Stop' $source = 'App/memmy-agent/src/tools/computer-history/win/win11-observer.ps1' @@ -124,8 +215,11 @@ jobs: } Write-Host "Validated $(@($apps).Count) installed or running app entries" - name: Verify agent History platform contracts + id: history-contracts run: | $ErrorActionPreference = 'Stop' + node node_modules/vitest/vitest.mjs run tests/windows-memory-workflow.test.ts + if ($LASTEXITCODE -ne 0) { throw 'Windows workflow and receipt contract regressions failed' } $tests = @( 'App/memmy-agent/tests/tools/computer-history/win/application-icon.test.ts' 'App/memmy-agent/tests/tools/computer-history/win/win11.test.ts' @@ -146,15 +240,93 @@ jobs: # On Windows, Vitest's Vite runner raises a collection-time SyntaxError for # this valid ESM test; the Node ESM runner executes the same assertions. - name: Verify packaged History assets - run: >- - node node_modules/vitest/vitest.mjs run - tests/computer-history-packaging.test.mjs - --experimental.viteModuleRunner=false + id: packaged-assets + run: | + node node_modules/vitest/vitest.mjs run tests/computer-history-packaging.test.mjs --experimental.viteModuleRunner=false + if ($LASTEXITCODE -ne 0) { throw 'Packaged History assets failed' } - name: Verify installer enforces the unpacked History observer - run: >- - node node_modules/vitest/vitest.mjs run - App/shell/desktop/tests/packaged-runtime-boundary.test.ts - -t "requires the native Windows 11 History recorder" + id: installer-boundary + run: | + node node_modules/vitest/vitest.mjs run App/shell/desktop/tests/packaged-runtime-boundary.test.ts -t "requires the native Windows 11 History recorder" + if ($LASTEXITCODE -ne 0) { throw 'Installer History boundary failed' } + - name: Produce Windows provider receipt after complete validation + id: provider-receipt + if: ${{ success() }} + shell: node {0} + env: + EXPECTED_HEAD_SHA: ${{ steps.source.outputs.sha }} + SOURCE_MODE: ${{ steps.source.outputs.mode }} + CANDIDATE_REPOSITORY: ${{ steps.source.outputs.repository }} + VALIDATION_STEPS: ${{ toJSON(steps) }} + SOURCE_REPOSITORY: ${{ github.repository }} + PROVIDER_RUN_ID: ${{ github.run_id }} + PROVIDER_RUN_ATTEMPT: ${{ github.run_attempt }} + CONTROLLER_WORKFLOW_REF: ${{ github.workflow_ref }} + CONTROLLER_WORKFLOW_SHA: ${{ github.workflow_sha }} + run: | + const fs = require('node:fs'); + const path = require('node:path'); + const os = require('node:os'); + const { execFileSync } = require('node:child_process'); + const env = process.env; + const required = [ + 'source', 'exact-head', 'windows-os', 'workspace-dependencies', + 'agent-dependencies', 'native-go', 'computer-use-go', 'source-build', 'app-icons', + 'native-runtime', 'history-contracts', 'packaged-assets', 'installer-boundary', + ]; + const results = JSON.parse(env.VALIDATION_STEPS); + for (const id of required) { + if (results[id]?.outcome !== 'success' || results[id]?.conclusion !== 'success') { + throw new Error(`Missing or unsuccessful Windows validation: ${id}`); + } + } + const headSha = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + if (env.EXPECTED_HEAD_SHA?.length !== 40 || !/^[a-f0-9]{40}$/.test(env.EXPECTED_HEAD_SHA) || headSha !== env.EXPECTED_HEAD_SHA) { + throw new Error('Receipt HEAD differs from the validated candidate'); + } + execFileSync('git', ['diff', '--exit-code'], { stdio: 'pipe' }); + execFileSync('git', ['diff', '--cached', '--exit-code'], { stdio: 'pipe' }); + const build = Number(os.release().split('.')[2]); + if (os.platform() !== 'win32' || !Number.isInteger(build) || build < 22000) { + throw new Error('Receipt requires a Windows 11 native runner'); + } + if (!/^[1-9][0-9]*$/.test(env.PROVIDER_RUN_ID) || !/^[1-9][0-9]*$/.test(env.PROVIDER_RUN_ATTEMPT) || + !/^[a-fA-F0-9]{40}$/.test(env.CONTROLLER_WORKFLOW_SHA)) { + throw new Error('Missing provider run/controller identity'); + } + const receipt = { + schemaVersion: 1, + provider: 'windows', status: 'passed', headSha, + repository: env.SOURCE_REPOSITORY, + sourceRepository: env.CANDIDATE_REPOSITORY, + runId: env.PROVIDER_RUN_ID, runAttempt: Number(env.PROVIDER_RUN_ATTEMPT), + workflow_sha: env.CONTROLLER_WORKFLOW_SHA, + controller: { workflowRef: env.CONTROLLER_WORKFLOW_REF, workflowSha: env.CONTROLLER_WORKFLOW_SHA }, + workflow: '.github/workflows/windows-computer-history-validation.yml', + sourceMode: env.SOURCE_MODE, + os: { platform: os.platform(), release: os.release(), build, arch: os.arch() }, + nativeModules: { + windowsComputerUse: { goTests: 'passed', goVet: 'passed' }, + windowsHistory: { goTests: 'passed', goVet: 'passed', sourceBuild: 'passed', + binaryIdentity: 'passed', pureSelfTest: 'passed', workerSelfTest: 'passed', + appCatalog: 'passed', appIcons: 'passed' }, + }, + validationSteps: Object.fromEntries(required.map(id => [id, results[id].outcome])), + liveCaptureValidated: false, signedInstallerValidated: false, + }; + const artifactName = `windows-provider-receipt-${headSha}-${env.PROVIDER_RUN_ATTEMPT}`; + const directory = path.join(env.RUNNER_TEMP, artifactName); + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync(path.join(directory, 'windows-provider-receipt.json'), JSON.stringify(receipt, null, 2) + '\n'); + fs.appendFileSync(env.GITHUB_OUTPUT, `path=${directory}\nartifact_name=${artifactName}\n`); + - name: Upload Windows provider receipt + if: ${{ success() && steps.provider-receipt.outcome == 'success' }} + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.provider-receipt.outputs.artifact_name }} + path: ${{ steps.provider-receipt.outputs.path }}/windows-provider-receipt.json + if-no-files-found: error + retention-days: 30 - name: Record validation scope if: ${{ always() }} run: | diff --git a/docs/draft-release-main-sync.md b/docs/draft-release-main-sync.md new file mode 100644 index 000000000..227db505d --- /dev/null +++ b/docs/draft-release-main-sync.md @@ -0,0 +1,70 @@ +# Synchronize main after a Draft Release + +After the Draft Release workflow uploads all installers and metadata, it verifies +the Release, source SHA and asset inventory, then publishes a synchronization request +artifact. The Release does not need to be published. Upload failures retain the +existing cleanup behavior; a later request failure preserves the completed Draft. + +The existing cloud CodingAgent GitHub webhook receives `workflow_run.completed` +notifications. Notifications only wake the Scheduler. It re-reads the exact workflow +run attempt, original artifact and digest, Release, main history and target configuration +before admitting a task for each maintained branch. Actions does not run the repair +agent or require a separate model credential. + +## Maintained branches and PRs + +Maintained targets are listed in `.github/main-sync.json`: `v1.2.5`, `v1.2.6` and +`v1.2.7`. Add or retire maintained branches by updating this list through code review. +Historical v branches are not enrolled automatically. + +For each target, CodingAgent uses `sync/main-to-` and creates or reuses the +corresponding Draft PR. It merges both histories, preserves human commits and pushes +only the managed synchronization branch. It never directly updates main or the target, +force-pushes, merges a PR or publishes a Release. + +Validation failures resume the same CodingAgent task, branch and PR. A changed PR head +or target tip invalidates old results and requires fresh integration and validation. +Review readiness requires successful Linux public suites, type checks, lint, builds, +smoke plan tests and native Windows validation for the current head/base, plus the +remaining GitHub checks. `Main Sync / Full` is a CI status, not Project Harness or +maintainer approval. + +Defaults are six repair rounds, two consecutive rounds without progress and 180 minutes +total. When a limit is reached, the loop stops and preserves the workspace and PR for +a maintainer. These limits are configured in the same JSON file. Closing the PR stops +management. + +## Deployment and activation + +Merge the Memmy workflow changes into main. Deploy the matching Scheduler and +code-runner builds from `cloud-coding-agent`, and apply the database migration. +The cloud repository's `docs/main-sync.md` contains the full configuration. +Subscribe the existing signed GitHub repository webhook to **Workflow runs**, and +enable `main_sync.enabled` in the registered Memmy repository configuration. +The feature is disabled by default. + +Reuse the existing model configuration, bot push/fork registration and repository +allowlists. The GitHub token needs contents push, pull-request write, Actions +read/write, checks read and commit-status write permissions. Keep credentials in +secret management, not request artifacts or the repository. Source changes alone +do not update live deployments, configuration or webhooks. + +## Recovery + +If the Draft is complete but its synchronization request failed, run the recovery +workflow on main. Set `RELEASE_ID` to the existing Release's numeric GitHub ID and +`SOURCE_SHA` to its exact 40-character hexadecimal main commit SHA: + +```sh +gh workflow run main-sync.yml --repo MemTensor/memmy-agent --ref main \ + -f release_id="$RELEASE_ID" -f source_sha="$SOURCE_SHA" +``` + +Recovery re-reads the actual Release and source; branch names cannot replace SHAs. +Repeated repository/release/source/target requests reuse the existing synchronization +request rather than create duplicate coding tasks. Published stable Releases are +supported if their tag resolves to the exact source. + +Diagnose failures using the request workflow, original cloud task and specific +`main-sync-validation.yml` run. A queued workflow, model completion claim or green +result for an older commit does not establish readiness. diff --git a/package.json b/package.json index 1705443c1..b21f0f713 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "lint": "npm run memory:lint && npm run workspace:lint", "typecheck": "npm run memory:lint && npm run workspace:typecheck", "test": "npm run test:release-workflow && npm run test:packaging-guards && npm run test:contracts && npm run test:opencode && npm run memory:test && npm run workspace:test && npm run agent:test:tui-cursor", - "test:release-workflow": "vitest run tests/release-workflow.test.ts tests/oss-crc64.test.mjs tests/windows-memory-workflow.test.ts", + "test:release-workflow": "vitest run tests/release-workflow.test.ts tests/oss-crc64.test.mjs tests/windows-memory-workflow.test.ts tests/main-sync", "test:packaging-guards": "vitest run tests/package-version-guard.test.mjs tests/packaged-runtime-config.test.mjs tests/open-computer-use-packaging.test.mjs tests/computer-history-packaging.test.mjs tests/prune-packaged-runtime.test.mjs tests/prune-native-platforms.test.mjs tests/windows-desktop-sqlite-vec.test.mjs tests/package-logging.test.mjs tests/renderer-bootstrap.test.mjs tests/macos-library-deployment-target.test.mjs tests/runtime-bundle.test.ts", "test:linux-cli": "vitest run tests/linux-cli-packaging.test.mjs tests/linux-open-computer-use-bundle.test.mjs tests/linux-computer-use-deps.test.mjs && npm --prefix App/memmy-agent exec vitest run tests/entrypoints/cli/linux-systemd-gateway.test.ts tests/entrypoints/cli/gateway-lifecycle.test.ts tests/entrypoints/cli/root-terminal-options.test.ts tests/entrypoints/cli/terminal-target.test.ts tests/tools/computer-use/mac-permission-settings.test.ts tests/tools/computer-use/mac-permission-preflight.test.ts && npm --prefix Memory test -- tests/cli-setup.test.ts", "serve": "npm run memory:serve", diff --git a/scripts/main-sync/cli.mjs b/scripts/main-sync/cli.mjs new file mode 100644 index 000000000..01850767c --- /dev/null +++ b/scripts/main-sync/cli.mjs @@ -0,0 +1,75 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; +import { GitHubCLI } from './github.mjs'; +import { assertSha, positiveInteger, normalizeConfig, validateRelease, digest, requireValue } from './core.mjs'; + +const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../..'); +const metadata=['MD5SUMS.txt','SHA256SUMS.txt','RELEASE_NOTES.md','RELEASE_NOTES_SOURCE.json','QUALITY_REPORT.json','RELEASE_EVIDENCE.json']; +export async function tagCommit(api,tag) { + let value=api.request('GET','/git/ref/tags/'+encodeURIComponent(tag),undefined,{optional404:true}); + if (!value) return undefined; + for (let depth=0;depth<5;depth++) { + if (value.object?.type ==='commit') return assertSha(value.object.sha); + requireValue(value.object?.type ==='tag','Unsupported tag object'); + value=api.request('GET','/git/tags/'+assertSha(value.object.sha)); + } + throw new Error('Tag nesting limit exceeded'); +} +async function fileDigest(file) { + const hash=createHash('sha256'); + for await (const chunk of fs.createReadStream(file)) hash.update(chunk); + return hash.digest('hex'); +} +export async function createRequest({api,env,configBytes,assetDirectory,allowPublished=false}) { + const config=normalizeConfig(JSON.parse(configBytes)); + requireValue(env.GITHUB_REPOSITORY ===config.repository && env.GITHUB_REF ==='refs/heads/main','Trusted main workflow required'); + const repositoryId=positiveInteger(env.GITHUB_REPOSITORY_ID,'repository id'); + const sourceSha=assertSha(env.SYNC_SOURCE_SHA); + const controllerSha=assertSha(env.GITHUB_WORKFLOW_SHA || env.SYNC_CONTROLLER_SHA); + const release=env.SYNC_RELEASE_ID ? api.request('GET','/releases/'+positiveInteger(env.SYNC_RELEASE_ID,'release id')) : + api.request('GET','/releases/tags/'+encodeURIComponent(env.SYNC_TAG)); + const assets=release.assets; + requireValue(Array.isArray(assets) && assets.filter(item=>item.name?.startsWith('Memmy-')).length >=6 && + metadata.every(name=>assets.some(item=>item.name ===name)),'Canonical release assets are missing'); + let expectedAssets; + if (assetDirectory) { + const names=fs.readdirSync(assetDirectory).filter(name=>name.startsWith('Memmy-') || metadata.includes(name)).sort(); + requireValue(names.filter(name=>name.startsWith('Memmy-')).length >=6 && metadata.every(name=>names.includes(name)),'Local release assets are missing'); + expectedAssets=[]; + for (const name of names) { + const file=path.join(assetDirectory,name),stat=fs.lstatSync(file); + requireValue(stat.isFile() && !stat.isSymbolicLink(),'Only ordinary release files may be emitted'); + expectedAssets.push({name,size:stat.size,sha256:await fileDigest(file)}); + } + } + const releaseInfo=validateRelease(release,{releaseId:release.id,sourceSha,tagSha:await tagCommit(api,release.tag_name),expectedAssets,allowPublished}); + const main=api.request('GET','/git/ref/heads/main'); + const comparison=api.request('GET','/compare/'+sourceSha+'...'+assertSha(main.object?.sha)); + requireValue(comparison.merge_base_commit?.sha ===sourceSha,'Release source is not in main history'); + const workflowRunId=positiveInteger(env.GITHUB_RUN_ID,'workflow run'); + const workflowRunAttempt=positiveInteger(env.GITHUB_RUN_ATTEMPT,'workflow attempt'); + const event=env.GITHUB_EVENT_NAME; + requireValue(['pull_request_target','workflow_dispatch'].includes(event),'Unsupported request producer event'); + const pullRequestNumber=event ==='pull_request_target' ? positiveInteger(env.SYNC_RELEASE_PR_NUMBER,'release pull request') : null; + return {schemaVersion:1,kind:'memmy.main-sync-request', + requestId:digest([repositoryId,releaseInfo.releaseId,sourceSha].join(':')), + repository:config.repository,repositoryId,...releaseInfo,controllerSha, + configPath:'.github/main-sync.json',configSha256:digest(configBytes), + workflowRunId,workflowRunAttempt,event,pullRequestNumber, + phase:allowPublished?'manual_recovery':'draft_ready', + assets:(expectedAssets || assets.map(item=>({name:item.name,size:item.size,sha256:item.digest?.replace(/^sha256:/,'') || null}))).sort((a,b)=>a.name.localeCompare(b.name))}; +} +export async function main(env=process.env) { + const configBytes=fs.readFileSync(path.join(root,'.github/main-sync.json')); + const request=await createRequest({api:new GitHubCLI('MemTensor/memmy-agent',{env}),env,configBytes, + assetDirectory:env.SYNC_ASSET_DIR ? path.resolve(env.SYNC_ASSET_DIR) : undefined, + allowPublished:process.argv[2] ==='recover'}); + const output=path.resolve(env.MAIN_SYNC_REQUEST_PATH); + fs.mkdirSync(path.dirname(output),{recursive:true}); + fs.writeFileSync(output,JSON.stringify(request,null,2)+'\n',{mode:0o600}); + if (env.GITHUB_OUTPUT) fs.appendFileSync(env.GITHUB_OUTPUT,'sync_ready=true\nrelease_id='+request.releaseId+'\nsource_sha='+request.sourceSha+'\n'); + if (env.GITHUB_STEP_SUMMARY) fs.appendFileSync(env.GITHUB_STEP_SUMMARY,'## CodingAgent synchronization requested\n\n- Release: '+request.tag+'\n- Source: '+request.sourceSha+'\n- Request: '+request.requestId+'\n- CodingAgent must independently validate this request before creating or resuming tasks.\n'); +} +if (process.argv[1] && path.resolve(process.argv[1]) ===fileURLToPath(import.meta.url)) main().catch(error=>{console.error('Main sync request blocked: '+error.message);process.exitCode=1;}); diff --git a/scripts/main-sync/core.mjs b/scripts/main-sync/core.mjs new file mode 100644 index 000000000..754866fdd --- /dev/null +++ b/scripts/main-sync/core.mjs @@ -0,0 +1,52 @@ +import { createHash } from 'node:crypto'; + +export function requireValue(condition, message) { + if (!condition) throw new Error(message); +} +export function assertSha(value) { + requireValue(typeof value === 'string' && value.length === 40 && /^[a-f0-9]{40}$/.test(value), 'Expected a complete lowercase commit SHA'); + return value; +} +export function positiveInteger(value, name = 'identifier') { + const number = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value; + requireValue(Number.isSafeInteger(number) && number > 0, 'Invalid ' + name); + return number; +} +export function normalizeConfig(input) { + requireValue(input?.schemaVersion === 1 && input.repository === 'MemTensor/memmy-agent', 'Unexpected main-sync configuration'); + requireValue(Array.isArray(input.targets) && input.targets.length > 0 && input.targets.length <= 10, 'Expected an active target allowlist'); + for (const target of input.targets) requireValue(typeof target === 'string' && target.length <= 100 && /^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(target), 'Invalid maintained target'); + requireValue(new Set(input.targets).size === input.targets.length, 'Duplicate maintained target'); + for (const [name, fallback, maximum] of [['maxRounds',6,12],['maxNoProgress',2,4],['maxDurationMinutes',180,360]]) { + const value=input[name] ?? fallback; + requireValue(Number.isSafeInteger(value) && value > 0 && value <= maximum, 'Invalid ' + name); + } + return { schemaVersion:1,repository:input.repository,targets:[...input.targets], + maxRounds:input.maxRounds ?? 6,maxNoProgress:input.maxNoProgress ?? 2,maxDurationMinutes:input.maxDurationMinutes ?? 180 }; +} +export function validateRelease(release, { releaseId, sourceSha, tagSha, expectedAssets, allowPublished = false }) { + positiveInteger(releaseId, 'release id'); assertSha(sourceSha); + requireValue(release?.id === Number(releaseId), 'Release id changed'); + requireValue((release.draft === true || (allowPublished && release.draft === false)) && release.prerelease !== true, 'A stable release is required'); + requireValue(typeof release.tag_name === 'string' && /^v\d+\.\d+\.\d+$/.test(release.tag_name), 'Unsupported release tag'); + requireValue(Number.isFinite(Date.parse(release.created_at)), 'Missing release creation time'); + if (tagSha !== undefined) requireValue(assertSha(tagSha) === sourceSha, 'Tag points to another source'); + else requireValue(release.draft === true && release.target_commitish === sourceSha, 'Release source is ambiguous'); + requireValue(Array.isArray(release.assets) && release.assets.length > 0, 'Release assets are incomplete'); + const names=new Set(); + for (const asset of release.assets) { + requireValue(typeof asset.name === 'string' && !names.has(asset.name), 'Invalid or duplicate asset'); + names.add(asset.name); + requireValue(asset.state === 'uploaded' && Number.isSafeInteger(asset.size) && asset.size > 0, 'Asset upload is incomplete'); + } + if (expectedAssets) { + requireValue(expectedAssets.length === release.assets.length, 'Asset set changed'); + for (const expected of expectedAssets) { + const actual=release.assets.find(item=>item.name === expected.name); + requireValue(actual && actual.size === expected.size, 'Missing asset or size mismatch: ' + expected.name); + if (actual.digest) requireValue(actual.digest === 'sha256:' + expected.sha256, 'Asset digest mismatch: ' + expected.name); + } + } + return { releaseId:release.id,sourceSha,tag:release.tag_name,releaseCreatedAt:release.created_at }; +} +export function digest(value) { return createHash('sha256').update(value).digest('hex'); } diff --git a/scripts/main-sync/github.mjs b/scripts/main-sync/github.mjs new file mode 100644 index 000000000..9ef6a05ed --- /dev/null +++ b/scripts/main-sync/github.mjs @@ -0,0 +1,34 @@ +import { spawnSync } from 'node:child_process'; + +export class GitHubCLIError extends Error { + constructor(message, status, cause) { super(message,{cause});this.status=status;this.statusCode=status; } +} +// This producer only reads GitHub. CodingAgent owns all coding/PR mutations. +export class GitHubCLI { + constructor(repository='MemTensor/memmy-agent',{env=process.env,exec=spawnSync}={}) { + if (repository !== 'MemTensor/memmy-agent') throw new Error('Unexpected repository'); + this.repository=repository;this.env={...env,GH_HOST:'github.com',GH_PROMPT_DISABLED:'1'};this.exec=exec; + } + request(method,route,data,{optional404=false}={}) { + if (method !== 'GET' || data !== undefined || typeof route !== 'string' || !route.startsWith('/') || + /[\\\r\n#]/.test(route) || route.includes('://') || route.split('/').includes('..') || + route.startsWith('/repos/')) throw new Error('Only repository-scoped GitHub reads are permitted'); + const decoded = decodeURIComponent(route.split('?')[0]); + if (decoded.startsWith('//') || decoded.split('/').some(part => part === '..' || part === '.') || + /[%\\\r\n#]/.test(decoded)) throw new Error('Unsafe GitHub read path'); + let result; + try { result=this.exec('gh',['api','--hostname','github.com','--method','GET','--include', + '/repos/'+this.repository+route,'-H','Accept: application/vnd.github+json','-H','X-GitHub-Api-Version: 2022-11-28'], + {env:this.env,encoding:'utf8',maxBuffer:20*1024*1024,timeout:60000}); } + catch(cause) { throw new GitHubCLIError('GitHub read failed',null,cause); } + const stdout=String(result.stdout ?? ''),stderr=String(result.stderr ?? ''); + const header=stdout.match(/^HTTP\/[\d.]+\s+(\d{3})[^\r\n]*(?:\r?\n[^\r\n]+)*\r?\n\r?\n/); + const status=Number(header?.[1] ?? stderr.match(/\(HTTP (\d{3})\)/)?.[1]) || null; + if (result.error || result.status !== 0 || (status && status >=400)) { + if (!result.error && optional404 && status ===404) return null; + throw new GitHubCLIError('GitHub read failed'+(status?' (HTTP '+status+')':''),status,result.error); + } + try { return JSON.parse(header?stdout.slice(header[0].length):stdout); } + catch(cause) { throw new GitHubCLIError('GitHub returned invalid JSON',status,cause); } + } +} diff --git a/scripts/main-sync/validate-source.mjs b/scripts/main-sync/validate-source.mjs new file mode 100644 index 000000000..be29c0345 --- /dev/null +++ b/scripts/main-sync/validate-source.mjs @@ -0,0 +1,34 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { GitHubCLI } from './github.mjs'; +import { assertSha, positiveInteger, normalizeConfig, requireValue } from './core.mjs'; + +export function verifyCandidate({ api, config, env }) { + requireValue(env.GITHUB_REPOSITORY === config.repository && env.GITHUB_REF === 'refs/heads/main', 'Trusted main validation controller required'); + const head = assertSha(env.SYNC_CANDIDATE_SHA), base = assertSha(env.SYNC_BASE_SHA); + const number = positiveInteger(env.SYNC_PR_NUMBER, 'pull request'); + const key = env.SYNC_VALIDATION_KEY; + requireValue(typeof key === 'string' && key.length === 64 && /^[a-f0-9]+$/.test(key), 'Invalid validation identity'); + const pr = api.request('GET', '/pulls/' + number); + requireValue(pr.state === 'open' && pr.head?.sha === head && pr.base?.repo?.full_name === config.repository && + config.targets.includes(pr.base?.ref) && pr.head?.ref === 'sync/main-to-' + pr.base.ref, 'Candidate PR changed scope'); + requireValue(typeof pr.head.repo?.full_name === 'string' && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(pr.head.repo.full_name), 'Invalid head repository'); + const ref = api.request('GET', '/git/ref/heads/' + encodeURIComponent(pr.base.ref)); + requireValue(ref.object?.sha === base, 'Target advanced; refresh the sync candidate'); + const comparison = api.request('GET', '/compare/' + base + '...' + head); + requireValue(comparison.merge_base_commit?.sha === base, 'Candidate has not integrated the current target'); + return { headSha: head, baseSha: base, prNumber: number, validationKey: key, headRepository: pr.head.repo.full_name }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const config = normalizeConfig(JSON.parse(fs.readFileSync(path.join(root, '.github/main-sync.json'), 'utf8'))); + const result = verifyCandidate({ api: new GitHubCLI(config.repository), config, env: process.env }); + for (const [key, value] of Object.entries({ head: result.headSha, base: result.baseSha, pr: result.prNumber, + validation_key: result.validationKey, head_repository: result.headRepository })) { + fs.appendFileSync(process.env.GITHUB_OUTPUT, key + '=' + value + '\n'); + } + } catch (error) { console.error('Candidate validation blocked: ' + error.message); process.exitCode = 1; } +} diff --git a/tests/main-sync-core.test.mjs b/tests/main-sync-core.test.mjs new file mode 100644 index 000000000..2c1850a05 --- /dev/null +++ b/tests/main-sync-core.test.mjs @@ -0,0 +1,127 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { createHash } from 'node:crypto'; +import { afterEach, describe, expect, it } from 'vitest'; +import YAML from 'yaml'; +import { createRequest } from '../scripts/main-sync/cli.mjs'; +import { normalizeConfig, validateRelease } from '../scripts/main-sync/core.mjs'; + +const roots=[]; +afterEach(()=>{for(const root of roots.splice(0))rmSync(root,{recursive:true,force:true});}); +const source='a'.repeat(40),controller='b'.repeat(40),main='c'.repeat(40); +const config=Buffer.from(JSON.stringify({schemaVersion:1,repository:'MemTensor/memmy-agent',targets:['v1.2.5','v1.2.6','v1.2.7']})); +const env={GITHUB_REPOSITORY:'MemTensor/memmy-agent',GITHUB_REPOSITORY_ID:'1302496224',GITHUB_REF:'refs/heads/main', + GITHUB_WORKFLOW_SHA:controller,GITHUB_RUN_ID:'12345',GITHUB_RUN_ATTEMPT:'1',GITHUB_EVENT_NAME:'workflow_dispatch',SYNC_SOURCE_SHA:source,SYNC_RELEASE_ID:'123'}; +function fixture() { + const directory=mkdtempSync(join(tmpdir(),'main-sync-signal-'));roots.push(directory); + const names=['Memmy-win-cn.exe','Memmy-win-intl.exe','Memmy-arm-cn.dmg','Memmy-arm-intl.dmg','Memmy-x64-cn.dmg','Memmy-x64-intl.dmg', + 'MD5SUMS.txt','SHA256SUMS.txt','RELEASE_NOTES.md','RELEASE_NOTES_SOURCE.json','QUALITY_REPORT.json','RELEASE_EVIDENCE.json']; + const assets=names.map(name=>{ + const value=Buffer.from('fixture '+name);writeFileSync(join(directory,name),value); + return {name,state:'uploaded',size:value.length,digest:'sha256:'+createHash('sha256').update(value).digest('hex')}; + }); + const release={id:123,tag_name:'v1.2.401',target_commitish:source,draft:true,prerelease:false,created_at:'2026-10-28T00:00:00Z',assets}; + const requests=[]; + const api={request(method,path){ + requests.push({method,path});expect(method).toBe('GET'); + if(path==='/releases/123')return release; + if(path.startsWith('/git/ref/tags/'))return {object:{type:'commit',sha:source}}; + if(path==='/git/ref/heads/main')return {object:{sha:main}}; + if(path.startsWith('/compare/'))return {merge_base_commit:{sha:source}}; + throw new Error('Unexpected read '+path); + }}; + return {directory,release,api,requests}; +} + +describe('CodingAgent Draft synchronization request',()=>{ + it('writes a stable business identity and freezes source/config/assets',async()=>{ + const f=fixture(); + const first=await createRequest({api:f.api,env,configBytes:config,assetDirectory:f.directory}); + const retry=await createRequest({api:f.api,env:{...env,GITHUB_RUN_ID:'99999',GITHUB_RUN_ATTEMPT:'2'},configBytes:config,assetDirectory:f.directory}); + expect(first.requestId).toBe(retry.requestId); + expect(first.sourceSha).toBe(source); + expect(first.controllerSha).toBe(controller); + expect(first.configSha256).toBe(createHash('sha256').update(config).digest('hex')); + expect(first.assets).toHaveLength(12); + expect(first.kind).toBe('memmy.main-sync-request'); + expect(first.phase).toBe('draft_ready'); + expect(first).not.toHaveProperty('token'); + }); + it.each(['GITHUB_REPOSITORY','GITHUB_REF','GITHUB_REPOSITORY_ID','GITHUB_WORKFLOW_SHA','GITHUB_RUN_ID'])('rejects invalid producer field %s',async field=>{ + const f=fixture(); + await expect(createRequest({api:f.api,env:{...env,[field]:'invalid'},configBytes:config,assetDirectory:f.directory})).rejects.toThrow(); + }); + it('rejects published initial requests, but supports explicit recovery with exact tag',async()=>{ + const f=fixture();f.release.draft=false; + await expect(createRequest({api:f.api,env,configBytes:config,assetDirectory:f.directory})).rejects.toThrow(); + const recovered=await createRequest({api:f.api,env,configBytes:config,allowPublished:true}); + expect(recovered.phase).toBe('manual_recovery'); + expect(recovered.sourceSha).toBe(source); + }); + it('checks upload state, exact asset set, size and digest',async()=>{ + for(const mutate of [ + f=>f.release.assets.pop(), + f=>f.release.assets[0].state='new', + f=>f.release.assets[0].size++, + f=>f.release.assets[0].digest='sha256:'+'0'.repeat(64), + f=>f.release.assets.push({...f.release.assets[0]}) + ]) { + const f=fixture();mutate(f); + await expect(createRequest({api:f.api,env,configBytes:config,assetDirectory:f.directory})).rejects.toThrow(); + } + }); + it('requires a real release PR number for the automatic release path',async()=>{ + const f=fixture(); + await expect(createRequest({api:f.api,env:{...env,GITHUB_EVENT_NAME:'pull_request_target'},configBytes:config,assetDirectory:f.directory})).rejects.toThrow(); + const request=await createRequest({api:f.api,env:{...env,GITHUB_EVENT_NAME:'pull_request_target',SYNC_RELEASE_PR_NUMBER:'729'},configBytes:config,assetDirectory:f.directory}); + expect(request.pullRequestNumber).toBe(729); + }); + it('keeps an explicit target allowlist and finite repair budgets',()=>{ + expect(normalizeConfig(JSON.parse(config)).targets).toEqual(['v1.2.5','v1.2.6','v1.2.7']); + for(const changed of [{targets:['main']},{targets:['v1.2.5','v1.2.5']},{maxRounds:100000},{maxDurationMinutes:0}]) + expect(()=>normalizeConfig({...JSON.parse(config),...changed})).toThrow(); + }); + it('binds draft/tag source even before a Draft has created its tag',()=>{ + const f=fixture(); + expect(validateRelease(f.release,{releaseId:123,sourceSha:source}).sourceSha).toBe(source); + expect(()=>validateRelease({...f.release,target_commitish:'main'},{releaseId:123,sourceSha:source})).toThrow(); + expect(()=>validateRelease(f.release,{releaseId:123,sourceSha:source,tagSha:main})).toThrow(); + }); + it('only publishes a request after upload and verified readback',()=>{ + const path=new URL('../.github/workflows/github-draft-release-v2.yml',import.meta.url); + const document=YAML.parse(readFileSync(path,'utf8')); + const steps=document.jobs.release.steps; + const create=steps.findIndex(step=>step.name==='Create draft release and upload every asset'); + const verify=steps.findIndex(step=>step.name==='Verify completed draft for synchronization'); + const publish=steps.findIndex(step=>step.name==='Publish CodingAgent synchronization request'); + expect(verify).toBeGreaterThan(create);expect(publish).toBeGreaterThan(verify); + expect(steps[create].run).toContain('trap - EXIT'); + expect(steps[verify].run).not.toContain('release delete'); + expect(steps[publish].if).toContain("steps.sync-source.outputs.sync_ready == 'true'"); + expect(steps[publish].with.path).toContain('main-sync-request.json'); + expect(document.jobs).not.toHaveProperty('sync-main'); + expect(readFileSync(new URL('../.github/workflows/main-sync.yml',import.meta.url),'utf8')).not.toContain('codex-action'); + }); + it('executes the actual upload cleanup boundary without deleting a complete draft on later failure',()=>{ + const document=YAML.parse(readFileSync(new URL('../.github/workflows/github-draft-release-v2.yml',import.meta.url),'utf8')); + const script=document.jobs.release.steps.find(step=>step.name==='Create draft release and upload every asset').run; + const directory=mkdtempSync(join(tmpdir(),'main-sync-upload-'));roots.push(directory); + const bin=join(directory,'bin');mkdirSync(bin); + const log=join(directory,'calls'); + writeFileSync(join(bin,'gh'),'#!/bin/sh\nprintf "%s\\n" "$*" >> "$FIXTURE_LOG"\nif [ "$1 $2" = "release upload" ] && [ "$FAIL_UPLOAD" = "1" ]; then exit 1; fi\nexit 0\n',{mode:0o755}); + for(const fail of ['0','1']) { + writeFileSync(log,''); + const result=spawnSync('bash',['-c',script],{cwd:directory,encoding:'utf8',env:{...process.env,PATH:bin+':'+process.env.PATH,FIXTURE_LOG:log,FAIL_UPLOAD:fail,TAG:'v1.2.401',TARGET_SHA:source,TAG_PREEXISTS:'false',GITHUB_REPOSITORY:'MemTensor/memmy-agent'}}); + expect(result.status).toBe(fail==='0'?0:1); + const calls=readFileSync(log,'utf8'); + expect(calls.includes('release delete')).toBe(fail==='1'); + if(fail==='0') { + // A distinct post-upload step may fail; its process has no cleanup trap. + expect(spawnSync('bash',['-c','exit 17'],{cwd:directory}).status).toBe(17); + expect(readFileSync(log,'utf8')).not.toContain('release delete'); + } + } + }); +}); diff --git a/tests/main-sync-github.test.mjs b/tests/main-sync-github.test.mjs new file mode 100644 index 000000000..11991af9e --- /dev/null +++ b/tests/main-sync-github.test.mjs @@ -0,0 +1,33 @@ +import { describe,expect,it } from 'vitest'; +import { GitHubCLI } from '../scripts/main-sync/github.mjs'; +const success=data=>({status:0,stdout:'HTTP/2.0 200 OK\r\ncontent-type: application/json\r\n\r\n'+JSON.stringify(data),stderr:''}); +describe('read-only synchronization producer GitHub adapter',()=>{ + it('uses argv and GitHub API JSON without a shell',()=>{ + let invocation; + const api=new GitHubCLI('MemTensor/memmy-agent',{env:{GH_TOKEN:'fixture'},exec:(...args)=>{invocation=args;return success({id:123});}}); + expect(api.request('GET','/releases/123')).toEqual({id:123}); + expect(invocation[0]).toBe('gh'); + expect(invocation[1]).toContain('/repos/MemTensor/memmy-agent/releases/123'); + expect(invocation[2].shell).toBeUndefined(); + expect(invocation[2].timeout).toBe(60000); + }); + it.each(['POST','PATCH','DELETE','PUT'])('never allows producer mutations: %s',method=>{ + const api=new GitHubCLI(undefined,{exec:()=>{throw new Error('must not execute');}}); + expect(()=>api.request(method,'/git/refs',{ref:'refs/heads/main'})).toThrow(/reads/); + }); + it.each(['https://evil.example/api','/repos/Other/repo/releases','/../Other/repo','/releases\ninjected'])('rejects unsafe routes %s',route=>{ + const api=new GitHubCLI(undefined,{exec:()=>{throw new Error('must not execute');}}); + expect(()=>api.request('GET',route)).toThrow(); + }); + it('only converts explicit provider 404 into an optional not-found result',()=>{ + const missing=new GitHubCLI(undefined,{exec:()=>({status:1,stdout:'',stderr:'gh: Not Found (HTTP 404)'})}); + expect(missing.request('GET','/git/ref/tags/v1',undefined,{optional404:true})).toBeNull(); + const unavailable=new GitHubCLI(undefined,{exec:()=>({status:1,error:new Error('network'),stdout:'',stderr:''})}); + expect(()=>unavailable.request('GET','/git/ref/tags/v1',undefined,{optional404:true})).toThrow(); + const forbidden=new GitHubCLI(undefined,{exec:()=>({status:1,stdout:'',stderr:'Forbidden (HTTP 403)'})}); + expect(()=>forbidden.request('GET','/git/ref/tags/v1',undefined,{optional404:true})).toThrow(); + }); + it('rejects malformed JSON instead of treating it as a successful read',()=>{ + expect(()=>new GitHubCLI(undefined,{exec:()=>({status:0,stdout:'not JSON'})}).request('GET','/releases/1')).toThrow(/invalid JSON/); + }); +}); diff --git a/tests/main-sync-validation.test.mjs b/tests/main-sync-validation.test.mjs new file mode 100644 index 000000000..c0db4222a --- /dev/null +++ b/tests/main-sync-validation.test.mjs @@ -0,0 +1,77 @@ +import {readFileSync} from 'node:fs'; +import {runInNewContext} from 'node:vm'; +import {describe,expect,it} from 'vitest'; +import YAML from 'yaml'; +import {verifyCandidate} from '../scripts/main-sync/validate-source.mjs'; +import {normalizeConfig} from '../scripts/main-sync/core.mjs'; +const H='a'.repeat(40),B='b'.repeat(40),C='c'.repeat(40),key='d'.repeat(64); +const config=normalizeConfig({schemaVersion:1,repository:'MemTensor/memmy-agent',targets:['v1.2.5']}); +const env={GITHUB_REPOSITORY:config.repository,GITHUB_REF:'refs/heads/main', + SYNC_CANDIDATE_SHA:H,SYNC_BASE_SHA:B,SYNC_PR_NUMBER:'71',SYNC_VALIDATION_KEY:key}; +function fixture(){ + const pr={state:'open',head:{sha:H,ref:'sync/main-to-v1.2.5',repo:{full_name:'Fixture/memmy-agent'}}, + base:{ref:'v1.2.5',repo:{full_name:config.repository}}}; + const ref={object:{sha:B}},comparison={merge_base_commit:{sha:B}},calls=[]; + const api={request(method,path){ + calls.push(path);expect(method).toBe('GET'); + if(path==='/pulls/71')return pr; + if(path==='/git/ref/heads/v1.2.5')return ref; + if(path==='/compare/'+B+'...'+H)return comparison; + throw new Error('Unexpected provider read'); + }}; + return {api,pr,ref,comparison,calls}; +} +const verify=f=>verifyCandidate({api:f.api,config,env}); +describe('authoritative validation source',()=>{ + it('binds current H/B and the actual fork repository',()=>{ + const f=fixture();expect(verify(f)).toEqual({headSha:H,baseSha:B,prNumber:71,validationKey:key,headRepository:'Fixture/memmy-agent'}); + expect(f.calls).toHaveLength(3); + }); + it.each([['GITHUB_REPOSITORY','Fork/memmy-agent'],['GITHUB_REF','refs/heads/candidate'], + ['SYNC_CANDIDATE_SHA','a'.repeat(39)],['SYNC_CANDIDATE_SHA',H+'\n'],['SYNC_BASE_SHA','main'], + ['SYNC_PR_NUMBER','0'],['SYNC_VALIDATION_KEY','d'.repeat(63)],['SYNC_VALIDATION_KEY',key+'\n']]) + ('rejects invalid %s before provider reads',(field,value)=>{ + const f=fixture();expect(()=>verifyCandidate({api:f.api,config,env:{...env,[field]:value}})).toThrow();expect(f.calls).toHaveLength(0); + }); + it.each([f=>{f.pr.state='closed';},f=>{f.pr.head.sha=C;},f=>{f.pr.base.repo.full_name='Other/repo';}, + f=>{f.pr.base.ref='main';},f=>{f.pr.head.ref='main';},f=>{f.pr.head.repo.full_name='bad/repo/extra';}, + f=>{f.ref.object.sha=C;},f=>{f.comparison.merge_base_commit.sha=C;}]) + ('rejects changed scope or missing base integration',mutate=>{const f=fixture();mutate(f);expect(()=>verify(f)).toThrow();}); +}); +const source=readFileSync(new URL('../.github/workflows/main-sync-validation.yml',import.meta.url),'utf8'); +const workflow=YAML.parse(source),jobs=workflow.jobs; +describe('trusted validation workflow',()=>{ + it('uses a read-only main controller and exact candidate checkout',()=>{ + expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch']); + expect(workflow.permissions).toEqual({contents:'read',actions:'read'}); + expect(jobs.source.if).toContain("github.ref == 'refs/heads/main'"); + expect(jobs.source.steps.find(s=>s.uses?.startsWith('actions/checkout@')).with) + .toMatchObject({ref:'${{ github.workflow_sha }}','persist-credentials':false}); + expect(jobs.linux.steps.find(s=>s.uses?.startsWith('actions/checkout@')).with) + .toMatchObject({repository:'${{ needs.source.outputs.head_repository }}',ref:'${{ needs.source.outputs.head }}','persist-credentials':false}); + expect(jobs.windows).toMatchObject({uses:'MemTensor/memmy-agent/.github/workflows/windows-computer-history-validation.yml@main', + with:{source_sha:'${{ needs.source.outputs.head }}',source_repository:'${{ needs.source.outputs.head_repository }}'}}); + expect(source).not.toMatch(/secrets\.|pull_request_target|codex-action|contents: write/); + expect(jobs.record.needs).toEqual(['source','linux','windows']); + expect(jobs.linux.steps.map(step=>step.run??'').join('\n')).toContain('npm run version:check'); + for(const job of Object.values(jobs))expect(job.name).toMatch(/^Main sync /); + }); +}); +function record(overrides={}){ + const writes=[],results={source:{result:'success'},linux:{result:'success'},windows:{result:'success'},...overrides}; + runInNewContext(jobs.record.steps[0].run,{process:{env:{SOURCE_SHA:H,BASE_SHA:B,PR_NUMBER:'71',VALIDATION_KEY:key, + CONTROLLER_SHA:C,PROVIDER_RUN_ID:'1234',PROVIDER_ATTEMPT:'2',JOB_RESULTS:JSON.stringify(results),RUNNER_TEMP:'/runner-temp'}}, + require(name){ + if(name==='node:fs')return {mkdirSync(){},writeFileSync(file,value){writes.push(value);}}; + if(name==='node:path')return {join:(...parts)=>parts.join('/')}; + throw new Error('Unexpected module'); + }}); + expect(writes).toHaveLength(1);return JSON.parse(writes[0]); +} +describe('actual provider record producer',()=>{ + it('records exact identity and successful provider outcomes',()=>expect(record()).toMatchObject({ + headSha:H,baseSha:B,prNumber:71,validationKey:key,controllerSha:C,runId:'1234',runAttempt:2,passed:true})); + it.each([['linux','failure'],['linux','cancelled'],['windows','failure'],['windows','skipped']]) + ('does not pass when %s is %s',(job,result)=>expect(record({[job]:{result}}).passed).toBe(false)); + it('rejects missing provider results',()=>expect(()=>record({windows:undefined})).toThrow()); +}); diff --git a/tests/windows-memory-workflow.test.ts b/tests/windows-memory-workflow.test.ts index eac8dea85..f59cffd4b 100644 --- a/tests/windows-memory-workflow.test.ts +++ b/tests/windows-memory-workflow.test.ts @@ -1,4 +1,5 @@ import { existsSync, readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; import { spawnSync } from "node:child_process"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; @@ -109,3 +110,174 @@ describe.skipIf(process.platform !== "win32" && !hasPowerShell)("Windows session expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(accepted ? 0 : 1); }); }); + + +// Execute the producer's actual inline JavaScript with controlled provider and +// native-process boundaries. No fixture result is uploaded as provider evidence. +const historyPath = resolve(import.meta.dirname, "../.github/workflows/windows-computer-history-validation.yml"); +const historySource = readFileSync(historyPath, "utf8"); +const historyWorkflow = YAML.parse(historySource); +const historySteps = historyWorkflow.jobs.validate.steps as WorkflowStep[]; +const historyStep = (id: string) => historySteps.find(step => step.id === id)!; +const candidateSha = "a".repeat(40); +const callerSha = "b".repeat(40); +const requiredWindowsSteps = ["source", "exact-head", "windows-os", "workspace-dependencies", "agent-dependencies", + "native-go", "computer-use-go", "source-build", "app-icons", "native-runtime", "history-contracts", "packaged-assets", "installer-boundary"]; +const passedWindowsSteps = () => Object.fromEntries(requiredWindowsSteps.map(id => [id, { outcome: "success", conclusion: "success" }])); + +function executeHistoryStep(id: string, overrides: Record = {}, actualHead = candidateSha, platform = "win32", release = "10.0.26100", dirty = false) { + const writes = new Map(); + const env = { + INPUT_SOURCE_SHA: candidateSha, EVENT_NAME: "workflow_dispatch", EVENT_SHA: callerSha, + SOURCE_REPOSITORY: "MemTensor/memmy-agent", CANDIDATE_REPOSITORY: "MemTensor/memmy-agent", + CONTROLLER_WORKFLOW_REF: "MemTensor/memmy-agent/.github/workflows/main-sync.yml@refs/heads/main", + CONTROLLER_WORKFLOW_SHA: callerSha, GITHUB_OUTPUT: "/output", RUNNER_TEMP: "/runner-temp", + EXPECTED_HEAD_SHA: candidateSha, SOURCE_MODE: "workflow_call", PROVIDER_RUN_ID: "12345", PROVIDER_RUN_ATTEMPT: "2", + VALIDATION_STEPS: JSON.stringify(passedWindowsSteps()), ...overrides, + }; + const modules: Record = { + "node:fs": { + appendFileSync: (file: string, value: string) => writes.set(file, (writes.get(file) ?? "") + value), + writeFileSync: (file: string, value: string) => writes.set(file, value), mkdirSync: () => {}, + }, + "node:path": { join: (...parts: string[]) => parts.join("/") }, + "node:os": { platform: () => platform, release: () => release, arch: () => "x64" }, + "node:child_process": { execFileSync: (command: string, args: string[]) => { + expect(command).toBe("git"); + if (args[0] === "diff") { if (dirty) throw new Error("Tracked source changed"); return ""; } + expect(args).toEqual(["rev-parse", "HEAD"]); return actualHead + "\n"; + } }, + }; + let error: unknown; + try { runInNewContext(historyStep(id).run!, { process: { env }, require: (name: string) => { + if (!(name in modules)) throw new Error(`Unexpected module ${name}`); return modules[name]; + } }); } catch (caught) { error = caught; } + return { writes, error }; +} + +const receipts = (result: ReturnType) => [...result.writes.entries()] + .filter(([file]) => file.endsWith("windows-provider-receipt.json")); + +describe("Windows History reusable provider source contract", () => { + it("keeps PR/manual entry points and adds a required exact-SHA reusable input", () => { + expect(Object.keys(historyWorkflow.on).sort()).toEqual(["pull_request", "workflow_call", "workflow_dispatch"]); + expect(historyWorkflow.on.workflow_call.inputs.source_sha).toMatchObject({ required: true, type: "string" }); + expect(historyWorkflow.on.pull_request.paths).toContain("tests/windows-memory-workflow.test.ts"); + expect(historyWorkflow.permissions).toEqual({ contents: "read" }); + expect(historyWorkflow.jobs.validate.permissions).toBeUndefined(); + expect(historySource).not.toMatch(/secrets\.|github\.token|pull_request_target|contents: write|actions: write/); + const checkout = historySteps.find(step => step.uses?.startsWith("actions/checkout@"))!; + expect(checkout.with).toMatchObject({ ref: "${{ steps.source.outputs.sha }}", "persist-credentials": false }); + expect(historySteps.indexOf(historyStep("source"))).toBeLessThan(historySteps.indexOf(checkout)); + expect(historySteps.indexOf(historyStep("exact-head"))).toBeGreaterThan(historySteps.indexOf(checkout)); + }); + + it.each(["github-draft-release-v2.yml", "main-sync.yml", "main-sync-validation.yml"])("binds nested reusable %s to input SHA instead of caller github.sha", caller => { + const result = executeHistoryStep("source", { CONTROLLER_WORKFLOW_REF: `MemTensor/memmy-agent/.github/workflows/${caller}@refs/heads/main` }); + expect(result.error).toBeUndefined(); + expect(result.writes.get("/output")).toBe(`sha=${candidateSha}\nmode=workflow_call\nrepository=MemTensor/memmy-agent\n`); + }); + + it.each(["pull_request", "workflow_dispatch"])("preserves %s selected/merge SHA with no reusable input", event => { + const result = executeHistoryStep("source", { INPUT_SOURCE_SHA: "", EVENT_NAME: event, CONTROLLER_WORKFLOW_REF: "MemTensor/memmy-agent/.github/workflows/windows-computer-history-validation.yml@refs/heads/main" }); + expect(result.error).toBeUndefined(); + expect(result.writes.get("/output")).toBe(`sha=${callerSha}\nmode=${event}\nrepository=MemTensor/memmy-agent\n`); + }); + + it.each(["a".repeat(39), "g".repeat(40), "refs/heads/main", candidateSha + "\n", "$(echo attack)"])("rejects invalid candidate %j before checkout", value => { + const result = executeHistoryStep("source", { INPUT_SOURCE_SHA: value }); + expect(result.error).toBeDefined(); expect(result.writes.size).toBe(0); + }); + + it.each([ + { SOURCE_REPOSITORY: "fork/memmy-agent" }, + { CONTROLLER_WORKFLOW_REF: "fork/memmy-agent/.github/workflows/main-sync.yml@refs/heads/main" }, + { CONTROLLER_WORKFLOW_REF: "MemTensor/memmy-agent/.github/workflows/main-sync.yml@refs/heads/candidate" }, + { CONTROLLER_WORKFLOW_REF: "MemTensor/memmy-agent/.github/workflows/other.yml@refs/heads/main" }, + { CONTROLLER_WORKFLOW_SHA: "short" }, + { INPUT_SOURCE_SHA: "", EVENT_NAME: "workflow_run" }, + { INPUT_SOURCE_SHA: "", EVENT_NAME: "workflow_dispatch" }, + ])("rejects untrusted or unbound reusable invocation %j", overrides => { + const result = executeHistoryStep("source", overrides); + expect(result.error).toBeDefined(); expect(result.writes.size).toBe(0); + }); + + it("fails exact checkout mismatch before native validation", () => { + const result = executeHistoryStep("exact-head", {}, callerSha); + expect(result.error).toBeDefined(); expect(result.writes.size).toBe(0); + }); + it("binds the authoritative fork head repository", () => { + const result = executeHistoryStep("source", { INPUT_SOURCE_REPOSITORY: "Fixture/memmy-agent" }); + expect(result.error).toBeUndefined(); + expect(result.writes.get("/output")).toContain("repository=Fixture/memmy-agent\n"); + expect(historySteps.find(step => step.uses?.startsWith("actions/checkout@"))?.with?.repository) + .toBe("${{ steps.source.outputs.repository }}"); + }); +}); + +describe("Windows History provider completion contract", () => { + it("requires every existing native and package check without soft failures", () => { + expect(historyWorkflow.jobs.validate["runs-on"]).toBe("windows-2025"); + for (const id of requiredWindowsSteps) { + expect(historyStep(id), id).toBeDefined(); + expect(historyStep(id).if).toBeUndefined(); + expect(historyStep(id)["continue-on-error"]).toBeUndefined(); + } + expect(historyStep("native-go").run).toContain("go test ./..."); + expect(historyStep("native-go").run).toContain("go vet -unsafeptr=false ./..."); + expect(historyStep("computer-use-go").run).toContain("go test ./..."); + expect(historyStep("computer-use-go").run).toContain("go vet ./..."); + expect(historyStep("history-contracts").run).toContain("tests/windows-memory-workflow.test.ts"); + for (const id of ["workspace-dependencies", "agent-dependencies", "native-go", "source-build", "app-icons", "native-runtime", "history-contracts", "packaged-assets", "installer-boundary"]) { + expect(historyStep(id).run, id).toContain("$LASTEXITCODE -ne 0"); + } + expect(historyStep("native-runtime").run).toContain("$workerProbe.workerApiCompiled"); + expect(historyStep("native-runtime").run).toContain("$probe.pureSelfTest"); + expect(historyStep("native-runtime").run).toContain("$probe.desktopAccess -ne $false"); + expect(historyStep("provider-receipt").shell).toBe("node {0}"); + expect(historyStep("provider-receipt").if).toBe("${{ success() }}"); + const upload = historySteps.find(step => step.uses?.startsWith("actions/upload-artifact@"))!; + expect(upload.if).toContain("steps.provider-receipt.outcome == 'success'"); + expect(upload.with?.["if-no-files-found"]).toBe("error"); + expect(upload.with?.name).toBe("${{ steps.provider-receipt.outputs.artifact_name }}"); + expect(historySteps.indexOf(historyStep("provider-receipt"))).toBeGreaterThan(historySteps.indexOf(historyStep("installer-boundary"))); + }); + + it("writes an exact-head receipt only after complete Windows native validation", () => { + const dirty = executeHistoryStep("provider-receipt", {}, candidateSha, "win32", "10.0.26100", true); + expect(dirty.error).toBeDefined(); expect(receipts(dirty)).toHaveLength(0); + const result = executeHistoryStep("provider-receipt"); + expect(result.error).toBeUndefined(); expect(receipts(result)).toHaveLength(1); + const [file, json] = receipts(result)[0]!; + expect(file).toContain(`windows-provider-receipt-${candidateSha}-2/`); + expect(JSON.parse(json)).toMatchObject({ provider: "windows", status: "passed", headSha: candidateSha, + runId: "12345", runAttempt: 2, workflow_sha: callerSha, + os: { platform: "win32", build: 26100, arch: "x64" }, + nativeModules: { windowsHistory: { goTests: "passed", goVet: "passed", workerSelfTest: "passed" } }, + liveCaptureValidated: false, signedInstallerValidated: false }); + expect(result.writes.get("/output")).toContain(`artifact_name=windows-provider-receipt-${candidateSha}-2`); + }); + + it.each(requiredWindowsSteps)("does not emit passed receipt when %s is missing, skipped or failed", id => { + for (const state of [undefined, { outcome: "skipped", conclusion: "skipped" }, + { outcome: "failure", conclusion: "success" }, { outcome: "success", conclusion: "failure" }]) { + const steps = passedWindowsSteps() as Record; + if (state) steps[id] = state; else delete steps[id]; + const result = executeHistoryStep("provider-receipt", { VALIDATION_STEPS: JSON.stringify(steps) }); + expect(result.error).toBeDefined(); expect(receipts(result)).toHaveLength(0); expect(result.writes.size).toBe(0); + } + }); + + it.each([ + { overrides: {}, head: callerSha }, + { overrides: { EXPECTED_HEAD_SHA: "short" } }, + { overrides: { PROVIDER_RUN_ATTEMPT: "0" } }, + { overrides: { PROVIDER_RUN_ID: "" } }, + { overrides: { CONTROLLER_WORKFLOW_SHA: "short" } }, + { overrides: {}, platform: "darwin" }, + { overrides: {}, release: "10.0.19045" }, + ])("does not issue provider success for invalid head/provider/OS %j", fixture => { + const result = executeHistoryStep("provider-receipt", fixture.overrides, fixture.head, fixture.platform, fixture.release); + expect(result.error).toBeDefined(); expect(receipts(result)).toHaveLength(0); expect(result.writes.size).toBe(0); + }); +});