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 diff --git a/e2e/harness.ts b/e2e/harness.ts index 00762694..73ef6e71 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 = await 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..cb29e214 --- /dev/null +++ b/e2e/workflow-env.ts @@ -0,0 +1,81 @@ +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).pathname + +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> +} + +const flagsOf = (args: string): ReadonlyArray => + [...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 = (): 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 = ( + steps: ReadonlyArray, + expressions: Readonly>, + app: string, + subcommand: string, + args: string, +): Record => { + 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( + 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)) })), +}) 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: