diff --git a/.github/workflows/desktop-installed-gate.yml b/.github/workflows/desktop-installed-gate.yml index 8782cd8462..84e262555f 100644 --- a/.github/workflows/desktop-installed-gate.yml +++ b/.github/workflows/desktop-installed-gate.yml @@ -7,59 +7,65 @@ name: desktop installed-artifact gate # publication wiring into release.yml is a separate change. on: - workflow_dispatch: - inputs: - version: - description: Release version whose desktop artifacts the gate installs - required: true - type: string - from-version: - description: Older release used for the staged npm runtime and the Linux update phases - required: true - type: string - # Hook inputs are FILE NAMES, never command text. The runner's operator installs - # audited executables in a hooks directory (vars.OPENCODEX_GATE_HOOKS_DIR) and a - # dispatch picks among them by name; the gate executes the file directly, so this - # workflow can never become an arbitrary-shell surface on a persistent runner. - consent-hook: - description: Name of the runner hook that answers the takeover consent prompt - required: false - type: string - tray-click-hook: - description: Name of the runner hook that left-clicks the tray icon - required: false - type: string - tray-quit-hook: - description: Name of the runner hook that opens the tray menu and chooses Quit - required: false - type: string - tray-check-hook: - description: Name of the runner hook that chooses Check for Updates in the tray - required: false - type: string - tray-install-hook: - description: Name of the runner hook that chooses Install update in the tray - required: false - type: string - elevate-accept-hook: - description: Name of the runner hook that answers the deb update's elevation prompt (drives the accept path) - required: false - type: string + # repository_dispatch always loads this workflow from the default branch. Unlike + # workflow_dispatch, a caller cannot select a branch-owned workflow definition and + # thereby route arbitrary steps onto these privileged self-hosted runners. + repository_dispatch: + types: [desktop-installed-gate] permissions: contents: read concurrency: - group: desktop-installed-gate-${{ inputs.version }} + group: desktop-installed-gate-${{ github.event.client_payload.version }} cancel-in-progress: false jobs: + validate: + # repository_dispatch carries a free-form client_payload — nothing upstream + # enforces required fields, so shape-check it on a hosted runner before a + # privileged self-hosted machine is even scheduled or a maintainer approval is + # requested. The patterns mirror parseGateArguments (strict semver, plain hook + # file names) so a malformed event fails here instead of on the gate runner. + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + VERSION: ${{ github.event.client_payload['version'] }} + FROM_VERSION: ${{ github.event.client_payload['from-version'] }} + CONSENT_HOOK: ${{ github.event.client_payload['consent-hook'] }} + TRAY_CLICK_HOOK: ${{ github.event.client_payload['tray-click-hook'] }} + TRAY_QUIT_HOOK: ${{ github.event.client_payload['tray-quit-hook'] }} + TRAY_CHECK_HOOK: ${{ github.event.client_payload['tray-check-hook'] }} + TRAY_INSTALL_HOOK: ${{ github.event.client_payload['tray-install-hook'] }} + ELEVATE_ACCEPT_HOOK: ${{ github.event.client_payload['elevate-accept-hook'] }} + steps: + - name: Validate the dispatch payload + run: | + set -euo pipefail + semver='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' + hook_name='^[A-Za-z0-9][A-Za-z0-9._-]*$' + for pair in "version:$VERSION" "from-version:$FROM_VERSION"; do + name="${pair%%:*}"; value="${pair#*:}" + if ! [[ "$value" =~ $semver ]]; then + echo "client_payload.$name must be strict semver (x.y.z[-suffix]), got '$value'" >&2 + exit 1 + fi + done + for pair in "consent-hook:$CONSENT_HOOK" "tray-click-hook:$TRAY_CLICK_HOOK" "tray-quit-hook:$TRAY_QUIT_HOOK" "tray-check-hook:$TRAY_CHECK_HOOK" "tray-install-hook:$TRAY_INSTALL_HOOK" "elevate-accept-hook:$ELEVATE_ACCEPT_HOOK"; do + name="${pair%%:*}"; value="${pair#*:}" + if [ -n "$value" ] && ! [[ "$value" =~ $hook_name ]]; then + echo "client_payload.$name must be a plain hook file name, got '$value'" >&2 + exit 1 + fi + done + macos: + needs: validate runs-on: [self-hosted, opencodex-gate-macos] timeout-minutes: 60 # Required-review environment: no run reaches the GUI runner without a maintainer - # approval, and the checkout below pins the driver to the protected dev branch, so a - # dispatched ref cannot smuggle modified gate code onto the machine. + # approval. repository_dispatch loads the workflow from the default branch, and the + # checkout below pins the driver itself to the protected dev branch. environment: opencodex-desktop-gate defaults: run: @@ -77,7 +83,7 @@ jobs: - name: Download the release artifact env: GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ inputs.version }} + RELEASE_VERSION: ${{ github.event.client_payload['version'] }} GATE_ARTIFACTS: ${{ runner.temp }}/gate-artifacts run: | mkdir -p "$GATE_ARTIFACTS" @@ -88,11 +94,11 @@ jobs: - name: Run the installed-artifact gate env: - RELEASE_VERSION: ${{ inputs.version }} - FROM_VERSION: ${{ inputs.from-version }} - CONSENT_HOOK: ${{ inputs.consent-hook }} - TRAY_CLICK_HOOK: ${{ inputs.tray-click-hook }} - TRAY_QUIT_HOOK: ${{ inputs.tray-quit-hook }} + RELEASE_VERSION: ${{ github.event.client_payload['version'] }} + FROM_VERSION: ${{ github.event.client_payload['from-version'] }} + CONSENT_HOOK: ${{ github.event.client_payload['consent-hook'] }} + TRAY_CLICK_HOOK: ${{ github.event.client_payload['tray-click-hook'] }} + TRAY_QUIT_HOOK: ${{ github.event.client_payload['tray-quit-hook'] }} GATE_HOOKS_DIR: ${{ vars.OPENCODEX_GATE_HOOKS_DIR }} GATE_ARTIFACTS: ${{ runner.temp }}/gate-artifacts GATE_WORK: ${{ runner.temp }}/installed-gate @@ -124,6 +130,7 @@ jobs: if-no-files-found: error windows: + needs: validate runs-on: [self-hosted, opencodex-gate-windows] timeout-minutes: 60 environment: opencodex-desktop-gate @@ -143,7 +150,7 @@ jobs: - name: Download the release artifact env: GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ inputs.version }} + RELEASE_VERSION: ${{ github.event.client_payload['version'] }} GATE_ARTIFACTS: ${{ runner.temp }}/gate-artifacts run: | mkdir -p "$GATE_ARTIFACTS" @@ -154,11 +161,11 @@ jobs: - name: Run the installed-artifact gate env: - RELEASE_VERSION: ${{ inputs.version }} - FROM_VERSION: ${{ inputs.from-version }} - CONSENT_HOOK: ${{ inputs.consent-hook }} - TRAY_CLICK_HOOK: ${{ inputs.tray-click-hook }} - TRAY_QUIT_HOOK: ${{ inputs.tray-quit-hook }} + RELEASE_VERSION: ${{ github.event.client_payload['version'] }} + FROM_VERSION: ${{ github.event.client_payload['from-version'] }} + CONSENT_HOOK: ${{ github.event.client_payload['consent-hook'] }} + TRAY_CLICK_HOOK: ${{ github.event.client_payload['tray-click-hook'] }} + TRAY_QUIT_HOOK: ${{ github.event.client_payload['tray-quit-hook'] }} GATE_HOOKS_DIR: ${{ vars.OPENCODEX_GATE_HOOKS_DIR }} GATE_ARTIFACTS: ${{ runner.temp }}/gate-artifacts GATE_WORK: ${{ runner.temp }}/installed-gate @@ -190,6 +197,7 @@ jobs: if-no-files-found: error linux: + needs: validate runs-on: [self-hosted, opencodex-gate-linux] timeout-minutes: 60 environment: opencodex-desktop-gate @@ -218,8 +226,8 @@ jobs: - name: Download the release artifacts env: GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ inputs.version }} - FROM_VERSION: ${{ inputs.from-version }} + RELEASE_VERSION: ${{ github.event.client_payload['version'] }} + FROM_VERSION: ${{ github.event.client_payload['from-version'] }} ARTIFACT_SUFFIX: ${{ matrix.suffix }} GATE_ARTIFACTS: ${{ runner.temp }}/gate-artifacts run: | @@ -235,16 +243,16 @@ jobs: - name: Run the installed-artifact gate env: - RELEASE_VERSION: ${{ inputs.version }} - FROM_VERSION: ${{ inputs.from-version }} + RELEASE_VERSION: ${{ github.event.client_payload['version'] }} + FROM_VERSION: ${{ github.event.client_payload['from-version'] }} GATE_FORMAT: ${{ matrix.format }} ARTIFACT_SUFFIX: ${{ matrix.suffix }} - CONSENT_HOOK: ${{ inputs.consent-hook }} - TRAY_CLICK_HOOK: ${{ inputs.tray-click-hook }} - TRAY_QUIT_HOOK: ${{ inputs.tray-quit-hook }} - TRAY_CHECK_HOOK: ${{ inputs.tray-check-hook }} - TRAY_INSTALL_HOOK: ${{ inputs.tray-install-hook }} - ELEVATE_ACCEPT_HOOK: ${{ inputs.elevate-accept-hook }} + CONSENT_HOOK: ${{ github.event.client_payload['consent-hook'] }} + TRAY_CLICK_HOOK: ${{ github.event.client_payload['tray-click-hook'] }} + TRAY_QUIT_HOOK: ${{ github.event.client_payload['tray-quit-hook'] }} + TRAY_CHECK_HOOK: ${{ github.event.client_payload['tray-check-hook'] }} + TRAY_INSTALL_HOOK: ${{ github.event.client_payload['tray-install-hook'] }} + ELEVATE_ACCEPT_HOOK: ${{ github.event.client_payload['elevate-accept-hook'] }} GATE_HOOKS_DIR: ${{ vars.OPENCODEX_GATE_HOOKS_DIR }} GATE_ARTIFACTS: ${{ runner.temp }}/gate-artifacts GATE_WORK: ${{ runner.temp }}/installed-gate diff --git a/tests/ci-workflows/installed-gate-drivers.test.ts b/tests/ci-workflows/installed-gate-drivers.test.ts index 21c7039caf..29084536c5 100644 --- a/tests/ci-workflows/installed-gate-drivers.test.ts +++ b/tests/ci-workflows/installed-gate-drivers.test.ts @@ -458,12 +458,16 @@ describe("installed-artifact gate workflow", () => { const jobs = workflow.jobs ?? {}; type Job = NonNullable[string] & { environment?: string; + needs?: string | string[]; steps?: Array<{ name?: string; uses?: string; run?: string; if?: string; with?: Record }>; }; test("it is dispatch-only: a stateful GUI machine must never run because a push happened", () => { const triggers = Array.isArray(workflow.on) ? workflow.on : Object.keys(workflow.on ?? {}); - expect(triggers).toEqual(["workflow_dispatch"]); + expect(triggers).toEqual(["repository_dispatch"]); + expect((workflow.on as Record).repository_dispatch?.types).toEqual([ + "desktop-installed-gate", + ]); }); test("least privilege: read-only contents and nothing else", () => { @@ -471,7 +475,7 @@ describe("installed-artifact gate workflow", () => { }); test("every platform job targets a self-hosted gate runner with a bounded timeout", () => { - const gateJobs = Object.entries(jobs).filter(([name]) => name !== "report"); + const gateJobs = Object.entries(jobs).filter(([name]) => name !== "validate" && name !== "report"); expect(gateJobs.map(([name]) => name)).toEqual(["macos", "windows", "linux"]); for (const [, job] of gateJobs) { const runsOn = Array.isArray(job["runs-on"]) ? job["runs-on"] : [job["runs-on"]]; @@ -483,7 +487,8 @@ describe("installed-artifact gate workflow", () => { }); test("the gate report is uploaded even when the gate failed", () => { - for (const [name, job] of Object.entries(jobs)) { + for (const name of ["macos", "windows", "linux"] as const) { + const job = jobs[name]!; const uploads = (job.steps ?? []).filter(step => step.uses?.startsWith("actions/upload-artifact@")); expect(uploads.length, `${name} must upload its report`).toBe(1); expect(uploads[0]?.if).toContain("always()"); @@ -508,16 +513,51 @@ describe("installed-artifact gate workflow", () => { }); test("GUI automation inputs are hook names, never command text", () => { - const dispatch = (workflow.on as Record }>).workflow_dispatch; - const inputNames = Object.keys(dispatch?.inputs ?? {}); - expect(inputNames).toContain("consent-hook"); - expect(inputNames).toContain("tray-quit-hook"); - expect(inputNames).toContain("elevate-accept-hook"); + const inputNames = [...text.matchAll(/client_payload\[['"]([^'"]+)['"]\]/g)].map(match => match[1]); + // client_payload is free-form JSON, so the dispatch contract is exactly this key + // set: a dropped required key or an incidental extra reference both break it. + expect(new Set(inputNames)).toEqual(new Set([ + "version", + "from-version", + "consent-hook", + "tray-click-hook", + "tray-quit-hook", + "tray-check-hook", + "tray-install-hook", + "elevate-accept-hook", + ])); for (const name of inputNames) { expect(name).not.toMatch(/command$/); } }); + test("dispatch payloads are validated on a hosted runner before a gate job is scheduled", () => { + // repository_dispatch carries a free-form client_payload: required versions and + // hook names must pass a shape check before a privileged runner is requested. + const validate = jobs.validate as Job | undefined; + expect(validate, "a validate job must precede the privileged gate jobs").toBeDefined(); + const runsOn = Array.isArray(validate?.["runs-on"]) ? validate?.["runs-on"] : [validate?.["runs-on"]]; + expect(runsOn).not.toContain("self-hosted"); + expect(validate?.environment, "validate runs before the gated environment is requested").toBeUndefined(); + const runScript = (validate?.steps ?? []).map(step => step.run ?? "").join("\n"); + for (const key of [ + "version", + "from-version", + "consent-hook", + "tray-click-hook", + "tray-quit-hook", + "tray-check-hook", + "tray-install-hook", + "elevate-accept-hook", + ]) { + expect(runScript, `validate must check client_payload.${key}`).toContain(key); + } + for (const name of ["macos", "windows", "linux"] as const) { + const needs = (jobs[name] as Job | undefined)?.needs; + expect(needs, `${name} must wait on payload validation`).toBe("validate"); + } + }); + test("the npm package is never constructed from raw input", () => { // The driver derives the spec from the repository's own package.json; a workflow // that interpolates a package name would reopen npm alias injection. @@ -527,9 +567,10 @@ describe("installed-artifact gate workflow", () => { test("every gate job sits behind a required-review environment and checks out protected dev", () => { // The runners install software and hold sudo; the workflow must never execute a // dispatcher-selected ref on them. - for (const [name, job] of Object.entries(jobs) as Array<[string, Job]>) { - expect(job.environment, `${name} must declare the gated environment`).toBe("opencodex-desktop-gate"); - const checkout = (job.steps ?? []).find(step => step.uses?.startsWith("actions/checkout@")); + for (const name of ["macos", "windows", "linux"] as const) { + const job = jobs[name] as Job | undefined; + expect(job?.environment, `${name} must declare the gated environment`).toBe("opencodex-desktop-gate"); + const checkout = (job?.steps ?? []).find(step => step.uses?.startsWith("actions/checkout@")); expect(checkout?.with?.ref, `${name} must check out the protected integration branch`).toBe("dev"); } });