Skip to content
Merged
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
119 changes: 119 additions & 0 deletions .github/workflows/fleet-write.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Fleet write relay — the broker a cloud seat can reach.
#
# A cloud seat container cannot act as the fleet App `objectstack-fleet`: its
# egress proxy replaces every `Authorization` header with the session's own
# token and refuses the `/app/**` path a mint needs, so `scripts/pm/fleet-token.mjs`
# cannot mint there and a minted token could not be used there. What the proxy
# does pass is `POST /repos/objectstack-ai/objectstack/dispatches` with the
# session token. So a seat packs ONE stroke — the same requests it would have
# sent directly — into one `repository_dispatch`, and this workflow executes
# it here as `objectstack-fleet[bot]` against the repository the payload names.
#
# The rule, the closed op list, the payload schema, the sender gate and the
# executor's exit contract all live in `scripts/pm/fleet-write/`; each header
# there is authoritative and this file is the invocation. Four steps, no
# logic of its own: validate → mint → execute, with the checkout the scripts
# need in front.
#
# ⛔ The token this run WRITES with is the App installation token step 3
# mints — narrowed to the one target repository and to the permissions the
# op table declares (`issues: write`, `pull-requests: write`, and the
# `metadata: read` the sender gate's permission read needs) — and revoked by
# that action's post step. The workflow's own `GITHUB_TOKEN` gets nothing
# (`permissions: {}` below); the job grants it `contents: read` for the
# checkout and nothing else, so the run's ONLY write path is the App token,
# and it exists only inside the execute step's environment.
#
# The private key never reaches a script: `actions/create-github-app-token`
# reads the organization secret and hands the executor a token through `env:`.
# The executor prints no token, writes none to a file, and scrubs every line
# it emits; the runner's own secret masking is the second net.
#
# Authorization is the TARGET repo's: the executor asks
# `GET /repos/{payload.repo}/collaborators/{sender}/permission` for the login
# GitHub resolved from the dispatching token (`github.event.sender.login`)
# and refuses unless the answer is write, maintain or admin — a run failure
# with zero writes. There is no sender allowlist to keep: a seat that can push
# to a repository can write to it as the fleet, and one that cannot, cannot.
#
# ⛔ Only `repository_dispatch` with `types: [fleet-write]` starts this. No
# push, no schedule, no manual dispatch: every run is a seat's stroke, named
# after its request id so the seat's run-poller finds it by name.
name: Fleet Write

run-name: 'fleet-write ${{ github.event.client_payload.request_id }}'

on:
repository_dispatch:
types: [fleet-write]

# Nothing for the workflow's own token — see the header. The job re-grants
# exactly the read the checkout needs.
permissions: {}

jobs:
relay:
name: Fleet write relay
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
persist-credentials: false

# Pinned to the same major and spelling as every other setup-node in this
# repo, for the measured reason the sibling guards record: a setup-node
# major whose package-manager-cache default is on shells out to pnpm and
# kills the job in the SETUP step. This job installs no package manager:
# the three scripts are dependency-free and import sibling modules only.
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'

# Everything reaches the scripts through `env:`, never through `${{ }}`
# inside a `run:` line. A payload is seat-authored text, and an
# expression interpolated into a shell line is substituted before bash
# sees it; through `env:` it is inert data. The validator refuses an
# unknown key, an unknown op, a target outside the organization, a
# non-integer number, an over-cap string or an over-cap payload with
# every reason in the step summary and zero writes; on success it hands
# the target's NAME to the mint below through `$GITHUB_OUTPUT`.
- name: Validate the payload (closed schema, zero writes on refusal)
id: validate
env:
FLEET_WRITE_PAYLOAD: ${{ toJSON(github.event.client_payload) }}
run: node scripts/pm/fleet-write/validate.mjs --from-env --github-output

# The fleet identity, minted on the runner and never held by a seat:
# `OS_FLEET_APP_ID` is an organization variable, `OS_FLEET_PRIVATE_KEY`
# an organization secret (both set by the maintainer). `repositories`
# narrows the token to the ONE repository the payload named — the
# validator's output, never the payload read again here — and the
# `permission-*` inputs narrow it to what `scripts/pm/fleet-write/ops.mjs`
# declares. The action revokes the token in its post step.
- name: Mint the fleet App token, narrowed to the target and the op table
id: token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ vars.OS_FLEET_APP_ID }}
private-key: ${{ secrets.OS_FLEET_PRIVATE_KEY }}
owner: objectstack-ai
repositories: ${{ steps.validate.outputs.repo_name }}
permission-issues: write
permission-pull-requests: write
permission-metadata: read

