From 411d245d5be3d6a784b05b84b2693a553320b063 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Thu, 8 Oct 2026 04:27:35 +0000 Subject: [PATCH 1/3] test(release): run each e2e tool with the env its release.yml step gives it The harness exported GITHUB_TOKEN to every command, so a workflow step that gave its tool no token still passed the e2e. tool() now exports only the runner's defaults plus the env of the release.yml step that runs the same app, subcommand and flags --- e2e/harness.ts | 13 ++++++-- e2e/package.json | 3 +- e2e/workflow-env.ts | 72 +++++++++++++++++++++++++++++++++++++++++++++ pnpm-lock.yaml | 3 ++ 4 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 e2e/workflow-env.ts diff --git a/e2e/harness.ts b/e2e/harness.ts index 00762694..f2925058 100644 --- a/e2e/harness.ts +++ b/e2e/harness.ts @@ -1,4 +1,5 @@ import { GenericContainer, type StartedTestContainer, Wait } from 'testcontainers' +import { releaseToolSteps, workflowEnvOf } from './workflow-env.js' export const IMAGE = 'pnpm-release-management-e2e:local' export const BINS_DIR = '/opt/prm' @@ -91,15 +92,20 @@ export const parseJson = (text: string): unknown => { return parsed } -const DEFAULT_ENV: Readonly> = { +const RUNNER_ENV: Readonly> = { NO_COLOR: '1', GITHUB_API_URL, - GITHUB_TOKEN, GITHUB_REPOSITORY, } +const WORKFLOW_EXPRESSIONS: Readonly> = { + 'github.token': GITHUB_TOKEN, +} + +const RELEASE_TOOL_STEPS = releaseToolSteps() + const shellScript = (command: string, options: ExecOptions): string => { - const lines = Object.entries({ ...DEFAULT_ENV, ...options.env }).map(([name, value]) => + const lines = Object.entries({ ...RUNNER_ENV, ...options.env }).map(([name, value]) => `export ${name}=${quote(value)}` ) if (options.cwd !== undefined) lines.push(`cd ${quote(options.cwd)}`) @@ -160,6 +166,7 @@ export const makeWorld = (container: StartedTestContainer, listener: Listener): return run(parts.join(' '), { cwd: FIXTURE, ...options, + env: { ...workflowEnvOf(RELEASE_TOOL_STEPS, WORKFLOW_EXPRESSIONS, app, subcommand, args), ...options.env }, }) } diff --git a/e2e/package.json b/e2e/package.json index 0cdfdaef..34803957 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -9,7 +9,8 @@ "effect": "^4.0.0-rc.112", "testcontainers": "^12.1.0", "typescript": "^7", - "vitest": "^4" + "vitest": "^4", + "yaml": "^2.9.0" }, "exports": { "./package.json": "./package.json" diff --git a/e2e/workflow-env.ts b/e2e/workflow-env.ts new file mode 100644 index 00000000..16c4a7a2 --- /dev/null +++ b/e2e/workflow-env.ts @@ -0,0 +1,72 @@ +import { readFileSync } from 'node:fs' +import { parse } from 'yaml' + +const RELEASE_WORKFLOW = new URL('../.github/workflows/release.yml', import.meta.url) + +const TOOL_COMMAND = /nix develop --command (\S+) (\S+)([^\n]*(?:\\\n[^\n]*)*)/g + +export interface ToolStep { + readonly job: string + readonly step: string + readonly app: string + readonly subcommand: string + readonly flags: ReadonlyArray + readonly env: Readonly> +} + +interface WorkflowStep { + readonly name?: string + readonly id?: string + readonly run?: string + readonly env?: Record +} + +interface Workflow { + readonly jobs: Record }> +} + +const flagsOf = (args: string): ReadonlyArray => + [...args.matchAll(/(?:^|\s)(--[a-z][a-z-]*)/g)].flatMap((match) => match[1] === undefined ? [] : [match[1]]).sort() + +const sameFlags = (left: ReadonlyArray, right: ReadonlyArray): boolean => + left.length === right.length && left.every((flag, index) => flag === right[index]) + +export const releaseToolSteps = (): ReadonlyArray => { + const workflow = parse(readFileSync(RELEASE_WORKFLOW, 'utf8')) as Workflow + return Object.entries(workflow.jobs).flatMap(([job, { steps = [] }]) => + steps.flatMap((step) => + [...(step.run ?? '').matchAll(TOOL_COMMAND)].map((match) => ({ + job, + step: step.name ?? step.id ?? '(unnamed)', + app: match[1] ?? '', + subcommand: match[2] ?? '', + flags: flagsOf(match[3] ?? ''), + env: step.env ?? {}, + })) + ) + ) +} + +export const workflowEnvOf = ( + steps: ReadonlyArray, + expressions: Readonly>, + app: string, + subcommand: string, + args: string, +): Record => { + const candidates = steps.filter((step) => step.app === app && step.subcommand === subcommand) + const flags = flagsOf(args) + const exact = candidates.filter((step) => sameFlags(step.flags, flags)) + const chosen = exact.length > 0 ? exact : candidates + const resolve = (value: string): string => + value.replaceAll(/\$\{\{\s*([^}]+?)\s*\}\}/g, (_, expression: string) => { + const resolved = expressions[expression] + if (resolved === undefined) { + throw new Error(`release.yml uses \${{ ${expression} }}, which the e2e cannot resolve`) + } + return resolved + }) + return Object.fromEntries( + chosen.flatMap((step) => Object.entries(step.env).map(([name, value]) => [name, resolve(value)])), + ) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ef64e1e..6960d0d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -306,6 +306,9 @@ importers: vitest: specifier: ^4 version: 4.1.11(@types/node@24.13.4)(vite@8.2.2(@types/node@24.13.4)(jiti@2.6.1)(yaml@2.9.0)) + yaml: + specifier: ^2.9.0 + version: 2.9.0 packages/adoption-adapter: dependencies: From e6f0f767eb823d8314d35b43732106cb024918c7 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Thu, 8 Oct 2026 04:37:22 +0000 Subject: [PATCH 2/3] test(release): read release.yml through Effect FileSystem and a schema --- e2e/harness.ts | 2 +- e2e/workflow-env.ts | 73 ++++++++++++++++++++++++------------------ e2e/workflow.schema.ts | 12 +++++++ 3 files changed, 54 insertions(+), 33 deletions(-) create mode 100644 e2e/workflow.schema.ts diff --git a/e2e/harness.ts b/e2e/harness.ts index f2925058..73ef6e71 100644 --- a/e2e/harness.ts +++ b/e2e/harness.ts @@ -102,7 +102,7 @@ const WORKFLOW_EXPRESSIONS: Readonly> = { 'github.token': GITHUB_TOKEN, } -const RELEASE_TOOL_STEPS = releaseToolSteps() +const RELEASE_TOOL_STEPS = await releaseToolSteps() const shellScript = (command: string, options: ExecOptions): string => { const lines = Object.entries({ ...RUNNER_ENV, ...options.env }).map(([name, value]) => diff --git a/e2e/workflow-env.ts b/e2e/workflow-env.ts index 16c4a7a2..cb29e214 100644 --- a/e2e/workflow-env.ts +++ b/e2e/workflow-env.ts @@ -1,7 +1,11 @@ -import { readFileSync } from 'node:fs' +import { NodeServices } from '@effect/platform-node' +import { Effect } from 'effect' +import { FileSystem } from 'effect/FileSystem' +import * as S from 'effect/Schema' import { parse } from 'yaml' +import { Workflow } from './workflow.schema.js' -const RELEASE_WORKFLOW = new URL('../.github/workflows/release.yml', import.meta.url) +const RELEASE_WORKFLOW = new URL('../.github/workflows/release.yml', import.meta.url).pathname const TOOL_COMMAND = /nix develop --command (\S+) (\S+)([^\n]*(?:\\\n[^\n]*)*)/g @@ -14,37 +18,44 @@ export interface ToolStep { readonly env: Readonly> } -interface WorkflowStep { - readonly name?: string - readonly id?: string - readonly run?: string - readonly env?: Record -} - -interface Workflow { - readonly jobs: Record }> -} - const flagsOf = (args: string): ReadonlyArray => - [...args.matchAll(/(?:^|\s)(--[a-z][a-z-]*)/g)].flatMap((match) => match[1] === undefined ? [] : [match[1]]).sort() + [...args.matchAll(/(?:^|\s)(--[a-z][a-z-]*)/g)].flatMap((match) => match[1] ?? []).sort() const sameFlags = (left: ReadonlyArray, right: ReadonlyArray): boolean => left.length === right.length && left.every((flag, index) => flag === right[index]) -export const releaseToolSteps = (): ReadonlyArray => { - const workflow = parse(readFileSync(RELEASE_WORKFLOW, 'utf8')) as Workflow - return Object.entries(workflow.jobs).flatMap(([job, { steps = [] }]) => - steps.flatMap((step) => - [...(step.run ?? '').matchAll(TOOL_COMMAND)].map((match) => ({ - job, - step: step.name ?? step.id ?? '(unnamed)', - app: match[1] ?? '', - subcommand: match[2] ?? '', - flags: flagsOf(match[3] ?? ''), - env: step.env ?? {}, - })) - ) +export const releaseToolSteps = (): Promise> => + Effect.runPromise( + Effect.gen(function*() { + const fs = yield* FileSystem + const text = yield* fs.readFileString(RELEASE_WORKFLOW) + const workflow = yield* S.decodeUnknownEffect(Workflow)(parse(text)) + return Object.entries(workflow.jobs).flatMap(([job, { steps = [] }]) => + steps.flatMap((step) => + [...(step.run ?? '').matchAll(TOOL_COMMAND)].map((match) => ({ + job, + step: step.name ?? step.id ?? '(unnamed)', + app: match[1] ?? '', + subcommand: match[2] ?? '', + flags: flagsOf(match[3] ?? ''), + env: step.env ?? {}, + })) + ) + ) + }).pipe(Effect.provide(NodeServices.layer)), ) + +const stepsRunning = ( + steps: ReadonlyArray, + app: string, + subcommand: string, + args: string, +): ReadonlyArray => { + const candidates = steps.filter((step) => step.app === app && step.subcommand === subcommand) + const flags = flagsOf(args) + const exact = candidates.filter((step) => sameFlags(step.flags, flags)) + if (exact.length > 0) return exact + return candidates } export const workflowEnvOf = ( @@ -54,10 +65,6 @@ export const workflowEnvOf = ( subcommand: string, args: string, ): Record => { - const candidates = steps.filter((step) => step.app === app && step.subcommand === subcommand) - const flags = flagsOf(args) - const exact = candidates.filter((step) => sameFlags(step.flags, flags)) - const chosen = exact.length > 0 ? exact : candidates const resolve = (value: string): string => value.replaceAll(/\$\{\{\s*([^}]+?)\s*\}\}/g, (_, expression: string) => { const resolved = expressions[expression] @@ -67,6 +74,8 @@ export const workflowEnvOf = ( return resolved }) return Object.fromEntries( - chosen.flatMap((step) => Object.entries(step.env).map(([name, value]) => [name, resolve(value)])), + stepsRunning(steps, app, subcommand, args).flatMap((step) => + Object.entries(step.env).map(([name, value]) => [name, resolve(value)]) + ), ) } diff --git a/e2e/workflow.schema.ts b/e2e/workflow.schema.ts new file mode 100644 index 00000000..dce5b0c0 --- /dev/null +++ b/e2e/workflow.schema.ts @@ -0,0 +1,12 @@ +import * as S from 'effect/Schema' + +export const WorkflowStep = S.Struct({ + name: S.optional(S.String), + id: S.optional(S.String), + run: S.optional(S.String), + env: S.optional(S.Record(S.String, S.String)), +}) + +export const Workflow = S.Struct({ + jobs: S.Record(S.String, S.Struct({ steps: S.optional(S.Array(WorkflowStep)) })), +}) From a6ac68cb8176548ba06d54e3a6caff14de416fee Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Thu, 8 Oct 2026 04:43:52 +0000 Subject: [PATCH 3/3] ci(release): give the pr step the GITHUB_TOKEN its tool reads The reusable workflow gave github-release-management pr GH_TOKEN, but the tool reads only GITHUB_TOKEN, so creating the release PR failed with 'Requires authentication'. The step now passes GITHUB_TOKEN like the plan, capture and release steps; the gh dispatch step keeps GH_TOKEN --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c7dd3e5..464b0a80 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,7 +73,7 @@ jobs: - name: Open or update the release PR env: - GH_TOKEN: ${{ github.token }} + GITHUB_TOKEN: ${{ github.token }} run: nix develop --command github-release-management pr - name: Run the caller's CI on the release PR