Skip to content

Commit 5b185aa

Browse files
committed
Fix CircleCI pipeline definition ID and add UUID validation guardrail
1 parent fd39389 commit 5b185aa

8 files changed

Lines changed: 198 additions & 18 deletions

File tree

.changeset/honest-bars-begin.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@agent-facets/adapter": patch
3+
"@agent-facets/adapter-claude-code": patch
4+
"@agent-facets/adapter-codex": patch
5+
"@agent-facets/adapter-opencode": patch
6+
"@agent-facets/brand": patch
7+
"agent-facets": patch
8+
"@agent-facets/common": patch
9+
"@agent-facets/core": patch
10+
---
11+
12+
Correct CircleCI deployment keys

docs/contributing/release-pipeline.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ for tag in \
8686
"https://circleci.com/api/v2/project/gh/agent-facets/facets/pipeline/run" \
8787
-H "Circle-Token: $CIRCLECI_API_TOKEN" \
8888
-H "content-type: application/json" \
89-
--data "{\"definition_id\":\"229d2f5823-f2c9-4cba-918a-e7d0dc2f658a\",\"config\":{\"tag\":\"$tag\"},\"checkout\":{\"tag\":\"$tag\"}}"
89+
--data "{\"definition_id\":\"9d2f5823-f2c9-4cba-918a-e7d0dc2f658a\",\"config\":{\"tag\":\"$tag\"},\"checkout\":{\"tag\":\"$tag\"}}"
9090
done
9191
```
9292

scripts/README.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,12 @@ scripts/
2222
│ └── targets.ts # Platform target matrix definitions
2323
2424
├── lib/ # Shared utilities
25-
│ ├── io/ # IO adapter (split by domain)
26-
│ │ ├── index.ts # Composed io object (re-exports all domains)
25+
│ ├── io/ # IO adapter (split by domain, nested namespaces)
26+
│ │ ├── index.ts # Composes io = { npm, git, gh, circleci, shell, console }
2727
│ │ ├── npm.ts # npm CLI commands
2828
│ │ ├── git.ts # git CLI commands
2929
│ │ ├── github.ts # GitHub CLI commands
30+
│ │ ├── circleci.ts # CircleCI API v2 calls
3031
│ │ ├── shell.ts # Shell, filesystem, build, CI tokens, network
3132
│ │ └── console.ts # log and error
3233
│ ├── ci.ts # Workspace package loading, token minting
@@ -93,10 +94,19 @@ The CLI package (`agent-facets`) is marked `"private": true` in its `package.jso
9394

9495
## IO Adapter
9596

96-
All external side effects (shell commands, file operations, network calls) go through the `io` object exported from `lib/io/`. Tests mock individual methods via `spyOn(io, 'method')`.
97+
All external side effects (shell commands, file operations, network calls) go through the `io` object exported from `lib/io/`. The adapter is split into domain files and exposed as **nested namespaces** — one per domain.
9798

98-
The IO adapter is split into domain files for readability but presents a single flat interface. Import it as:
99+
Import it as:
99100

100101
```ts
101102
import { io } from '../lib/io'
103+
104+
await io.npm.publish(pkgDir)
105+
await io.git.pushAllTags('origin')
106+
await io.gh.prCreate('main', head, title, body)
107+
await io.circleci.triggerPipelineForTag(slug, defId, tag)
108+
await io.shell.readFile(path)
109+
io.console.log('hello')
102110
```
111+
112+
Tests mock individual methods via the domain: `spyOn(io.npm, 'publish')`, `spyOn(io.git, 'tagAt')`, etc.

