create-agentic-pdlc 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,114 @@
1
+ # Design: Set PROJECT_ID as Actions Variable During Install
2
+
3
+ **Issue:** #179
4
+ **Date:** 2026-06-05
5
+ **Status:** Approved
6
+
7
+ ## Problem
8
+
9
+ The `create-agentic-pdlc` installer embeds the GitHub Project ID (`PVT_xxx`) directly into workflow YAML files by replacing `{{PROJECT_ID}}` placeholders during `scaffoldFullTemplates`. This means new repo installs get workflows with a hardcoded value in source-controlled files — brittle, hard to rotate, and inconsistent with how GitHub recommends storing non-secret configuration.
10
+
11
+ The fix: set `vars.PROJECT_ID` as a GitHub Actions Variable via the REST API during install, and have workflow templates source it from `vars` at runtime.
12
+
13
+ ## Approach: env block sourced from `vars`
14
+
15
+ Keep the `PROJECT_ID:` entry in the workflow-level `env` block — just change its value from a hardcoded placeholder to `${{ vars.PROJECT_ID }}`. All `process.env.PROJECT_ID` references inside `actions/github-script` bodies remain unchanged. Minimal diff, maximum behavioral parity.
16
+
17
+ Rejected alternatives:
18
+ - **Inline `vars` everywhere**: larger diff, all script bodies change, no benefit.
19
+ - **Set vars + keep YAML hardcode**: adds complexity, defeats the goal.
20
+
21
+ ## Changes
22
+
23
+ ### 1. Templates — 3 workflow files
24
+
25
+ Files: `templates/.github/workflows/project-automation.yml`, `add-to-board.yml`, `agent-trigger.yml`
26
+
27
+ Every occurrence of:
28
+
29
+ | Before | After |
30
+ |---|---|
31
+ | `PROJECT_ID: "{{PROJECT_ID}}"` | `PROJECT_ID: ${{ vars.PROJECT_ID }}` |
32
+ | `env.PROJECT_ID != '{{PROJECT_ID}}'` | `env.PROJECT_ID != ''` |
33
+
34
+ Guard correctness: when `vars.PROJECT_ID` is unset, `${{ vars.PROJECT_ID }}` resolves to `''` at workflow startup → `env.PROJECT_ID != ''` suppresses execution cleanly. Verified: `vars.*` in workflow-level `env` block resolves before jobs run.
35
+
36
+ `pdlc.md` keeps `{{PROJECT_ID}}` substitution — it is a documentation file, not a YAML workflow.
37
+
38
+ ### 2. `bin/cli.js` — new helper `setActionsVariable`
39
+
40
+ ```javascript
41
+ function setActionsVariable(repo, name, value) {
42
+ try {
43
+ execFileSync('gh', ['api', `repos/${repo}/actions/variables/${name}`,
44
+ '--method', 'PATCH', '-f', `name=${name}`, '-f', `value=${value}`],
45
+ { stdio: ['ignore', 'pipe', 'pipe'] });
46
+ } catch (err) {
47
+ const msg = err.stderr?.toString() || '';
48
+ if (msg.includes('404') || msg.includes('Not Found')) {
49
+ execFileSync('gh', ['api', `repos/${repo}/actions/variables`,
50
+ '--method', 'POST', '-f', `name=${name}`, '-f', `value=${value}`],
51
+ { stdio: ['ignore', 'pipe', 'pipe'] });
52
+ } else {
53
+ throw err; // 403 bubbles up → caller emits user-visible warning
54
+ }
55
+ }
56
+ }
57
+ ```
58
+
59
+ Uses `gh api` via `execFileSync` — consistent with all other API calls in `cli.js`. PATCH on existing variable, POST on 404, throws on 403 so the caller can warn the user.
60
+
61
+ **Token scope requirement:** fine-grained PAT needs `variables:write`; classic PAT needs `repo` scope. `GITHUB_TOKEN` (workflow-issued) will return 403 — cannot set repo variables. The installer already calls `gh auth token` for `PROJECT_PAT`; the same authenticated session is used here.
62
+
63
+ ### 3. `bin/cli.js` — `scaffoldFullTemplates` (line 345)
64
+
65
+ Remove the single line that substitutes `{{PROJECT_ID}}` in `project-automation.yml`:
66
+
67
+ ```javascript
68
+ // REMOVE this line:
69
+ if (projectId) wfContent = wfContent.replace(/\{\{PROJECT_ID\}\}/g, () => projectId);
70
+ ```
71
+
72
+ All other substitutions on lines 346–355 (`STATUS_FIELD_ID`, `ID_BRAINSTORMING`, `ID_DETAILING`, etc.) are preserved unchanged.
73
+
74
+ ### 4. Create flow — call site
75
+
76
+ Inside the existing `if (projectId)` block (after `PROJECT_PAT` secret is set, ~line 541):
77
+
78
+ ```javascript
79
+ try {
80
+ setActionsVariable(repo, 'PROJECT_ID', projectId);
81
+ console.log(`${green}✅ vars.PROJECT_ID set as Actions Variable.${reset}`);
82
+ } catch (_) {
83
+ console.log(`${yellow}⚠️ Could not set vars.PROJECT_ID — token may lack variables:write scope.\n Set it manually: repo Settings → Secrets and variables → Variables → PROJECT_ID = ${projectId}${reset}`);
84
+ }
85
+ ```
86
+
87
+ Non-fatal. Install continues regardless.
88
+
89
+ ### 5. `--update` flow — same call site
90
+
91
+ The `--update` command is a **lite → full upgrade** — it exits early if the profile is already `full` (line 865). For lite → full, a new board is created via `createProjectV2` (line 920), producing a fresh `projectId`. Call `setActionsVariable` in the same `if (projectId)` block after board creation. Identical error handling to the create flow.
92
+
93
+ No "find existing project" query is needed. `projectId` is always available from the mutation result in both flows.
94
+
95
+ ## Acceptance Criteria
96
+
97
+ - `npx create-agentic-pdlc` → `vars.PROJECT_ID` set as GitHub Actions Variable on the target repo
98
+ - No `PVT_xxx` value appears in any installed YAML file
99
+ - `resolve-ids` job resolves column IDs without any YAML modification
100
+ - `--update` (lite → full) → `vars.PROJECT_ID` set with the newly created board ID
101
+ - If token lacks scope → installer prints actionable warning with manual steps; install does not abort
102
+
103
+ ## Out of Scope
104
+
105
+ - Migrating existing full installs (board already set up, YAML already hardcoded)
106
+ - Changing how `PROJECT_ID` is resolved during install (GraphQL flow unchanged)
107
+ - `--update` on an already-full install (exits early before any board logic runs)
108
+
109
+ ## Files to Modify
110
+
111
+ - `templates/.github/workflows/project-automation.yml`
112
+ - `templates/.github/workflows/add-to-board.yml`
113
+ - `templates/.github/workflows/agent-trigger.yml`
114
+ - `bin/cli.js` — `setActionsVariable` helper, `scaffoldFullTemplates` line 345, create flow call site, update flow call site
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-agentic-pdlc",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Agentic PDLC Framework - Conversational setup for your AI coding assistants",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -0,0 +1,20 @@
1
+ // Single source of truth for label → board column classification.
2
+ // Used by the event dispatcher (project-automation.yml) and the reconciliation cron (board-reconciliation.yml).
3
+ // pr:* beats stage:* — a live PR is higher-confidence signal than a stage label that may not have been cleaned up.
4
+ const LABEL_PRIORITY = [
5
+ { label: 'pr:in-review', column: 'code_review_pr' },
6
+ { label: 'pr:approved', column: 'code_review_pr' },
7
+ { label: 'stage:development', column: 'development' },
8
+ { label: 'stage:approval', column: 'approval' },
9
+ { label: 'stage:detailing', column: 'detailing' },
10
+ { label: 'stage:brainstorming', column: 'brainstorming' },
11
+ ];
12
+
13
+ function classifyItem(labelNames) {
14
+ for (const { label, column } of LABEL_PRIORITY) {
15
+ if (labelNames.includes(label)) return column;
16
+ }
17
+ return null;
18
+ }
19
+
20
+ module.exports = { classifyItem, LABEL_PRIORITY };
@@ -5,7 +5,7 @@ on:
5
5
  types: [opened]