# The sender gate, then the actions in order, then the step summary —
# request id · sender and role · session · target · one row per request.
# The first failure stops the run and the summary says which later
# actions were NOT attempted. Exit codes: `scripts/pm/fleet-write/execute.mjs`.
- name: Execute the actions as the fleet (sender gate first, stop at the first failure)
env:
FLEET_WRITE_PAYLOAD: ${{ toJSON(github.event.client_payload) }}
FLEET_WRITE_SENDER: ${{ github.event.sender.login }}
GITHUB_TOKEN: ${{ steps.token.outputs.token }}
run: node scripts/pm/fleet-write/execute.mjs
38 changes: 38 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1436,6 +1436,44 @@ jobs:
- name: Card-closure helper self-test
run: pnpm check:pm-close-cards

# Fleet-write relay self-tests — the same family and the same split as
# the steps above, in three files because the relay has three halves.
# The LIVE paths are a `repository_dispatch` from a seat, a workflow run
# here (`fleet-write.yml`) and writes as the fleet App on a target repo,
# none of which CI runs on a pull request; the offline halves are what CI
# can hold, and `check:self-test-wired` requires them because
# `fleet-write.yml` runs two of the scripts.
#
# What the validator's self-test instruments is the closed schema a clean
# tree cannot exercise: an unknown envelope or per-action key refused BY
# NAME, an op outside the closed list, a target outside the organization,
# a numeric string where an integer is required, byte caps, the platform's
# own `client_payload` ceilings pinned with their source, and — by
# construction over every request every op can spell — that no row reaches
# a merge, a review, a ref, contents, a workflow, a release or an
# organization endpoint, and none issues a whole-set PUT.
- name: Fleet-write payload validator self-test
run: pnpm check:pm-fleet-write-validate

# The executor's self-test drives a fake platform through the sender gate
# (write, maintain and admin pass; read, triage, none and a stranger are
# refused with ZERO writes), every op as the request the table declares,
# stop at the first failure with later actions untouched, the idempotent
# 404 on a directed label DELETE, the GraphQL ops behind a node-id read,
# one summary row per request, and a known token that comes back out of
# NO summary line, log line or error.
- name: Fleet-write executor self-test
run: pnpm check:pm-fleet-write-execute

# The seat-side dispatcher's self-test pins the transport selector that
# never guesses (an explicit OS_FLEET_TRANSPORT wins; `auto` is dispatch
# only under the cloud discriminator and direct without it), the session
# on the envelope, ONE paced dispatch per stroke, a run found by its
# request id and waited to its conclusion under an injected clock, and
# both ceilings answered UNCONFIRMED and never retried.
- name: Fleet-write dispatcher self-test
run: pnpm check:pm-fleet-write-dispatch

# Board-snapshot archiver self-test (#17390) — same family and the same
# split as every step around it. The LIVE run reads the whole board over
# REST and commits the result to an orphan branch, so it is not a thing
Expand Down
13 changes: 11 additions & 2 deletions scripts/pm/fleet-write/validate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -339,11 +339,20 @@ const battery = (name) => {
};
let selfTestReachedVerdict = false;

/** A payload every case starts from; each case mutates a copy. */
/**
* A payload every case starts from; each case mutates a copy.
*
* The target is spelled from `TARGET_OWNER`, not as one quoted slug: the
* dispatch derivation reads every separator-bearing literal in this module's
* body as a path this gate declares, and a slug names no tracked file, so a
* bare `owner/name` here is a dead declaration `check:declared-population-live`
* refuses. This gate's real population is the relay's four files, inherited
* from `RELAY_FILES` in `ops.mjs` and checked on disk by the self-test below.
*/
export function fixturePayload(overrides = {}) {
return {
request_id: 'fw-20260922T090000Z-abc123',
repo: 'objectstack-ai/objectstack',
repo: `${TARGET_OWNER}/objectstack`,
session: 'session_01ABCDEFGHJKMNPQRSTVWXYZ',
actions: [{ op: 'comment', issue: 19701, body: 'Hello from the relay.' }],
...overrides,
Expand Down
Loading