scripts/lib/constants.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { CIRCLECI_PROJECT_SLUG, CIRCLECI_RELEASE_PIPELINE_DEFINITION_ID } from './constants'
3+
4+
/**
5+
* Guardrail for CircleCI API trigger constants.
6+
*
7+
* This test imports the REAL exported constants (not a mock) and validates
8+
* their shape. Any future typo — including a stray character in a hand-copied
9+
* UUID — fails `bun check` locally before it can reach CI.
10+
*
11+
* Context: an earlier 10-character-first-segment typo in
12+
* CIRCLECI_RELEASE_PIPELINE_DEFINITION_ID caused the release pipeline trigger
13+
* to 400 in production. No existing test exercised the raw constant, only
14+
* the mocked io helper, so the bad value passed check. Don't regress.
15+
*/
16+
describe('CircleCI constants', () => {
17+
test('CIRCLECI_RELEASE_PIPELINE_DEFINITION_ID is a valid UUID (8-4-4-4-12)', () => {
18+
expect(CIRCLECI_RELEASE_PIPELINE_DEFINITION_ID).toMatch(
19+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
20+
)
21+
})
22+
23+
test('CIRCLECI_PROJECT_SLUG matches the gh/<org>/<repo> format', () => {
24+
expect(CIRCLECI_PROJECT_SLUG).toMatch(/^gh\/[\w-]+\/[\w-]+$/)
25+
})
26+
})

scripts/lib/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,4 @@ export const PUBLISH_TAG = 'latest'
4848
export const CIRCLECI_PROJECT_SLUG = 'gh/agent-facets/facets'
4949

5050
/** CircleCI pipeline definition ID for the release pipeline (.circleci/release.yml). */
51-
export const CIRCLECI_RELEASE_PIPELINE_DEFINITION_ID = '229d2f5823-f2c9-4cba-918a-e7d0dc2f658a'
51+
export const CIRCLECI_RELEASE_PIPELINE_DEFINITION_ID = '9d2f5823-f2c9-4cba-918a-e7d0dc2f658a'

