Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 71 additions & 63 deletions .github/workflows/desktop-installed-gate.yml

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Explicit security review remains required

This change affects a GitHub Actions security boundary targeting privileged self-hosted runners. Repository policy requires explicit security review before merge.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -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]
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

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:
Expand All @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: |
Expand All @@ -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
Expand Down
63 changes: 52 additions & 11 deletions tests/ci-workflows/installed-gate-drivers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,20 +458,24 @@ describe("installed-artifact gate workflow", () => {
const jobs = workflow.jobs ?? {};
type Job = NonNullable<typeof jobs>[string] & {
environment?: string;
needs?: string | string[];
steps?: Array<{ name?: string; uses?: string; run?: string; if?: string; with?: Record<string, unknown> }>;
};

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<string, { types?: string[] }>).repository_dispatch?.types).toEqual([
"desktop-installed-gate",
]);
});

test("least privilege: read-only contents and nothing else", () => {
expect(workflow.permissions).toEqual({ contents: "read" });
});

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"]];
Expand All @@ -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()");
Expand All @@ -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<string, { inputs?: Record<string, unknown> }>).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.
Expand All @@ -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");
}
});
Expand Down
Loading