6
6
 
7
7
  env:
8
- PROJECT_ID: "{{PROJECT_ID}}"
8
+ PROJECT_ID: ${{ vars.PROJECT_ID }}
9
9
  STATUS_FIELD_ID: "{{STATUS_FIELD_ID}}"
10
10
  STATUS_IDEA: "{{ID_IDEA}}"
11
11
 
@@ -17,7 +17,7 @@ jobs:
17
17
  PROJECT_TOKEN: ${{ secrets.PROJECT_TOKEN }}
18
18
  steps:
19
19
  - name: Add issue to project board
20
- if: ${{ env.PROJECT_TOKEN != '' && env.PROJECT_ID != '{{PROJECT_ID}}' }}
20
+ if: ${{ env.PROJECT_TOKEN != '' && env.PROJECT_ID != '' }}
21
21
  uses: actions/github-script@v8
22
22
  with:
23
23
  github-token: ${{ env.PROJECT_TOKEN }}
@@ -18,7 +18,7 @@ jobs:
18
18
  contents: read
19
19
  env:
20
20
  PROJECT_TOKEN: ${{ secrets.PROJECT_TOKEN }}
21
- PROJECT_ID: "{{PROJECT_ID}}"
21
+ PROJECT_ID: ${{ vars.PROJECT_ID }}
22
22
  STATUS_FIELD_ID: "{{STATUS_FIELD_ID}}"