scripts/lib/io/circleci.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
2+
import { io } from './index'
3+
4+
describe('io.circleci.triggerPipelineForTag', () => {
5+
let originalFetch: typeof globalThis.fetch
6+
7+
beforeEach(() => {
8+
originalFetch = globalThis.fetch
9+
})
10+
11+
afterEach(() => {
12+
globalThis.fetch = originalFetch
13+
mock.restore()
14+
delete process.env.CIRCLECI_API_TOKEN
15+
})
16+
17+
test('throws when CIRCLECI_API_TOKEN is not set', async () => {
18+
delete process.env.CIRCLECI_API_TOKEN
19+
20+
await expect(
21+
io.circleci.triggerPipelineForTag('gh/x/y', '9d2f5823-f2c9-4cba-918a-e7d0dc2f658a', 'some-tag'),
22+
).rejects.toThrow(/CIRCLECI_API_TOKEN not set/)
23+
})
24+
25+
test('throws with actionable error when definitionId is not a valid UUID', async () => {
26+
process.env.CIRCLECI_API_TOKEN = 'fake-token'
27+
const fetchSpy = mock(() => Promise.resolve(new Response('', { status: 200 })))
28+
globalThis.fetch = fetchSpy as unknown as typeof fetch
29+
30+
// First segment is 10 chars instead of 8 — the exact bug that shipped to prod.
31+
await expect(
32+
io.circleci.triggerPipelineForTag('gh/x/y', '229d2f5823-f2c9-4cba-918a-e7d0dc2f658a', 'some-tag'),
33+
).rejects.toThrow(/not a valid UUID/)
34+
35+
// Critically: fetch must NOT have been called — we failed before hitting the API.
36+
expect(fetchSpy).not.toHaveBeenCalled()
37+
})
38+
39+
test('rejects obviously-not-a-uuid values', async () => {
40+
process.env.CIRCLECI_API_TOKEN = 'fake-token'
41+
const fetchSpy = mock(() => Promise.resolve(new Response('', { status: 200 })))
42+
globalThis.fetch = fetchSpy as unknown as typeof fetch
43+
44+
await expect(io.circleci.triggerPipelineForTag('gh/x/y', 'not-a-uuid', 'some-tag')).rejects.toThrow(
45+
/not a valid UUID/,
46+
)
47+
await expect(io.circleci.triggerPipelineForTag('gh/x/y', '', 'some-tag')).rejects.toThrow(/not a valid UUID/)
48+
expect(fetchSpy).not.toHaveBeenCalled()
49+
})
50+
51+
test('posts to CircleCI API with expected body when inputs are valid', async () => {
52+
process.env.CIRCLECI_API_TOKEN = 'fake-token'
53+
const fetchSpy = mock((_url: string, _init?: RequestInit) =>
54+
Promise.resolve(new Response(JSON.stringify({ id: 'abc', number: 42 }), { status: 200 })),
55+
)
56+
globalThis.fetch = fetchSpy as unknown as typeof fetch
57+
58+
const result = await io.circleci.triggerPipelineForTag(
59+
'gh/agent-facets/facets',
60+
'9d2f5823-f2c9-4cba-918a-e7d0dc2f658a',
61+
'@agent-facets/core@1.0.0',
62+
)
63+
64+
expect(result).toEqual({ id: 'abc', number: 42 })
65+
expect(fetchSpy).toHaveBeenCalledTimes(1)
66+
67+
const [url, init] = fetchSpy.mock.calls[0] ?? []
68+
expect(url).toBe('https://circleci.com/api/v2/project/gh/agent-facets/facets/pipeline/run')
69+
const headers = (init as RequestInit | undefined)?.headers as Record<string, string>
70+
expect(headers['Circle-Token']).toBe('fake-token')
71+
expect(headers['Content-Type']).toBe('application/json')
72+
73+
const body = JSON.parse((init as RequestInit).body as string)
74+
expect(body).toEqual({
75+
definition_id: '9d2f5823-f2c9-4cba-918a-e7d0dc2f658a',
76+
config: { tag: '@agent-facets/core@1.0.0' },
77+
checkout: { tag: '@agent-facets/core@1.0.0' },
78+
})
79+
})
80+
81+
test('throws with CircleCI error body on non-2xx response', async () => {
82+
process.env.CIRCLECI_API_TOKEN = 'fake-token'
83+
const fetchSpy = mock(() =>
84+
Promise.resolve(
85+
new Response(JSON.stringify({ message: "Field 'definition_id' must be a valid uuid." }), { status: 400 }),
86+
),
87+
)
88+
globalThis.fetch = fetchSpy as unknown as typeof fetch
89+
90+
await expect(
91+
io.circleci.triggerPipelineForTag(
92+
'gh/agent-facets/facets',
93+
'9d2f5823-f2c9-4cba-918a-e7d0dc2f658a',
94+
'@agent-facets/core@1.0.0',
95+
),
96+
).rejects.toThrow(/CircleCI pipeline trigger failed .* 400/)
97+
})
98+
})

scripts/lib/io/circleci.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
* docs/contributing/release-pipeline.mdx for the full story.
1010
*/
1111

