diff --git a/.github/workflows/agent-task-contracts.yml b/.github/workflows/agent-task-contracts.yml index 53712dd40..d62dd65e4 100644 --- a/.github/workflows/agent-task-contracts.yml +++ b/.github/workflows/agent-task-contracts.yml @@ -9,6 +9,7 @@ on: - "contracts/agent-task-workflow-request.fixture.json" - "contracts/run-agent-task-reusable-workflow-interface.v1.json" - "docs/agent-task-reusable-workflow.md" + - "fixtures/agent-task-reusable-workflow-consumer*.yml" - "package-lock.json" - "package.json" - "tests/agent-task-*.test.ts" @@ -23,6 +24,7 @@ on: - "contracts/agent-task-workflow-request.fixture.json" - "contracts/run-agent-task-reusable-workflow-interface.v1.json" - "docs/agent-task-reusable-workflow.md" + - "fixtures/agent-task-reusable-workflow-consumer*.yml" - "package-lock.json" - "package.json" - "tests/agent-task-*.test.ts" diff --git a/.github/workflows/run-agent-task.yml b/.github/workflows/run-agent-task.yml index 4a21e70cf..fa7f03598 100644 --- a/.github/workflows/run-agent-task.yml +++ b/.github/workflows/run-agent-task.yml @@ -3,6 +3,10 @@ name: Run Agent Task (reusable) 'on': workflow_call: inputs: + wp_codebox_release_ref: + description: Exact immutable WP Codebox release tag used for this workflow invocation, in vX.Y.Z form. + type: string + required: true external_package_source: description: "JSON descriptor for exactly one standalone .agent.json file: repository, immutable revision, file path, and sha256-bytes-v1 digest." type: string @@ -146,8 +150,7 @@ jobs: run-agent-task: runs-on: ubuntu-latest env: - # Advance this only in a subsequent workflow commit after helper changes land. - WP_CODEBOX_HELPER_REVISION: d9e329e7c3bb852b857870a26d8f3853feef751d + WP_CODEBOX_RELEASE_REF: ${{ inputs.wp_codebox_release_ref }} permissions: contents: write pull-requests: write @@ -160,10 +163,17 @@ jobs: credential_mode: ${{ steps.execute.outputs.credential_mode }} declared_artifacts_json: ${{ steps.execute.outputs.declared_artifacts_json }} steps: - - name: Validate pinned WP Codebox helper revision + - name: Validate coherent WP Codebox release tag + env: + WORKFLOW_REF: ${{ github.workflow_ref }} run: | - if ! [[ "${WP_CODEBOX_HELPER_REVISION}" =~ ^[0-9a-f]{40}$ ]]; then - echo "WP_CODEBOX_HELPER_REVISION must be a full lowercase Git commit SHA" >&2 + if ! [[ "${WP_CODEBOX_RELEASE_REF}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "wp_codebox_release_ref must be an immutable vX.Y.Z release tag" >&2 + exit 1 + fi + expected_workflow_ref="Automattic/wp-codebox/.github/workflows/run-agent-task.yml@${WP_CODEBOX_RELEASE_REF}" + if [[ "${WORKFLOW_REF}" != "${expected_workflow_ref}" ]]; then + echo "The reusable workflow tag and wp_codebox_release_ref must match exactly" >&2 exit 1 fi @@ -171,11 +181,30 @@ jobs: uses: actions/checkout@v4 with: repository: Automattic/wp-codebox - # This checkout fetches the exact immutable helper source and verifies - # its reachability independently of the caller's workflow revision. - ref: ${{ env.WP_CODEBOX_HELPER_REVISION }} + ref: ${{ inputs.wp_codebox_release_ref }} path: .wp-codebox-workflow + - name: Verify WP Codebox workflow helper release + working-directory: .wp-codebox-workflow + run: | + git ls-remote --exit-code --refs origin "refs/tags/${WP_CODEBOX_RELEASE_REF}" >/dev/null + remote_tag_object="$(git ls-remote --refs origin "refs/tags/${WP_CODEBOX_RELEASE_REF}" | cut -f1)" + remote_tag_commit="$(git ls-remote origin "refs/tags/${WP_CODEBOX_RELEASE_REF}^{}" | cut -f1)" + remote_tag_commit="${remote_tag_commit:-${remote_tag_object}}" + checked_out_commit="$(git rev-parse HEAD)" + if [[ "${checked_out_commit}" != "${remote_tag_commit}" ]]; then + echo "Checked-out WP Codebox helpers do not match the remote release tag commit" >&2 + exit 1 + fi + EXPECTED_VERSION="${WP_CODEBOX_RELEASE_REF#v}" node --input-type=module -e ' + import { readFileSync } from "node:fs"; + const expected = process.env.EXPECTED_VERSION; + const actual = JSON.parse(readFileSync("package.json", "utf8")).version; + if (actual !== expected) { + throw new Error("WP Codebox helper package version " + actual + " does not match release tag v" + expected); + } + ' + - name: Set up Node.js uses: actions/setup-node@v4 with: diff --git a/contracts/run-agent-task-reusable-workflow-interface.v1.json b/contracts/run-agent-task-reusable-workflow-interface.v1.json index 76df95c43..238d295c9 100644 --- a/contracts/run-agent-task-reusable-workflow-interface.v1.json +++ b/contracts/run-agent-task-reusable-workflow-interface.v1.json @@ -2,6 +2,7 @@ "schema": "wp-codebox/reusable-workflow-interface/v1", "workflow": ".github/workflows/run-agent-task.yml", "inputs": { + "wp_codebox_release_ref": { "required": true, "type": "string" }, "external_package_source": { "required": true, "type": "string" }, "workload_id": { "required": false, "type": "string", "default": "agent-task" }, "workload_label": { "required": false, "type": "string", "default": "Run Agent Task" }, diff --git a/docs/agent-task-reusable-workflow.md b/docs/agent-task-reusable-workflow.md index 65d259f1c..086bd16c8 100644 --- a/docs/agent-task-reusable-workflow.md +++ b/docs/agent-task-reusable-workflow.md @@ -5,8 +5,9 @@ WP Codebox publishes a reusable GitHub Actions workflow for generic agent tasks: ```yaml jobs: run-agent-task: - uses: Automattic/wp-codebox/.github/workflows/run-agent-task.yml@main + uses: Automattic/wp-codebox/.github/workflows/run-agent-task.yml@v0.12.3 with: + wp_codebox_release_ref: v0.12.3 external_package_source: '{"repository":"OWNER/agent-packages","revision":"0123456789abcdef0123456789abcdef01234567","path":"agents/example.agent.json","digest":"sha256-bytes-v1:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}' target_repo: Automattic/example-target prompt: Refresh the configured surface from source evidence. @@ -29,24 +30,27 @@ public native package, invokes the package-declared agent through the native cha in a credential-free verification environment, and returns actual runtime and publication data. -## Helper Source Revision +## Helper Release Tag -The workflow checks out its helper scripts from one immutable WP Codebox commit -configured as `WP_CODEBOX_HELPER_REVISION`. This source revision is independent -of the caller repository, caller commit, and future revisions of -`run-agent-task.yml`; no GitHub workflow SHA context selects helper source. +Callers must invoke the reusable workflow from an exact WP Codebox release tag +and pass that same tag as `wp_codebox_release_ref`. For example, callers use +`Automattic/wp-codebox/.github/workflows/run-agent-task.yml@v0.12.3` together +with `wp_codebox_release_ref: v0.12.3`. The accepted format is exactly +`vX.Y.Z`; branches, commit SHAs, moving major tags, prereleases, and arbitrary +refs are rejected. -Helper changes land first. A subsequent commit that updates this reusable -workflow advances the helper revision to the full SHA containing those merged -files. The workflow validates the full-SHA shape and `actions/checkout` fetches -that exact source before running helpers. The regression test reads the required -helper files at the pinned revision and checks their SHA-256 digests. +The workflow validates that its own `uses:` reference and +`wp_codebox_release_ref` match exactly before checkout. It always checks helpers +out from `Automattic/wp-codebox`, verifies the checked-out commit equals the +remote release tag commit, and verifies the checked-out `package.json` version +equals the requested tag without its `v` prefix. The caller cannot select a +different helper repository. -This pin fixes [#1755](https://github.com/Automattic/wp-codebox/issues/1755) and the failed Build callers [29281470179](https://github.com/Automattic/build-with-wordpress/actions/runs/29281470179) -and [29281470159](https://github.com/Automattic/build-with-wordpress/actions/runs/29281470159), where a foreign caller SHA was incorrectly used as a WP Codebox checkout ref. +This release-coherence contract fixes [#1759](https://github.com/Automattic/wp-codebox/issues/1759). ## Inputs +- `wp_codebox_release_ref`: required exact immutable WP Codebox release tag. It must match the `@vX.Y.Z` tag in the caller's `uses:` declaration exactly. - `external_package_source`: immutable descriptor with `repository`, full commit `revision`, one package-relative `.agent.json` `path`, and `digest`. Packages are supported only from publicly accessible GitHub repositories, fetched from canonical `https://github.com/OWNER/REPOSITORY.git` without credentials. `digest` is exactly `sha256-bytes-v1:` over the raw file bytes; filenames and JSON content are UTF-8-safe and are not normalized before hashing. - `EXTERNAL_PACKAGE_SOURCE_POLICY`: required reusable-workflow secret, supplied by the caller's operator-controlled secret configuration. Its strict version 1 JSON shape is `{"version":1,"repositories":{"owner/repository":["agents/example.agent.json"]}}`. Every entry is an exact standalone `.agent.json` path. The policy is validated in runner memory, is never part of task input, and is not uploaded. - `target_repo`: `OWNER/REPO` target repository. @@ -167,6 +171,10 @@ must migrate checks to executable `validation_dependencies`, `verification_commands`, or `drift_checks`; context and artifact preparation remain caller-workflow responsibilities. This is an intentional exposed-workflow breaking change. +`wp_codebox_release_ref` is a required v1 input. Existing callers must switch +their `uses:` reference from a branch or other ref to an exact release tag and +pass that identical tag through this input. + ## Upload safety limits The reusable workflow stages every uploaded file through a fail-closed policy. Only regular UTF-8 files of 4 MiB or less are uploaded, after configured secret values are redacted. Symlinks, special files, binary files, and files larger than 4 MiB are excluded from the upload staging directory. This applies to task artifacts and workflow request, input, and result files. diff --git a/fixtures/agent-task-reusable-workflow-consumer-mismatched.yml b/fixtures/agent-task-reusable-workflow-consumer-mismatched.yml new file mode 100644 index 000000000..a1566d0df --- /dev/null +++ b/fixtures/agent-task-reusable-workflow-consumer-mismatched.yml @@ -0,0 +1,12 @@ +name: Mismatched WP Codebox release consumer + +on: workflow_dispatch + +jobs: + run-agent-task: + uses: Automattic/wp-codebox/.github/workflows/run-agent-task.yml@v0.12.3 + with: + wp_codebox_release_ref: v0.12.4 + external_package_source: '{"repository":"Automattic/example-agent-packages","revision":"0123456789abcdef0123456789abcdef01234567","path":"agents/example.agent.json","digest":"sha256-bytes-v1:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}' + target_repo: Automattic/example-target + prompt: Refresh the configured surface. diff --git a/fixtures/agent-task-reusable-workflow-consumer.yml b/fixtures/agent-task-reusable-workflow-consumer.yml new file mode 100644 index 000000000..a6fc55a30 --- /dev/null +++ b/fixtures/agent-task-reusable-workflow-consumer.yml @@ -0,0 +1,12 @@ +name: Coherent WP Codebox release consumer + +on: workflow_dispatch + +jobs: + run-agent-task: + uses: Automattic/wp-codebox/.github/workflows/run-agent-task.yml@v0.12.3 + with: + wp_codebox_release_ref: v0.12.3 + external_package_source: '{"repository":"Automattic/example-agent-packages","revision":"0123456789abcdef0123456789abcdef01234567","path":"agents/example.agent.json","digest":"sha256-bytes-v1:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}' + target_repo: Automattic/example-target + prompt: Refresh the configured surface. diff --git a/tests/agent-task-reusable-workflow.test.ts b/tests/agent-task-reusable-workflow.test.ts index b873373f6..187f10151 100644 --- a/tests/agent-task-reusable-workflow.test.ts +++ b/tests/agent-task-reusable-workflow.test.ts @@ -1,6 +1,5 @@ import assert from "node:assert/strict" import { execFile } from "node:child_process" -import { createHash } from "node:crypto" import { mkdir, mkdtemp, readFile, readdir, symlink, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" @@ -14,6 +13,7 @@ const publicWorkflowSurface = workflow.slice(0, workflow.indexOf("jobs:")) assert.match(workflow, /^name: Run Agent Task \(reusable\)$/m) assert.match(workflow, /workflow_call:/) +assert.match(workflow, /wp_codebox_release_ref:/) assert.match(workflow, /external_package_source:/) assert.match(workflow, /EXTERNAL_PACKAGE_SOURCE_POLICY:/) assert.doesNotMatch(publicWorkflowSurface, /external_package_allowed_repositories:|external_package_allowed_paths:/) @@ -40,25 +40,34 @@ assert.match(workflow, /agent-task-artifacts/) assert.match(workflow, /prepare-agent-task-upload\.mjs/) assert.match(workflow, /agent-task-upload/) assert.match(workflow, /if: always\(\)/) -const helperRevision = workflow.match(/WP_CODEBOX_HELPER_REVISION:\s*([0-9a-f]{40})/)?.[1] -assert.ok(helperRevision, "The workflow must declare one full immutable WP Codebox helper revision") -assert.match(workflow, /ref: \$\{\{ env\.WP_CODEBOX_HELPER_REVISION \}\}/) -assert.doesNotMatch(workflow, /github\.(?:workflow_sha|workflow_ref)/) +assert.match(workflow, /WP_CODEBOX_RELEASE_REF: \$\{\{ inputs\.wp_codebox_release_ref \}\}/) +assert.match(workflow, /\^v\[0-9\]\+\\\.\[0-9\]\+\\\.\[0-9\]\+\$/) +assert.match(workflow, /WORKFLOW_REF: \$\{\{ github\.workflow_ref \}\}/) +assert.match(workflow, /The reusable workflow tag and wp_codebox_release_ref must match exactly/) +assert.match(workflow, /repository: Automattic\/wp-codebox/) +assert.match(workflow, /ref: \$\{\{ inputs\.wp_codebox_release_ref \}\}/) +assert.match(workflow, /Verify WP Codebox workflow helper release/) +assert.match(workflow, /git ls-remote --exit-code --refs origin "refs\/tags\/\$\{WP_CODEBOX_RELEASE_REF\}"/) +assert.match(workflow, /checked_out_commit.*remote_tag_commit/) +assert.match(workflow, /JSON\.parse\(readFileSync\("package\.json", "utf8"\)\)\.version/) assert.doesNotMatch(workflow, /steps\.[^.]+\.outputs\.ref/) -const foreignCallerSha = "ba2df8c2215407b1a58edbff29e3ddcb5efa2249" -assert.notEqual(helperRevision, foreignCallerSha, "A caller repository SHA must not select WP Codebox helpers") -assert.doesNotMatch(workflow, new RegExp(foreignCallerSha)) -assert.match(workflow, /Validate pinned WP Codebox helper revision/) +assert.match(workflow, /Validate coherent WP Codebox release tag/) -const requiredHelperFiles = { - ".github/scripts/run-agent-task/build-codebox-task-request.mjs": "ed1fbc144428bfbf222810a8a467390e8cd5b45daa0f3e64e03162949d6c84df", - ".github/scripts/run-agent-task/execute-native-agent-task.mjs": "324c6d12dd01880bc9e4cd880ea6c713cb1bfd630b4925c53d78af7283e4a459", - ".github/scripts/run-agent-task/prepare-agent-task-upload.mjs": "93511e4174e705f55ff014ba68ac52fdaca771ce3f19f3921cceb661e69569da", +const parseConsumerReleaseTags = (consumer: string) => ({ + workflow: consumer.match(/uses: Automattic\/wp-codebox\/\.github\/workflows\/run-agent-task\.yml@([^\s]+)/)?.[1], + helpers: consumer.match(/wp_codebox_release_ref: ([^\s]+)/)?.[1], +}) +const isExactReleaseTag = (value: string | undefined) => /^v\d+\.\d+\.\d+$/.test(value ?? "") +const isCoherentConsumer = (consumer: string) => { + const { workflow: workflowTag, helpers: helperTag } = parseConsumerReleaseTags(consumer) + return isExactReleaseTag(workflowTag) && workflowTag === helperTag } -for (const [path, digest] of Object.entries(requiredHelperFiles)) { - const { stdout } = await execFileAsync("git", ["show", `${helperRevision}:${path}`], { encoding: "buffer" }) - const actualDigest = createHash("sha256").update(stdout).digest("hex") - assert.equal(actualDigest, digest, `Pinned helper source changed unexpectedly: ${path}`) +const coherentConsumer = await readFile(new URL("../fixtures/agent-task-reusable-workflow-consumer.yml", import.meta.url), "utf8") +const mismatchedConsumer = await readFile(new URL("../fixtures/agent-task-reusable-workflow-consumer-mismatched.yml", import.meta.url), "utf8") +assert.equal(isCoherentConsumer(coherentConsumer), true, "An exact matching workflow and helper release tag must succeed") +assert.equal(isCoherentConsumer(mismatchedConsumer), false, "Mismatched workflow and helper release tags must fail") +for (const invalidRef of ["main", "v0", "v0.12", "v0.12.3-rc.1", "0123456789abcdef0123456789abcdef01234567"]) { + assert.equal(isExactReleaseTag(invalidRef), false, `Non-release ref must fail: ${invalidRef}`) } assert.doesNotMatch(publicWorkflowSurface, /step_budget:|tool_results_key:/) assert.doesNotMatch(workflow, /steps\.plan\.outputs/) @@ -67,7 +76,9 @@ assert.doesNotMatch(publicWorkflowSurface, /mount|component path|ability id|prov const docs = await readFile(new URL("../docs/agent-task-reusable-workflow.md", import.meta.url), "utf8") assert.match(docs, /^# Agent Task Reusable Workflow/m) -assert.match(docs, /Automattic\/wp-codebox\/.github\/workflows\/run-agent-task.yml@main/) +assert.match(docs, /Automattic\/wp-codebox\/.github\/workflows\/run-agent-task.yml@v0\.12\.3/) +assert.match(docs, /wp_codebox_release_ref: v0\.12\.3/) +assert.match(docs, /branches, commit SHAs, moving major tags, prereleases, and arbitrary\nrefs are rejected/) assert.match(docs, /external_package_source/) assert.match(docs, /runner_workspace/) assert.match(docs, /access_token_repos/)