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
49 changes: 39 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -468,14 +468,16 @@ jobs:
# - `full`: adds the full keyed suites (protocol-routing, opencode-live,
# dispatch-levels). Nightly and on dispatch.
#
# The keys are passed only when the tier is not `none`, and the tier is
# always set explicitly: unset means `full` (so a local run with keys arms
# everything), and a dropped env line would arm the full suites on every
# pull request that arms the probes. Secrets are not exposed to fork or Dependabot PRs, and every
# credentialed case skips when its key is absent, so this stays green there
# while still checking that the endpoints answer. Add OPENCODE_API_KEY (Zen)
# and OPENCODE_GO_API_KEY (Go plan) under Settings → Secrets → Actions to arm
# the keyed cases.
# The keys are armed only when the tier is not `none`, by the sign-in step:
# GitHub mints a short-lived OIDC token for the Console's audience, the
# Console exchanges it for an access token, and only that token reaches the
# suite's environment. Nothing long-lived is stored in this repository or in
# Actions secrets. A fork or Dependabot pull request is issued neither an
# OIDC endpoint nor secrets, so the step exits with a notice and the keyed
# cases skip exactly as they do when the tier is `none`, while the keyless
# cases still check that the endpoints answer. The Console CI token's client
# id is public by design: the exchange binds it to the audience and the
# GitHub OIDC subject, so the id alone grants nothing.
#
# Both probe suites write a dated table of what each URL shape answered
# (docs/redirect-planes.md §13); the job appends them to the run summary and
Expand All @@ -484,6 +486,9 @@ jobs:
name: e2e · live gateway
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand Down Expand Up @@ -534,13 +539,37 @@ jobs:
node-version: 26
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Sign in to OpenCode Console
if: steps.tier.outputs.keyed != 'none'
env:
AUDIENCE: https://opencode.ai/console
CLIENT_ID: sk_baf57eb6-1b63-4eb7-a538-737dc3aea54d
run: |
# A fork or Dependabot pull request is issued no OIDC endpoint; leave
# the suite keyless, as its keyed cases already expect.
if [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then
echo "::notice::no OIDC identity for this run; the keyed cases will skip"
exit 0
fi
oidc=$(curl -sSf --oauth2-bearer "$ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=$AUDIENCE" | jq -r .value)
token=$(curl -sSf https://opencode.ai/console/auth/oauth/token \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d subject_token_type=urn:ietf:params:oauth:token-type:jwt \
-d client_id="$CLIENT_ID" \
-d subject_token="$oidc" | jq -r .access_token)
echo "::add-mask::$token"
# One workspace credential serves both planes; the suite reads the two
# names it always has, and only this step arms them.
{
echo "OPENCODE_API_KEY=$token"
echo "OPENCODE_GO_API_KEY=$token"
} >> "$GITHUB_ENV"
- run: pnpm run test:e2e
env:
OPENCODE_E2E: "1"
OPENCODE_E2E_KEYED: ${{ steps.tier.outputs.keyed }}
OPENCODE_E2E_RECORD_DIR: ${{ runner.temp }}/endpoint-families
OPENCODE_API_KEY: ${{ steps.tier.outputs.keyed != 'none' && secrets.OPENCODE_API_KEY || '' }}
OPENCODE_GO_API_KEY: ${{ steps.tier.outputs.keyed != 'none' && secrets.OPENCODE_GO_API_KEY || '' }}
- name: publish the endpoint-family record to the run summary
if: always()
env:
Expand Down
71 changes: 56 additions & 15 deletions test/keyed-probes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,12 +324,13 @@ describe("keyed probes · the cells", () => {
});

it("every live suite reads a key only through E2E.keyFor at its own tier: the keyed probes at `probes`, the full keyed suites at `full`, the keyless matrix never", () => {
// The live files are not loaded offline, and ci.yml passes both secrets
// on a `probes` pull request, so a full suite that read a key straight
// from the environment would spend it on every such pull request, and
// keyed probes armed at `full` would never probe one: both stay green
// everywhere else. `E2E.keyFor` (test/support/e2e-env.ts) is the one
// reader, and it arms a key through `armedKey` at the tier it is asked for.
// The live files are not loaded offline, and ci.yml arms both keys on any
// tier but `none` (the sign-in step mints them), so a full suite that read
// a key straight from the environment would spend it on every such pull
// request, and keyed probes armed at `full` would never probe one: both
// stay green everywhere else. `E2E.keyFor` (test/support/e2e-env.ts) is
// the one reader, and it arms a key through `armedKey` at the tier it is
// asked for.
const NEED: Readonly<Record<string, "full" | "probes">> = {
"dispatch-levels.e2e.ts": "full",
"keyed-probes.e2e.ts": "probes",
Expand Down Expand Up @@ -1188,6 +1189,13 @@ describe("keyed probes · what a plane decides", () => {
const expression = (inner: string): string =>
["$", "{{ ", inner, " }}"].join("");

/**
* A shell `${inner}` expansion, as a workflow spells it, without a literal
* `${` in a regular string (`no-template-curly-in-string` reads that one as a
* mistyped template literal).
*/
const expansion = (inner: string): string => ["$", "{", inner, "}"].join("");

describe("keyed probes · the CI wiring", () => {
const ci = readFileSync(
new URL("../.github/workflows/ci.yml", import.meta.url),
Expand Down Expand Up @@ -1328,23 +1336,56 @@ describe("keyed probes · the CI wiring", () => {
);
});

it("the test step names the tier and passes the keys only when it is not `none`", () => {
it("the sign-in step mints the key from the runner's OIDC identity, and only when a tier spends one", () => {
// The job must be allowed to ask for an OIDC token; without `id-token`
// the runner sets no request URL and the exchange can never run.
expect(job).toMatch(
/\n {4}permissions:\n {6}contents: read\n {6}id-token: write\n {4}steps:/u
);
const signIn =
steps.find((step) =>
step.includes("name: Sign in to OpenCode Console")
) ?? "";
expect(signIn).not.toBe("");
// One sign-in, at the tiers that spend a key, and not at `none`.
expect(signIn).toMatch(/^\s*if: steps\.tier\.outputs\.keyed != 'none'$/mu);
for (const line of [
"https://opencode.ai/console/auth/oauth/token",
"grant_type=urn:ietf:params:oauth:grant-type:token-exchange",
"subject_token_type=urn:ietf:params:oauth:token-type:jwt",
"client_id=",
"subject_token=",
"&audience=$AUDIENCE",
"::add-mask::",
"OPENCODE_API_KEY=$token",
"OPENCODE_GO_API_KEY=$token",
"$GITHUB_ENV",
// A fork is issued no OIDC request URL: the step must leave the suite
// keyless rather than fail on a request it cannot make.
expansion("ACTIONS_ID_TOKEN_REQUEST_URL:-"),
]) {
expect(signIn, line).toContain(line);
}
// No stored key is referenced anywhere in the job: the token is minted per
// run, so nothing long-lived sits in this public repository.
for (const step of steps) {
expect(step).not.toContain("secrets.OPENCODE");
}
});

it("the test step names the tier and the record directory, and no key: the sign-in step owns them", () => {
// Unset means `full`, so a step that dropped the tier line would arm the
// full keyed suites on every pull request that arms the probes.
for (const line of [
`OPENCODE_E2E_KEYED: ${expression("steps.tier.outputs.keyed")}`,
`OPENCODE_API_KEY: ${expression("steps.tier.outputs.keyed != 'none' && secrets.OPENCODE_API_KEY || ''")}`,
`OPENCODE_GO_API_KEY: ${expression("steps.tier.outputs.keyed != 'none' && secrets.OPENCODE_GO_API_KEY || ''")}`,
"OPENCODE_E2E_RECORD_DIR",
]) {
expect(testStep, line).toContain(line);
}
// And no other step of the job sees a key.
for (const step of steps) {
if (step !== testStep) {
expect(step).not.toContain("secrets.OPENCODE");
}
}
// The keys arrive through GITHUB_ENV from the sign-in step, not here: a
// step-level env entry would shadow the minted token with a stale one.
expect(testStep).not.toContain("OPENCODE_API_KEY");
expect(testStep).not.toContain("OPENCODE_GO_API_KEY");
});
});

Expand Down
Loading