From 35c9a31ef169bde5ec0e665440592849fac1e845 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 1 Nov 2026 16:10:49 -0800 Subject: [PATCH 1/2] feat(api): send the run environment on every v2 reconcile Reconcile now carries environment: { ci, provider? }, from the CI detection telemetry already performs. The service records when a repository last reconciled from CI, for the dashboard's CI indicator; verdicts are unchanged. Declared locally until the vendored v2 schema is re-vendored. --- .changeset/reconcile-run-environment.md | 5 ++++ packages/cli/src/api/v2.ts | 25 ++++++++++++++++- packages/cli/src/util/adoption-dimensions.ts | 25 +++++++++++++++++ packages/cli/test/adoption-dimensions.test.ts | 23 +++++++++++++++ packages/cli/test/api-v2.test.ts | 28 +++++++++++++++++++ 5 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 .changeset/reconcile-run-environment.md diff --git a/.changeset/reconcile-run-environment.md b/.changeset/reconcile-run-environment.md new file mode 100644 index 00000000..37227f08 --- /dev/null +++ b/.changeset/reconcile-run-environment.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +Reconcile now tells Taskless whether it is running in CI. Every `POST /cli/api/v2/reconcile` carries `environment: { ci, provider? }`, using the CI detection telemetry already performs (`provider` is the hyphenated name, such as `github-actions`, when it is recognized). The service uses it only to record when a repository last reconciled from CI, which drives the dashboard's "CI set up" indicator; verdicts, entitlements and output are unchanged, and a local run sends `ci: false`. diff --git a/packages/cli/src/api/v2.ts b/packages/cli/src/api/v2.ts index 17d35628..5c32690a 100644 --- a/packages/cli/src/api/v2.ts +++ b/packages/cli/src/api/v2.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import type { paths } from "../generated/api-v2"; import { getApiBaseUrl } from "./config"; import { parseRefusal, stripControlCharacters, type Refusal } from "./refusal"; +import { resolveRunEnvironment } from "../util/adoption-dimensions"; import { isRecord } from "../util/is-record"; import { CLI_VERSION, CLI_VERSION_HEADER } from "../version"; @@ -558,6 +559,21 @@ export type ReconcileBody = NonNullable< Operation<"/cli/api/v2/reconcile", "post">["requestBody"] >["content"]["application/json"]; +/** + * `environment` on the reconcile body, declared here until the vendored schema + * is refreshed. + * + * The live `/cli/api/v2/__schema` already publishes it as optional + * (`{ ci?: boolean; provider?: string }`, no other keys). Regenerating to pick + * it up would also vendor error codes the service has added since + * (`github_forbidden`, `github_unavailable`, `cli_upgrade_required`), which + * every v2 client must then handle; that is its own change. Once the schema + * is re-vendored this intersection is redundant and can be deleted. + */ +type ReconcileRequest = ReconcileBody & { + environment?: { ci?: boolean; provider?: string }; +}; + export type ReconcileResult = OkBody<"/cli/api/v2/reconcile", "post">; export type ReconcileCode = ErrorCode<"/cli/api/v2/reconcile", "post">; @@ -579,8 +595,15 @@ export function reconcileRules( body: ReconcileBody ): Promise> { const client = createV2Client(token); + // Attached here rather than by each caller, so no reconcile can be sent + // without it. The service only records when a repository last reconciled + // from CI; verdicts are identical with or without it. + const request: ReconcileRequest = { + ...body, + environment: resolveRunEnvironment(), + }; return settle( - () => client.POST("/cli/api/v2/reconcile", { body }), + () => client.POST("/cli/api/v2/reconcile", { body: request }), RECONCILE_CODES, acceptObject ); diff --git a/packages/cli/src/util/adoption-dimensions.ts b/packages/cli/src/util/adoption-dimensions.ts index a118c25e..2f3a0f76 100644 --- a/packages/cli/src/util/adoption-dimensions.ts +++ b/packages/cli/src/util/adoption-dimensions.ts @@ -149,6 +149,31 @@ export function resolveCiProvider( return matched ? matched.name : UNKNOWN_DIMENSION; } +/** + * Where the process is running, as the v2 reconcile body states it. + * + * The same detection telemetry reports as `ci` / `ciProvider`, so the two can + * never disagree about whether a run was CI. Only the spelling differs: the + * service's published schema names providers with hyphens (`github-actions`), + * while telemetry keeps its established underscores, which existing dashboards + * already group by. + * + * `provider` is omitted rather than sentinelled when there is no CI or the + * provider is not recognised. The sentinels exist to keep telemetry aggregates + * countable; on the wire they would be values the service has to know to + * ignore. + */ +export function resolveRunEnvironment( + environment: NodeJS.ProcessEnv = process.env +): { ci: boolean; provider?: string } { + const ci = isContinuousIntegration(environment); + const provider = resolveCiProvider(environment); + if (!ci || provider === NO_CI_PROVIDER || provider === UNKNOWN_DIMENSION) { + return { ci }; + } + return { ci, provider: provider.replaceAll("_", "-") }; +} + /** * Node's manifest, which `LANGUAGE_MARKERS` has no entry for. * diff --git a/packages/cli/test/adoption-dimensions.test.ts b/packages/cli/test/adoption-dimensions.test.ts index 084664c0..894f5b86 100644 --- a/packages/cli/test/adoption-dimensions.test.ts +++ b/packages/cli/test/adoption-dimensions.test.ts @@ -10,6 +10,7 @@ import { UNKNOWN_DIMENSION, isContinuousIntegration, resolveCiProvider, + resolveRunEnvironment, resolveLanguageStack, resolveRepositoryId, resolveWorkspaceId, @@ -193,6 +194,28 @@ describe("the CI dimensions", () => { }); }); +describe("resolveRunEnvironment", () => { + it("states a local run as ci:false with no provider", () => { + expect(resolveRunEnvironment({})).toEqual({ ci: false }); + expect( + resolveRunEnvironment({ CI: "false", GITHUB_ACTIONS: "true" }) + ).toEqual({ ci: false }); + }); + + it("names a recognized provider in the service's hyphenated spelling", () => { + expect( + resolveRunEnvironment({ CI: "true", GITHUB_ACTIONS: "true" }) + ).toEqual({ ci: true, provider: "github-actions" }); + expect( + resolveRunEnvironment({ CI: "1", BITBUCKET_BUILD_NUMBER: "7" }) + ).toEqual({ ci: true, provider: "bitbucket-pipelines" }); + }); + + it("omits an unrecognized provider rather than sending the sentinel", () => { + expect(resolveRunEnvironment({ CI: "true" })).toEqual({ ci: true }); + }); +}); + describe("languageStack", () => { it("reports the languages its root manifests evidence", () => { const root = mkdtempSync(join(tmpdir(), "taskless-stack-")); diff --git a/packages/cli/test/api-v2.test.ts b/packages/cli/test/api-v2.test.ts index 6779afe5..64737e0b 100644 --- a/packages/cli/test/api-v2.test.ts +++ b/packages/cli/test/api-v2.test.ts @@ -79,6 +79,34 @@ describe("v2 client", () => { expect(request.headers.get("authorization")).toBe("Bearer tok"); }); + it("states the run environment on every reconcile, CI or not", async () => { + // Stubbed both ways so the assertion does not depend on whether the + // suite itself happens to run in CI. + vi.stubEnv("CI", "true"); + vi.stubEnv("GITHUB_ACTIONS", "true"); + respond(200, { rules: [], unknown: [], entitlement: {} }); + await reconcileRules("tok", { + orgId: "o", + repositoryUrl: REPO, + rules: [], + }); + expect(await sent().json()).toEqual({ + orgId: "o", + repositoryUrl: REPO, + rules: [], + environment: { ci: true, provider: "github-actions" }, + }); + + fetchMock.mockClear(); + vi.stubEnv("CI", ""); + vi.stubEnv("GITHUB_ACTIONS", ""); + respond(200, { rules: [], unknown: [], entitlement: {} }); + await reconcileRules("tok", { repositoryUrl: REPO, rules: [] }); + const local = (await sent().json()) as { environment?: unknown }; + expect(local.environment).toEqual({ ci: false }); + vi.unstubAllEnvs(); + }); + it("puts the repository in the query for a GET, never the path", async () => { respond(200, SERVED); await fetchRule("tok", "no-eval-3fa9c21b", { repositoryUrl: REPO }); From 384270f753b2fe1a222fc80cad84b823e93e8a81 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Sun, 1 Nov 2026 16:31:19 -0800 Subject: [PATCH 2/2] refactor(util): return early off CI in resolveRunEnvironment --- packages/cli/src/util/adoption-dimensions.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/util/adoption-dimensions.ts b/packages/cli/src/util/adoption-dimensions.ts index 2f3a0f76..bc30a353 100644 --- a/packages/cli/src/util/adoption-dimensions.ts +++ b/packages/cli/src/util/adoption-dimensions.ts @@ -167,10 +167,9 @@ export function resolveRunEnvironment( environment: NodeJS.ProcessEnv = process.env ): { ci: boolean; provider?: string } { const ci = isContinuousIntegration(environment); + if (!ci) return { ci }; const provider = resolveCiProvider(environment); - if (!ci || provider === NO_CI_PROVIDER || provider === UNKNOWN_DIMENSION) { - return { ci }; - } + if (provider === UNKNOWN_DIMENSION) return { ci }; return { ci, provider: provider.replaceAll("_", "-") }; }