github-delivery-os 1.2.0 → 1.2.2
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.
- package/.github/scripts/authorize-deployment-verdict.js +55 -0
- package/.github/scripts/auto-close-sprint.js +124 -0
- package/.github/scripts/package.json +3 -0
- package/.github/scripts/sprint-child-creator.js +34 -0
- package/.github/workflows/authorize-deployment.yml +38 -13
- package/.github/workflows/auto-close-sprint.yml +13 -7
- package/.github/workflows/telegram-issues.yml +12 -6
- package/README.md +233 -9
- package/package.json +5 -1
- package/src/install.js +244 -41
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Pure verdict-computation logic for authorize-deployment.yml, extracted out
|
|
4
|
+
// of the inline actions/github-script step so it can be unit tested directly
|
|
5
|
+
// (see test/authorize-deployment-verdict.test.js) instead of only verified
|
|
6
|
+
// by hand whenever it changes. Has no dependency on the `github`/`context`
|
|
7
|
+
// globals actions/github-script injects — everything it needs is passed in.
|
|
8
|
+
|
|
9
|
+
// Anchored to comment start + word boundary so keywords must lead the
|
|
10
|
+
// comment (matches the documented convention) and can't match inside a
|
|
11
|
+
// larger word (e.g. "ok" no longer matches "okay", "reject" no longer
|
|
12
|
+
// matches "rejection") or as a substring anywhere in unrelated prose.
|
|
13
|
+
const DECLINE_RE = /^(declined|rejected|reject|not approved)\b/i;
|
|
14
|
+
const APPROVE_RE = /^(approved|approve|ok|go ahead)\b/i;
|
|
15
|
+
const QA_APPROVE_RE = /^(qa approved|approved|qa ok|looks good)\b/i;
|
|
16
|
+
|
|
17
|
+
const normalize = (s) => (s || '').toLowerCase();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Walks comments in chronological order and keeps the LATEST verdict from
|
|
21
|
+
* each approver, rather than stopping at the first decline seen. This lets a
|
|
22
|
+
* release approver re-approve after an earlier decline (e.g. once fixes
|
|
23
|
+
* land) instead of being permanently stuck as declined.
|
|
24
|
+
*
|
|
25
|
+
* @param {Array<{ user?: { login?: string | null } | null, body?: string | null }>} comments
|
|
26
|
+
* Chronological (oldest first), matching the order github.paginate(listComments) returns.
|
|
27
|
+
* @param {string} releaseApprover
|
|
28
|
+
* @param {string} qaApprover
|
|
29
|
+
* @returns {{ releaseVerdict: 'approved' | 'declined' | null, qaApproved: boolean }}
|
|
30
|
+
*/
|
|
31
|
+
function computeVerdict(comments, releaseApprover, qaApprover) {
|
|
32
|
+
let releaseVerdict = null;
|
|
33
|
+
let qaApproved = false;
|
|
34
|
+
|
|
35
|
+
for (const comment of comments) {
|
|
36
|
+
const login = comment.user && comment.user.login;
|
|
37
|
+
const body = (comment.body || '').trim();
|
|
38
|
+
|
|
39
|
+
if (normalize(login) === normalize(releaseApprover)) {
|
|
40
|
+
if (DECLINE_RE.test(body)) {
|
|
41
|
+
releaseVerdict = 'declined';
|
|
42
|
+
} else if (APPROVE_RE.test(body)) {
|
|
43
|
+
releaseVerdict = 'approved';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (normalize(login) === normalize(qaApprover) && QA_APPROVE_RE.test(body)) {
|
|
48
|
+
qaApproved = true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { releaseVerdict, qaApproved };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { computeVerdict, DECLINE_RE, APPROVE_RE, QA_APPROVE_RE };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Pure burn-down/health computation for auto-close-sprint.yml, extracted so
|
|
4
|
+
// it can be unit tested directly (see test/auto-close-sprint.test.js)
|
|
5
|
+
// instead of only verified by hand.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {string} body - a child issue's body
|
|
9
|
+
* @returns {number | null} the parent sprint issue number, or null if this
|
|
10
|
+
* issue isn't a sprint child (no "Parent Sprint: #N" line).
|
|
11
|
+
*/
|
|
12
|
+
function parseParentSprintNumber(body) {
|
|
13
|
+
const match = (body || '').match(/Parent Sprint:\s*#(\d+)/);
|
|
14
|
+
return match ? parseInt(match[1], 10) : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parses sprint dates out of a sprint issue's body, supporting both the
|
|
19
|
+
* combined "Sprint Dates: X to Y" format and the separate "Sprint Start" /
|
|
20
|
+
* "Sprint End" field format (the sprint_planning.yml template).
|
|
21
|
+
*
|
|
22
|
+
* @param {string} sprintBody - the sprint (parent) issue's body
|
|
23
|
+
* @returns {{ startDate: Date, endDate: Date } | null} null if neither
|
|
24
|
+
* format matched.
|
|
25
|
+
*/
|
|
26
|
+
function parseSprintDates(sprintBody) {
|
|
27
|
+
const body = sprintBody || '';
|
|
28
|
+
|
|
29
|
+
const rangeMatch = body.match(/Sprint Dates[\s\S]*?(\d{4}-\d{2}-\d{2})\s*to\s*(\d{4}-\d{2}-\d{2})/i);
|
|
30
|
+
if (rangeMatch) {
|
|
31
|
+
return { startDate: new Date(rangeMatch[1]), endDate: new Date(rangeMatch[2]) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const startMatch = body.match(/Sprint Start[\s\S]*?(\d{4}-\d{2}-\d{2})/i);
|
|
35
|
+
const endMatch = body.match(/Sprint End[\s\S]*?(\d{4}-\d{2}-\d{2})/i);
|
|
36
|
+
if (startMatch && endMatch) {
|
|
37
|
+
return { startDate: new Date(startMatch[1]), endDate: new Date(endMatch[1]) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {Date} startDate
|
|
45
|
+
* @param {Date} endDate
|
|
46
|
+
* @param {Date} now
|
|
47
|
+
* @returns {number} percent of the sprint's duration elapsed as of `now`,
|
|
48
|
+
* clamped to [0, 100]. Returns 100 for a zero-or-negative-length sprint
|
|
49
|
+
* (Start >= End — a data-entry mistake) or an unparseable date (e.g. an
|
|
50
|
+
* out-of-range "2026-13-05" typo, which regex-matches the YYYY-MM-DD
|
|
51
|
+
* shape but produces an Invalid Date) instead of dividing by zero or NaN:
|
|
52
|
+
* that NaN would otherwise flow into the posted burn-down as "Time
|
|
53
|
+
* Elapsed: NaN%", and every NaN comparison in computeHealthEmoji is
|
|
54
|
+
* false, so it'd silently default to the green/no-warning branch instead
|
|
55
|
+
* of flagging the broken dates. `!(totalDuration > 0)` (rather than
|
|
56
|
+
* `totalDuration <= 0`) catches NaN too, since every comparison against
|
|
57
|
+
* NaN is false — `NaN <= 0` is false, but so is `NaN > 0`, and negating
|
|
58
|
+
* that is true.
|
|
59
|
+
*/
|
|
60
|
+
function computeTimePercent(startDate, endDate, now) {
|
|
61
|
+
const totalDuration = endDate - startDate;
|
|
62
|
+
if (!(totalDuration > 0)) return 100;
|
|
63
|
+
const elapsed = now - startDate;
|
|
64
|
+
return Math.max(0, Math.min(100, Math.round((elapsed / totalDuration) * 100)));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {Array<{ state: string }>} children
|
|
69
|
+
* @returns {{ progressPercent: number, closedCount: number, totalCount: number }}
|
|
70
|
+
*/
|
|
71
|
+
function computeProgress(children) {
|
|
72
|
+
const totalCount = children.length;
|
|
73
|
+
const closedCount = children.filter((i) => i.state === 'closed').length;
|
|
74
|
+
const progressPercent = totalCount === 0 ? 0 : Math.round((closedCount / totalCount) * 100);
|
|
75
|
+
return { progressPercent, closedCount, totalCount };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Sprint health relative to time elapsed: red if progress trails time by
|
|
80
|
+
* more than 10 points, yellow if behind at all, green otherwise.
|
|
81
|
+
*
|
|
82
|
+
* @returns {'🟢' | '🟡' | '🔴'}
|
|
83
|
+
*/
|
|
84
|
+
function computeHealthEmoji(progressPercent, timePercent) {
|
|
85
|
+
if (progressPercent < timePercent - 10) return '🔴';
|
|
86
|
+
if (progressPercent < timePercent) return '🟡';
|
|
87
|
+
return '🟢';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @returns {string} a block-character progress bar, e.g. "████░░░░░░░░░░░░░░░░".
|
|
92
|
+
*/
|
|
93
|
+
function renderBurnDown(progressPercent, totalBars = 20) {
|
|
94
|
+
const filledBars = Math.round((progressPercent / 100) * totalBars);
|
|
95
|
+
return '█'.repeat(filledBars) + '░'.repeat(totalBars - filledBars);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Replaces any previous "## 🚦 Sprint Status" block in the sprint body with
|
|
100
|
+
* a freshly rendered one (appended at the end).
|
|
101
|
+
*/
|
|
102
|
+
function updateSprintBody(sprintBody, { progressPercent, timePercent, healthEmoji, burnDown }) {
|
|
103
|
+
let updatedBody = (sprintBody || '').replace(/## 🚦 Sprint Status[\s\S]*?---/g, '');
|
|
104
|
+
|
|
105
|
+
updatedBody +=
|
|
106
|
+
`\n\n---\n\n## 🚦 Sprint Status\n\n` +
|
|
107
|
+
`Progress: **${progressPercent}%**\n` +
|
|
108
|
+
`Time Elapsed: **${timePercent}%**\n\n` +
|
|
109
|
+
`Health: ${healthEmoji}\n\n` +
|
|
110
|
+
`### 📉 Burn-down\n` +
|
|
111
|
+
`\`${burnDown}\` ${progressPercent}%\n\n---`;
|
|
112
|
+
|
|
113
|
+
return updatedBody;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = {
|
|
117
|
+
parseParentSprintNumber,
|
|
118
|
+
parseSprintDates,
|
|
119
|
+
computeTimePercent,
|
|
120
|
+
computeProgress,
|
|
121
|
+
computeHealthEmoji,
|
|
122
|
+
renderBurnDown,
|
|
123
|
+
updateSprintBody,
|
|
124
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Pure parsing logic for sprint-child-creator.yml, extracted so it can be
|
|
4
|
+
// unit tested directly (see test/sprint-child-creator.test.js) instead of
|
|
5
|
+
// only verified by hand.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Extracts the feature list from a sprint-planning issue body's
|
|
9
|
+
* "### Sprint Features" section (one feature per line, up to the next
|
|
10
|
+
* heading or end of body).
|
|
11
|
+
*
|
|
12
|
+
* @param {string} body - the sprint issue's body
|
|
13
|
+
* @returns {string[]} feature titles, in the order they appear. Empty if
|
|
14
|
+
* there's no Sprint Features section, or it has no non-blank lines.
|
|
15
|
+
*/
|
|
16
|
+
function parseFeatures(body) {
|
|
17
|
+
const featuresMatch = (body || '').match(/### Sprint Features[\s\S]*?(?=###|$)/);
|
|
18
|
+
if (!featuresMatch) return [];
|
|
19
|
+
|
|
20
|
+
return featuresMatch[0]
|
|
21
|
+
.split('\n')
|
|
22
|
+
.map((line) => line.trim())
|
|
23
|
+
.filter((line) => line && !line.startsWith('###'));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {number} parentNumber - the sprint (parent) issue number
|
|
28
|
+
* @returns {string} body to use for each generated child issue
|
|
29
|
+
*/
|
|
30
|
+
function buildChildBody(parentNumber) {
|
|
31
|
+
return `Parent Sprint: #${parentNumber}\n\n---\n*Created by Delivery OS Sprint Child Creator*`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { parseFeatures, buildChildBody };
|
|
@@ -58,19 +58,40 @@ jobs:
|
|
|
58
58
|
|
|
59
59
|
const { releaseVerdict, qaApproved } = computeVerdict(comments, releaseApprover, qaApprover);
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
// Nothing to act on this run (most comments on a production issue
|
|
62
|
+
// are routine discussion, not a verdict-changing approval/decline)
|
|
63
|
+
// — return before the live label fetch below, which only matters
|
|
64
|
+
// once we actually have a verdict to check against current state.
|
|
65
|
+
const hasActionableVerdict =
|
|
66
|
+
releaseVerdict === "declined" || (releaseVerdict === "approved" && qaApproved);
|
|
67
|
+
if (!hasActionableVerdict) return;
|
|
68
|
+
|
|
69
|
+
// Fetch labels live rather than trusting context.payload.issue.labels:
|
|
70
|
+
// that's a snapshot fixed at the triggering event's delivery time.
|
|
71
|
+
// Two comments posted seconds apart queue under the concurrency
|
|
72
|
+
// group above and run sequentially, but each was still delivered
|
|
73
|
+
// (and its label snapshot captured) before either job actually ran
|
|
74
|
+
// — serializing execution order doesn't refresh an already-stale
|
|
75
|
+
// snapshot. A live re-fetch is what actually lets the second
|
|
76
|
+
// (queued) run see what the first one just did.
|
|
77
|
+
const { data: liveIssue } = await github.rest.issues.get({ owner, repo, issue_number: issueNumber });
|
|
78
|
+
const currentLabels = (liveIssue.labels || []).map((l) => (typeof l === "string" ? l : l.name));
|
|
62
79
|
const alreadyDeclined = currentLabels.includes("declined");
|
|
63
80
|
const alreadyReady = currentLabels.includes("ready-for-deploy");
|
|
64
81
|
|
|
65
82
|
if (releaseVerdict === "declined") {
|
|
66
83
|
if (alreadyDeclined) return; // avoid re-posting on every later comment
|
|
67
84
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
85
|
+
try {
|
|
86
|
+
await github.rest.issues.addLabels({
|
|
87
|
+
owner,
|
|
88
|
+
repo,
|
|
89
|
+
issue_number: issueNumber,
|
|
90
|
+
labels: ["declined"]
|
|
91
|
+
});
|
|
92
|
+
} catch (e) {
|
|
93
|
+
console.log(`Failed to add 'declined' label: ${e.message || e}`);
|
|
94
|
+
}
|
|
74
95
|
|
|
75
96
|
try {
|
|
76
97
|
await github.rest.issues.removeLabel({
|
|
@@ -103,12 +124,16 @@ jobs:
|
|
|
103
124
|
});
|
|
104
125
|
} catch (e) {}
|
|
105
126
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
127
|
+
try {
|
|
128
|
+
await github.rest.issues.addLabels({
|
|
129
|
+
owner,
|
|
130
|
+
repo,
|
|
131
|
+
issue_number: issueNumber,
|
|
132
|
+
labels: ["ready-for-deploy"]
|
|
133
|
+
});
|
|
134
|
+
} catch (e) {
|
|
135
|
+
console.log(`Failed to add 'ready-for-deploy' label: ${e.message || e}`);
|
|
136
|
+
}
|
|
112
137
|
|
|
113
138
|
await github.rest.issues.createComment({
|
|
114
139
|
owner,
|
|
@@ -70,17 +70,23 @@ jobs:
|
|
|
70
70
|
|
|
71
71
|
// Paginate: a plain listForRepo call caps at one page (100 issues) and
|
|
72
72
|
// would silently undercount children in repos with more open+closed
|
|
73
|
-
// issues than that, skewing the burn-down.
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
73
|
+
// issues than that, skewing the burn-down.
|
|
74
|
+
//
|
|
75
|
+
// Deliberately NOT scoped to labels: "sprint-active" (an earlier
|
|
76
|
+
// version of this workflow did that, for cost — cheaper in repos
|
|
77
|
+
// with a lot of unrelated issue history). That's wrong: a child
|
|
78
|
+
// issue relabeled away from sprint-active during triage (e.g. to
|
|
79
|
+
// "bug") while still open would silently drop out of BOTH the
|
|
80
|
+
// numerator and denominator of the progress calculation instead
|
|
81
|
+
// of just being excluded from the closed count — the sprint could
|
|
82
|
+
// report inflated progress, or even reach a false 100% and
|
|
83
|
+
// auto-close, while that untracked child is still open. Matches
|
|
84
|
+
// docs/governance.md's documented trigger ("body has Parent
|
|
85
|
+
// Sprint"), which is body-content-based, not label-based.
|
|
79
86
|
const allIssues = await github.paginate(github.rest.issues.listForRepo, {
|
|
80
87
|
owner: context.repo.owner,
|
|
81
88
|
repo: context.repo.repo,
|
|
82
89
|
state: "all",
|
|
83
|
-
labels: "sprint-active",
|
|
84
90
|
per_page: 100
|
|
85
91
|
});
|
|
86
92
|
|
|
@@ -103,14 +103,20 @@ jobs:
|
|
|
103
103
|
# Case-insensitive actor comparison: GitHub logins are case-insensitive,
|
|
104
104
|
# but a plain bash == is not, so a RELEASE_APPROVER var with different
|
|
105
105
|
# casing than the actual login would otherwise never match here.
|
|
106
|
-
# Anchored to the start of a line
|
|
107
|
-
# convention as authorize-deployment.yml's
|
|
108
|
-
# can't fire for a comment that
|
|
109
|
-
# decline (e.g. "not
|
|
110
|
-
|
|
106
|
+
# Anchored to the start of a line + word boundary (\b), matching the
|
|
107
|
+
# same keyword set and convention as authorize-deployment.yml's real
|
|
108
|
+
# DECLINE_RE/APPROVE_RE, so this alert can't fire for a comment that
|
|
109
|
+
# workflow wouldn't actually treat as a decline/approval (e.g. "not
|
|
110
|
+
# approved" mid-sentence, or "okay, I'll look at this tomorrow" —
|
|
111
|
+
# "ok" without \b would otherwise match as a false approval).
|
|
112
|
+
if [[ "${ACTOR,,}" == "${RELEASE_APPROVER,,}" ]] && echo "$COMMENT_BODY" | grep -iqE "^(declined|rejected|reject|not approved)\b"; then
|
|
111
113
|
MESSAGE="🔴🛑 RELEASE DECLINED%0A$TITLE%0A$URL%0A---%0A👤 $ACTOR%0A🕒 $TIMESTAMP"
|
|
112
114
|
|
|
113
|
-
|
|
115
|
+
# Matches authorize-deployment.yml's full APPROVE_RE keyword set
|
|
116
|
+
# (approved|approve|ok|go ahead), not just "approved" — it used to
|
|
117
|
+
# miss "ok"/"go ahead"/"approve", so a release could be approved
|
|
118
|
+
# for real with no Telegram alert sent for it.
|
|
119
|
+
elif [[ "${ACTOR,,}" == "${RELEASE_APPROVER,,}" ]] && echo "$COMMENT_BODY" | grep -iqE "^(approved|approve|ok|go ahead)\b"; then
|
|
114
120
|
MESSAGE="🟢🛡️ RELEASE APPROVED%0A$TITLE%0A$URL%0A---%0A👤 $ACTOR%0A🕒 $TIMESTAMP"
|
|
115
121
|
|
|
116
122
|
elif [[ "${{ contains(github.event.issue.labels.*.name || fromJSON('[]'), 'bug') }}" == "true" ]]; then
|
package/README.md
CHANGED
|
@@ -63,15 +63,15 @@ npx github-delivery-os uninstall --dry-run . # Preview (no changes)
|
|
|
63
63
|
|
|
64
64
|
**What gets installed:**
|
|
65
65
|
|
|
66
|
-
| Workflow | Purpose |
|
|
67
|
-
|
|
68
|
-
| `sprint-child-creator` |
|
|
69
|
-
| `auto-close-sprint` |
|
|
70
|
-
| `notify-release-approver` |
|
|
71
|
-
| `authorize-deployment` |
|
|
72
|
-
| `auto-assign-qa` |
|
|
73
|
-
| `telegram-issues` | Telegram
|
|
74
|
-
| `setup-labels` | One-time
|
|
66
|
+
| Workflow | Trigger | Purpose |
|
|
67
|
+
|----------|---------|---------|
|
|
68
|
+
| `sprint-child-creator` | Issue opened, title contains `SPRINT -` | Parses "Sprint Features (One Per Line)" and creates one child issue per line, each linked back with `Parent Sprint: #N` |
|
|
69
|
+
| `auto-close-sprint` | Issue closed, body contains `Parent Sprint` | Recomputes the parent sprint's burn-down/health and rewrites its status section; auto-closes the sprint at 100% |
|
|
70
|
+
| `notify-release-approver` | Issue opened, labeled `production` | Comments on the issue tagging the repo's `RELEASE_APPROVER` |
|
|
71
|
+
| `authorize-deployment` | Comment posted on a `production`-labeled issue | Checks the commenter and keyword against `RELEASE_APPROVER`/`QA_APPROVER`; once both approve, adds `ready-for-deploy` |
|
|
72
|
+
| `auto-assign-qa` | Issue opened/labeled `qa` or `qa-request` | Assigns the repo's configured `QA_ASSIGNEES` |
|
|
73
|
+
| `telegram-issues` | Issue/comment/PR events | Sends a Telegram alert if `TELEGRAM_BOT_TOKEN`/`TELEGRAM_CHAT_ID` are configured |
|
|
74
|
+
| `setup-labels` | Manual (`workflow_dispatch`) | One-time run that creates all labels Delivery OS needs |
|
|
75
75
|
|
|
76
76
|
Workflows and templates are **copied directly** into your repo. No `workflow_call` or external references.
|
|
77
77
|
|
|
@@ -104,6 +104,230 @@ When you open an issue using the **Sprint Planning** template with a title like
|
|
|
104
104
|
|
|
105
105
|
---
|
|
106
106
|
|
|
107
|
+
## Operating From Claude Code (`--with-skill`)
|
|
108
|
+
|
|
109
|
+
Installing with `--with-skill` drops a `.claude/skills/delivery-ops/SKILL.md` skill into the repo, scoped to *this* repo's Delivery OS install. It lets anyone using [Claude Code](https://claude.com/claude-code) drive the workflows above by asking in plain language — e.g. "create a sprint for the checkout redesign" — instead of hand-building `gh issue create` calls and remembering each template's exact field names.
|
|
110
|
+
|
|
111
|
+
Before doing anything, it checks the target repo: confirms Delivery OS is actually installed, that `Setup Labels` has been run, and that `RELEASE_APPROVER` / `QA_APPROVER` / `QA_ASSIGNEES` are configured — flagging (or offering to fix) gaps instead of silently creating an issue that does nothing. It also always shows the constructed title/body/labels or comment text for confirmation before creating or posting for real, since these are visible actions in the repo's activity, not a local preview.
|
|
112
|
+
|
|
113
|
+
### Create a sprint
|
|
114
|
+
1. Ask: *"Create a sprint called Sprint 14 for \<goal>, running \<start> to \<end>, with features: \<one per line>"*
|
|
115
|
+
2. It opens an issue titled `SPRINT - Sprint 14`, labeled `sprint` + `planning`, with `### Sprint Name` / `Start` / `End` / `Goal` / `Features (One Per Line)` / `Approved` fields filled in.
|
|
116
|
+
3. On open, `sprint-child-creator` splits each feature line into its own child issue, labeled `sprint-active` and linked back with `Parent Sprint: #N`.
|
|
117
|
+
4. Close each child issue as work finishes — that's what advances the burn-down; `auto-close-sprint` recomputes it and rewrites the parent's `## 🚦 Sprint Status` section, auto-closing the sprint at 100%.
|
|
118
|
+
|
|
119
|
+
### Request a production release
|
|
120
|
+
1. Ask: *"Open a production release for \<project> v1.2.0, sprint #N, summary: \<summary>"*
|
|
121
|
+
2. It opens an issue titled `PRODUCTION RELEASE - <project> - v1.2.0`, labeled `release` + `production` + `approval`, with the sprint reference, version, release summary, QA summary/links, and `Deployment Authorized: No`.
|
|
122
|
+
3. On open, `notify-release-approver` comments tagging the repo's configured `RELEASE_APPROVER`.
|
|
123
|
+
|
|
124
|
+
### Approve or decline a release
|
|
125
|
+
1. Ask: *"Approve release #N"* or *"Decline release #N — \<reason>"*.
|
|
126
|
+
2. It checks that the currently authenticated `gh` login matches the repo's `RELEASE_APPROVER` — if not, it stops and says so instead of posting a comment that would silently do nothing.
|
|
127
|
+
3. It posts a comment starting with a recognized keyword (`approved`, `ok`, `go ahead` to approve; `declined`, `rejected`, `not approved` to decline) — `authorize-deployment` only reacts to that leading keyword from the exact configured approver. A later comment from the same approver overrides an earlier one.
|
|
128
|
+
|
|
129
|
+
### Request QA
|
|
130
|
+
1. Ask: *"Open a QA request for \<feature>, related to issue #N, testing \<what/where>"*
|
|
131
|
+
2. It opens an issue titled `QA REQUEST - <feature>`, labeled `qa-request`, with the related task, what to test, environment/build link, and acceptance criteria.
|
|
132
|
+
3. On open, `auto-assign-qa` assigns the repo's configured `QA_ASSIGNEES`.
|
|
133
|
+
|
|
134
|
+
### Approve QA
|
|
135
|
+
1. Ask: *"Mark QA #N as approved"* (or *"looks good"*).
|
|
136
|
+
2. Same login check as release approval, but against `QA_APPROVER`.
|
|
137
|
+
3. Posts a comment starting with `qa approved`, `approved`, `qa ok`, or `looks good`.
|
|
138
|
+
|
|
139
|
+
### Report a bug
|
|
140
|
+
1. Ask: *"File a bug: \<one-line summary>, severity \<level>, steps: \<...>"*
|
|
141
|
+
2. It opens an issue titled `[BUG] <summary>`, labeled `bug` + `qa`, with platform, severity, build/version, steps to reproduce, expected vs. actual result, and test environment.
|
|
142
|
+
|
|
143
|
+
### Track a task
|
|
144
|
+
1. Ask: *"Create a task to \<summary>, owner \<name>, priority P1"*
|
|
145
|
+
2. It opens an issue titled `TASK - <summary>`, labeled `task`, with owner, priority, status, acceptance criteria, and links. (No workflow trigger — this is plain tracking.)
|
|
146
|
+
|
|
147
|
+
### Check status
|
|
148
|
+
- *"What's the status of issue #N?"* → current labels and latest comments.
|
|
149
|
+
- *"How's Sprint 14 doing?"* → reads the parent issue's `## 🚦 Sprint Status` section (progress %, time elapsed, health, burn-down bar).
|
|
150
|
+
- *"What releases are waiting on approval?"* / *"What sprints are active?"* / *"What QA requests are open?"* → lists issues by label (`production`, `sprint`, `qa-request`) so you don't need an issue number in hand.
|
|
151
|
+
|
|
152
|
+
See [How To](docs/how-to.md) for the underlying workflows this drives, field by field.
|
|
153
|
+
|
|
154
|
+
### Example: filing a bug and a task, step by step
|
|
155
|
+
|
|
156
|
+
Worked against `jkaweesi22/klero`, a repo with Delivery OS installed. The bug part of this was actually run — that issue really exists at [jkaweesi22/klero#1](https://github.com/jkaweesi22/klero/issues/1); the task part follows the identical steps but wasn't actually created, shown for the field shape only.
|
|
157
|
+
|
|
158
|
+
**Filing the bug:**
|
|
159
|
+
|
|
160
|
+
1. In Claude Code, inside (or pointed at, via `--repo`) the target repo, describe the bug in plain language:
|
|
161
|
+
> *File a bug on jkaweesi22/klero — the order request form submits with an empty phone number, severity high. Steps: fill in the form, leave phone blank, submit. Expected: should block submission. Actual: submits anyway, so there's no way to contact the customer. Tested on Chrome, desktop, production.*
|
|
162
|
+
2. Claude pre-flights the repo — confirms Delivery OS is installed and the `bug`/`qa` labels exist.
|
|
163
|
+
3. Claude shows the exact issue it's about to create before doing anything:
|
|
164
|
+
- Title: `[BUG] Order request form submits with empty phone number`
|
|
165
|
+
- Labels: `bug`, `qa`
|
|
166
|
+
- Body, field by field: `Platform(s) Affected` → Web, `Severity` → High, `Build / Version` → main (as deployed), `Bug Summary`, `Steps to Reproduce` (numbered), `Expected Result`, `Actual Result`, `Test Environment` → Chrome, desktop, production.
|
|
167
|
+
4. Confirm ("yes") when asked to create it.
|
|
168
|
+
5. Claude runs `gh issue create` and reports back the issue — [jkaweesi22/klero#1](https://github.com/jkaweesi22/klero/issues/1). Nothing else happens automatically; bug reports don't trigger a workflow, they're just labeled and tracked.
|
|
169
|
+
|
|
170
|
+
**Filing the follow-up task:**
|
|
171
|
+
|
|
172
|
+
1. Ask Claude to turn the bug into tracked work, referencing the bug's issue number:
|
|
173
|
+
> *Create a task to add phone number validation to the order form, owner @jkaweesi22, priority P1, status Backlog, acceptance: form blocks submission until a valid phone number is entered. Link it to #1.*
|
|
174
|
+
2. Claude shows the constructed issue:
|
|
175
|
+
- Title: `TASK - Add phone number validation to order form`
|
|
176
|
+
- Labels: `task`
|
|
177
|
+
- Body: `Task Summary`, `Description` (references #1), `Owner` → @jkaweesi22, `Priority` → P1 - High, `Status` → Backlog, `Acceptance Criteria`, `Artifacts / Links` → Related bug: #1.
|
|
178
|
+
3. Confirm ("yes") to create it.
|
|
179
|
+
4. Claude runs `gh issue create`. Same as the bug — no workflow trigger, just labeled and tracked, now linked back to the bug it addresses.
|
|
180
|
+
|
|
181
|
+
### Full example: shipping a feature end-to-end
|
|
182
|
+
|
|
183
|
+
A realistic Claude Code session, run from inside the consumer repo (the skill defaults to it since it was installed with `--with-skill` there). `RELEASE_APPROVER`, `QA_APPROVER`, and `QA_ASSIGNEES` are already configured.
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
You: Create a sprint called "Sprint 14 - Checkout Revamp", running 2026-09-16 to
|
|
187
|
+
2026-09-30, goal is shipping the new checkout flow, features:
|
|
188
|
+
- Redesign cart summary
|
|
189
|
+
- Add saved payment methods
|
|
190
|
+
- Fix tax rounding bug
|
|
191
|
+
|
|
192
|
+
Claude: This will create an issue in acme/storefront:
|
|
193
|
+
|
|
194
|
+
Title: SPRINT - Sprint 14 - Checkout Revamp
|
|
195
|
+
Labels: sprint, planning
|
|
196
|
+
Body:
|
|
197
|
+
### Sprint Name
|
|
198
|
+
Sprint 14 - Checkout Revamp
|
|
199
|
+
### Sprint Start
|
|
200
|
+
2026-09-16
|
|
201
|
+
### Sprint End
|
|
202
|
+
2026-09-30
|
|
203
|
+
### Sprint Goal
|
|
204
|
+
Ship the new checkout flow
|
|
205
|
+
### Sprint Features (One Per Line)
|
|
206
|
+
Redesign cart summary
|
|
207
|
+
Add saved payment methods
|
|
208
|
+
Fix tax rounding bug
|
|
209
|
+
### Sprint Approved
|
|
210
|
+
Pending
|
|
211
|
+
|
|
212
|
+
Create this issue?
|
|
213
|
+
|
|
214
|
+
You: yes
|
|
215
|
+
|
|
216
|
+
Claude: Created #52. sprint-child-creator will fire on open and create one child
|
|
217
|
+
issue per feature — #53 "Redesign cart summary", #54 "Add saved payment
|
|
218
|
+
methods", #55 "Fix tax rounding bug" — each labeled sprint-active and
|
|
219
|
+
linked back with "Parent Sprint: #52".
|
|
220
|
+
|
|
221
|
+
[ ... engineers do the work, close #53 and #54 as they land ... ]
|
|
222
|
+
|
|
223
|
+
You: How's Sprint 14 doing?
|
|
224
|
+
|
|
225
|
+
Claude: Reading #52... ## 🚦 Sprint Status: 67% complete (2/3 closed), 20% of
|
|
226
|
+
sprint time elapsed, 🟢 on track.
|
|
227
|
+
|
|
228
|
+
You: QA found a rounding issue in the tax calc — file a bug. Severity high,
|
|
229
|
+
repros on checkout with 3+ items, expected total matches line items,
|
|
230
|
+
actual is off by $0.01.
|
|
231
|
+
|
|
232
|
+
Claude: This will create an issue in acme/storefront:
|
|
233
|
+
|
|
234
|
+
Title: [BUG] Tax rounding off by $0.01 with 3+ items
|
|
235
|
+
Labels: bug, qa
|
|
236
|
+
Body: (Platform, Severity: High, Steps to Reproduce, Expected vs.
|
|
237
|
+
Actual Result, Test Environment filled in from what you said)
|
|
238
|
+
|
|
239
|
+
Create this issue?
|
|
240
|
+
|
|
241
|
+
You: yes
|
|
242
|
+
|
|
243
|
+
Claude: Created #56.
|
|
244
|
+
|
|
245
|
+
[ ... #55 fixed, referencing #56 ... ]
|
|
246
|
+
|
|
247
|
+
You: Open a QA request for the tax rounding fix, related to #55, test on
|
|
248
|
+
staging, acceptance: totals match to the cent for carts of 1-10 items.
|
|
249
|
+
|
|
250
|
+
Claude: This will create an issue in acme/storefront:
|
|
251
|
+
|
|
252
|
+
Title: QA REQUEST - Tax rounding fix
|
|
253
|
+
Labels: qa-request
|
|
254
|
+
Body: (Related Sprint Task Issue #55, What to Test, Environment +
|
|
255
|
+
Build Link: staging, Acceptance Criteria filled in)
|
|
256
|
+
|
|
257
|
+
Create this issue?
|
|
258
|
+
|
|
259
|
+
You: yes
|
|
260
|
+
|
|
261
|
+
Claude: Created #57. auto-assign-qa will assign it to the configured
|
|
262
|
+
QA_ASSIGNEES.
|
|
263
|
+
|
|
264
|
+
[ ... QA tests it, comes back clean ... ]
|
|
265
|
+
|
|
266
|
+
You: Mark QA #57 as approved, looks good.
|
|
267
|
+
|
|
268
|
+
Claude: Checking gh auth status... authenticated as jane-qa, which matches this
|
|
269
|
+
repo's QA_APPROVER. Posting:
|
|
270
|
+
|
|
271
|
+
"looks good — verified on staging, totals match to the cent"
|
|
272
|
+
|
|
273
|
+
on #57.
|
|
274
|
+
|
|
275
|
+
You: yes, post it
|
|
276
|
+
|
|
277
|
+
Claude: Posted. Now close #55 to update the sprint burn-down?
|
|
278
|
+
|
|
279
|
+
You: yes
|
|
280
|
+
|
|
281
|
+
Claude: Closed #55. Sprint 14 is now 100% complete — auto-close-sprint closed
|
|
282
|
+
#52 and posted a completion comment.
|
|
283
|
+
|
|
284
|
+
You: Open a production release for storefront v2.4.1, sprint #52, summary
|
|
285
|
+
"checkout revamp: new cart summary, saved payment methods, tax rounding
|
|
286
|
+
fix", QA evidence: #57.
|
|
287
|
+
|
|
288
|
+
Claude: This will create an issue in acme/storefront:
|
|
289
|
+
|
|
290
|
+
Title: PRODUCTION RELEASE - storefront - v2.4.1
|
|
291
|
+
Labels: release, production, approval
|
|
292
|
+
Body:
|
|
293
|
+
### Sprint Reference (Sprint Planning Issue #)
|
|
294
|
+
#52
|
|
295
|
+
### Version / Build Number
|
|
296
|
+
v2.4.1
|
|
297
|
+
### Release Summary
|
|
298
|
+
Checkout revamp: new cart summary, saved payment methods, tax
|
|
299
|
+
rounding fix
|
|
300
|
+
### QA Summary + Evidence Links
|
|
301
|
+
#57
|
|
302
|
+
### Overall QA Recommendation
|
|
303
|
+
Approve for Production
|
|
304
|
+
### Deployment Authorized
|
|
305
|
+
No
|
|
306
|
+
|
|
307
|
+
Create this issue?
|
|
308
|
+
|
|
309
|
+
You: yes
|
|
310
|
+
|
|
311
|
+
Claude: Created #58. notify-release-approver posted a comment tagging this
|
|
312
|
+
repo's RELEASE_APPROVER (john-lead).
|
|
313
|
+
|
|
314
|
+
[ ... some time later, as john-lead ... ]
|
|
315
|
+
|
|
316
|
+
You: Approve release #58.
|
|
317
|
+
|
|
318
|
+
Claude: Checking gh auth status... authenticated as john-lead, which matches
|
|
319
|
+
RELEASE_APPROVER. Posting "approved" on #58.
|
|
320
|
+
|
|
321
|
+
You: yes
|
|
322
|
+
|
|
323
|
+
Claude: Posted. authorize-deployment will add ready-for-deploy once both
|
|
324
|
+
approvers (release + QA) have a qualifying comment on record.
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Each `yes` above is a real confirmation gate — Claude shows the exact issue/comment content before it creates or posts anything, since it's a visible action in the repo.
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
107
331
|
## Documentation
|
|
108
332
|
|
|
109
333
|
| Document | Description |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "github-delivery-os",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "A GitHub-native Delivery Governance Framework for structured sprint execution, QA review, and collaborative production release control.",
|
|
5
5
|
"main": "src/install.js",
|
|
6
6
|
"bin": {
|
|
@@ -45,6 +45,10 @@
|
|
|
45
45
|
".github/workflows/auto-assign-qa.yml",
|
|
46
46
|
".github/workflows/telegram-issues.yml",
|
|
47
47
|
".github/workflows/setup-labels.yml",
|
|
48
|
+
".github/scripts/authorize-deployment-verdict.js",
|
|
49
|
+
".github/scripts/auto-close-sprint.js",
|
|
50
|
+
".github/scripts/sprint-child-creator.js",
|
|
51
|
+
".github/scripts/package.json",
|
|
48
52
|
".github/ISSUE_TEMPLATE",
|
|
49
53
|
".claude/skills/delivery-ops"
|
|
50
54
|
],
|
package/src/install.js
CHANGED
|
@@ -20,6 +20,33 @@ const WORKFLOWS = [
|
|
|
20
20
|
'setup-labels',
|
|
21
21
|
];
|
|
22
22
|
|
|
23
|
+
// Pure logic some of the workflows above require() at runtime from
|
|
24
|
+
// .github/scripts/<name>.js (see .github/workflows/authorize-deployment.yml
|
|
25
|
+
// etc.) — these are required dependencies of those workflows, not optional,
|
|
26
|
+
// so they're always copied alongside them, the same as WORKFLOWS. Also list
|
|
27
|
+
// them explicitly in package.json's "files".
|
|
28
|
+
const SCRIPTS = ['authorize-deployment-verdict', 'auto-close-sprint', 'sprint-child-creator'];
|
|
29
|
+
|
|
30
|
+
// These scripts are CommonJS (`require`/`module.exports`). Node picks CJS vs.
|
|
31
|
+
// ESM per-file by walking up to the nearest package.json — so a consumer repo
|
|
32
|
+
// whose own root package.json has `"type": "module"` would otherwise make
|
|
33
|
+
// Node treat these .js files as ES modules too, breaking `require()` at
|
|
34
|
+
// runtime with "ReferenceError: module is not defined in ES module scope".
|
|
35
|
+
// This override pins the .github/scripts subtree to CommonJS regardless of
|
|
36
|
+
// the consumer's own type field. Always installed alongside SCRIPTS, same as
|
|
37
|
+
// SCRIPTS is alongside WORKFLOWS — not itself require()'d by anything, but a
|
|
38
|
+
// required dependency of every script that is.
|
|
39
|
+
const SCRIPTS_PACKAGE_JSON = 'package.json';
|
|
40
|
+
|
|
41
|
+
// Which workflow requires which script, so `status` can flag a workflow
|
|
42
|
+
// that's present but whose required script is missing (an install that will
|
|
43
|
+
// fail with MODULE_NOT_FOUND the next time that workflow actually runs).
|
|
44
|
+
const REQUIRED_SCRIPT_BY_WORKFLOW = {
|
|
45
|
+
'authorize-deployment': 'authorize-deployment-verdict',
|
|
46
|
+
'auto-close-sprint': 'auto-close-sprint',
|
|
47
|
+
'sprint-child-creator': 'sprint-child-creator',
|
|
48
|
+
};
|
|
49
|
+
|
|
23
50
|
const LABELS = [
|
|
24
51
|
['intake', '0E8A16'],
|
|
25
52
|
['bug', 'D93F0B'],
|
|
@@ -101,6 +128,44 @@ function fetchLatestVersion(timeoutMs = 3000) {
|
|
|
101
128
|
});
|
|
102
129
|
}
|
|
103
130
|
|
|
131
|
+
// Copies each `${name}${ext}` from srcDir to destDir for a fixed list of
|
|
132
|
+
// expected filenames — the shared logic behind copying WORKFLOWS and SCRIPTS
|
|
133
|
+
// (both: a required, always-on set of individually-named files, as opposed
|
|
134
|
+
// to templates, which copies whatever's found in a directory, or the skill,
|
|
135
|
+
// a single optional file). `label` is what's printed for each entry, e.g.
|
|
136
|
+
// `.github/scripts/auto-close-sprint.js` — pass names already including
|
|
137
|
+
// their directory prefix so log lines are self-explanatory on their own.
|
|
138
|
+
function copyManagedFiles(names, ext, srcDir, destDir, { overwrite, dryRun, relDir }) {
|
|
139
|
+
let copied = 0;
|
|
140
|
+
let skipped = 0;
|
|
141
|
+
|
|
142
|
+
for (const name of names) {
|
|
143
|
+
const src = path.join(srcDir, `${name}${ext}`);
|
|
144
|
+
const dest = path.join(destDir, `${name}${ext}`);
|
|
145
|
+
const label = `${relDir}/${name}${ext}`;
|
|
146
|
+
|
|
147
|
+
if (!fs.existsSync(src)) {
|
|
148
|
+
console.log(` Warning: source not found: ${label}`);
|
|
149
|
+
skipped++; // missing source must block a "clean install" claim, not just warn
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (fs.existsSync(dest) && !overwrite) {
|
|
154
|
+
console.log(` Skipped (exists): ${label}`);
|
|
155
|
+
skipped++;
|
|
156
|
+
} else if (dryRun) {
|
|
157
|
+
console.log(` [dry-run] Would create: ${label}`);
|
|
158
|
+
copied++;
|
|
159
|
+
} else {
|
|
160
|
+
fs.copyFileSync(src, dest);
|
|
161
|
+
console.log(` Created: ${label}`);
|
|
162
|
+
copied++;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { copied, skipped };
|
|
167
|
+
}
|
|
168
|
+
|
|
104
169
|
function getPackageRoot() {
|
|
105
170
|
// When installed via npm, __dirname is node_modules/github-delivery-os/src
|
|
106
171
|
const possibleRoots = [
|
|
@@ -129,6 +194,7 @@ function runInstall(options) {
|
|
|
129
194
|
const pkgRoot = getPackageRoot();
|
|
130
195
|
const workflowsSrc = path.join(pkgRoot, '.github', 'workflows');
|
|
131
196
|
const templatesSrc = path.join(pkgRoot, '.github', 'ISSUE_TEMPLATE');
|
|
197
|
+
const scriptsSrc = path.join(pkgRoot, '.github', 'scripts');
|
|
132
198
|
const skillSrc = path.join(pkgRoot, SKILL_REL_PATH);
|
|
133
199
|
const targetAbs = path.resolve(process.cwd(), targetDir);
|
|
134
200
|
|
|
@@ -151,41 +217,57 @@ function runInstall(options) {
|
|
|
151
217
|
// Ensure target structure
|
|
152
218
|
const workflowsDest = path.join(targetAbs, '.github', 'workflows');
|
|
153
219
|
const templatesDest = path.join(targetAbs, '.github', 'ISSUE_TEMPLATE');
|
|
220
|
+
const scriptsDest = path.join(targetAbs, '.github', 'scripts');
|
|
154
221
|
|
|
155
222
|
if (!dryRun) {
|
|
156
223
|
fs.mkdirSync(workflowsDest, { recursive: true });
|
|
157
224
|
fs.mkdirSync(templatesDest, { recursive: true });
|
|
225
|
+
fs.mkdirSync(scriptsDest, { recursive: true });
|
|
158
226
|
}
|
|
159
227
|
|
|
160
228
|
let workflowsCopied = 0;
|
|
161
229
|
let templatesCopied = 0;
|
|
230
|
+
let scriptsCopied = 0;
|
|
162
231
|
let skillCopied = 0;
|
|
163
|
-
let workflowsSkipped
|
|
164
|
-
let
|
|
165
|
-
let skillSkipped = 0;
|
|
166
|
-
|
|
167
|
-
// Copy workflows
|
|
168
|
-
for (const wf of WORKFLOWS) {
|
|
169
|
-
const src = path.join(workflowsSrc, `${wf}.yml`);
|
|
170
|
-
const dest = path.join(workflowsDest, `${wf}.yml`);
|
|
232
|
+
let workflowsSkipped;
|
|
233
|
+
let scriptsSkipped;
|
|
171
234
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
235
|
+
// Copy workflows (always on — not optional)
|
|
236
|
+
({ copied: workflowsCopied, skipped: workflowsSkipped } = copyManagedFiles(
|
|
237
|
+
WORKFLOWS,
|
|
238
|
+
'.yml',
|
|
239
|
+
workflowsSrc,
|
|
240
|
+
workflowsDest,
|
|
241
|
+
{ overwrite, dryRun, relDir: '.github/workflows' }
|
|
242
|
+
));
|
|
243
|
+
|
|
244
|
+
// Copy the scripts the workflows above require() at runtime — required,
|
|
245
|
+
// not optional, so (unlike templates/skill) this always runs too.
|
|
246
|
+
({ copied: scriptsCopied, skipped: scriptsSkipped } = copyManagedFiles(
|
|
247
|
+
SCRIPTS,
|
|
248
|
+
'.js',
|
|
249
|
+
scriptsSrc,
|
|
250
|
+
scriptsDest,
|
|
251
|
+
{ overwrite, dryRun, relDir: '.github/scripts' }
|
|
252
|
+
));
|
|
253
|
+
|
|
254
|
+
// The CommonJS-pinning package.json (see SCRIPTS_PACKAGE_JSON above) —
|
|
255
|
+
// always installed alongside SCRIPTS, via the same helper, counted the
|
|
256
|
+
// same way (mirrors how scripts/install.sh reuses copy_managed_files for
|
|
257
|
+
// this exact file rather than hand-rolling the copy).
|
|
258
|
+
const { name: scriptsPkgName, ext: scriptsPkgExt } = path.parse(SCRIPTS_PACKAGE_JSON);
|
|
259
|
+
const scriptsPkgResult = copyManagedFiles(
|
|
260
|
+
[scriptsPkgName],
|
|
261
|
+
scriptsPkgExt,
|
|
262
|
+
scriptsSrc,
|
|
263
|
+
scriptsDest,
|
|
264
|
+
{ overwrite, dryRun, relDir: '.github/scripts' }
|
|
265
|
+
);
|
|
266
|
+
scriptsCopied += scriptsPkgResult.copied;
|
|
267
|
+
scriptsSkipped += scriptsPkgResult.skipped;
|
|
176
268
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
workflowsSkipped++;
|
|
180
|
-
} else if (dryRun) {
|
|
181
|
-
console.log(` [dry-run] Would create: ${wf}.yml`);
|
|
182
|
-
workflowsCopied++;
|
|
183
|
-
} else {
|
|
184
|
-
fs.copyFileSync(src, dest);
|
|
185
|
-
console.log(` Created: ${wf}.yml`);
|
|
186
|
-
workflowsCopied++;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
269
|
+
let templatesSkipped = 0;
|
|
270
|
+
let skillSkipped = 0;
|
|
189
271
|
|
|
190
272
|
// Copy templates
|
|
191
273
|
if (withTemplates && fs.existsSync(templatesSrc)) {
|
|
@@ -291,12 +373,28 @@ function runInstall(options) {
|
|
|
291
373
|
}
|
|
292
374
|
|
|
293
375
|
// Record what got installed so `status` can report a version and detect
|
|
294
|
-
// drift. Only claim a version when the on-disk files actually match it
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
|
|
376
|
+
// drift. Only claim a version when the on-disk files actually match it.
|
|
377
|
+
//
|
|
378
|
+
// Two ways this can go wrong, both of which must block the claim:
|
|
379
|
+
// 1. A skip-mode install left older content on disk for something that
|
|
380
|
+
// WAS requested this run (tracked by the *Skipped counters below).
|
|
381
|
+
// 2. An --overwrite run touches only what was explicitly requested
|
|
382
|
+
// (workflows + scripts always; templates/skill only if their flags
|
|
383
|
+
// were passed) — templates or skill already on disk from an earlier
|
|
384
|
+
// install, but not requested this run, are left untouched and stale,
|
|
385
|
+
// even though --overwrite makes every *Skipped counter read 0. Naively
|
|
386
|
+
// trusting `overwrite` alone would then claim the whole install is
|
|
387
|
+
// current when part of it demonstrably wasn't touched.
|
|
388
|
+
const templatesPresentButNotTouched =
|
|
389
|
+
!withTemplates && TEMPLATES.some((t) => fs.existsSync(path.join(templatesDest, t)));
|
|
390
|
+
const skillPresentButNotTouched = !withSkill && fs.existsSync(skillPath(targetAbs));
|
|
391
|
+
const cleanInstall =
|
|
392
|
+
workflowsSkipped === 0 &&
|
|
393
|
+
templatesSkipped === 0 &&
|
|
394
|
+
scriptsSkipped === 0 &&
|
|
395
|
+
skillSkipped === 0 &&
|
|
396
|
+
!templatesPresentButNotTouched &&
|
|
397
|
+
!skillPresentButNotTouched;
|
|
300
398
|
if (!dryRun && cleanInstall) {
|
|
301
399
|
const pkgVersion = require(path.join(pkgRoot, 'package.json')).version;
|
|
302
400
|
writeManifest(targetAbs, pkgVersion);
|
|
@@ -305,19 +403,28 @@ function runInstall(options) {
|
|
|
305
403
|
// Summary
|
|
306
404
|
console.log('');
|
|
307
405
|
if (!dryRun && !cleanInstall) {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
406
|
+
if (templatesPresentButNotTouched || skillPresentButNotTouched) {
|
|
407
|
+
console.log(' Note: previously-installed templates and/or the Claude Code skill exist');
|
|
408
|
+
console.log(' on disk but were not requested this run, so the recorded Delivery OS');
|
|
409
|
+
console.log(' version was not updated. Re-run with --overwrite plus --with-templates');
|
|
410
|
+
console.log(' and/or --with-skill to bring everything (and the recorded version) in sync.');
|
|
411
|
+
} else {
|
|
412
|
+
console.log(' Note: some files already existed and were skipped, so the recorded');
|
|
413
|
+
console.log(' Delivery OS version was not updated. Re-run with --overwrite to sync');
|
|
414
|
+
console.log(' all files (and the recorded version) to the latest release.');
|
|
415
|
+
}
|
|
311
416
|
console.log('');
|
|
312
417
|
}
|
|
313
|
-
if (workflowsCopied > 0 || templatesCopied > 0 || skillCopied > 0 || labelsCreated > 0) {
|
|
418
|
+
if (workflowsCopied > 0 || templatesCopied > 0 || scriptsCopied > 0 || skillCopied > 0 || labelsCreated > 0) {
|
|
314
419
|
if (dryRun) {
|
|
315
420
|
if (workflowsCopied > 0) console.log(`Would install ${workflowsCopied} workflow(s).`);
|
|
316
421
|
if (templatesCopied > 0) console.log(`Would copy ${templatesCopied} issue template(s).`);
|
|
422
|
+
if (scriptsCopied > 0) console.log(`Would install ${scriptsCopied} supporting script(s).`);
|
|
317
423
|
if (skillCopied > 0) console.log('Would add the Claude Code delivery-ops skill.');
|
|
318
424
|
} else {
|
|
319
425
|
if (workflowsCopied > 0) console.log(`Installed ${workflowsCopied} workflow(s).`);
|
|
320
426
|
if (templatesCopied > 0) console.log(`Copied ${templatesCopied} issue template(s).`);
|
|
427
|
+
if (scriptsCopied > 0) console.log(`Installed ${scriptsCopied} supporting script(s).`);
|
|
321
428
|
if (skillCopied > 0) console.log('Added the Claude Code delivery-ops skill.');
|
|
322
429
|
if (labelsCreated > 0) console.log(`Created ${labelsCreated} label(s).`);
|
|
323
430
|
}
|
|
@@ -380,7 +487,31 @@ async function runStatus(options) {
|
|
|
380
487
|
);
|
|
381
488
|
const skillInstalled = fs.existsSync(skillPath(targetAbs));
|
|
382
489
|
|
|
383
|
-
|
|
490
|
+
// A workflow can be present while the script it require()s at runtime is
|
|
491
|
+
// not — e.g. an install from before this check existed, or a manual
|
|
492
|
+
// partial copy. That workflow will fail (MODULE_NOT_FOUND) the next time
|
|
493
|
+
// it actually runs, silently, since nothing here executes the workflow
|
|
494
|
+
// itself to notice.
|
|
495
|
+
const brokenWorkflows = installedWorkflows.filter((wf) => {
|
|
496
|
+
const requiredScript = REQUIRED_SCRIPT_BY_WORKFLOW[wf];
|
|
497
|
+
if (!requiredScript) return false;
|
|
498
|
+
return !fs.existsSync(path.join(targetAbs, '.github', 'scripts', `${requiredScript}.js`));
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// A script can be present while the CommonJS-pinning package.json (see
|
|
502
|
+
// SCRIPTS_PACKAGE_JSON in src/install.js) is missing — e.g. an install from
|
|
503
|
+
// before this fix existed. That's fine in a repo whose own package.json
|
|
504
|
+
// has no "type" field or "type": "commonjs", but breaks with
|
|
505
|
+
// "ReferenceError: module is not defined in ES module scope" the moment
|
|
506
|
+
// the consumer repo's package.json has "type": "module". Flagged
|
|
507
|
+
// separately from brokenWorkflows since it's silent until that condition
|
|
508
|
+
// is hit, not an immediate break.
|
|
509
|
+
const scriptsRequiringPkgJson = installedWorkflows.some((wf) => REQUIRED_SCRIPT_BY_WORKFLOW[wf]);
|
|
510
|
+
const scriptsPkgJsonMissing =
|
|
511
|
+
scriptsRequiringPkgJson &&
|
|
512
|
+
!fs.existsSync(path.join(targetAbs, '.github', 'scripts', SCRIPTS_PACKAGE_JSON));
|
|
513
|
+
|
|
514
|
+
if (installedWorkflows.length > 0 || installedTemplates.length > 0 || skillInstalled) {
|
|
384
515
|
const manifest = readManifest(targetAbs);
|
|
385
516
|
if (manifest && manifest.version) {
|
|
386
517
|
const installedOn = manifest.installedAt ? ` (installed ${manifest.installedAt.slice(0, 10)})` : '';
|
|
@@ -411,6 +542,26 @@ async function runStatus(options) {
|
|
|
411
542
|
installedWorkflows.forEach((wf) => console.log(` ✓ ${wf}.yml`));
|
|
412
543
|
console.log('');
|
|
413
544
|
}
|
|
545
|
+
|
|
546
|
+
if (brokenWorkflows.length > 0) {
|
|
547
|
+
console.log('⚠️ Broken install detected:');
|
|
548
|
+
brokenWorkflows.forEach((wf) => {
|
|
549
|
+
console.log(` ${wf}.yml requires .github/scripts/${REQUIRED_SCRIPT_BY_WORKFLOW[wf]}.js, which is missing.`);
|
|
550
|
+
});
|
|
551
|
+
console.log(' That workflow will fail with MODULE_NOT_FOUND the next time it runs.');
|
|
552
|
+
console.log(' Fix: npx github-delivery-os@latest install --overwrite .');
|
|
553
|
+
console.log('');
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (scriptsPkgJsonMissing) {
|
|
557
|
+
console.log(`⚠️ .github/scripts/${SCRIPTS_PACKAGE_JSON} is missing.`);
|
|
558
|
+
console.log(' If this repo\'s own package.json has "type": "module", every workflow that');
|
|
559
|
+
console.log(' require()s a script under .github/scripts will fail with "module is not');
|
|
560
|
+
console.log(' defined in ES module scope" the next time it runs.');
|
|
561
|
+
console.log(' Fix: npx github-delivery-os@latest install --overwrite .');
|
|
562
|
+
console.log('');
|
|
563
|
+
}
|
|
564
|
+
|
|
414
565
|
if (installedTemplates.length > 0) {
|
|
415
566
|
console.log('Templates:');
|
|
416
567
|
installedTemplates.forEach((t) => console.log(` ✓ ${t}`));
|
|
@@ -424,7 +575,7 @@ async function runStatus(options) {
|
|
|
424
575
|
console.log('');
|
|
425
576
|
}
|
|
426
577
|
|
|
427
|
-
if (installedWorkflows.length > 0 || installedTemplates.length > 0) {
|
|
578
|
+
if (installedWorkflows.length > 0 || installedTemplates.length > 0 || skillInstalled) {
|
|
428
579
|
console.log('Claude Code skill:');
|
|
429
580
|
console.log(
|
|
430
581
|
skillInstalled
|
|
@@ -434,7 +585,7 @@ async function runStatus(options) {
|
|
|
434
585
|
console.log('');
|
|
435
586
|
}
|
|
436
587
|
|
|
437
|
-
if (installedWorkflows.length === 0 && installedTemplates.length === 0) {
|
|
588
|
+
if (installedWorkflows.length === 0 && installedTemplates.length === 0 && !skillInstalled) {
|
|
438
589
|
console.log('Delivery OS is not installed in this repository.');
|
|
439
590
|
console.log('Run: npx github-delivery-os install --with-templates .');
|
|
440
591
|
} else {
|
|
@@ -450,6 +601,7 @@ function runUninstall(options) {
|
|
|
450
601
|
const targetAbs = path.resolve(process.cwd(), targetDir);
|
|
451
602
|
const workflowsDest = path.join(targetAbs, '.github', 'workflows');
|
|
452
603
|
const templatesDest = path.join(targetAbs, '.github', 'ISSUE_TEMPLATE');
|
|
604
|
+
const scriptsDest = path.join(targetAbs, '.github', 'scripts');
|
|
453
605
|
|
|
454
606
|
console.log('=== GitHub Delivery Operating System — Uninstall ===');
|
|
455
607
|
console.log(`Target: ${targetAbs}`);
|
|
@@ -458,6 +610,7 @@ function runUninstall(options) {
|
|
|
458
610
|
|
|
459
611
|
let workflowsRemoved = 0;
|
|
460
612
|
let templatesRemoved = 0;
|
|
613
|
+
let scriptsRemoved = 0;
|
|
461
614
|
|
|
462
615
|
for (const wf of WORKFLOWS) {
|
|
463
616
|
const dest = path.join(workflowsDest, `${wf}.yml`);
|
|
@@ -472,6 +625,34 @@ function runUninstall(options) {
|
|
|
472
625
|
}
|
|
473
626
|
}
|
|
474
627
|
|
|
628
|
+
// Scripts are a required dependency of the workflows above, not optional,
|
|
629
|
+
// so (like workflows) they're always removed, not gated behind a flag.
|
|
630
|
+
for (const name of SCRIPTS) {
|
|
631
|
+
const dest = path.join(scriptsDest, `${name}.js`);
|
|
632
|
+
if (fs.existsSync(dest)) {
|
|
633
|
+
if (dryRun) {
|
|
634
|
+
console.log(` [dry-run] Would remove: .github/scripts/${name}.js`);
|
|
635
|
+
} else {
|
|
636
|
+
fs.unlinkSync(dest);
|
|
637
|
+
console.log(` Removed: .github/scripts/${name}.js`);
|
|
638
|
+
}
|
|
639
|
+
scriptsRemoved++;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// The CommonJS-pinning package.json travels with SCRIPTS — same
|
|
644
|
+
// unconditional removal.
|
|
645
|
+
const scriptsPkgDest = path.join(scriptsDest, SCRIPTS_PACKAGE_JSON);
|
|
646
|
+
if (fs.existsSync(scriptsPkgDest)) {
|
|
647
|
+
if (dryRun) {
|
|
648
|
+
console.log(` [dry-run] Would remove: .github/scripts/${SCRIPTS_PACKAGE_JSON}`);
|
|
649
|
+
} else {
|
|
650
|
+
fs.unlinkSync(scriptsPkgDest);
|
|
651
|
+
console.log(` Removed: .github/scripts/${SCRIPTS_PACKAGE_JSON}`);
|
|
652
|
+
}
|
|
653
|
+
scriptsRemoved++;
|
|
654
|
+
}
|
|
655
|
+
|
|
475
656
|
if (withTemplates) {
|
|
476
657
|
for (const t of TEMPLATES) {
|
|
477
658
|
const dest = path.join(templatesDest, t);
|
|
@@ -501,11 +682,27 @@ function runUninstall(options) {
|
|
|
501
682
|
}
|
|
502
683
|
}
|
|
503
684
|
|
|
504
|
-
// Remove the version manifest too —
|
|
505
|
-
//
|
|
506
|
-
//
|
|
685
|
+
// Remove the version manifest too — but only once nothing Delivery-OS-
|
|
686
|
+
// related actually remains. Templates and the skill are kept by default
|
|
687
|
+
// (only removed with their own flags), and if they're still on disk, the
|
|
688
|
+
// manifest's version is still meaningful for them — deleting it would make
|
|
689
|
+
// a later `status` report "unknown version" for files that are, in fact,
|
|
690
|
+
// still fully present and version-tracked.
|
|
691
|
+
const anyWorkflowsRemain = WORKFLOWS.some((wf) => fs.existsSync(path.join(workflowsDest, `${wf}.yml`)));
|
|
692
|
+
const anyTemplatesRemain = TEMPLATES.some((t) => fs.existsSync(path.join(templatesDest, t)));
|
|
693
|
+
// Scripts are removed unconditionally just above, so this is normally
|
|
694
|
+
// always false by the time we get here — included anyway for the same
|
|
695
|
+
// reason the other three are checked explicitly rather than assumed:
|
|
696
|
+
// defensive completeness against a future change (e.g. a failed unlink,
|
|
697
|
+
// or script removal ever becoming flag-gated like templates/skill).
|
|
698
|
+
const anyScriptsRemain =
|
|
699
|
+
SCRIPTS.some((name) => fs.existsSync(path.join(scriptsDest, `${name}.js`))) ||
|
|
700
|
+
fs.existsSync(path.join(scriptsDest, SCRIPTS_PACKAGE_JSON));
|
|
701
|
+
const skillRemains = fs.existsSync(skillPath(targetAbs));
|
|
702
|
+
const nothingLeft = !anyWorkflowsRemain && !anyTemplatesRemain && !anyScriptsRemain && !skillRemains;
|
|
703
|
+
|
|
507
704
|
const manifestDest = manifestPath(targetAbs);
|
|
508
|
-
if (fs.existsSync(manifestDest)) {
|
|
705
|
+
if (nothingLeft && fs.existsSync(manifestDest)) {
|
|
509
706
|
if (dryRun) {
|
|
510
707
|
console.log(' [dry-run] Would remove: delivery-os.json');
|
|
511
708
|
} else {
|
|
@@ -528,6 +725,9 @@ function runUninstall(options) {
|
|
|
528
725
|
if (!withSkill) {
|
|
529
726
|
console.log('Claude Code skill (if installed) was kept. Re-run with --with-skill to remove it.');
|
|
530
727
|
}
|
|
728
|
+
if (!nothingLeft) {
|
|
729
|
+
console.log('delivery-os.json was kept — something Delivery-OS-related is still on disk.');
|
|
730
|
+
}
|
|
531
731
|
}
|
|
532
732
|
} else {
|
|
533
733
|
console.log('No Delivery OS files found to remove.');
|
|
@@ -550,5 +750,8 @@ module.exports = {
|
|
|
550
750
|
SKILL_REL_PATH,
|
|
551
751
|
WORKFLOWS,
|
|
552
752
|
TEMPLATES,
|
|
753
|
+
SCRIPTS,
|
|
754
|
+
SCRIPTS_PACKAGE_JSON,
|
|
755
|
+
REQUIRED_SCRIPT_BY_WORKFLOW,
|
|
553
756
|
},
|
|
554
757
|
};
|