12+
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
13+
1214
export const circleciIo = {
1315
/**
1416
* Trigger a pipeline run for a specific tag via CircleCI API v2.
@@ -35,6 +37,17 @@ export const circleciIo = {
3537
)
3638
}
3739

40+
// Fail fast on a malformed UUID rather than getting a cryptic 400 from
41+
// CircleCI ("Field 'definition_id' must be a valid uuid"). See the
42+
// constants.test.ts guardrail which also validates the real constant
43+
// at build time.
44+
if (!UUID_REGEX.test(definitionId)) {
45+
throw new Error(
46+
`Invalid pipeline definition ID: "${definitionId}" is not a valid UUID (expected 8-4-4-4-12 format). ` +
47+
'Check scripts/lib/constants.ts. Fetch the correct ID from CircleCI UI → Project Settings → Pipelines.',
48+
)
49+
}
50+
3851
const resp = await fetch(`https://circleci.com/api/v2/project/${projectSlug}/pipeline/run`, {
3952
method: 'POST',
4053
headers: {

scripts/release/README.md

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,31 +5,52 @@ Publishes `@agent-facets/core`, `@agent-facets/brand`, `@agent-facets/adapter`,
55
## Flow
66

77
```
8-
Tag push: @agent-facets/core@X.Y.Z
8+
Version PR merged to main
99
1010
11+
┌────────────────────────────────────────────────────────────────────┐
12+
│ release/tag.ts (main-pipeline) │
13+
│ │
14+
│ 1. Detect version PR merge │
15+
│ 2. Create git tags for each unpublished package version │
16+
│ 3. git push --tags origin │
17+
│ 4. For each tag: POST to CircleCI API v2 /pipeline/run │
18+
│ with {definition_id, config: {tag}, checkout: {tag}} │
19+
│ │
20+
│ Why explicit API trigger? GitHub-to-CircleCI tag-push webhooks │
21+
│ are unreliable when the bot GitHub App pushes tags — the CircleCI │
22+
│ App installation drops events from other bot actors. See │
23+
│ docs/contributing/release-pipeline.mdx. │
24+
└────────────────────────────────────────────────────────────────────┘
25+
26+
▼ (one release pipeline per tag)
1127
┌──────────────────────────────────────────────┐
1228
│ release/publish.ts │
1329
│ │
1430
│ 1. Parse package name + version from tag │
1531
│ 2. Find package in workspace │
1632
│ 3. Skip if private (guard) │
17-
│ 4. Mint OIDC token (npm trusted publishing) │
18-
│ 5. Build via turbo │
19-
│ 6. npm publish --access public │
20-
│ 7. Create GitHub Release │
21-
│ 8. Send Slack notification │
33+
│ 4. Skip if version already on npm (guard) │
34+
│ 5. Mint OIDC token (npm trusted publishing) │
35+
│ 6. Build via turbo │
36+
│ 7. npm publish --access public │
37+
│ 8. Create GitHub Release │
38+
│ 9. Send Slack notification │
2239
└──────────────────────────────────────────────┘
2340
```
2441

2542
## Scripts
2643

27-
| Script | CircleCI Job | Trigger | Purpose |
28-
|---------------------|-----------------|--------------------------------|---------------------------------------------------------------|
29-
| `version.ts` | `main-pipeline` | Push to `main` | Run `changeset version`, create/update Version Packages PR |
30-
| `tag.ts` | `main-pipeline` | Push to `main` | Detect merged version PR, create git tags for bumped packages |
31-
| `publish.ts` | `release` | Tag push (`@agent-facets/*@*`) | Build and publish one library package to npm |
32-
| `seed-adapters.ts` | (manual) | One-time bootstrap | Seed adapter/library package names on npm with v0.0.1 |
44+
| Script | CircleCI Job | Trigger | Purpose |
45+
|---------------------|-----------------|--------------------------------|---------------------------------------------------------------------------|
46+
| `version.ts` | `main-pipeline` | Push to `main` | Run `changeset version`, create/update Version Packages PR |
47+
| `tag.ts` | `main-pipeline` | Push to `main` | Detect merged version PR, create git tags, trigger release pipelines |
48+
| `publish.ts` | `release` | API trigger (`@agent-facets/*@*`) | Build and publish one library package to npm |
49+
| `seed-adapters.ts` | (manual) | One-time bootstrap | Seed adapter/library package names on npm with v0.0.1 |
50+
51+
## Required secrets
52+
53+
`tag.ts` requires `CIRCLECI_API_TOKEN` in the `bot-context` CircleCI context. It's a personal API token with write access to the project, used to POST to `/api/v2/project/<slug>/pipeline/run`. See [CI Architecture docs](../../docs/contributing/ci-architecture.mdx) for context rotation steps.
3354

3455
## Private Package Guard
3556

0 commit comments

Comments
 (0)