23
23
  STATUS_DEVELOPMENT: "{{ID_DEVELOPMENT}}"
24
24
  steps:
@@ -62,7 +62,7 @@ jobs:
62
62
  });
63
63
 
64
64
  - name: Move board card to Development
65
- if: ${{ env.PROJECT_TOKEN != '' && env.PROJECT_ID != '{{PROJECT_ID}}' }}
65
+ if: ${{ env.PROJECT_TOKEN != '' && env.PROJECT_ID != '' }}
66
66
  continue-on-error: true
67
67
  uses: actions/github-script@v8
68
68
  with:
@@ -6,7 +6,7 @@ on:
6
6
  - cron: '0 8 * * 1' # Every Monday at 8am
7
7
 
8
8
  env:
9
- PROJECT_ID: "{{PROJECT_ID}}"
9
+ PROJECT_ID: ${{ vars.PROJECT_ID }}
10
10
  STATUS_FIELD_ID: "{{STATUS_FIELD_ID}}"
11
11
  STATUS_BRAINSTORMING: "{{ID_BRAINSTORMING}}"
12
12
  STATUS_DETAILING: "{{ID_DETAILING}}"
@@ -26,7 +26,7 @@ jobs:
26
26
  issues: write
27
27
  steps:
28
28
  - name: Validate Board Configuration
29
- if: ${{ env.PROJECT_TOKEN != '' && env.PROJECT_ID != '{{PROJECT_ID}}' }}
29
+ if: ${{ env.PROJECT_TOKEN != '' && env.PROJECT_ID != '' }}
30
30
  uses: actions/github-script@v8
31
31
  with:
32
32
  github-token: ${{ env.PROJECT_TOKEN }}
@@ -9,7 +9,7 @@ on:
9
9
  types: [labeled, edited, closed]
10
10
 
11
11
  env:
12
- PROJECT_ID: "{{PROJECT_ID}}"
12
+ PROJECT_ID: ${{ vars.PROJECT_ID }}
13
13
  STATUS_FIELD_ID: "{{STATUS_FIELD_ID}}"
14
14
  STATUS_IDEA: "{{ID_IDEA}}"
15
15
  STATUS_BRAINSTORMING: "{{ID_BRAINSTORMING}}"
@@ -30,7 +30,7 @@ jobs:
30
30
  PROJECT_TOKEN: ${{ secrets.PROJECT_TOKEN }}
31
31
  steps:
32
32
  - name: Detect Label and Move Issue
33
- if: ${{ env.PROJECT_TOKEN != '' && env.PROJECT_ID != '{{PROJECT_ID}}' }}
33
+ if: ${{ env.PROJECT_TOKEN != '' && env.PROJECT_ID != '' }}
34
34
  uses: actions/github-script@v8
35
35
  with:
36
36
  github-token: ${{ env.PROJECT_TOKEN }}
@@ -91,7 +91,7 @@ jobs:
91
91
  PROJECT_TOKEN: ${{ secrets.PROJECT_TOKEN }}
92
92
  steps:
93
93
  - name: Check spec markers and swap labels
94
- if: ${{ env.PROJECT_TOKEN != '' && env.PROJECT_ID != '{{PROJECT_ID}}' }}
94
+ if: ${{ env.PROJECT_TOKEN != '' && env.PROJECT_ID != '' }}
95
95
  uses: actions/github-script@v8
96
96
  with:
97
97
  github-token: ${{ env.PROJECT_TOKEN }}
@@ -149,7 +149,7 @@ jobs:
149
149
  # runs-on: ubuntu-latest
150
150
  # steps:
151
151
  # - name: Move issue to Idea
152
- # if: ${{ env.PROJECT_TOKEN != '' && env.PROJECT_ID != '{{PROJECT_ID}}' }}
152
+ # if: ${{ env.PROJECT_TOKEN != '' && env.PROJECT_ID != '' }}
153
153
  # uses: actions/github-script@v8
154
154
  # with:
155
155
  # github-token: ${{ env.PROJECT_TOKEN }}
@@ -179,7 +179,7 @@ jobs:
179
179
  PROJECT_TOKEN: ${{ secrets.PROJECT_TOKEN }}
