diff --git a/.github/workflows/agentplugins-release.yml b/.github/workflows/agentplugins-release.yml index 85be5b31..85fab3a4 100644 --- a/.github/workflows/agentplugins-release.yml +++ b/.github/workflows/agentplugins-release.yml @@ -7,12 +7,43 @@ on: description: Exact stable tag, for example agentplugins-v0.1.0 required: true producer_mode: - description: Bounded release contract; publishes verified GitHub binary assets only + description: Binary-only publication, read-only paired preparation, or explicit promotion required: true default: binary-only type: choice options: - binary-only + - paired-preparation + - paired-promotion + source_sha: + description: Exact source and workflow SHA (required for paired preparation) + required: false + plugin_kit_version: + description: Explicit plugin-kit version (paired first cut requires 2.0.0) + required: false + preparation_run: + description: Exact completed preparation run ID (promotion only) + required: false + preparation_attempt: + description: Exact preparation run attempt (promotion only) + required: false + preparation_artifact: + description: Exact preparation artifact ID (promotion only) + required: false + preparation_digest: + description: Independently selected preparation ZIP SHA256 (promotion only) + required: false + promotion_operation: + description: Promote exact drafts or reconcile interrupted drafts/partial publication + required: true + default: promote + type: choice + options: + - promote + - reconcile + promotion_record: + description: Fixed canonical promotion record bindings; unsupported native contracts reject + required: false permissions: contents: read @@ -23,6 +54,7 @@ concurrency: jobs: validate: + if: ${{ inputs.producer_mode == 'binary-only' }} runs-on: ubuntu-latest env: GOWORK: "off" @@ -144,6 +176,7 @@ jobs: run: npm test && npm pack --dry-run --ignore-scripts build: + if: ${{ inputs.producer_mode == 'binary-only' }} needs: validate runs-on: ubuntu-latest env: @@ -200,6 +233,7 @@ jobs: retention-days: 3 stage-draft: + if: ${{ inputs.producer_mode == 'binary-only' }} needs: [validate, build] runs-on: ubuntu-latest environment: agentplugins-release @@ -308,6 +342,7 @@ jobs: retention-days: 7 platform-proof: + if: ${{ inputs.producer_mode == 'binary-only' }} needs: [validate, stage-draft] uses: ./.github/workflows/agentplugins-platform-proof.yml permissions: @@ -322,6 +357,7 @@ jobs: release_assets_artifact: ${{ needs.stage-draft.outputs.assets_artifact }} promote-release: + if: ${{ inputs.producer_mode == 'binary-only' }} needs: [validate, stage-draft, platform-proof] runs-on: ubuntu-latest environment: agentplugins-release @@ -378,3 +414,233 @@ jobs: gh release edit "${TAG}" --repo "${GITHUB_REPOSITORY}" --draft=false gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \ --jq 'select(.draft == false and .prerelease == false) | .tag_name' | grep -Fx -- "${TAG}" + + paired-preparation: + if: ${{ inputs.producer_mode == 'paired-preparation' }} + runs-on: ubuntu-latest + permissions: + contents: read + env: + SOURCE_SHA: ${{ inputs.source_sha }} + WORKFLOW_SHA: ${{ github.sha }} + TAG: ${{ inputs.tag }} + KIT_VERSION: ${{ inputs.plugin_kit_version }} + GOTOOLCHAIN: local + GOMAXPROCS: "2" + steps: + - name: Validate explicit paired identity before checkout + shell: bash + run: | + set -euo pipefail + [[ "${SOURCE_SHA}" =~ ^[0-9a-f]{40}$ && ! "${SOURCE_SHA}" =~ ^0{40}$ ]] + test "${SOURCE_SHA}" = "${WORKFLOW_SHA}" + [[ "${TAG}" =~ ^agentplugins-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] + test "${KIT_VERSION}" = "2.0.0" + test "${TAG#agentplugins-v}" != "${KIT_VERSION}" + test "${GITHUB_REPOSITORY}" = "777genius/universal-agent-plugins" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ inputs.source_sha }} + - name: Check exact checkout + run: test "$(git rev-parse HEAD)" = "${SOURCE_SHA}" + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: 1.25.13 + cache: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + package-manager-cache: false + - name: Provision declared Go modules for offline producer + env: + GOMODCACHE: ${{ runner.temp }}/paired-modules + GOCACHE: ${{ runner.temp }}/paired-cache + run: go mod download + - name: Freeze one full release-contract pair and project verified bytes + shell: bash + env: + GOMODCACHE: ${{ runner.temp }}/paired-modules + GOCACHE: ${{ runner.temp }}/paired-cache + run: | + set -euo pipefail + export TRUSTED_GO="$(command -v go)" + node <<'NODE' + const fs = require('node:fs'); + const path = require('node:path'); + const { stageCandidate } = require('./npm/agentplugins/scripts/stage-dual-authoring-candidate'); + const { prepareAuthoringRelease, verifyAuthoringRelease } = require('./npm/agentplugins/scripts/release-assets'); + const root = fs.mkdtempSync(path.join(process.env.RUNNER_TEMP, 'authoring-preparation-')); + const workParent = path.join(root, 'work'); + fs.mkdirSync(workParent, { mode: 0o700 }); + const identity = { repository: '777genius/universal-agent-plugins', + commit: process.env.SOURCE_SHA, engine_revision: process.env.SOURCE_SHA, + versions: { agentplugins: process.env.TAG.slice('agentplugins-v'.length), 'plugin-kit-ai': process.env.KIT_VERSION } }; + const common = { candidate: true, identity, go: process.env.TRUSTED_GO, workParent, + assetScope: 'six-platform-pair', authoringMode: 'release-cli-contract-v1' }; + // stageCandidate builds once and independently verifies before sealing. + const candidate = path.join(root, 'candidate'); + const staged = stageCandidate({ ...common, repo: process.env.GITHUB_WORKSPACE, + modCache: process.env.GOMODCACHE, output: candidate }); + fs.writeFileSync(path.join(root, 'candidate-identity.json'), JSON.stringify({ identity, ...staged }, null, 2) + '\n', { flag: 'wx' }); + const options = { ...common, root: candidate, manifestDigest: staged.manifest_sha256, + outputs: { agentplugins: path.join(root, 'agentplugins'), 'plugin-kit-ai': path.join(root, 'plugin-kit-ai') }, + pairMarker: path.join(root, 'pair-prepared.json') }; + prepareAuthoringRelease(options); + verifyAuthoringRelease(options); + fs.appendFileSync(process.env.GITHUB_ENV, `PAIRED_OUTPUT=${root}\n`); + NODE + - name: Upload candidate identity and both prepared projections together + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 + with: + name: authoring-preparation-${{ inputs.source_sha }} + path: | + ${{ env.PAIRED_OUTPUT }}/candidate-identity.json + ${{ env.PAIRED_OUTPUT }}/candidate/candidate.json + ${{ env.PAIRED_OUTPUT }}/pair-prepared.json + ${{ env.PAIRED_OUTPUT }}/agentplugins/* + ${{ env.PAIRED_OUTPUT }}/plugin-kit-ai/* + if-no-files-found: error + retention-days: 7 + + paired-promotion-admission: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.producer_mode == 'paired-promotion' }} + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Validate promotion identity before checkout + env: + SOURCE_SHA: ${{ inputs.source_sha }} + WORKFLOW_SHA: ${{ github.sha }} + WORKFLOW_REF: ${{ github.ref }} + TAG: ${{ inputs.tag }} + KIT_VERSION: ${{ inputs.plugin_kit_version }} + shell: bash + run: | + set -euo pipefail + [[ "${SOURCE_SHA}" =~ ^[0-9a-f]{40}$ && ! "${SOURCE_SHA}" =~ ^0{40}$ ]] + test "${SOURCE_SHA}" = "${WORKFLOW_SHA}" + [[ "${TAG}" =~ ^agentplugins-v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] + test "${WORKFLOW_REF}" = "refs/tags/${TAG}" + test "${KIT_VERSION}" = "2.0.0" + test "${TAG#agentplugins-v}" != "${KIT_VERSION}" + test "${GITHUB_REPOSITORY}" = "777genius/universal-agent-plugins" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ inputs.source_sha }} + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.21.1 + package-manager-cache: false + - name: Reject missing native terminal contracts before protected effects + env: + PROMOTION_RECORD: ${{ inputs.promotion_record }} + TAG: ${{ inputs.tag }} + WORKFLOW_REF: ${{ github.ref }} + KIT_VERSION: ${{ inputs.plugin_kit_version }} + WORKFLOW_SHA: ${{ github.sha }} + run: | + node <<'NODE' + const p = require('./npm/agentplugins/scripts/authoring-promotion'); + const input = process.env.PROMOTION_RECORD || '{}'; + if (Buffer.byteLength(input) > 1024 * 1024) throw Error('bounded promotion input required'); + const selected = { tag: process.env.TAG, ref: process.env.WORKFLOW_REF, source: process.env.WORKFLOW_SHA, + versions: { agentplugins: process.env.TAG.slice('agentplugins-v'.length), 'plugin-kit-ai': process.env.KIT_VERSION } }; + const record = p.validateSelection(Buffer.from(input), selected); + p.requireNativeContracts(record.qualification?.lanes || []); + NODE + + paired-sign-and-promote: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.producer_mode == 'paired-promotion' }} + needs: paired-promotion-admission + runs-on: ubuntu-latest + environment: agentplugins-release + permissions: + contents: write + actions: read + id-token: write + attestations: write + artifact-metadata: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ inputs.source_sha }} + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.21.1 + package-manager-cache: false + # Admission currently always rejects: real terminal producer integration is + # required before any artifact download, attestation or release mutation. + - name: Acquire exact frozen preparation after native admission + env: + GH_TOKEN: ${{ github.token }} + PROMOTION_RECORD: ${{ inputs.promotion_record }} + TAG: ${{ inputs.tag }} + WORKFLOW_REF: ${{ github.ref }} + KIT_VERSION: ${{ inputs.plugin_kit_version }} + WORKFLOW_SHA: ${{ github.sha }} + PREPARATION_RUN: ${{ inputs.preparation_run }} + PREPARATION_ATTEMPT: ${{ inputs.preparation_attempt }} + PREPARATION_ARTIFACT: ${{ inputs.preparation_artifact }} + PREPARATION_DIGEST: ${{ inputs.preparation_digest }} + SOURCE_SHA: ${{ inputs.source_sha }} + run: | + node <<'NODE' + const fs = require('node:fs'); + const path = require('node:path'); + const p = require('./npm/agentplugins/scripts/authoring-promotion'); + const selected = { tag: process.env.TAG, ref: process.env.WORKFLOW_REF, source: process.env.WORKFLOW_SHA, + versions: { agentplugins: process.env.TAG.slice('agentplugins-v'.length), 'plugin-kit-ai': process.env.KIT_VERSION } }; + const record = p.admitRecord(Buffer.from(process.env.PROMOTION_RECORD), selected); + if (record.identity.commit !== process.env.SOURCE_SHA) throw Error('exact promotion source required'); + const scratch = fs.mkdtempSync(path.join(process.env.RUNNER_TEMP, 'paired-promotion-')); + const preparation = { run_id: Number(process.env.PREPARATION_RUN), run_attempt: Number(process.env.PREPARATION_ATTEMPT), + artifact_id: Number(process.env.PREPARATION_ARTIFACT), artifact_sha256: process.env.PREPARATION_DIGEST }; + p.acquireArtifact(preparation, p.WORKFLOW, record.identity.commit, scratch); + const recordFile = path.join(scratch, 'authoring-promotion.json'); + fs.writeFileSync(recordFile, p.encodeRecord(record), { flag: 'wx', mode: 0o400 }); + fs.writeFileSync(path.join(scratch, 'options.json'), JSON.stringify({ record: recordFile, + root: path.join(scratch, 'frozen'), scratch: path.join(scratch, 'provider'), workflow_sha: process.env.SOURCE_SHA, preparation, selected }), { flag: 'wx' }); + fs.mkdirSync(path.join(scratch, 'provider'), { mode: 0o700 }); + fs.appendFileSync(process.env.GITHUB_ENV, `PROMOTION_ROOT=${scratch}\n`); + NODE + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ inputs.preparation_artifact }} + run-id: ${{ inputs.preparation_run }} + github-token: ${{ github.token }} + repository: 777genius/universal-agent-plugins + merge-multiple: true + path: ${{ env.PROMOTION_ROOT }}/frozen + - name: Recheck admission and exact subject closure before signing + id: subjects + env: + GH_TOKEN: ${{ github.token }} + PROMOTION_OPERATION: ${{ inputs.promotion_operation }} + run: | + case "${PROMOTION_OPERATION}" in + promote) admission=admit ;; + reconcile) admission=admit-reconciliation ;; + *) exit 1 ;; + esac + node npm/agentplugins/scripts/authoring-promotion.js "${admission}" "${PROMOTION_ROOT}/options.json" > "${PROMOTION_ROOT}/admitted.json" + node <<'NODE' + const fs = require('node:fs'); + const result = JSON.parse(fs.readFileSync(`${process.env.PROMOTION_ROOT}/admitted.json`)); + if (!['qualified-for-promotion', 'reconciliation-required'].includes(result.status) || result.subjects.length !== 19) throw Error('exact admitted subjects required'); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `sign_required=${result.sign_required}\npaths< s.file).join('\n')}\nSUBJECTS\n`); + NODE + - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + if: ${{ steps.subjects.outputs.sign_required == 'true' }} + with: + subject-path: ${{ steps.subjects.outputs.paths }} + - name: Reverify all signatures and reconcile both native releases + env: + GH_TOKEN: ${{ github.token }} + PROMOTION_OPERATION: ${{ inputs.promotion_operation }} + run: | + case "${PROMOTION_OPERATION}" in promote|reconcile) ;; *) exit 1 ;; esac + node npm/agentplugins/scripts/authoring-promotion.js "${PROMOTION_OPERATION}" "${PROMOTION_ROOT}/options.json" diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index 6497cc45..89ecfa6a 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -16,6 +16,16 @@ jobs: attestations: write artifact-metadata: write steps: + - name: Reject standard-first versions on legacy producer + shell: bash + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + if [[ ! "${RELEASE_TAG}" =~ ^v1\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "Legacy GoReleaser supports plugin-kit-ai v1 only. Use agentplugins-release.yml paired-preparation for plugin-kit-ai major 2; publication requires later qualification." >&2 + exit 1 + fi - uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/cli/plugin-kit-ai/cmd/agentplugins/release_workflow_test.go b/cli/plugin-kit-ai/cmd/agentplugins/release_workflow_test.go index b1820e52..fda5cdcd 100644 --- a/cli/plugin-kit-ai/cmd/agentplugins/release_workflow_test.go +++ b/cli/plugin-kit-ai/cmd/agentplugins/release_workflow_test.go @@ -1,11 +1,19 @@ package main import ( + "go/ast" + "go/parser" + "go/token" "os" + "os/exec" "path/filepath" + "regexp" "runtime" + "strconv" "strings" "testing" + + "gopkg.in/yaml.v3" ) func TestStableReleaseRequiresVerifiedReproducibleBootstrapBeforeBuild(t *testing.T) { @@ -60,3 +68,428 @@ func TestStableReleaseRequiresVerifiedReproducibleBootstrapBeforeBuild(t *testin t.Fatal("release binary test does not inspect the complete forbidden conformance variable set") } } + +// Parse the job graph as well as executing its preflight shell. A new job or +// dispatch mode must not accidentally inherit the publication permissions. +type producerWorkflow struct { + Concurrency struct { + Group string `yaml:"group"` + Cancel bool `yaml:"cancel-in-progress"` + } `yaml:"concurrency"` + Name string `yaml:"name"` + On struct { + Run struct { + Workflows []string `yaml:"workflows"` + } `yaml:"workflow_run"` + Dispatch struct { + Inputs map[string]struct { + Default string `yaml:"default"` + Options []string `yaml:"options"` + } `yaml:"inputs"` + } `yaml:"workflow_dispatch"` + } `yaml:"on"` + Permissions map[string]string `yaml:"permissions"` + Jobs map[string]struct { + If string `yaml:"if"` + Environment any `yaml:"environment"` + Needs any `yaml:"needs"` + Uses string `yaml:"uses"` + Permissions map[string]string `yaml:"permissions"` + Env map[string]string `yaml:"env"` + Steps []struct { + Name string `yaml:"name"` + Run string `yaml:"run"` + Uses string `yaml:"uses"` + With map[string]any `yaml:"with"` + Env map[string]string `yaml:"env"` + } `yaml:"steps"` + } `yaml:"jobs"` +} + +func readProducerWorkflow(t *testing.T, name string) producerWorkflow { + t.Helper() + _, source, _, _ := runtime.Caller(0) + body, err := os.ReadFile(filepath.Join(filepath.Dir(source), "../../../../.github/workflows", name)) + if err != nil { + t.Fatal(err) + } + var workflow producerWorkflow + if err := yaml.Unmarshal(body, &workflow); err != nil { + t.Fatal(err) + } + return workflow +} + +func runProducerPreflight(t *testing.T, script string, values map[string]string, success bool) { + t.Helper() + command := exec.Command("/bin/bash", "-c", script) + command.Dir = t.TempDir() + command.Env = []string{"PATH=/usr/local/bin:/usr/bin:/bin"} + for key, value := range values { + command.Env = append(command.Env, key+"="+value) + } + body, err := command.CombinedOutput() + if (err == nil) != success { + t.Fatalf("preflight success=%v, expected %v: %v\n%s", err == nil, success, err, body) + } +} + +// YAML decoding alone accepts runner expressions in job env, but GitHub rejects +// that context there before creating a run. Step env supports runner instead. +func TestReleaseWorkflowRunnerCacheContext(t *testing.T) { + w := readProducerWorkflow(t, "agentplugins-release.yml") + runnerExpression := regexp.MustCompile(`\$\{\{[^}]*\brunner\s*[.\[]`) + for name, job := range w.Jobs { + for key, value := range job.Env { + if runnerExpression.MatchString(value) { + t.Errorf("jobs.%s.env.%s uses unavailable runner context", name, key) + } + } + } + consumers := map[string]bool{ + "Provision declared Go modules for offline producer": false, + "Freeze one full release-contract pair and project verified bytes": false, + } + for _, step := range w.Jobs["paired-preparation"].Steps { + if _, ok := consumers[step.Name]; !ok { + continue + } + consumers[step.Name] = true + for key, want := range map[string]string{ + "GOMODCACHE": "${{ runner.temp }}/paired-modules", + "GOCACHE": "${{ runner.temp }}/paired-cache", + } { + if step.Env[key] != want { + t.Errorf("%s must set step env %s=%q, got %q", step.Name, key, want, step.Env[key]) + } + } + } + for name, found := range consumers { + if !found { + t.Errorf("missing cache consumer %q", name) + } + } +} + +func TestReleasePairedPreparationReadOnlyGraph(t *testing.T) { + w := readProducerWorkflow(t, "agentplugins-release.yml") + mode := w.On.Dispatch.Inputs["producer_mode"] + if mode.Default != "binary-only" || strings.Join(mode.Options, ",") != "binary-only,paired-preparation,paired-promotion" { + t.Fatal("default binary-only dispatch contract changed") + } + if len(w.Permissions) != 1 || w.Permissions["contents"] != "read" { + t.Fatal("workflow must default to contents-read") + } + if len(w.Jobs) != 8 { + t.Fatal("review every new producer job for preparation reachability") + } + for name, job := range w.Jobs { + if name == "paired-promotion-admission" || name == "paired-sign-and-promote" { + if job.If != "${{ github.event_name == 'workflow_dispatch' && inputs.producer_mode == 'paired-promotion' }}" { + t.Fatalf("%s loses explicit promotion isolation", name) + } + continue + } + if name != "paired-preparation" { + if job.If != "${{ inputs.producer_mode == 'binary-only' }}" { + t.Fatalf("%s reachable from paired route", name) + } + continue + } + if job.If != "${{ inputs.producer_mode == 'paired-preparation' }}" || job.Needs != nil || job.Uses != "" { + t.Fatal("paired route must be an independent explicit job") + } + if len(job.Permissions) != 1 || job.Permissions["contents"] != "read" { + t.Fatal("paired route has write or attestation permission") + } + var scripts strings.Builder + uploads := 0 + for _, step := range job.Steps { + scripts.WriteString(step.Run) + if step.Uses != "" && !strings.HasPrefix(step.Uses, "actions/checkout@") && !strings.HasPrefix(step.Uses, "actions/setup-go@") && + !strings.HasPrefix(step.Uses, "actions/setup-node@") && !strings.HasPrefix(step.Uses, "actions/upload-artifact@") { + t.Fatalf("unreviewed paired action: %s", step.Uses) + } + if strings.HasPrefix(step.Uses, "actions/upload-artifact@") { + uploads++ + paths, _ := step.With["path"].(string) + for _, required := range []string{"/candidate-identity.json", "/candidate/candidate.json", "/pair-prepared.json", "/agentplugins/*", "/plugin-kit-ai/*"} { + if !strings.Contains(paths, required) { + t.Fatalf("paired upload lacks %s", required) + } + } + } + } + body := scripts.String() + if uploads != 1 || strings.Count(body, "stageCandidate({") != 1 || strings.Count(body, "prepareAuthoringRelease(options)") != 1 || + strings.Count(body, "verifyAuthoringRelease(options)") != 1 { + t.Fatal("must freeze once, project once and verify before one upload") + } + for _, required := range []string{"six-platform-pair", "release-cli-contract-v1", "manifestDigest: staged.manifest_sha256", "commit: process.env.SOURCE_SHA, engine_revision: process.env.SOURCE_SHA"} { + if !strings.Contains(body, required) { + t.Fatalf("missing paired binding %q", required) + } + } + for _, forbidden := range []string{"gh ", "goreleaser", "go build", "npm publish", "twine", "homebrew", "git push", "git tag", "workflow_dispatch"} { + if strings.Contains(body, forbidden) { + t.Fatalf("preparation reaches forbidden effect %q", forbidden) + } + } + if job.Steps[0].Name != "Validate explicit paired identity before checkout" { + t.Fatal("identity must fail before checkout/setup/build") + } + valid := map[string]string{"SOURCE_SHA": strings.Repeat("a", 40), "WORKFLOW_SHA": strings.Repeat("a", 40), + "TAG": "agentplugins-v0.1.54", "KIT_VERSION": "2.0.0", "GITHUB_REPOSITORY": "777genius/universal-agent-plugins"} + runProducerPreflight(t, job.Steps[0].Run, valid, true) + for key, invalid := range map[string]string{"SOURCE_SHA": "latest", "WORKFLOW_SHA": strings.Repeat("b", 40), "TAG": "agentplugins-v01.2.3", "KIT_VERSION": "1.2.4", "GITHUB_REPOSITORY": "777genius/plugin-kit-ai"} { + values := make(map[string]string) + for k, v := range valid { + values[k] = v + } + values[key] = invalid + runProducerPreflight(t, job.Steps[0].Run, values, false) + } + } + // Preserve the existing publication gate dependency chain. + for name, expected := range map[string]string{"build": "validate", "stage-draft": "validate,build", "platform-proof": "validate,stage-draft", "promote-release": "validate,stage-draft,platform-proof"} { + var needs []string + switch value := w.Jobs[name].Needs.(type) { + case string: + needs = []string{value} + case []any: + for _, item := range value { + needs = append(needs, item.(string)) + } + } + if strings.Join(needs, ",") != expected { + t.Fatalf("%s lost required gates", name) + } + } +} + +func TestReleaseLegacyGoReleaserRejectsMajorTwoBeforeEffects(t *testing.T) { + w := readProducerWorkflow(t, "release-assets.yml") + job := w.Jobs["goreleaser"] + if len(job.Steps) == 0 || job.Steps[0].Name != "Reject standard-first versions on legacy producer" || job.Steps[0].Uses != "" { + t.Fatal("legacy version guard must precede checkout, tooling and publication") + } + guard := job.Steps[0].Run + if !strings.Contains(guard, "paired-preparation") { + t.Fatal("guard must direct users to paired producer") + } + for _, tag := range []string{"v1.0.0", "v1.2.4", "v1.99.100"} { + runProducerPreflight(t, guard, map[string]string{"RELEASE_TAG": tag}, true) + } + for _, tag := range []string{"v2.0.0", "v2.1.0", "v20.0.0", "v2.0.0-rc.1", "v01.2.4", "latest", "", "v1.2.4; touch forbidden"} { + runProducerPreflight(t, guard, map[string]string{"RELEASE_TAG": tag}, false) + } +} + +func TestReleasePairedRouteCannotTriggerDownstreamPublication(t *testing.T) { + paired := readProducerWorkflow(t, "agentplugins-release.yml") + if paired.Name != "Agentplugins Release Assets" { + t.Fatal("review downstream triggers before renaming producer") + } + for _, name := range []string{"npm-publish.yml", "pypi-publish.yml", "homebrew-tap.yml"} { + w := readProducerWorkflow(t, name) + if strings.Join(w.On.Run.Workflows, ",") != "Release Assets" { + t.Fatalf("%s may be triggered by paired producer", name) + } + for jobName, job := range w.Jobs { + // Dispatch-only jobs may be added by B. Every automatic route must + // reject failed legacy runs; trigger names above isolate paired runs. + for _, conclusion := range []string{"failure", "cancelled", "skipped", ""} { + if downstreamCondition(t, job.If, "workflow_run", conclusion) { + t.Fatalf("%s/%s admits failed legacy run", name, jobName) + } + } + legacy := jobName == "publish-npm" || jobName == "publish-pypi" || jobName == "update-homebrew-tap" + if legacy && !downstreamCondition(t, job.If, "workflow_run", "success") { + t.Fatalf("%s lost successful automatic v1 route", name) + } + if !legacy && downstreamCondition(t, job.If, "workflow_run", "success") { + t.Fatalf("%s/%s new paired job must be dispatch-only", name, jobName) + } + } + } + w := readProducerWorkflow(t, "agentplugins-npm-publish.yml") + if len(w.On.Run.Workflows) != 0 { + t.Fatal("agentplugins npm publication must require separate dispatch") + } +} + +// Parse the restricted boolean expression grammar, rejecting unknown syntax. +// Testing a failed event must evaluate the whole OR/AND graph, not find a token. +func downstreamCondition(t *testing.T, expression, event, conclusion string) bool { + t.Helper() + expression = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(expression), "${{"), "}}")) + values := map[string]string{"github.event_name": event, "github.event.workflow_run.conclusion": conclusion, + "vars.NPM_PUBLISH_READY": "true", "vars.PYPI_TRUSTED_PUBLISHING_READY": "true", "inputs.producer_mode": "paired-promotion"} + words := regexp.MustCompile(`'[^']*'|[a-zA-Z_][a-zA-Z0-9_.]*`) + expression = words.ReplaceAllStringFunc(expression, func(s string) string { + if strings.HasPrefix(s, "'") { + return strconv.Quote(s[1 : len(s)-1]) + } + if value, ok := values[s]; ok { + return strconv.Quote(value) + } + if s == "true" || s == "false" { + return s + } + t.Fatalf("unreviewed downstream expression context %q", s) + return "false" + }) + tree, err := parser.ParseExpr(expression) + if err != nil { + t.Fatal(err) + } + var evaluate func(ast.Expr) any + evaluate = func(e ast.Expr) any { + switch v := e.(type) { + case *ast.ParenExpr: + return evaluate(v.X) + case *ast.BasicLit: + if v.Kind != token.STRING { + t.Fatal("non-string expression literal") + } + value, err := strconv.Unquote(v.Value) + if err != nil { + t.Fatal(err) + } + return value + case *ast.Ident: + if v.Name == "true" { + return true + } + if v.Name == "false" { + return false + } + case *ast.BinaryExpr: + left, right := evaluate(v.X), evaluate(v.Y) + switch v.Op { + case token.EQL: + return left == right + case token.NEQ: + return left != right + case token.LAND: + return left.(bool) && right.(bool) + case token.LOR: + return left.(bool) || right.(bool) + } + } + t.Fatal("unsupported downstream expression syntax") + return false + } + result, ok := evaluate(tree).(bool) + if !ok { + t.Fatal("non-boolean job condition") + } + return result +} + +func TestReleaseDownstreamEventIsolationNegativeControls(t *testing.T) { + for _, expression := range []string{ + "github.event.workflow_run.conclusion == 'success' || true", + "github.event_name == 'workflow_run' || github.event.workflow_run.conclusion == 'success'", + } { + if !downstreamCondition(t, expression, "workflow_run", "failure") { + t.Fatal("negative control failed") + } + } + for _, conclusion := range []string{"success", "failure"} { + if downstreamCondition(t, "${{ github.event_name == 'workflow_dispatch' }}", "workflow_run", conclusion) { + t.Fatal("dispatch job reachable automatically") + } + } +} + +func TestReleasePairedPromotionProtectedGraph(t *testing.T) { + w := readProducerWorkflow(t, "agentplugins-release.yml") + admission, signing := w.Jobs["paired-promotion-admission"], w.Jobs["paired-sign-and-promote"] + if w.Concurrency.Group != "agentplugins-release-${{ inputs.tag }}" || w.Concurrency.Cancel { + t.Fatal("promotion must serialize on the independently selected, record-bound tag") + } + for _, job := range []string{"paired-promotion-admission", "paired-sign-and-promote"} { + for _, step := range w.Jobs[job].Steps { + if _, ok := step.Env["PROMOTION_RECORD"]; !ok { + continue + } + for key, want := range map[string]string{"TAG": "${{ inputs.tag }}", "WORKFLOW_REF": "${{ github.ref }}", "WORKFLOW_SHA": "${{ github.sha }}", "KIT_VERSION": "${{ inputs.plugin_kit_version }}"} { + if step.Env[key] != want { + t.Fatalf("%s loses independent %s binding", step.Name, key) + } + } + binding := strings.Index(step.Run, "Buffer.from(") + effect := strings.Index(step.Run, "p.acquireArtifact(") + if binding < 0 || !strings.Contains(step.Run, ", selected)") || (effect >= 0 && binding > effect) { + t.Fatalf("%s must bind canonical record before native/provider effects", step.Name) + } + } + } + if admission.Needs != nil || len(admission.Permissions) != 1 || admission.Permissions["contents"] != "read" || signing.Needs != "paired-promotion-admission" { + t.Fatal("native admission must precede protected promotion") + } + if signing.Environment != "agentplugins-release" { + t.Fatal("paired signing requires protected release environment") + } + if signing.Permissions["contents"] != "write" || signing.Permissions["id-token"] != "write" || signing.Permissions["attestations"] != "write" { + t.Fatal("missing protected signing boundary") + } + valid := map[string]string{"SOURCE_SHA": strings.Repeat("a", 40), "WORKFLOW_SHA": strings.Repeat("a", 40), + "TAG": "agentplugins-v0.1.54", "KIT_VERSION": "2.0.0", "GITHUB_REPOSITORY": "777genius/universal-agent-plugins", "WORKFLOW_REF": "refs/tags/agentplugins-v0.1.54"} + runProducerPreflight(t, admission.Steps[0].Run, valid, true) + for _, key := range []string{"SOURCE_SHA", "WORKFLOW_SHA", "TAG", "KIT_VERSION", "GITHUB_REPOSITORY", "WORKFLOW_REF"} { + values := make(map[string]string) + for k, v := range valid { + values[k] = v + } + values[key] = "invalid" + runProducerPreflight(t, admission.Steps[0].Run, values, false) + } + scripts := "" + attestIndex, admitIndex, promoteIndex := -1, -1, -1 + for i, step := range signing.Steps { + scripts += step.Run + if strings.Contains(step.Run, "admission=admit") { + admitIndex = i + } + if step.Uses == "actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6" { + attestIndex = i + } + if strings.Contains(step.Run, "case \"${PROMOTION_OPERATION}\" in promote|reconcile)") { + promoteIndex = i + } + } + if admitIndex < 0 || attestIndex <= admitIndex || promoteIndex <= attestIndex { + t.Fatal("admit -> pinned attest -> reverify/promote required") + } + for _, bad := range []string{"go build", "stageCandidate", "prepareAuthoringRelease", "npm publish", "--clobber", "git push"} { + if strings.Contains(scripts, bad) { + t.Fatalf("promotion reaches %s", bad) + } + } + if !strings.Contains(admission.Steps[len(admission.Steps)-1].Run, "requireNativeContracts") { + t.Fatal("unsupported contracts must reject before protected job") + } +} + +func TestReleasePairedPromotionShellSyntax(t *testing.T) { + w := readProducerWorkflow(t, "agentplugins-release.yml") + if len(w.On.Dispatch.Inputs) != 10 { + t.Fatal("review dispatch input limit and closed input contract") + } + for _, name := range []string{"paired-promotion-admission", "paired-sign-and-promote"} { + for _, step := range w.Jobs[name].Steps { + if step.Run == "" { + continue + } + cmd := exec.Command("/bin/bash", "-n") + cmd.Dir = t.TempDir() + cmd.Env = []string{"PATH=/usr/local/bin:/usr/bin:/bin"} + cmd.Stdin = strings.NewReader(step.Run) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s: %v %s", step.Name, err, output) + } + } + } +} diff --git a/npm/agentplugins/scripts/authoring-promotion.js b/npm/agentplugins/scripts/authoring-promotion.js new file mode 100644 index 00000000..d14607ef --- /dev/null +++ b/npm/agentplugins/scripts/authoring-promotion.js @@ -0,0 +1,456 @@ +#!/usr/bin/env node +"use strict"; + +// P's fixed encoding and protected process boundary. Structural validity never +// authorizes effects. No accepted all-target native evidence issuer exists yet. +const fs = require("node:fs"); +const path = require("node:path"); +const cp = require("node:child_process"); +const { isDeepStrictEqual: equal } = require("node:util"); +const c = require("./dual-authoring-candidate"); +const REPOSITORY = c.REPOSITORY; +const WORKFLOW = ".github/workflows/agentplugins-release.yml"; +const URL = `https://github.com/${REPOSITORY}`; +const SIGNER = `github.com/${REPOSITORY}/${WORKFLOW}`; +const GH = "/usr/bin/gh"; +const GH_VERSION = "2.83.2"; +const MODE = "release-cli-contract-v1"; +const SCOPE = "six-platform-pair"; +const SCHEMA = "authoring-promotion/v1"; +const SLSA = "https://slsa.dev/provenance/v1"; +const LIMIT = 1024 * 1024; +const LANES = Object.freeze([...c.PRODUCTS.flatMap(p => c.TARGETS.map(t => `${p}/${t}`)), "public-packed-pair"]); +const fail = message => { throw new Error(message); }; +const exact = (a, b, label) => { if (!equal(a, b)) fail(`${label}: binding mismatch`); }; +const sha = (v, n = 64) => { + if (typeof v !== "string" || !new RegExp(`^[0-9a-f]{${n}}$`).test(v) || /^0+$/.test(v)) fail(`exact nonzero SHA-${n === 40 ? "1 source" : "256"} required`); + return v; +}; +const integer = (v, max = Number.MAX_SAFE_INTEGER) => { + if (!Number.isSafeInteger(v) || v < 1 || v > max) fail("bounded positive integer required"); + return v; +}; +const tag = (id, p) => `${p === "agentplugins" ? "agentplugins-" : ""}v${id.versions[p]}`; +function identity(v) { + c.identity(v); sha(v.commit, 40); + if (v.versions["plugin-kit-ai"] !== "2.0.0" || Object.values(v.versions).some(x => x.length > 32)) fail("first-cut paired versions required"); + return { repository: REPOSITORY, commit: v.commit, engine_revision: v.commit, + versions: { agentplugins: v.versions.agentplugins, "plugin-kit-ai": "2.0.0" } }; +} +// Fixed field ordering, even when input objects were constructed in another order. +function asset(v, id, product, target) { + c.keys(v, ["file", "sha256", "size", "binary"], "asset"); + c.keys(v.binary, ["file", "sha256", "size"], "binary"); + exact(v.file, c.assetName(product, id.versions[product], target), "asset name"); + exact(v.binary.file, c.executableName(product, target), "binary name"); + const result = { file: v.file, sha256: sha(v.sha256), size: integer(v.size, 128 * LIMIT), + binary: { file: v.binary.file, sha256: sha(v.binary.sha256), size: integer(v.binary.size, 128 * LIMIT) } }; + if (product === "agentplugins") exact([result.sha256, result.size], [result.binary.sha256, result.binary.size], "raw executable"); + return result; +} +function producer(v, source) { + c.keys(v, ["workflow", "source", "run_id", "run_attempt"], "producer"); + exact(v.workflow, WORKFLOW, "producer workflow"); exact(v.source, source, "producer source"); + return { workflow: WORKFLOW, source: sha(v.source, 40), run_id: integer(v.run_id), run_attempt: integer(v.run_attempt, 1000) }; +} +function locator(v) { + c.keys(v, ["run_id", "run_attempt", "artifact_id", "artifact_sha256"], "artifact locator"); + return { run_id: integer(v.run_id), run_attempt: integer(v.run_attempt, 1000), + artifact_id: integer(v.artifact_id), artifact_sha256: sha(v.artifact_sha256) }; +} +function lane(v, name, products, id) { + c.keys(v, ["lane", "schema", "sha256", "workflow", "source", "artifact", "subjects"], "terminal evidence binding"); + exact(v.lane, name, "required lane order"); + // schema is an identifier only, not an adapter registration or trust claim. + if (typeof v.schema !== "string" || !/^[a-z][a-z0-9-]{0,79}\/v[1-9][0-9]?$/.test(v.schema)) fail("bounded evidence schema identifier required"); + if (typeof v.workflow !== "string" || !/^\.github\/workflows\/[a-z0-9-]{1,80}\.yml$/.test(v.workflow)) fail("canonical evidence workflow path required"); + exact(v.source, id.commit, "evidence source"); + const selected = name === "public-packed-pair" ? c.PRODUCTS.flatMap(p => c.TARGETS.map(t => [p, t])) : [name.split("/")]; + const subjects = selected.map(([p, t]) => ({ product: p, target: t, + sha256: products[p].assets[t].sha256, binary_sha256: products[p].assets[t].binary.sha256 })); + exact(v.subjects, subjects, "frozen lane subjects"); + return { lane: name, schema: v.schema, sha256: sha(v.sha256), workflow: v.workflow, + source: id.commit, artifact: locator(v.artifact), subjects }; +} +function recordShape(v) { + c.keys(v, ["schema", "identity", "authoring_mode", "asset_scope", "candidate_sha256", "pair_marker_sha256", "products", "qualification", "producer"], "promotion"); + exact([v.schema, v.authoring_mode, v.asset_scope], [SCHEMA, MODE, SCOPE], "promotion schema/mode/scope"); + const id = identity(v.identity); + c.keys(v.products, c.PRODUCTS, "products"); + const products = {}; + const binaries = new Set(); + for (const p of c.PRODUCTS) { + const value = v.products[p]; + c.keys(value, ["tag", "manifest_sha256", "checksums_sha256", "assets"], "product"); + exact(value.tag, tag(id, p), "product tag"); c.keys(value.assets, c.TARGETS, "six targets"); + products[p] = { tag: value.tag, manifest_sha256: sha(value.manifest_sha256), checksums_sha256: sha(value.checksums_sha256), assets: {} }; + for (const t of c.TARGETS) { + const pin = asset(value.assets[t], id, p, t); + if (binaries.has(pin.binary.sha256)) fail("duplicate product/target binary"); + binaries.add(pin.binary.sha256); products[p].assets[t] = pin; + } + } + c.keys(v.qualification, ["lanes"], "qualification"); + if (!Array.isArray(v.qualification.lanes) || v.qualification.lanes.length !== LANES.length) fail(`missing required lanes: ${LANES.join(", ")}`); + const lanes = v.qualification.lanes.map((x, i) => lane(x, LANES[i], products, id)); + if (new Set(lanes.map(x => x.sha256)).size !== LANES.length) fail("duplicate terminal report subject"); + return { schema: SCHEMA, identity: id, authoring_mode: MODE, asset_scope: SCOPE, + candidate_sha256: sha(v.candidate_sha256), pair_marker_sha256: sha(v.pair_marker_sha256), products, + qualification: { lanes }, producer: producer(v.producer, id.commit) }; +} +function encodeRecord(value) { + const body = c.encode(recordShape(value)); + if (body.length > LIMIT) fail("promotion record exceeds byte bound"); + return body; +} +// Canonical bytes reject duplicate keys, extra whitespace, noncanonical UTF-8, +// key order and number spellings. Objects must not be accepted as signed bytes. +function decodeRecord(body) { + if (!Buffer.isBuffer(body) || body.length === 0 || body.length > LIMIT) fail("bounded promotion bytes required"); + const value = JSON.parse(body.toString("utf8")); + if (!body.equals(encodeRecord(value))) fail("noncanonical promotion record"); + return recordShape(value); +} +function requireNativeContracts(lanes) { + if (!Array.isArray(lanes) || lanes.length > LANES.length) fail("bounded terminal lane array required"); + const seen = new Set(); + for (const value of lanes) { + if (!LANES.includes(value?.lane) || seen.has(value.lane)) fail("unknown or duplicate terminal lane"); + seen.add(value.lane); + } + const missing = LANES.filter(x => !seen.has(x)); + // Deliberately NO caller-supplied adapter, issuer, success boolean or policy. + // Native owners must deliver reviewed terminal schemas AND their producer + // source asserting the concrete native/installer and public packed gates. + const unsupported = lanes.map(x => `${x.lane}:${String(x.schema).slice(0, 100)}`); + fail(`NATIVE_EVIDENCE_INTEGRATION_REQUIRED: missing lanes [${missing.join(", ")}]; unsupported contracts [${unsupported.join(", ")}]. ` + + "No accepted frozen-pair all-target terminal producer/schema is integrated. dual-authoring-public-native/v1 is Linux fixture evidence with false release claims; private-packed or SLSA build success cannot qualify this pair. Signing and promotion are disabled."); +} +function validateSelection(body, selected) { + const record = decodeRecord(body); + c.keys(selected, ["tag", "ref", "source", "versions"], "selected promotion identity"); + exact(selected, { tag: record.products.agentplugins.tag, ref: `refs/tags/${record.products.agentplugins.tag}`, + source: record.identity.commit, versions: record.identity.versions }, "selected promotion identity"); + return record; +} +function admitRecord(body, selected) { + const record = validateSelection(body, selected); + requireNativeContracts(record.qualification.lanes); + return record; // Unreachable until real native terminal adapters are reviewed. +} + +// No executable/issuer configuration input. Offline tests replace spawnSync in +// their own process, never through production flags or inherited environment. +function gh(args, cwd, maximum = 4 * LIMIT, encoding = "utf8") { + c.safeDirectory(cwd); + const env = { PATH: "/usr/local/bin:/usr/bin:/bin", HOME: cwd, GH_CONFIG_DIR: cwd, + GH_HOST: "github.com", GH_PROMPT_DISABLED: "1", GH_PAGER: "cat", GH_NO_UPDATE_NOTIFIER: "1" }; + // Only the supported workflow token context is forwarded; no auth-store reads. + if (process.env.GITHUB_ACTIONS === "true" && process.env.GITHUB_REPOSITORY === REPOSITORY && process.env.GH_TOKEN) env.GH_TOKEN = process.env.GH_TOKEN; + const result = cp.spawnSync(GH, args, { cwd, env, encoding, timeout: 30000, + killSignal: "SIGKILL", maxBuffer: maximum, shell: false }); + if (result.error || result.signal || result.status !== 0) fail(`trusted gh failed (${result.error?.code || result.signal || result.status}); provider denial or uncertain state: stop, do not retry or reroute`); + return result.stdout; +} +function cliVersion(cwd) { + if (!gh(["--version"], cwd).startsWith(`gh version ${GH_VERSION} (`)) fail(`trusted /usr/bin/gh ${GH_VERSION} required`); +} +function api(endpoint, cwd) { + return JSON.parse(gh(["api", "--hostname", "github.com", "-H", "Accept: application/vnd.github+json", + "-H", "X-GitHub-Api-Version: 2022-11-28", `repos/${REPOSITORY}/${endpoint}`], cwd)); +} +// The provider attempt endpoint is mandatory: latest run state cannot substitute +// for an independently selected attempt. Metadata alone does not admit evidence. +function inspectArtifact(pin, workflow, source, cwd) { + const loc = locator(pin); sha(source, 40); + if (typeof workflow !== "string" || !/^\.github\/workflows\/[a-z0-9-]{1,80}\.yml$/.test(workflow)) fail("approved workflow required"); + const run = api(`actions/runs/${loc.run_id}/attempts/${loc.run_attempt}`, cwd); + if (run.id !== loc.run_id || run.run_attempt !== loc.run_attempt || run.status !== "completed" || run.conclusion !== "success" || + run.repository?.full_name !== REPOSITORY || run.head_repository?.full_name !== REPOSITORY || + run.head_sha !== source || run.path !== workflow) fail("exact successful workflow/source/run/attempt required"); + const item = api(`actions/artifacts/${loc.artifact_id}`, cwd); + if (item.id !== loc.artifact_id || item.expired !== false || item.digest !== `sha256:${loc.artifact_sha256}` || + item.workflow_run?.id !== loc.run_id || item.workflow_run?.head_sha !== source || + typeof item.name !== "string" || item.name.length > 128) fail("artifact identity/digest/run mismatch"); + integer(item.size_in_bytes, 2 * 1024 * LIMIT); + // Artifact metadata lacks a run_attempt field. The accepted producer adapter + // must additionally bind its bytes to this attempt; names never supply that. + return { run, item }; +} +function acquireArtifact(pin, workflow, source, cwd) { + cliVersion(cwd); + const before = inspectArtifact(pin, workflow, source, cwd); + const file = path.join(cwd, `artifact-${pin.artifact_id}.zip`); + if (fs.existsSync(file)) fail("artifact destination already exists"); + // gh api follows the provider's supported artifact ZIP redirect. No free URL. + // Binary output is bounded in memory, never extracted or executed here. + const zip = gh(["api", "--hostname", "github.com", `repos/${REPOSITORY}/actions/artifacts/${pin.artifact_id}/zip`], cwd, 2 * 1024 * LIMIT, null); + if (zip.length !== before.item.size_in_bytes || c.digest(zip) !== pin.artifact_sha256) fail("artifact ZIP digest/size mismatch"); + exact(inspectArtifact(pin, workflow, source, cwd), before, "artifact changed during acquisition"); + fs.writeFileSync(file, zip, { flag: "wx", mode: 0o400 }); + return file; +} + +// Mapping for fresh `gh attestation verify --format json` results. This function +// is structural; only verifySubject calls the cryptographic boundary. B must +// never promote supplied JSON into authenticated proof by calling this mapper. +function mapVerifiedOutput(output, expected) { + if (typeof output !== "string" || Buffer.byteLength(output) > 4 * LIMIT) fail("bounded verifier output required"); + const results = JSON.parse(output); + if (!Array.isArray(results) || results.length !== 1) fail("one verified attestation required"); + const statement = results[0]?.verificationResult?.statement; + if (statement?._type !== "https://in-toto.io/Statement/v1" || statement.predicateType !== SLSA) fail("verified in-toto/SLSA type mismatch"); + // actions/attest signs the complete subject-path set in one statement. Both + // products legitimately have a release-manifest.json/checksums.txt basename. + // Compare the entire independently pinned multiset, never merely find one hash. + const subjects = expected.subjects; + if (!Array.isArray(subjects) || subjects.length < 1 || subjects.length > 19) fail("bounded expected subject set required"); + const normalized = subjects.map(s => { + c.keys(s, ["name", "digest"], "subject"); c.keys(s.digest, ["sha256"], "subject digest"); sha(s.digest.sha256); + if (typeof s.name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,150}$/.test(s.name)) fail("subject basename required"); + return { name: s.name, digest: { sha256: s.digest.sha256 } }; + }); + const order = list => [...list].sort((a,b) => `${a.name}:${a.digest.sha256}`.localeCompare(`${b.name}:${b.digest.sha256}`, "en")); + if (new Set(normalized.map(s => `${s.name}:${s.digest.sha256}`)).size !== normalized.length || + !normalized.some(s => s.name === expected.name && s.digest.sha256 === expected.sha256)) fail("duplicate or missing selected subject"); + if (!Array.isArray(statement.subject) || statement.subject.length !== normalized.length) fail("verified subject count mismatch"); + exact(order(statement.subject), order(normalized), "verified subject set"); + const build = statement.predicate?.buildDefinition; + if (build?.buildType !== "https://actions.github.io/buildtypes/workflow/v1") fail("verified Actions build type mismatch"); + exact(build.externalParameters?.workflow, { ref: expected.ref, repository: URL, path: WORKFLOW }, "verified workflow"); + exact(build.resolvedDependencies, [{ uri: `git+${URL}@${expected.ref}`, digest: { gitCommit: expected.source } }], "verified source"); + const run = statement.predicate?.runDetails; + exact(run?.metadata?.invocationId, `${URL}/actions/runs/${expected.run_id}/attempts/${expected.run_attempt}`, "verified invocation"); + exact(run?.builder?.id, "https://github.com/actions/runner/github-hosted", "verified runner"); + return statement; +} +function verifySubject(file, expected, cwd) { + c.keys(expected, ["name", "sha256", "source", "workflow_sha", "ref", "run_id", "run_attempt", "subjects"], "verification expectations"); + sha(expected.sha256); sha(expected.source, 40); sha(expected.workflow_sha, 40); + integer(expected.run_id); integer(expected.run_attempt, 1000); + if (expected.name !== path.basename(file) || !/^refs\/tags\/agentplugins-v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(expected.ref)) fail("exact subject basename and agent tag ref required"); + if (c.digest(c.readFile(file)) !== expected.sha256) fail("subject changed before signature verification"); + cliVersion(cwd); + const output = gh(["attestation", "verify", file, "--repo", REPOSITORY, + "--signer-workflow", SIGNER, "--signer-digest", expected.workflow_sha, + "--source-digest", expected.source, "--source-ref", expected.ref, + "--cert-oidc-issuer", "https://token.actions.githubusercontent.com", + "--deny-self-hosted-runners", "--predicate-type", SLSA, "--format", "json"], cwd); + const statement = mapVerifiedOutput(output, expected); + if (c.digest(c.readFile(file)) !== expected.sha256) fail("subject changed after signature verification"); + return statement; +} + +// Projection roots keep their exact eight-file contract. Proof metadata sits +// beside them. Reconstruct the expected manifests/marker from pinned candidate +// bytes; this never builds or launches native subjects, even for Go build info. +function frozenSubjects(root, record) { + c.safeDirectory(root); + const candidateFile = path.join(root, "candidate", "candidate.json"); + const body = c.readFile(candidateFile, LIMIT); + exact(c.digest(body), record.candidate_sha256, "candidate digest"); + const manifest = JSON.parse(body); + if (!body.equals(c.encode(manifest))) fail("noncanonical candidate"); + c.manifestShape(manifest, record.identity, SCOPE, MODE); + const result = [{ file: candidateFile, sha256: record.candidate_sha256 }]; + const products = {}; + for (const p of c.PRODUCTS) { + const projection = path.join(root, p); c.safeDirectory(projection); + exact(manifest.products[p].assets, record.products[p].assets, "candidate product pins"); + const m = { schema_version: 3, status: "CANDIDATE", product: p, repository: REPOSITORY, + tag: tag(record.identity, p), version: record.identity.versions[p], commit: record.identity.commit, + engine_revision: record.identity.commit, versions: record.identity.versions, candidate_sha256: record.candidate_sha256, + authoring_mode: MODE, asset_scope: SCOPE, assets: manifest.products[p].assets, + release_eligible: false, platform_acceptance: false, attested: false }; + const mBody = c.encode(m); + const checks = Buffer.from([...Object.values(m.assets).map(a => `${a.sha256} ${a.file}`), `${c.digest(mBody)} release-manifest.json`].join("\n") + "\n"); + for (const [name, bytes, hash] of [["release-manifest.json", mBody, record.products[p].manifest_sha256], ["checksums.txt", checks, record.products[p].checksums_sha256]]) { + const file = path.join(projection, name); + exact(c.readFile(file, LIMIT), bytes, "projection bytes"); exact(c.digest(bytes), hash, "independent projection pin"); + result.push({ file, sha256: hash }); + } + for (const a of Object.values(m.assets)) { + const file = path.join(projection, a.file); const bytes = c.readFile(file); + exact(c.metadata(bytes), { sha256: a.sha256, size: a.size }, "asset bytes"); + const binary = p === "plugin-kit-ai" ? c.unpack(bytes, a.binary.file) : bytes; + exact(c.metadata(binary), { sha256: a.binary.sha256, size: a.binary.size }, "inner binary bytes"); + result.push({ file, sha256: a.sha256 }); + } + exact(fs.readdirSync(projection).sort(), [...Object.values(m.assets).map(a => a.file), "release-manifest.json", "checksums.txt"].sort(), "projection closure"); + products[p] = { manifest_sha256: c.digest(mBody), checksums_sha256: c.digest(checks) }; + } + const marker = { schema: "authoring-release-pair/v1", status: "CANDIDATE", identity: record.identity, + candidate_sha256: record.candidate_sha256, authoring_mode: MODE, asset_scope: SCOPE, products, + release_eligible: false, platform_acceptance: false, attested: false }; + const markerFile = path.join(root, "pair-prepared.json"); + exact(c.readFile(markerFile, LIMIT), c.encode(marker), "pair marker"); + exact(c.digest(c.encode(marker)), record.pair_marker_sha256, "pair marker pin"); + result.push({ file: markerFile, sha256: record.pair_marker_sha256 }); + return result; // 18 unchanged input subjects, promotion record is the 19th. +} +function releasePins(record, p) { + return [...Object.values(record.products[p].assets).map(a => ({ name: a.file, sha256: a.sha256, size: a.size })), + { name: "release-manifest.json", sha256: record.products[p].manifest_sha256 }, + { name: "checksums.txt", sha256: record.products[p].checksums_sha256 }, + { name: "candidate.json", sha256: record.candidate_sha256 }, + { name: "pair-prepared.json", sha256: record.pair_marker_sha256 }, + { name: "authoring-promotion.json", sha256: c.digest(encodeRecord(record)) }]; +} +function checkTag(record, p, cwd) { + // The commit endpoint peels annotated tags too; tag names are derived, never URLs. + const result = api(`commits/${tag(record.identity, p)}`, cwd); + exact(result.sha, record.identity.commit, "moved release tag"); +} +function inspectRelease(record, p, cwd) { + checkTag(record, p, cwd); + // GraphQL distinguishes definitive absence from errors without treating every + // HTTP failure as not-found. No credentials or server error text is logged. + const query = 'query($owner:String!,$name:String!,$tag:String!){repository(owner:$owner,name:$name){release(tagName:$tag){databaseId}}}'; + const response = JSON.parse(gh(["api", "graphql", "-f", `query=${query}`, "-f", "owner=777genius", "-f", "name=universal-agent-plugins", "-f", `tag=${tag(record.identity, p)}`], cwd)); + if (response.errors || !response.data?.repository || !Object.hasOwn(response.data.repository, "release")) fail("uncertain release lookup"); + if (response.data.repository.release === null) return null; + const id = integer(response.data.repository.release.databaseId); + const release = api(`releases/${id}`, cwd); + if (release.id !== id || release.tag_name !== tag(record.identity, p) || typeof release.draft !== "boolean" || release.prerelease !== false) fail("release identity/state mismatch"); + const expected = releasePins(record, p); + if (!Array.isArray(release.assets) || release.assets.length > expected.length) fail("release asset allowlist mismatch"); + const seen = new Set(), ids = new Set(); + for (const a of release.assets) { + const pin = expected.find(x => x.name === a.name); + if (!pin || seen.has(a.name) || ids.has(a.id) || a.state !== "uploaded" || a.digest !== `sha256:${pin.sha256}`) fail("release asset bytes/digest mismatch"); + integer(a.id); integer(a.size, 128 * LIMIT); if (pin.size !== undefined) exact(a.size, pin.size, "release size"); + const bytes = gh(["api", "--hostname", "github.com", "-H", "Accept: application/octet-stream", + `repos/${REPOSITORY}/releases/assets/${a.id}`], cwd, 128 * LIMIT, null); + if (bytes.length !== a.size || c.digest(bytes) !== pin.sha256) fail("downloaded release asset bytes mismatch"); + seen.add(a.name); ids.add(a.id); + } + const missing_assets = expected.filter(pin => !seen.has(pin.name)).map(pin => pin.name); + if (missing_assets.length && !release.draft) fail("incomplete public release"); + return { ...release, missing_assets }; +} +function inspectPair(record, cwd) { + const pair = c.PRODUCTS.map(p => inspectRelease(record, p, cwd)); + // This describes observed state only; it never authorizes a write. + const states = pair.map(r => r === null ? "absent" : r.missing_assets.length ? "incomplete-draft" : r.draft ? "draft" : "public"); + return { pair, states, reconciliation_required: states.includes("incomplete-draft") || + (states.includes("public") && !states.every(s => s === "public")) }; +} + +function options(v) { + c.keys(v, ["record", "root", "scratch", "workflow_sha", "preparation", "selected"], "promotion options"); + for (const name of ["root", "scratch"]) c.safeDirectory(v[name]); + if (v.root === v.scratch || v.root.startsWith(v.scratch + path.sep) || v.scratch.startsWith(v.root + path.sep)) fail("scratch and inputs must be disjoint"); + if (typeof v.record !== "string" || !path.isAbsolute(v.record) || path.basename(v.record) !== "authoring-promotion.json") fail("absolute authoring-promotion.json required"); + sha(v.workflow_sha, 40); locator(v.preparation); + return v; +} +function admittedInputs(input) { + const o = options(input); + const record = admitRecord(c.readFile(o.record, LIMIT), o.selected); // Before gh, output or attestation. + exact(o.workflow_sha, record.identity.commit, "integrated workflow source"); + cliVersion(o.scratch); + inspectArtifact(o.preparation, WORKFLOW, record.identity.commit, o.scratch); + // Integration must acquire/validate the accepted native terminal artifacts + // here, including actual asserted gates and exact attempt/subject bindings. + // requireNativeContracts remains unconditional until that implementation lands. + const subjects = frozenSubjects(o.root, record); + subjects.push({ file: o.record, sha256: c.digest(encodeRecord(record)) }); + return { o, record, subjects }; +} +function verifyAll(state) { + for (const subject of state.subjects) verifySubject(subject.file, { + name: path.basename(subject.file), sha256: subject.sha256, source: state.record.identity.commit, + workflow_sha: state.o.workflow_sha, ref: `refs/tags/${tag(state.record.identity, "agentplugins")}`, + run_id: state.record.producer.run_id, run_attempt: state.record.producer.run_attempt, + subjects: state.subjects.map(s => ({ name: path.basename(s.file), digest: { sha256: s.sha256 } })) + }, state.o.scratch); +} +function promote(input, reconciliation = false) { + return promotePair(() => { const state = admittedInputs(input); verifyAll(state); return state; }, reconciliation); +} +// Private sequencing seam: the only production caller supplies fresh admission +// AND signature verification. It is not exported or configurable through input. +function promotePair(recheck, reconciliation) { + let state = recheck(); + let observed = inspectPair(state.record, state.o.scratch); + if (observed.reconciliation_required && !reconciliation) fail("PARTIAL_NATIVE_PROMOTION: exact pair reconciliation required; no automatic second publication"); + if (observed.states.every(s => s === "public")) return { status: "qualified-for-promotion", public_readback: observed.states }; + if (reconciliation && !observed.reconciliation_required && !observed.pair.some(r => r?.draft)) fail("explicit reconciliation requires an existing draft or partial pair"); + const normalizeStates = states => states.map(s => s === "incomplete-draft" ? "draft" : s); + const expectedStates = normalizeStates(observed.states); + const ids = observed.pair.map(r => r?.id ?? null); + const readPair = () => { + const next = inspectPair(state.record, state.o.scratch); + next.pair.forEach((r, i) => exact(r?.id ?? null, ids[i], "release identity changed")); + exact(normalizeStates(next.states), expectedStates, "pair changed during draft preparation or publication"); + return next; + }; + // Draft creation is possible only after real admission and all 19 signatures. + for (const p of c.PRODUCTS) { + state = recheck(); + const index = c.PRODUCTS.indexOf(p); + const existing = readPair().pair[index]; + if (existing === null || existing.missing_assets.length) { + if (existing && !reconciliation) fail("incomplete draft requires explicit reconciliation"); + const projection = path.join(state.o.root, p); + const files = releasePins(state.record, p).filter(pin => !existing || existing.missing_assets.includes(pin.name)).map(pin => { + if (pin.name === "authoring-promotion.json") return state.o.record; + if (pin.name === "candidate.json") return path.join(state.o.root, "candidate", pin.name); + if (pin.name === "pair-prepared.json") return path.join(state.o.root, pin.name); + return path.join(projection, pin.name); + }); + for (const product of c.PRODUCTS) checkTag(state.record, product, state.o.scratch); + if (existing) gh(["release", "upload", tag(state.record.identity, p), ...files, "--repo", REPOSITORY], state.o.scratch); + else gh(["release", "create", tag(state.record.identity, p), ...files, "--repo", REPOSITORY, "--verify-tag", "--target", state.record.identity.commit, + "--draft", "--title", tag(state.record.identity, p), "--notes", "Qualified frozen authoring pair; publication is reconciled separately."], state.o.scratch); + const after = inspectRelease(state.record, p, state.o.scratch); + if (!after || !after.draft || after.missing_assets.length) fail("draft preparation incomplete; reconcile exact pair"); + if (existing) exact(after.id, existing.id, "draft identity changed during upload"); + ids[index] = after.id; + expectedStates[index] = "draft"; + } else if (!existing.draft && !reconciliation) fail("release changed during draft preparation; reconcile exact pair"); + } + observed = readPair(); + if (!observed.states.every(s => s === "draft" || s === "public")) fail("complete pair required before publication"); + exact(observed.states, expectedStates, "pair changed during draft preparation"); + for (const p of c.PRODUCTS) { + state = recheck(); + observed = readPair(); + const index = c.PRODUCTS.indexOf(p); + exact(observed.states, expectedStates, "pair changed before publication"); + if (observed.states[index] === "public") continue; + // No retry on uncertain response. The next invocation stops on partial state. + for (const product of c.PRODUCTS) checkTag(state.record, product, state.o.scratch); + try { gh(["release", "edit", tag(state.record.identity, p), "--repo", REPOSITORY, "--draft=false"], state.o.scratch); } + catch { fail(`PARTIAL_NATIVE_PROMOTION: ${p} mutation uncertain; inspect both exact tags/assets before any further action`); } + const after = inspectRelease(state.record, p, state.o.scratch); + if (!after || after.draft) fail("public transition readback required; reconcile exact pair"); + exact(after.id, ids[index], "release identity changed during publication"); + expectedStates[index] = "public"; + } + state = recheck(); observed = readPair(); + exact(observed.states, ["public", "public"], "both public readbacks required"); + return { status: "qualified-for-promotion", public_readback: observed.states }; +} +function main(args) { + if (args.length !== 2 || !["admit", "admit-reconciliation", "promote", "reconcile", "check-contracts"].includes(args[0]) || !path.isAbsolute(args[1])) fail("usage: authoring-promotion.js "); + const value = JSON.parse(c.readFile(args[1], LIMIT)); + if (args[0] === "check-contracts") { c.keys(value, ["lanes"], "terminal contracts"); requireNativeContracts(value.lanes); } + if (args[0] === "promote" || args[0] === "reconcile") return promote(value, args[0] === "reconcile"); + const state = admittedInputs(value); + const observed = inspectPair(state.record, state.o.scratch); + if (observed.reconciliation_required && args[0] !== "admit-reconciliation") fail("PARTIAL_NATIVE_PROMOTION: reconcile before signing"); + if (args[0] === "admit-reconciliation" && !observed.reconciliation_required && !observed.pair.some(r => r?.draft) && !observed.states.every(s => s === "public")) fail("reconciliation requires an existing draft or public pair"); + // Resuming an exact record keeps the original signing invocation. A later run + // reuses/verifies those signatures instead of adding a different invocation. + const signRequired = args[0] !== "admit-reconciliation" && String(state.record.producer.run_id) === process.env.GITHUB_RUN_ID && + String(state.record.producer.run_attempt) === process.env.GITHUB_RUN_ATTEMPT; + if (!signRequired) verifyAll(state); + return { status: observed.states.includes("incomplete-draft") ? "reconciliation-required" : "qualified-for-promotion", missing_assets: observed.pair.map(r => r?.missing_assets ?? []), subjects: state.subjects, sign_required: signRequired }; +} +if (require.main === module) { + try { process.stdout.write(JSON.stringify(main(process.argv.slice(2))) + "\n"); } + catch (error) { process.stderr.write(`authoring promotion: ${error.message}\n`); process.exitCode = 1; } +} +module.exports = { SCHEMA, WORKFLOW, GH_VERSION, LANES, encodeRecord, decodeRecord, validateSelection, admitRecord, requireNativeContracts, + inspectArtifact, acquireArtifact, mapVerifiedOutput, verifySubject, frozenSubjects, releasePins, inspectPair, promote }; diff --git a/npm/agentplugins/test/authoring-promotion.test.js b/npm/agentplugins/test/authoring-promotion.test.js new file mode 100644 index 00000000..73686733 --- /dev/null +++ b/npm/agentplugins/test/authoring-promotion.test.js @@ -0,0 +1,527 @@ +"use strict"; +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const cp = require("node:child_process"); +const nodeTest = require("node:test"); +const test = (name, fn) => nodeTest(name, { skip: process.env.AGENTPLUGINS_STAGED_TEST_CHILD === "1" }, fn); +const c = require("../scripts/dual-authoring-candidate"); +const p = require("../scripts/authoring-promotion"); +const ID = { repository: c.REPOSITORY, commit: "a".repeat(40), engine_revision: "a".repeat(40), + versions: { agentplugins: "0.1.54", "plugin-kit-ai": "2.0.0" } }; +const selected = { tag: "agentplugins-v0.1.54", ref: "refs/tags/agentplugins-v0.1.54", source: ID.commit, versions: ID.versions }; +const hash = text => c.digest(Buffer.from(text)); +const pin = { run_id: 21, run_attempt: 2, artifact_id: 31, artifact_sha256: hash("zip fixture") }; +const url = `https://github.com/${c.REPOSITORY}`; + +// Text/ustar structural fixtures ONLY. No native compiler or subject execution, +// no valid terminal contract and no authentic signature proof is manufactured. +function fixture() { + const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), "promotion-")); + const root = path.join(sandbox, "frozen"), scratch = path.join(sandbox, "provider"); + fs.mkdirSync(root); fs.mkdirSync(scratch); fs.mkdirSync(path.join(root, "candidate")); + const manifest = { schema: c.SCHEMA, status: "CANDIDATE", identity: structuredClone(ID), asset_scope: "six-platform-pair", + build: { method: "controlled-git-archive-go-build/v1", go_version: "go1.25.13", go_sha256: hash("go fixture"), + source_archive_sha256: hash("source fixture"), authoring_mode: "release-cli-contract-v1" }, products: {}, release_eligible: false }; + for (const product of c.PRODUCTS) { + const assets = {}; + for (const target of c.TARGETS) { + const binary = Buffer.from(`NOT EXECUTABLE: ${product}/${target}`); + const name = c.executableName(product, target), file = c.assetName(product, ID.versions[product], target); + const bytes = product === "plugin-kit-ai" ? c.archive(binary, name) : binary; + assets[target] = { file, ...c.metadata(bytes), binary: { file: name, ...c.metadata(binary) } }; + if (!fs.existsSync(path.join(root, product))) fs.mkdirSync(path.join(root, product)); + fs.writeFileSync(path.join(root, product, file), bytes); + } + manifest.products[product] = { version: ID.versions[product], assets }; + } + const candidate = c.encode(manifest); fs.writeFileSync(path.join(root, "candidate/candidate.json"), candidate); + const record = { schema: p.SCHEMA, identity: structuredClone(ID), authoring_mode: "release-cli-contract-v1", asset_scope: "six-platform-pair", + candidate_sha256: c.digest(candidate), pair_marker_sha256: "", products: {}, qualification: { lanes: [] }, + producer: { workflow: p.WORKFLOW, source: ID.commit, run_id: 41, run_attempt: 3 } }; + const marker = { schema: "authoring-release-pair/v1", status: "CANDIDATE", identity: structuredClone(ID), candidate_sha256: record.candidate_sha256, + authoring_mode: record.authoring_mode, asset_scope: record.asset_scope, products: {}, release_eligible: false, platform_acceptance: false, attested: false }; + for (const product of c.PRODUCTS) { + const tag = `${product === "agentplugins" ? "agentplugins-" : ""}v${ID.versions[product]}`; + const assets = manifest.products[product].assets; + const projected = { schema_version: 3, status: "CANDIDATE", product, repository: c.REPOSITORY, tag, + version: ID.versions[product], commit: ID.commit, engine_revision: ID.commit, versions: ID.versions, + candidate_sha256: record.candidate_sha256, authoring_mode: record.authoring_mode, asset_scope: record.asset_scope, + assets, release_eligible: false, platform_acceptance: false, attested: false }; + const bytes = c.encode(projected); + const checks = Buffer.from([...Object.values(assets).map(a => `${a.sha256} ${a.file}`), `${c.digest(bytes)} release-manifest.json`].join("\n") + "\n"); + fs.writeFileSync(path.join(root, product, "release-manifest.json"), bytes); + fs.writeFileSync(path.join(root, product, "checksums.txt"), checks); + marker.products[product] = { manifest_sha256: c.digest(bytes), checksums_sha256: c.digest(checks) }; + record.products[product] = { tag, ...marker.products[product], assets }; + } + fs.writeFileSync(path.join(root, "pair-prepared.json"), c.encode(marker)); record.pair_marker_sha256 = c.digest(c.encode(marker)); + record.qualification.lanes = p.LANES.map((lane, i) => { + const selected = lane === "public-packed-pair" ? c.PRODUCTS.flatMap(x => c.TARGETS.map(t => [x, t])) : [lane.split("/")]; + return { lane, schema: "fixture-terminal/v1", sha256: hash(`TEST ONLY ${i}`), workflow: ".github/workflows/fixture-only.yml", + source: ID.commit, artifact: { ...pin, artifact_id: 100 + i }, subjects: selected.map(([product, target]) => ({ product, target, + sha256: record.products[product].assets[target].sha256, binary_sha256: record.products[product].assets[target].binary.sha256 })) }; + }); + const recordFile = path.join(sandbox, "authoring-promotion.json"); + fs.writeFileSync(recordFile, p.encodeRecord(record)); + return { sandbox, root, scratch, record, recordFile, options: { record: recordFile, root, scratch, workflow_sha: ID.commit, preparation: pin, selected } }; +} +function expected(f) { + return { name: "authoring-promotion.json", sha256: c.digest(fs.readFileSync(f.recordFile)), source: ID.commit, + workflow_sha: "b".repeat(40), ref: "refs/tags/agentplugins-v0.1.54", run_id: 41, run_attempt: 3, + subjects: [{ name: "authoring-promotion.json", digest: { sha256: c.digest(fs.readFileSync(f.recordFile)) } }] }; +} +function verified(e) { + return [{ verificationResult: { statement: { + _type: "https://in-toto.io/Statement/v1", subject: structuredClone(e.subjects), predicateType: "https://slsa.dev/provenance/v1", + predicate: { buildDefinition: { buildType: "https://actions.github.io/buildtypes/workflow/v1", + externalParameters: { workflow: { ref: e.ref, repository: url, path: p.WORKFLOW } }, + resolvedDependencies: [{ uri: `git+${url}@${e.ref}`, digest: { gitCommit: e.source } }] }, + runDetails: { builder: { id: "https://github.com/actions/runner/github-hosted" }, metadata: { invocationId: `${url}/actions/runs/${e.run_id}/attempts/${e.run_attempt}` } } } + } } }]; +} +// Actual subprocesses execute the production orchestration. Only the test +// process redirects the hard-coded executable to a disposable script. Responses +// are deliberately synthetic, never proof that gh verified a real signature. +function provider(t, f, routes, mutation = null) { + const script = path.join(f.sandbox, "provider-fixture.js"), log = path.join(f.sandbox, "calls.jsonl"); + const routeFile = path.join(f.sandbox, "routes.json"); + fs.writeFileSync(routeFile, JSON.stringify(routes)); + fs.writeFileSync(script, `const fs = require('node:fs'); +const args = process.argv.slice(2); +fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify({args,env:process.env})+'\\n'); +const routes=JSON.parse(fs.readFileSync(${JSON.stringify(routeFile)})); +const mutation=${JSON.stringify(mutation)}; +if(args[0]==='release' && mutation) { + const release=Object.values(routes).find(r=>r.body?.tag_name===args[2]).body; + const pins=mutation.assets[args[2]]; + if(args[1]==='create') { routes['graphql:tag='+args[2]].body.data.repository.release={databaseId:release.id}; release.assets=[]; } + if(args[1]==='upload' || args[1]==='create') { + const files=args.slice(3,args.indexOf('--repo')); + for(const file of files.slice(0,mutation.interrupt ?? files.length)) { + const name=require('node:path').basename(file), pin=pins.find(a=>a.name===name); + if(!pin || release.assets.some(a=>a.name===name) || require('node:crypto').createHash('sha256').update(fs.readFileSync(file)).digest('hex')!==pin.digest.slice(7)) throw Error('immutable upload violation'); + release.assets.push(pin); + } + if(mutation.moveTag) routes[${JSON.stringify(endpoint('commits/v2.0.0'))}].body.sha='b'.repeat(40); + if(mutation.replaceID) { release.id+=10000; routes['graphql:tag='+args[2]].body.data.repository.release.databaseId=release.id; routes[${JSON.stringify(endpoint('releases/'))}+release.id]={body:release}; } + } else if(args[1]==='edit') { if(release.assets.length!==11) throw Error('premature public effect'); release.draft=false; } + else throw Error('forbidden fixture mutation'); + fs.writeFileSync(${JSON.stringify(routeFile)},JSON.stringify(routes)); + if(mutation.interrupt!==undefined || mutation.failEdit===args[2] && args[1]==='edit') process.exit(9); + process.exit(0); +} +if(args[0]==='--version'){console.log('gh version ${p.GH_VERSION} (fixture only)');process.exit(0)} +const key=args.includes('graphql')?'graphql:'+args.at(-1):args.includes('verify')?'verify':args.at(-1); +const r=routes[key]; if(!r) {process.stderr.write('fixture unexpected call');process.exit(9)} +if(r.exit) process.exit(r.exit); +if(r.flood) { process.stdout.write('X'.repeat(5*1024*1024)); } +else if(r.binary) process.stdout.write(Buffer.from(r.binary,'base64')); +else process.stdout.write(typeof r.body==='string'?r.body:JSON.stringify(r.body)); +`); + const spawn = cp.spawnSync; + t.mock.method(cp, "spawnSync", function(executable, args, options) { + assert.equal(executable, "/usr/bin/gh"); assert.equal(options.shell, false); assert.equal(options.timeout, 30000); + assert.equal(options.killSignal, "SIGKILL"); assert.equal(options.env.PATH, "/usr/local/bin:/usr/bin:/bin"); + assert.equal(options.env.HOME, f.scratch); assert.equal(options.env.GH_CONFIG_DIR, f.scratch); + assert.equal(options.env.GH_TOKEN, undefined); assert.equal(options.env.NODE_OPTIONS, undefined); + return spawn(process.execPath, [script, ...args], options); + }); + return () => fs.existsSync(log) ? fs.readFileSync(log, "utf8").trim().split("\n").map(JSON.parse) : []; +} +const endpoint = suffix => `repos/${c.REPOSITORY}/${suffix}`; +function artifactRoutes() { + return { + [endpoint("actions/runs/21/attempts/2")]: { body: { id: 21, run_attempt: 2, status: "completed", conclusion: "success", + repository: { full_name: c.REPOSITORY }, head_repository: { full_name: c.REPOSITORY }, head_sha: ID.commit, path: p.WORKFLOW } }, + [endpoint("actions/artifacts/31")]: { body: { id: 31, expired: false, digest: `sha256:${pin.artifact_sha256}`, name: "test-preparation", + workflow_run: { id: 21, head_sha: ID.commit }, size_in_bytes: Buffer.byteLength("zip fixture") } }, + [endpoint("actions/artifacts/31/zip")]: { binary: Buffer.from("zip fixture").toString("base64") } + }; +} +function releaseRoutes(f, states = ["draft", "draft"]) { + const routes = {}; + c.PRODUCTS.forEach((product, i) => { + const tag = f.record.products[product].tag; + routes[endpoint(`commits/${tag}`)] = { body: { sha: ID.commit } }; + routes[`graphql:tag=${tag}`] = { body: { data: { repository: { release: states[i] === "absent" ? null : { databaseId: 200 + i } } } } }; + const assets = p.releasePins(f.record, product).map((a, j) => { + const file = a.name === "authoring-promotion.json" ? f.recordFile : a.name === "candidate.json" ? path.join(f.root, "candidate", a.name) : + a.name === "pair-prepared.json" ? path.join(f.root, a.name) : path.join(f.root, product, a.name); + const bytes = fs.readFileSync(file), id = 1000 + i * 100 + j; + routes[endpoint(`releases/assets/${id}`)] = { binary: bytes.toString("base64") }; + return { id, name: a.name, size: bytes.length, digest: `sha256:${a.sha256}`, state: "uploaded" }; + }); + routes[endpoint(`releases/${200 + i}`)] = { body: { id: 200 + i, tag_name: tag, draft: states[i] !== "public", prerelease: false, assets } }; + }); + return routes; +} + +test("fixed canonical record ignores construction order but accepts no synthetic qualification", () => { + const f = fixture(), encoded = p.encodeRecord(f.record); + const reorder = v => Array.isArray(v) ? v.map(reorder) : v && typeof v === "object" ? Object.fromEntries(Object.entries(v).reverse().map(([k,x]) => [k,reorder(x)])) : v; + assert.deepEqual(p.encodeRecord(reorder(f.record)), encoded); + assert.deepEqual(p.decodeRecord(encoded), f.record); + assert.throws(() => p.admitRecord(encoded, selected), /NATIVE_EVIDENCE_INTEGRATION_REQUIRED.*fixture-terminal/); + assert.equal(p.frozenSubjects(f.root, f.record).length, 18); + for (const product of c.PRODUCTS) { + assert.equal(fs.readdirSync(path.join(f.root, product)).length, 8); + const m = JSON.parse(fs.readFileSync(path.join(f.root, product, "release-manifest.json"))); + assert.equal(m.attested, false); assert.equal(m.platform_acceptance, false); assert.equal(m.release_eligible, false); + } +}); +for (const [label, mutate] of Object.entries({ + "extra field": r => { r.approved = true; }, "missing lane": r => { r.qualification.lanes.pop(); }, + "duplicate report": r => { r.qualification.lanes[1].sha256 = r.qualification.lanes[0].sha256; }, + "duplicate lane": r => { r.qualification.lanes[1] = r.qualification.lanes[0]; }, + "mixed version": r => { r.identity.versions["plugin-kit-ai"] = "1.2.4"; }, + "missing target": r => { delete r.products.agentplugins.assets["darwin-arm64"]; }, + "subject swap": r => { r.qualification.lanes[0].subjects[0].sha256 = hash("swapped"); }, + "inner swap": r => { r.qualification.lanes[0].subjects[0].binary_sha256 = hash("swapped"); }, + "terminal boolean": r => { r.qualification.terminal = true; }, + "run string": r => { r.producer.run_id = "41"; }, "attempt float": r => { r.producer.run_attempt = 1.5; }, + "huge run": r => { r.producer.run_id = Number.MAX_SAFE_INTEGER + 1; }, "huge asset": r => { r.products.agentplugins.assets["linux-amd64"].size = 2 ** 32; }, + "source branch": r => { r.producer.source = "main"; }, "wrong workflow": r => { r.producer.workflow = ".github/workflows/other.yml"; }, + "evidence source": r => { r.qualification.lanes[0].source = "b".repeat(40); }, + "artifact URL": r => { r.qualification.lanes[0].artifact.url = "https://example.invalid"; } +})) test(`encoder rejects ${label}`, () => { const f = fixture(); mutate(f.record); assert.throws(() => p.encodeRecord(f.record)); }); + +test("signed-byte decoder rejects duplicates, alternate bytes and bounds", () => { + const f = fixture(), body = p.encodeRecord(f.record).toString(); + for (const bytes of [Buffer.from(body.replace('"schema":', '"schema": "forged",\n "schema":')), + Buffer.from(body + " "), Buffer.from(body.replace('"run_id": 41', '"run_id": 4.1e1')), Buffer.alloc(1024*1024+1), Buffer.from("{bad"), Buffer.from(body.replace("authoring-promotion/v1", "authoring-promotion/v2"))]) { + assert.throws(() => p.decodeRecord(bytes)); + } +}); + +test("missing and unsupported native inputs reject before any subprocess or writes", t => { + const f = fixture(), calls = provider(t, f, {}); + for (const lanes of [[], [{ lane: p.LANES[0], schema: "dual-authoring-public-native/v1" }], f.record.qualification.lanes]) { + assert.throws(() => p.requireNativeContracts(lanes), /NATIVE_EVIDENCE_INTEGRATION_REQUIRED/); + } + assert.throws(() => p.promote(f.options), /NATIVE_EVIDENCE_INTEGRATION_REQUIRED/); + assert.throws(() => p.promote(f.options, true), /NATIVE_EVIDENCE_INTEGRATION_REQUIRED/); + assert.deepEqual(calls(), []); assert.deepEqual(fs.readdirSync(f.scratch), []); +}); + +test("fresh verifier subprocess binds independently selected signer revision and exact statement", t => { + const f = fixture(), e = expected(f), calls = provider(t, f, { verify: { body: verified(e) } }); + p.verifySubject(f.recordFile, e, f.scratch); + const args = calls()[1].args; + for (const [flag, value] of [["--repo", c.REPOSITORY], ["--signer-digest", e.workflow_sha], ["--source-digest", ID.commit], + ["--source-ref", e.ref], ["--signer-workflow", `github.com/${c.REPOSITORY}/${p.WORKFLOW}`], ["--cert-oidc-issuer", "https://token.actions.githubusercontent.com"]]) { + assert.equal(args[args.indexOf(flag)+1], value); + } + assert(args.includes("--deny-self-hosted-runners")); +}); +for (const [label, mutate] of Object.entries({ + "wrong subject": s => { s.subject[0].digest.sha256 = hash("other"); }, "wrong name": s => { s.subject[0].name = "other"; }, + "predicate": s => { s.predicateType = "test/terminal"; }, "statement": s => { s._type = "wrong"; }, + "workflow": s => { s.predicate.buildDefinition.externalParameters.workflow.path = ".github/workflows/other.yml"; }, + "source": s => { s.predicate.buildDefinition.resolvedDependencies[0].digest.gitCommit = "b".repeat(40); }, + "ref": s => { s.predicate.buildDefinition.externalParameters.workflow.ref = "refs/heads/main"; }, + "invocation": s => { s.predicate.runDetails.metadata.invocationId = `${url}/actions/runs/41/attempts/2`; }, + "runner": s => { s.predicate.runDetails.builder.id = "self-hosted"; } +})) test(`successful fixture verifier with ${label} cannot pass binding`, t => { + const f = fixture(), e = expected(f), response = verified(e); mutate(response[0].verificationResult.statement); + provider(t, f, { verify: { body: response } }); assert.throws(() => p.verifySubject(f.recordFile, e, f.scratch)); +}); +for (const response of [{ exit: 1 }, { body: "not JSON" }, { body: [] }, { flood: true }]) test(`verifier process failure ${JSON.stringify(response)}`, t => { + const f = fixture(); provider(t, f, { verify: response }); assert.throws(() => p.verifySubject(f.recordFile, expected(f), f.scratch)); +}); +test("verifier timeout stops and never publishes", t => { + const f = fixture(); let calls = 0; + t.mock.method(cp, "spawnSync", () => { calls++; return { error: Object.assign(Error("timeout"), { code: "ETIMEDOUT" }), status: null, signal: "SIGKILL" }; }); + assert.throws(() => p.verifySubject(f.recordFile, expected(f), f.scratch), /ETIMEDOUT/); assert.equal(calls, 1); +}); +test("subject mutation rejects before invoking verifier", t => { + const f = fixture(), e = expected(f), calls = provider(t, f, {}); + fs.writeFileSync(f.recordFile, "corruption"); assert.throws(() => p.verifySubject(f.recordFile, e, f.scratch), /changed before/); assert.deepEqual(calls(), []); +}); + +test("exact provider run attempt and artifact ZIP acquired without extraction", t => { + const f = fixture(), calls = provider(t, f, artifactRoutes()); + const file = p.acquireArtifact(pin, p.WORKFLOW, ID.commit, f.scratch); + assert.equal(fs.readFileSync(file, "utf8"), "zip fixture"); assert.equal(calls().length, 6); + assert.throws(() => p.acquireArtifact(pin, p.WORKFLOW, ID.commit, f.scratch), /already exists/); +}); +for (const [label, mutate] of Object.entries({ + "wrong run": r => { r[endpoint("actions/runs/21/attempts/2")].body.id = 22; }, + "stale attempt": r => { r[endpoint("actions/runs/21/attempts/2")].body.run_attempt = 1; }, + "fork": r => { r[endpoint("actions/runs/21/attempts/2")].body.head_repository.full_name = "other/repo"; }, + "workflow": r => { r[endpoint("actions/runs/21/attempts/2")].body.path = ".github/workflows/other.yml"; }, + "source": r => { r[endpoint("actions/runs/21/attempts/2")].body.head_sha = "b".repeat(40); }, + "incomplete": r => { r[endpoint("actions/runs/21/attempts/2")].body.status = "in_progress"; }, + "failure": r => { r[endpoint("actions/runs/21/attempts/2")].body.conclusion = "failure"; }, + "swapped artifact": r => { r[endpoint("actions/artifacts/31")].body.id = 32; }, + "expired": r => { r[endpoint("actions/artifacts/31")].body.expired = true; }, + "digest": r => { r[endpoint("actions/artifacts/31")].body.digest = `sha256:${hash("other")}`; }, + "ZIP bytes": r => { r[endpoint("actions/artifacts/31/zip")].binary = Buffer.from("wrong").toString("base64"); }, + "denial": r => { r[endpoint("actions/runs/21/attempts/2")] = { exit: 1 }; } +})) test(`artifact ${label} rejects without output`, t => { + const f = fixture(), routes = artifactRoutes(); mutate(routes); provider(t, f, routes); + assert.throws(() => p.acquireArtifact(pin, p.WORKFLOW, ID.commit, f.scratch)); assert.deepEqual(fs.readdirSync(f.scratch), []); +}); + +test("modified manifest with recomputed hashes still differs from frozen projection", () => { + const f = fixture(), file = path.join(f.root, "agentplugins/release-manifest.json"); + const m = JSON.parse(fs.readFileSync(file)); m.attested = true; const body = c.encode(m); fs.writeFileSync(file, body); + f.record.products.agentplugins.manifest_sha256 = c.digest(body); + assert.throws(() => p.frozenSubjects(f.root, f.record), /projection bytes/); +}); +for (const states of [["absent", "absent"], ["draft", "draft"], ["public", "draft"], ["public", "public"]]) test(`pair observation ${states.join("/")} is read-only`, t => { + const f = fixture(), calls = provider(t, f, releaseRoutes(f, states)); + const observed = p.inspectPair(f.record, f.scratch); + assert.deepEqual(observed.states, states); assert.equal(observed.reconciliation_required, states[0] === "public" && states[1] === "draft"); + assert(calls().every(x => x.args[0] === "api")); +}); +for (const [label, mutate] of Object.entries({ + "moved tag": r => { r[endpoint("commits/v2.0.0")].body.sha = "b".repeat(40); }, + "prerelease": r => { r[endpoint("releases/201")].body.prerelease = true; }, + "missing second readback": r => { r[endpoint("releases/201")] = { exit: 1 }; }, + "missing public asset": r => { r[endpoint("releases/201")].body.assets.pop(); }, + "duplicate asset": r => { r[endpoint("releases/201")].body.assets[1] = r[endpoint("releases/201")].body.assets[0]; }, + "duplicate asset ID": r => { r[endpoint("releases/201")].body.assets[1].id = 1100; }, + "wrong size type": r => { r[endpoint("releases/201")].body.assets[0].size = "42"; }, + "wrong state": r => { r[endpoint("releases/201")].body.assets[0].state = "starter"; }, + "wrong draft type": r => { r[endpoint("releases/201")].body.draft = "true"; }, + "extra asset": r => { r[endpoint("releases/201")].body.assets.push({ name: "extra" }); }, + "wrong digest": r => { r[endpoint("releases/201")].body.assets[0].digest = `sha256:${hash("other")}`; }, + "changed download": r => { r[endpoint("releases/assets/1100")].binary = Buffer.from("other").toString("base64"); }, + "uncertain not-found": r => { r["graphql:tag=v2.0.0"] = { body: { errors: [{ message: "provider denied" }] } }; } +})) test(`pair ${label} never reports success`, t => { + const f = fixture(), routes = releaseRoutes(f, ["public", "public"]); mutate(routes); provider(t, f, routes); + assert.throws(() => p.inspectPair(f.record, f.scratch)); +}); + +test("CLI rejects unsupported contracts with concrete diagnostics, no local outputs", () => { + const f = fixture(), config = path.join(f.sandbox, "contracts.json"); + fs.writeFileSync(config, JSON.stringify({ lanes: [] })); + const result = cp.spawnSync(process.execPath, [path.resolve(__dirname, "../scripts/authoring-promotion.js"), "check-contracts", config], { + env: { PATH: "/usr/local/bin:/usr/bin:/bin" }, cwd: f.sandbox, encoding: "utf8", timeout: 10000 }); + assert.equal(result.status, 1); assert.match(result.stderr, /NATIVE_EVIDENCE_INTEGRATION_REQUIRED.*agentplugins\/darwin-amd64.*public-packed-pair/); + assert.equal(result.stdout, ""); assert.deepEqual(fs.readdirSync(f.scratch), []); +}); + +test("pinned attest multi-subject statement binds both projection basename collisions", t => { + const f = fixture(), e = expected(f); + e.subjects = [...p.frozenSubjects(f.root, f.record).map(s => ({ name: path.basename(s.file), digest: { sha256: s.sha256 } })), ...e.subjects]; + const response = verified(e); response[0].verificationResult.statement.subject.reverse(); + provider(t, f, { verify: { body: response } }); + assert.equal(e.subjects.length, 19); p.verifySubject(f.recordFile, e, f.scratch); + const corrupt = verified(e); corrupt[0].verificationResult.statement.subject[0].digest.sha256 = hash("another subject"); + assert.throws(() => p.mapVerifiedOutput(JSON.stringify(corrupt), e), /subject set/); + corrupt[0].verificationResult.statement.subject.pop(); + assert.throws(() => p.mapVerifiedOutput(JSON.stringify(corrupt), e), /subject count/); +}); + +// Load unchanged production source into a test-local module and expose ONLY its +// private sequencing seam and exact post-admission main tail. No native adapter is substituted and no production +// caller can inject this recheck. The callback models admission; signatures and +// provider effects still execute their real orchestration against child fixtures. +function sequencing() { + const file = require.resolve("../scripts/authoring-promotion"); + const Module = require("node:module"), m = { exports: {} }; + const body = fs.readFileSync(file, "utf8"); + // Model only the state AFTER admission; never replace admittedInputs or make + // the unconditional native gate positive, even inside this private VM. + const tail = body.slice(body.indexOf(" const observed = inspectPair(state.record, state.o.scratch);", body.indexOf("function main(args)")), + body.indexOf("\nif (require.main === module)")); + require("node:vm").runInThisContext(Module.wrap(body.replace(/^#!.*\n/, "") + + "\nmodule.exports = { promotePair, verifyAll, admissionTail: (state,args) => {\n" + tail + "};"), { filename: file })(m.exports, Module.createRequire(file), m, file, path.dirname(file)); + assert.equal(p.promotePair, undefined); + return m.exports; +} +for (const count of [0, 10, 11]) test(`exact ${count}/11 draft sequencing and full control`, t => { + const f = fixture(), routes = releaseRoutes(f), assets = Object.fromEntries(c.PRODUCTS.map((product,i) => + [f.record.products[product].tag, routes[endpoint(`releases/${200+i}`)].body.assets])); + routes[endpoint("releases/200")].body.assets = assets[selected.tag].slice(0,count); + const e = expected(f), subjects = [...p.frozenSubjects(f.root,f.record), { file: f.recordFile, sha256: e.sha256 }]; + e.workflow_sha = ID.commit; e.subjects = subjects.map(s => ({ name: path.basename(s.file), digest: { sha256: s.sha256 } })); + routes.verify = { body: verified(e) }; + const calls = provider(t,f,routes,{assets}), seq = sequencing(); let checks = 0; + const recheck = () => { checks++; fs.appendFileSync(path.join(f.sandbox,"calls.jsonl"), JSON.stringify({admission:checks})+"\n"); + const record = p.validateSelection(fs.readFileSync(f.recordFile),selected), state = { o:f.options,record,subjects }; + p.frozenSubjects(f.root,record); seq.verifyAll(state); return state; }; + const before = p.inspectPair(f.record,f.scratch); + assert.equal(before.states[0],count===11 ? "draft" : "incomplete-draft"); + assert.equal(before.reconciliation_required,count!==11); + assert.equal(before.pair[0].missing_assets.length,11-count); assert.equal(before.status,undefined); + if(count!==11) { assert.throws(()=>seq.promotePair(recheck,false),/reconciliation required/); assert(!calls().some(c=>c.args?.[0]==="release")); } + assert.deepEqual(seq.promotePair(recheck,count!==11).public_readback,["public","public"]); + const effects = calls().filter(c=>c.args?.[0]==="release").map(c=>c.args); + assert.deepEqual(effects.map(a=>a[1]),count===11 ? ["edit","edit"] : ["upload","edit","edit"]); + if(count!==11) assert.deepEqual(effects[0].slice(3,effects[0].indexOf("--repo")).map(x=>path.basename(x)),assets[selected.tag].slice(count).map(a=>a.name)); + let segment=[]; + for(const call of calls()) { + if(call.admission) segment=[]; + else if(call.args[0]==="release") { + assert.equal(segment.filter(a=>a.includes("verify")).length,19); + for(const product of c.PRODUCTS) assert(segment.some(a=>a.at(-1)===endpoint(`commits/${f.record.products[product].tag}`))); + for(const id of [200,201]) assert(segment.some(a=>a.at(-1)===endpoint(`releases/${id}`))); + } else segment.push(call.args); + } + assert(checks>=6); +}); +for (const mode of ["create0", "create10", "upload", "edit", "moveTag", "replaceID", "admission", "signature"]) test(`partial ${mode} failure preserves immutable state and stops`, t => { + const f=fixture(), routes=releaseRoutes(f,mode.startsWith("create") ? ["absent","draft"] : ["draft","draft"]); + const assets=Object.fromEntries(c.PRODUCTS.map((product,i)=>[f.record.products[product].tag,routes[endpoint(`releases/${200+i}`)].body.assets])); + if(!mode.startsWith("create")) routes[endpoint("releases/200")].body.assets=assets[selected.tag].slice(0,10); + const mutation={assets,...(mode.startsWith("create") ? {interrupt:Number(mode.slice(6))} : mode==="upload" ? {interrupt:1} : mode==="edit" ? {failEdit:selected.tag} : {[mode]:true})}; + const calls=provider(t,f,routes,mutation), seq=sequencing(); let checks=0; + const original=fs.readFileSync(f.recordFile); + assert.throws(()=>seq.promotePair(()=>{ + checks++; if(mode==="admission" && checks===2) p.admitRecord(original,selected); + const state={o:f.options,record:p.validateSelection(original,selected),subjects:[{file:f.recordFile,sha256:c.digest(original)}]}; + if(mode==="signature" && checks===2) seq.verifyAll(state); + return state; + },!mode.startsWith("create"))); + const effects=calls().filter(c=>c.args?.[0]==="release").map(c=>c.args[1]); + assert.deepEqual(effects,["admission","signature"].includes(mode) ? [] : mode==="edit" ? ["upload","edit"] : [mode.startsWith("create") ? "create" : "upload"]); + const after=JSON.parse(fs.readFileSync(path.join(f.sandbox,"routes.json"))); + assert.deepEqual(after[endpoint("releases/201")],routes[endpoint("releases/201")]); + const present=after[endpoint("releases/200")].body.assets; + assert.deepEqual(present,assets[selected.tag].slice(0,mode.startsWith("create") ? Number(mode.slice(6)) : ["admission","signature"].includes(mode) ? 10 : 11)); + assert.deepEqual(fs.readFileSync(f.recordFile),original); p.frozenSubjects(f.root,f.record); + if(["create0","create10","upload","edit"].includes(mode)) { + // A NEW explicit invocation reads the preserved provider state. No retry + // occurs inside the failed invocation, even if all upload bytes arrived. + const offset=calls().length, e=expected(f), subjects=[...p.frozenSubjects(f.root,f.record),{file:f.recordFile,sha256:e.sha256}]; + e.workflow_sha=ID.commit; e.subjects=subjects.map(s=>({name:path.basename(s.file),digest:{sha256:s.sha256}})); + after.verify={body:verified(e)}; t.mock.restoreAll(); const resumed=provider(t,f,after,{assets}); + assert.deepEqual(seq.promotePair(()=>{ + const state={o:f.options,record:p.validateSelection(fs.readFileSync(f.recordFile),selected),subjects}; + p.frozenSubjects(f.root,state.record); seq.verifyAll(state); return state; + },true).public_readback,["public","public"]); + const next=resumed().slice(offset).filter(c=>c.args?.[0]==="release").map(c=>c.args[1]); + assert.deepEqual(next,mode.startsWith("create") ? ["upload","edit","edit"] : mode==="upload" ? ["edit","edit"] : ["edit"]); + } +}); + +test("actual preflight and record shells bind dispatch before native acquisition, including resume", t => { + const f=fixture(), yaml=fs.readFileSync(path.resolve(__dirname,"../../../.github/workflows/agentplugins-release.yml"),"utf8"); + const script=name=>{ const step=yaml.slice(yaml.indexOf(` - name: ${name}\n`)); + return step.match(/ run: \|\n((?: .*\n|\n)+)/)[1].split("\n").map(l=>l.slice(10)).join("\n"); }; + const env={PATH:path.dirname(process.execPath)+":/usr/local/bin:/usr/bin:/bin",SOURCE_SHA:ID.commit,WORKFLOW_SHA:ID.commit, + TAG:selected.tag,WORKFLOW_REF:selected.ref,KIT_VERSION:"2.0.0",GITHUB_REPOSITORY:c.REPOSITORY,PROMOTION_RECORD:fs.readFileSync(f.recordFile,"utf8")}; + const trap=path.join(f.sandbox,"effect-trap.js"), effects=path.join(f.sandbox,"shell-effects"); + fs.writeFileSync(trap, `require('node:child_process').spawnSync=()=>{require('node:fs').appendFileSync(${JSON.stringify(effects)},'effect');throw Error('unexpected process effect')}`); + env.NODE_OPTIONS=`--require=${trap}`; env.RUNNER_TEMP=f.scratch; + const cwd=path.resolve(__dirname,"../../.."); + for(const changed of [false,true]) { + const values={...env,...(changed ? {TAG:"agentplugins-v0.1.55",WORKFLOW_REF:"refs/tags/agentplugins-v0.1.55"} : {})}; + const run=name=>cp.spawnSync("/bin/bash",["-e","-o","pipefail","-s"],{cwd,env:values,input:script(name),encoding:"utf8",timeout:10000}); + assert.equal(run("Validate promotion identity before checkout").status,0); + for(const name of ["Reject missing native terminal contracts before protected effects","Acquire exact frozen preparation after native admission"]) { + const result=run(name); assert.equal(result.status,1); assert.equal(result.stdout,""); + assert.match(result.stderr,changed ? /selected promotion identity/ : /NATIVE_EVIDENCE_INTEGRATION_REQUIRED/); + if(changed) assert.doesNotMatch(result.stderr,/NATIVE_EVIDENCE_INTEGRATION_REQUIRED/); + } + } + for(const operation of ["admit","admit-reconciliation","promote","reconcile"]) for(const run of ["41","99"]) { + const config=path.join(f.sandbox,"resume.json"); + fs.writeFileSync(config,JSON.stringify({...f.options,selected:{...selected,tag:"agentplugins-v0.1.55",ref:"refs/tags/agentplugins-v0.1.55",versions:{...ID.versions,agentplugins:"0.1.55"}}})); + const result=cp.spawnSync(process.execPath,[require.resolve("../scripts/authoring-promotion"),operation,config], + {cwd,env:{...env,GITHUB_RUN_ID:run,GITHUB_RUN_ATTEMPT:"3"},encoding:"utf8",timeout:10000}); + assert.equal(result.status,1); assert.equal(result.stdout,""); assert.match(result.stderr,/selected promotion identity/); + } + assert.equal(fs.existsSync(effects),false); + const calls=provider(t,f,{}); + for(const mutation of [{tag:"agentplugins-v0.1.55",ref:"refs/tags/agentplugins-v0.1.55",versions:{...ID.versions,agentplugins:"0.1.55"}}, {ref:"refs/heads/main"}, {source:"b".repeat(40)}, {versions:{...ID.versions,"plugin-kit-ai":"2.0.1"}}]) { + const options={...f.options,selected:{...selected,...mutation}}; + for(const resume of [false,true]) assert.throws(()=>p.promote(options,resume),/selected promotion identity/); + } + assert.deepEqual(calls(),[]); assert.deepEqual(fs.readdirSync(f.scratch),[]); +}); + +for(const defect of ["digest","duplicate","extra","bytes"]) test(`incomplete draft still rejects present ${defect}`,t=>{ + const f=fixture(), routes=releaseRoutes(f), assets=routes[endpoint("releases/200")].body.assets; + assets.pop(); + if(defect==="digest") assets[0].digest=`sha256:${hash("bad")}`; + if(defect==="duplicate") assets[1]=assets[0]; + if(defect==="extra") assets.push({name:"unexpected"}); + if(defect==="bytes") routes[endpoint("releases/assets/1000")].binary=Buffer.from("bad").toString("base64"); + const calls=provider(t,f,routes); assert.throws(()=>p.inspectPair(f.record,f.scratch)); + assert(calls().every(c=>c.args[0]==="api")); +}); + +// Shared offline state for route + sequence tests; all nineteen signatures use +// the real verifier orchestration, with synthetic child-provider responses. +function reconciliationFixture(t, states = ["draft", "draft"], mutation = {}) { + const f = fixture(), routes = releaseRoutes(f, states), seq = sequencing(); + const assets = Object.fromEntries(c.PRODUCTS.map((product,i) => [f.record.products[product].tag, structuredClone(routes[endpoint(`releases/${200+i}`)].body.assets)])); + const e = expected(f), subjects = [...p.frozenSubjects(f.root,f.record), {file:f.recordFile, sha256:e.sha256}]; + e.workflow_sha = ID.commit; e.subjects = subjects.map(s => ({name:path.basename(s.file),digest:{sha256:s.sha256}})); + routes.verify = {body:verified(e)}; + const calls = provider(t,f,routes,{assets,...mutation}); + const change = fn => { const file=path.join(f.sandbox,"routes.json"), next=JSON.parse(fs.readFileSync(file)); fn(next); fs.writeFileSync(file,JSON.stringify(next)); }; + const state = () => ({o:f.options, record:p.validateSelection(fs.readFileSync(f.recordFile),selected), subjects}); + const recheck = () => { const s=state(); p.frozenSubjects(f.root,s.record); seq.verifyAll(s); return s; }; + const writes = () => calls().filter(c => c.args[0] === "release").map(c => c.args); + return {f,seq,calls,change,state,recheck,writes}; +} +for (const failure of ["second-edit", "final-readback"]) test(`completed public pair reconciliation route after ${failure} uncertainty`, t => { + const b=reconciliationFixture(t,undefined,failure === "second-edit" ? {failEdit:"v2.0.0"} : {}); + assert.throws(() => b.seq.promotePair(() => { + if (failure === "final-readback" && b.writes().filter(a => a[1] === "edit").length === 2) throw Error("interrupted final readback"); + return b.recheck(); + },false),failure === "second-edit" ? /plugin-kit-ai mutation uncertain/ : /interrupted final readback/); + assert.deepEqual(b.writes().map(a => a[1]),["edit","edit"]); + const offset=b.calls().length; + const admission=b.seq.admissionTail(b.state(),["admit-reconciliation"]); + assert.equal(admission.sign_required,false); assert.equal(admission.status,"qualified-for-promotion"); + assert.deepEqual(admission.missing_assets,[[],[]]); + assert.equal(b.calls().slice(offset).filter(c => c.args.includes("verify")).length,19); + assert.deepEqual(b.seq.promotePair(b.recheck,true).public_readback,["public","public"]); + assert.equal(b.calls().slice(offset).filter(c => c.args.includes("verify")).length,38); + assert.equal(b.calls().slice(offset).filter(c => c.args[0] === "release").length,0); +}); +for (const invalid of ["absent", "incomplete-public", "signature"]) test(`reconciliation admission rejects ${invalid} without mutation`, t => { + const b=reconciliationFixture(t,invalid === "absent" ? ["absent","absent"] : ["public","public"]); + if (invalid === "incomplete-public") b.change(r => r[endpoint("releases/201")].body.assets.pop()); + if (invalid === "signature") b.change(r => r.verify={exit:9}); + assert.throws(() => b.seq.admissionTail(b.state(),["admit-reconciliation"]), + invalid === "absent" ? /reconciliation requires/ : invalid === "signature" ? /trusted gh failed/ : /incomplete public release/); + assert.deepEqual(b.writes(),[]); +}); +for (const write of ["create", "upload"]) for (const when of ["first", "subsequent"]) for (const transition of ["public", "draft", "absent", "appeared"]) { + test(`peer ${transition} before ${when} ${write} stops all subsequent mutations`, t => { + const target=when === "first" ? 0 : 1, peer=1-target; + const states=["draft","draft"]; states[target]=write === "create" ? "absent" : "draft"; + if (when === "first") states[peer]=transition === "draft" ? "public" : transition === "appeared" ? "absent" : "draft"; + // On subsequent writes the peer is our own verified create or upload. + // A public->draft case starts with a public peer (no earlier write needed). + if (when === "subsequent") states[peer]=transition === "draft" ? "public" : write === "create" ? "absent" : "draft"; + const b=reconciliationFixture(t,states); let checks=0, before; + if (write === "upload") b.change(r => { + r[endpoint(`releases/${200+target}`)].body.assets.pop(); + if (when === "subsequent" && transition !== "draft") r[endpoint(`releases/${200+peer}`)].body.assets.pop(); + }); + assert.throws(() => b.seq.promotePair(() => { + if (++checks === (when === "first" ? 2 : 3)) { + before=b.writes().length; + b.change(r => { + const lookup=r[`graphql:tag=${b.f.record.products[c.PRODUCTS[peer]].tag}`].body.data.repository; + if (transition === "absent") lookup.release=null; + else if (transition === "appeared") { + lookup.release={databaseId:300+peer}; r[endpoint(`releases/${300+peer}`)]={body:{...r[endpoint(`releases/${200+peer}`)].body,id:300+peer}}; + } else r[endpoint(`releases/${200+peer}`)].body.draft=transition === "draft"; + }); + } + return b.recheck(); + },!states.every(s => s === "absent")),/pair changed|release identity changed/); + assert.equal(b.writes().length,before); + assert.deepEqual(b.writes().map(a => a[1]),when === "subsequent" && transition !== "draft" ? [write] : []); + }); +} + +test("verified own creates advance expected presence without an extra upload", t => { + const b=reconciliationFixture(t,["absent","absent"]); + assert.deepEqual(b.seq.promotePair(b.recheck,false).public_readback,["public","public"]); + assert.deepEqual(b.writes().map(a => a[1]),["create","create","edit","edit"]); + const before=b.writes().length; + assert.equal(b.seq.admissionTail(b.state(),["admit-reconciliation"]).sign_required,false); + assert.deepEqual(b.seq.promotePair(b.recheck,true).public_readback,["public","public"]); + assert.equal(b.writes().length,before); +});