180
180
  steps:
181
181
  - name: Move linked issue to Testing
182
- if: ${{ env.PROJECT_TOKEN != '' && env.PROJECT_ID != '{{PROJECT_ID}}' }}
182
+ if: ${{ env.PROJECT_TOKEN != '' && env.PROJECT_ID != '' }}
183
183
  uses: actions/github-script@v8
184
184
  with:
185
185
  github-token: ${{ env.PROJECT_TOKEN }}
@@ -233,7 +233,7 @@ jobs:
233
233
  PROJECT_TOKEN: ${{ secrets.PROJECT_TOKEN }}
234
234
  steps:
235
235
  - name: Move linked issue to Code Review / PR
236
- if: ${{ env.PROJECT_TOKEN != '' && env.PROJECT_ID != '{{PROJECT_ID}}' }}
236
+ if: ${{ env.PROJECT_TOKEN != '' && env.PROJECT_ID != '' }}
237
237
  uses: actions/github-script@v8
238
238
  with:
239
239
  github-token: ${{ env.PROJECT_TOKEN }}
@@ -281,7 +281,7 @@ jobs:
281
281
  PROJECT_TOKEN: ${{ secrets.PROJECT_TOKEN }}
282
282
  steps:
283
283
  - name: Swap PR labels
284
- if: ${{ env.PROJECT_TOKEN != '' && env.PROJECT_ID != '{{PROJECT_ID}}' }}
284
+ if: ${{ env.PROJECT_TOKEN != '' && env.PROJECT_ID != '' }}
285
285
  uses: actions/github-script@v8
286
286
  with:
287
287
  github-token: ${{ env.PROJECT_TOKEN }}
@@ -300,7 +300,7 @@ jobs:
300
300
  PROJECT_TOKEN: ${{ secrets.PROJECT_TOKEN }}
301
301
  steps:
302
302
  - name: Move issue to Production
303
- if: ${{ env.PROJECT_TOKEN != '' && env.PROJECT_ID != '{{PROJECT_ID}}' }}
303
+ if: ${{ env.PROJECT_TOKEN != '' && env.PROJECT_ID != '' }}
304
304
  uses: actions/github-script@v8
305
305
  with:
306
306
  github-token: ${{ env.PROJECT_TOKEN }}
@@ -359,3 +359,42 @@ jobs:
359
359
  await github.rest.issues.removeLabel({ owner, repo, issue_number, name: label }).catch(() => {});
360
360
  }
361
361
  console.log(`Issue #${issue_number} labels cleaned up`);
362
+
363
+ move-card-on-issue-close:
364
+ name: Closed issue → Archive from board
365
+ if: github.event_name == 'issues' && github.event.action == 'closed'
366
+ runs-on: ubuntu-latest
367
+ env:
368
+ PROJECT_TOKEN: ${{ secrets.PROJECT_TOKEN }}
369
+ steps:
370
+ - name: Archive board card
371
+ if: ${{ env.PROJECT_TOKEN != '' && env.PROJECT_ID != '' }}
372
+ uses: actions/github-script@v8
373
+ with:
374
+ github-token: ${{ env.PROJECT_TOKEN }}
375
+ script: |
376
+ const nodeId = context.payload.issue.node_id;
377
+ let itemId;
378
+ try {
379
+ const added = await github.graphql(`
380
+ mutation($p: ID!, $c: ID!) {
381
+ addProjectV2ItemById(input: {projectId: $p, contentId: $c}) { item { id } }
382
+ }`, { p: process.env.PROJECT_ID, c: nodeId });
383
+ itemId = added.addProjectV2ItemById.item.id;
384
+ if (!itemId) {
385
+ console.log(`Could not extract itemId from add response`);
386
+ return;
387
+ }
388
+ } catch (e) {
389
+ console.log(`Could not add issue to project: ${e.message}`);
390
+ return;
391
+ }
392
+ try {
393
+ await github.graphql(`
394
+ mutation($p: ID!, $i: ID!) {
395
+ archiveProjectV2Item(input: {projectId: $p, itemId: $i}) { item { id } }
396
+ }`, { p: process.env.PROJECT_ID, i: itemId });
397
+ console.log(`Issue #${context.payload.issue.number} archived from board`);
398
+ } catch (e) {
399
+ console.log(`Could not archive item: ${e.message}`);
400
+ }
package/tests/cli.test.js CHANGED
@@ -1,5 +1,9 @@
1
1
  const { describe, it } = require('node:test');
2
2
  const assert = require('node:assert/strict');
3
+ const os = require('os');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const crypto = require('crypto');
3
7
 
4
8
  const { resolveMode } = require('../bin/cli.js');
5
9
 
@@ -30,3 +34,85 @@ describe('buildFullClaudeContent', () => {
30
34
  assert.ok(result.includes('## Extra'));
31
35
  });
32
36
  });
37
+
38
+ describe('setActionsVariable', () => {
39
+ it('calls PATCH first', () => {
40
+ const calls = [];
41
+ const execFn = (cmd, args, _opts) => { calls.push([...args]); };
42
+ const { setActionsVariable } = require('../bin/cli.js');
43
+ setActionsVariable('owner/repo', 'PROJECT_ID', 'PVT_abc', execFn);
44
+ assert.equal(calls.length, 1);
45
+ assert.ok(calls[0].includes('--method'));
46
+ assert.ok(calls[0].includes('PATCH'));
47
+ assert.ok(calls[0].some(a => a.includes('PROJECT_ID')));
48
+ assert.ok(calls[0].some(a => a.includes('PVT_abc')));
49
+ });
50
+
51
+ it('falls back to POST on 404', () => {
52
+ const calls = [];
53
+ let callCount = 0;
54
+ const execFn = (cmd, args, _opts) => {
55
+ calls.push([...args]);
56
+ callCount++;
57
+ if (callCount === 1) {
58
+ const err = new Error('Not Found');
59
+ err.stderr = Buffer.from('Not Found');
60
+ throw err;
61
+ }
62
+ };
63
+ const { setActionsVariable } = require('../bin/cli.js');
64
+ setActionsVariable('owner/repo', 'PROJECT_ID', 'PVT_abc', execFn);
65
+ assert.equal(calls.length, 2);
66
+ assert.ok(calls[0].includes('PATCH'));
67
+ assert.ok(calls[0].some(a => a.includes('PVT_abc')));
68
+ assert.ok(calls[1].includes('POST'));
69
+ assert.ok(calls[1].some(a => a.includes('PVT_abc')));
70
+ });
71
+
72
+ it('throws on 403', () => {
73
+ const execFn = () => {
74
+ const err = new Error('Forbidden');
75
+ err.stderr = Buffer.from('Forbidden');
76
+ throw err;
77
+ };
78
+ const { setActionsVariable } = require('../bin/cli.js');
79
+ assert.throws(
80
+ () => setActionsVariable('owner/repo', 'PROJECT_ID', 'PVT_abc', execFn),
81
+ /Forbidden/
82
+ );
83
+ });
84
+ });
85
+
86
+ describe('scaffoldLiteTemplates', () => {
87
+ it('copies CLAUDE.md and AGENTS.md but excludes .github/workflows', () => {
88
+ const { scaffoldLiteTemplates } = require('../bin/cli.js');
89
+ const src = path.join(__dirname, '..');
90
+ const tmp = path.join(os.tmpdir(), `pdlc-lite-${crypto.randomBytes(4).toString('hex')}`);
91
+ try {
92
+ scaffoldLiteTemplates(src, tmp);
93
+ const base = path.join(tmp, '.agentic-pdlc', 'templates');
94
+ assert.ok(fs.existsSync(path.join(base, 'CLAUDE.md')), 'CLAUDE.md should exist');
95
+ assert.ok(fs.existsSync(path.join(base, 'AGENTS.md')), 'AGENTS.md should exist');
96
+ assert.ok(!fs.existsSync(path.join(base, '.github', 'workflows')), '.github/workflows must not exist in lite');
97
+ } finally {
98
+ fs.rmSync(tmp, { recursive: true, force: true });
99
+ }
100
+ });
101
+ });
102
+
103
+ describe('scaffoldFullTemplates', () => {
104
+ it('copies .github/workflows with at least one yml file', () => {
105
+ const { scaffoldFullTemplates } = require('../bin/cli.js');
106
+ const src = path.join(__dirname, '..');
107
+ const tmp = path.join(os.tmpdir(), `pdlc-full-${crypto.randomBytes(4).toString('hex')}`);
108
+ try {
109
+ scaffoldFullTemplates(src, tmp, null, null, {}, 'owner', 'repo');
110
+ const wfDir = path.join(tmp, '.agentic-pdlc', 'templates', '.github', 'workflows');
111
+ assert.ok(fs.existsSync(wfDir), '.github/workflows should exist in full');
112
+ const ymls = fs.readdirSync(wfDir).filter(f => f.endsWith('.yml'));
113
+ assert.ok(ymls.length > 0, 'should contain at least one .yml file');
114
+ } finally {
115
+ fs.rmSync(tmp, { recursive: true, force: true });
116
+ }
117
+ });
118
+ });