github-delivery-os 1.0.3 → 1.2.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.
- package/.claude/skills/delivery-ops/SKILL.md +172 -0
- package/.github/workflows/authorize-deployment.yml +59 -46
- package/.github/workflows/auto-assign-qa.yml +1 -1
- package/.github/workflows/auto-close-sprint.yml +45 -47
- package/.github/workflows/sprint-child-creator.yml +14 -8
- package/.github/workflows/telegram-issues.yml +9 -2
- package/README.md +11 -2
- package/package.json +12 -4
- package/src/cli.js +14 -3
- package/src/install.js +220 -11
- package/.github/workflows/pages.yml +0 -46
- package/.github/workflows/release.yml +0 -27
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: delivery-ops
|
|
3
|
+
description: Operate a repo that has GitHub Delivery OS installed — create sprint/production-release/QA-request/bug issues that actually trigger its automation, comment as an approver in phrasing its workflows recognize, and check status (labels, latest comments, burn-down). Targets a specific repo via --repo; defaults to the current repo if this skill was installed into it and none is named. Use when asked to create a sprint, request a release, approve/decline a release, check release or sprint status, or demo/test Delivery OS against a given repo.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Operate Delivery OS
|
|
7
|
+
|
|
8
|
+
This drives the actual product — the workflows Delivery OS installs into a consumer repo — as a user of that repo would, not the tooling that ships the `github-delivery-os` package itself (that's the separate `release` skill). Use it to create issues that correctly trigger the installed automation, comment in a way the automation actually recognizes, and check what state something is in.
|
|
9
|
+
|
|
10
|
+
## Which repo?
|
|
11
|
+
|
|
12
|
+
Every command below takes `--repo <owner>/<name>` explicitly — never assume based on the working directory alone. Two ways this gets decided:
|
|
13
|
+
|
|
14
|
+
- **This skill was installed via `npx github-delivery-os install --with-skill`, into a specific repo's own `.claude/skills/`.** In that case the current working directory *is* the repo Delivery OS is installed in, so it's a reasonable default target if the user doesn't name a different one — just confirm that's what they mean before acting.
|
|
15
|
+
- **This skill is installed generically** (copied into `~/.claude/skills/`, available across every project). Here there's no natural default — the working directory could be anything. Ask which repo if it isn't named.
|
|
16
|
+
|
|
17
|
+
Creating issues and comments in a repo is a visible, outward action — other collaborators see it. Confirm the target repo and intent before creating anything real, the same as any other action that shows up in someone else's GitHub activity.
|
|
18
|
+
|
|
19
|
+
## Pre-flight check
|
|
20
|
+
|
|
21
|
+
Before creating anything that depends on configuration, check the target repo actually has Delivery OS installed and configured — a silent no-op (nothing happens because a variable is unset) is more confusing than an upfront "this won't do much yet":
|
|
22
|
+
|
|
23
|
+
- **Installed?** `gh api repos/<owner>/<repo>/contents/.github/workflows/authorize-deployment.yml --silent` (404 = not installed — suggest `npx github-delivery-os status .` or `install --with-templates` in that repo).
|
|
24
|
+
- **Labels set up?** `gh label list --repo <owner>/<repo>` — look for `production`, `qa`, `qa-request`, `sprint`, `sprint-active`, `planning`, `declined`, `ready-for-deploy`. Missing labels mean `Setup Labels` hasn't been run there yet — offer to fix it directly rather than just reporting the gap: `gh workflow run setup-labels.yml --repo <owner>/<repo>` (it's a `workflow_dispatch` trigger, so this actually creates them on the spot). Confirm with the user first since it's a real change to their repo.
|
|
25
|
+
- **Repo variables set?** `gh variable list --repo <owner>/<repo>` — look for `RELEASE_APPROVER`, `QA_APPROVER`, `QA_ASSIGNEES`. If unset, say so plainly: the issue will still get created, but `notify-release-approver` will ping the literal placeholder `release-approver`/`qa-approver`, not a real person. Setting these requires repo admin access (`gh variable set NAME --repo <owner>/<repo> --body <value>`) — don't set them without being asked to, since they name a real person as approver.
|
|
26
|
+
|
|
27
|
+
## Creating issues
|
|
28
|
+
|
|
29
|
+
`gh issue create` does not render GitHub's Issue Forms (the `.github/ISSUE_TEMPLATE/*.yml` files) — those only exist in the web UI. So the body has to be hand-built to match what a form submission would actually produce: `### <Field Label>` headings with the answer beneath each, because that heading text is exactly what the workflows regex-parse. Labels have to be passed explicitly too, since the template's auto-applied labels are also bypassed.
|
|
30
|
+
|
|
31
|
+
**Show the constructed title, body, and labels before actually creating the issue** — this is a real, visible action in someone else's repo, not a preview in this conversation. Get confirmation on the content, not just the target repo, before calling `gh issue create`.
|
|
32
|
+
|
|
33
|
+
**Sprint Planning** — triggers `sprint-child-creator` (one child issue per feature line, each labeled `sprint-active`, on open):
|
|
34
|
+
- Title **must contain** the literal string `SPRINT -`, e.g. `SPRINT - Sprint 14`
|
|
35
|
+
- Labels: `sprint`, `planning`
|
|
36
|
+
- Body:
|
|
37
|
+
```
|
|
38
|
+
### Sprint Name
|
|
39
|
+
|
|
40
|
+
<name>
|
|
41
|
+
|
|
42
|
+
### Sprint Start
|
|
43
|
+
|
|
44
|
+
YYYY-MM-DD
|
|
45
|
+
|
|
46
|
+
### Sprint End
|
|
47
|
+
|
|
48
|
+
YYYY-MM-DD
|
|
49
|
+
|
|
50
|
+
### Sprint Goal
|
|
51
|
+
|
|
52
|
+
<goal>
|
|
53
|
+
|
|
54
|
+
### Sprint Features (One Per Line)
|
|
55
|
+
|
|
56
|
+
<feature one>
|
|
57
|
+
<feature two>
|
|
58
|
+
<feature three>
|
|
59
|
+
|
|
60
|
+
### Sprint Approved
|
|
61
|
+
|
|
62
|
+
Pending
|
|
63
|
+
```
|
|
64
|
+
One feature per line, no bullets/numbering (matches the template's own instruction — `sprint-child-creator`'s parser just splits on newlines).
|
|
65
|
+
|
|
66
|
+
**Production Release** — triggers `notify-release-approver` on open (posts a comment tagging `RELEASE_APPROVER`), and later `authorize-deployment` on comments:
|
|
67
|
+
- Title: `PRODUCTION RELEASE - <project> - vX.X.X`
|
|
68
|
+
- Labels: `release`, `production`, `approval`
|
|
69
|
+
- Body:
|
|
70
|
+
```
|
|
71
|
+
### Sprint Reference (Sprint Planning Issue #)
|
|
72
|
+
|
|
73
|
+
#<N>
|
|
74
|
+
|
|
75
|
+
### Version / Build Number
|
|
76
|
+
|
|
77
|
+
vX.X.X
|
|
78
|
+
|
|
79
|
+
### Release Summary
|
|
80
|
+
|
|
81
|
+
<summary>
|
|
82
|
+
|
|
83
|
+
### QA Summary + Evidence Links
|
|
84
|
+
|
|
85
|
+
<links, or "None yet">
|
|
86
|
+
|
|
87
|
+
### Overall QA Recommendation
|
|
88
|
+
|
|
89
|
+
Approve for Production
|
|
90
|
+
|
|
91
|
+
### Deployment Authorized
|
|
92
|
+
|
|
93
|
+
No
|
|
94
|
+
```
|
|
95
|
+
(`qa_recommendation` drives the "QA Recommendation" line `notify-release-approver` puts in its comment — use `Approve for Production`, `Reject Release`, or `Conditional Approval` verbatim, those are the three strings it checks for.)
|
|
96
|
+
|
|
97
|
+
**QA Request** — triggers `auto-assign-qa` (assigns `QA_ASSIGNEES`) on open:
|
|
98
|
+
- Title: `QA REQUEST - <feature/issue>`
|
|
99
|
+
- Labels: `qa-request`
|
|
100
|
+
- Body:
|
|
101
|
+
```
|
|
102
|
+
### Related Sprint Task Issue (#)
|
|
103
|
+
|
|
104
|
+
#<N>
|
|
105
|
+
|
|
106
|
+
### What to Test
|
|
107
|
+
|
|
108
|
+
<what to test>
|
|
109
|
+
|
|
110
|
+
### Environment + Build Link
|
|
111
|
+
|
|
112
|
+
<build link>
|
|
113
|
+
|
|
114
|
+
### Acceptance Criteria
|
|
115
|
+
|
|
116
|
+
<criteria>
|
|
117
|
+
|
|
118
|
+
### QA Outcome
|
|
119
|
+
|
|
120
|
+
Pending
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**Bug Report**:
|
|
124
|
+
- Title: `[BUG] <one-line summary>`
|
|
125
|
+
- Labels: `bug`, `qa`
|
|
126
|
+
- Body: mirror `bug_report.yml`'s fields (`Platform(s) Affected`, `Severity`, `Build / Version`, `Bug Summary`, `Steps to Reproduce`, `Expected Result`, `Actual Result`, `Test Environment`) as `### <label>` / answer pairs.
|
|
127
|
+
|
|
128
|
+
**Task** — no automation trigger, just structured tracking:
|
|
129
|
+
- Title: `TASK - <one-line summary>`
|
|
130
|
+
- Labels: `task`
|
|
131
|
+
- Body: mirror `task.yml`'s fields (`Task Summary`, `Description`, `Owner`, `Priority` — `P0 - Critical` / `P1 - High` / `P2 - Medium` / `P3 - Low`, `Status` — `Backlog` / `In Progress` / `Blocked` / `Ready for Review` / `Done`, `Acceptance Criteria`, `Artifacts / Links`) as `### <label>` / answer pairs.
|
|
132
|
+
|
|
133
|
+
## Commenting as an approver
|
|
134
|
+
|
|
135
|
+
`authorize-deployment` only registers a comment if **both** of these hold:
|
|
136
|
+
- It's posted by the exact GitHub login configured in the repo's `RELEASE_APPROVER` or `QA_APPROVER` variable. `gh issue comment` posts as whichever account `gh auth status` shows — if that's not the configured approver, the comment is just a comment, nothing fires.
|
|
137
|
+
- The comment **leads with** one of the recognized keywords (case-insensitive; anything after the keyword is fine, but the keyword itself has to be at the start):
|
|
138
|
+
- Release approve: `approved`, `approve`, `ok`, `go ahead`
|
|
139
|
+
- Release decline: `declined`, `rejected`, `reject`, `not approved`
|
|
140
|
+
- QA approve: `qa approved`, `approved`, `qa ok`, `looks good`
|
|
141
|
+
|
|
142
|
+
A later qualifying comment from the same approver overrides an earlier one — a decline can be superseded by a later approval once fixes land, and vice versa.
|
|
143
|
+
|
|
144
|
+
**Before posting, check that the authenticated login actually matches the approver you're commenting as** — `gh auth status` (or `gh api user --jq .login`) against the `RELEASE_APPROVER`/`QA_APPROVER` value from the pre-flight check. If they don't match, say so and stop: the comment would still post, look successful, and do nothing — a silent no-op that's easy to miss without this check, since `gh issue comment` succeeds either way.
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
gh issue comment <number> --repo <owner>/<repo> --body "Approved, ship it"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Checking status
|
|
151
|
+
|
|
152
|
+
- **Latest comment(s):** `gh issue view <number> --repo <owner>/<repo> --comments`
|
|
153
|
+
- **Current labels:** `gh issue view <number> --repo <owner>/<repo> --json labels`
|
|
154
|
+
- **Sprint burn-down:** read the sprint (parent) issue's body — `gh issue view <sprint-number> --repo <owner>/<repo> --json body` — and look for the `## 🚦 Sprint Status` section `auto-close-sprint` maintains (progress %, time elapsed %, health emoji, burn-down bar). It only exists after at least one child issue has closed.
|
|
155
|
+
|
|
156
|
+
## Advancing a sprint
|
|
157
|
+
|
|
158
|
+
Closing a sprint task (child) issue is what actually moves the burn-down — creating the sprint only creates the children, nothing updates until they close:
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
gh issue close <number> --repo <owner>/<repo>
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`auto-close-sprint` fires on close, re-reads every `sprint-active` issue whose body contains `Parent Sprint: #<N>`, recomputes progress, and rewrites the sprint issue's `## 🚦 Sprint Status` section. At 100% it also closes the sprint issue itself and posts a completion comment. Re-check the sprint issue's body afterward to see the update — it happens as a side effect of closing the child, not as a response visible on the child issue itself.
|
|
165
|
+
|
|
166
|
+
## Finding things
|
|
167
|
+
|
|
168
|
+
When there's no issue number in hand yet:
|
|
169
|
+
- **Production releases awaiting a decision:** `gh issue list --repo <owner>/<repo> --label production --state open`
|
|
170
|
+
- **Active sprints:** `gh issue list --repo <owner>/<repo> --label sprint --state open` (title contains `SPRINT -`)
|
|
171
|
+
- **Open QA requests:** `gh issue list --repo <owner>/<repo> --label qa-request --state open`
|
|
172
|
+
- **A sprint's own children:** `gh issue list --repo <owner>/<repo> --label sprint-active --search "\"Parent Sprint: #<N>\" in:body"` — the exact-phrase quotes matter, otherwise the search matches "Parent", "Sprint", and the number as separate free-text terms instead of the literal phrase
|
|
@@ -6,6 +6,7 @@ on:
|
|
|
6
6
|
|
|
7
7
|
permissions:
|
|
8
8
|
issues: write
|
|
9
|
+
contents: read
|
|
9
10
|
|
|
10
11
|
jobs:
|
|
11
12
|
authorize:
|
|
@@ -13,8 +14,22 @@ jobs:
|
|
|
13
14
|
runs-on: ubuntu-latest
|
|
14
15
|
permissions:
|
|
15
16
|
issues: write
|
|
17
|
+
contents: read # needed for actions/checkout, so the verdict script can be required from the repo
|
|
18
|
+
# Serialize per-issue: two comments posted seconds apart (e.g. a decline
|
|
19
|
+
# immediately corrected with an approval) can trigger overlapping runs.
|
|
20
|
+
# Both would recompute the same final verdict correctly, but each reads
|
|
21
|
+
# its "already posted?" check from the stale label snapshot on its own
|
|
22
|
+
# triggering event, so without this they could both post a status
|
|
23
|
+
# comment. Scoped per issue (not per repo) so unrelated releases still
|
|
24
|
+
# process concurrently.
|
|
25
|
+
concurrency:
|
|
26
|
+
group: authorize-deployment-${{ github.event.issue.number }}
|
|
27
|
+
cancel-in-progress: false
|
|
16
28
|
|
|
17
29
|
steps:
|
|
30
|
+
- name: Checkout
|
|
31
|
+
uses: actions/checkout@v6
|
|
32
|
+
|
|
18
33
|
- name: Check Dual Approval (Flexible + Decline)
|
|
19
34
|
uses: actions/github-script@v7
|
|
20
35
|
env:
|
|
@@ -22,81 +37,79 @@ jobs:
|
|
|
22
37
|
QA_APPROVER: ${{ vars.QA_APPROVER || 'qa-approver' }}
|
|
23
38
|
with:
|
|
24
39
|
script: |
|
|
40
|
+
// Pure verdict logic lives in a real, unit-tested module (see
|
|
41
|
+
// .github/scripts/authorize-deployment-verdict.js and
|
|
42
|
+
// test/authorize-deployment-verdict.test.js) rather than inline here,
|
|
43
|
+
// so it isn't only verified by hand whenever it changes.
|
|
44
|
+
const { computeVerdict } = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/authorize-deployment-verdict.js`);
|
|
45
|
+
|
|
25
46
|
const issueNumber = context.issue.number;
|
|
26
47
|
const owner = context.repo.owner;
|
|
27
48
|
const repo = context.repo.repo;
|
|
28
49
|
const releaseApprover = process.env.RELEASE_APPROVER;
|
|
29
50
|
const qaApprover = process.env.QA_APPROVER;
|
|
30
51
|
|
|
31
|
-
const comments = await github.rest.issues.listComments
|
|
52
|
+
const comments = await github.paginate(github.rest.issues.listComments, {
|
|
32
53
|
owner,
|
|
33
54
|
repo,
|
|
34
|
-
issue_number: issueNumber
|
|
55
|
+
issue_number: issueNumber,
|
|
56
|
+
per_page: 100
|
|
35
57
|
});
|
|
36
58
|
|
|
37
|
-
|
|
38
|
-
let qaApproved = false;
|
|
39
|
-
|
|
40
|
-
for (const comment of comments.data) {
|
|
41
|
-
const body = (comment.body || "").trim().toLowerCase();
|
|
59
|
+
const { releaseVerdict, qaApproved } = computeVerdict(comments, releaseApprover, qaApprover);
|
|
42
60
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
) {
|
|
61
|
+
const currentLabels = (context.payload.issue.labels || []).map((l) => l.name);
|
|
62
|
+
const alreadyDeclined = currentLabels.includes("declined");
|
|
63
|
+
const alreadyReady = currentLabels.includes("ready-for-deploy");
|
|
47
64
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
repo,
|
|
51
|
-
issue_number: issueNumber,
|
|
52
|
-
labels: ["declined"]
|
|
53
|
-
});
|
|
65
|
+
if (releaseVerdict === "declined") {
|
|
66
|
+
if (alreadyDeclined) return; // avoid re-posting on every later comment
|
|
54
67
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
});
|
|
62
|
-
} catch (e) {}
|
|
68
|
+
await github.rest.issues.addLabels({
|
|
69
|
+
owner,
|
|
70
|
+
repo,
|
|
71
|
+
issue_number: issueNumber,
|
|
72
|
+
labels: ["declined"]
|
|
73
|
+
});
|
|
63
74
|
|
|
64
|
-
|
|
75
|
+
try {
|
|
76
|
+
await github.rest.issues.removeLabel({
|
|
65
77
|
owner,
|
|
66
78
|
repo,
|
|
67
79
|
issue_number: issueNumber,
|
|
68
|
-
|
|
80
|
+
name: "ready-for-deploy"
|
|
69
81
|
});
|
|
82
|
+
} catch (e) {}
|
|
83
|
+
|
|
84
|
+
await github.rest.issues.createComment({
|
|
85
|
+
owner,
|
|
86
|
+
repo,
|
|
87
|
+
issue_number: issueNumber,
|
|
88
|
+
body: "🔴 **Release Declined**\n\nThis release requires additional fixes before production deployment."
|
|
89
|
+
});
|
|
70
90
|
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
if (
|
|
75
|
-
comment.user.login === releaseApprover &&
|
|
76
|
-
/^(approved|approve|ok|go ahead)/i.test(body)
|
|
77
|
-
) {
|
|
78
|
-
releaseApproved = true;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (
|
|
82
|
-
comment.user.login === qaApprover &&
|
|
83
|
-
/(qa approved|approved|qa ok|looks good)/i.test(body)
|
|
84
|
-
) {
|
|
85
|
-
qaApproved = true;
|
|
86
|
-
}
|
|
91
|
+
return;
|
|
87
92
|
}
|
|
88
93
|
|
|
89
|
-
if (
|
|
94
|
+
if (releaseVerdict === "approved" && qaApproved) {
|
|
95
|
+
if (alreadyReady) return; // avoid re-posting on every later comment
|
|
90
96
|
|
|
91
97
|
try {
|
|
92
|
-
await github.rest.issues.
|
|
98
|
+
await github.rest.issues.removeLabel({
|
|
93
99
|
owner,
|
|
94
100
|
repo,
|
|
95
101
|
issue_number: issueNumber,
|
|
96
|
-
|
|
102
|
+
name: "declined"
|
|
97
103
|
});
|
|
98
104
|
} catch (e) {}
|
|
99
105
|
|
|
106
|
+
await github.rest.issues.addLabels({
|
|
107
|
+
owner,
|
|
108
|
+
repo,
|
|
109
|
+
issue_number: issueNumber,
|
|
110
|
+
labels: ["ready-for-deploy"]
|
|
111
|
+
});
|
|
112
|
+
|
|
100
113
|
await github.rest.issues.createComment({
|
|
101
114
|
owner,
|
|
102
115
|
repo,
|
|
@@ -19,7 +19,7 @@ jobs:
|
|
|
19
19
|
steps:
|
|
20
20
|
# pozil/auto-assign-issue does not support 'labels' input; labels come from issue template (qa_request.yml)
|
|
21
21
|
- name: Assign QA team to QA submission issues
|
|
22
|
-
uses: pozil/auto-assign-issue@v1
|
|
22
|
+
uses: pozil/auto-assign-issue@d11e715efc663fe323c3d8d4d3cbbfdddd539baf # v1
|
|
23
23
|
with:
|
|
24
24
|
assignees: ${{ vars.QA_ASSIGNEES }}
|
|
25
25
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
@@ -6,14 +6,25 @@ on:
|
|
|
6
6
|
|
|
7
7
|
permissions:
|
|
8
8
|
issues: write
|
|
9
|
+
contents: read
|
|
9
10
|
|
|
10
11
|
jobs:
|
|
11
12
|
update-sprint:
|
|
12
13
|
runs-on: ubuntu-latest
|
|
13
14
|
permissions:
|
|
14
15
|
issues: write
|
|
16
|
+
contents: read # needed for actions/checkout, so the burn-down script can be required from the repo
|
|
17
|
+
# Serialize runs: closing several child issues in quick succession triggers
|
|
18
|
+
# concurrent runs that each read-modify-write the same sprint issue body,
|
|
19
|
+
# which can race and drop an update (or double-fire the 100% auto-close).
|
|
20
|
+
concurrency:
|
|
21
|
+
group: auto-close-sprint-${{ github.repository }}
|
|
22
|
+
cancel-in-progress: false
|
|
15
23
|
|
|
16
24
|
steps:
|
|
25
|
+
- name: Checkout
|
|
26
|
+
uses: actions/checkout@v6
|
|
27
|
+
|
|
17
28
|
- name: Update sprint health, burn-down, and auto-close
|
|
18
29
|
uses: actions/github-script@v7
|
|
19
30
|
env:
|
|
@@ -21,13 +32,25 @@ jobs:
|
|
|
21
32
|
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
|
22
33
|
with:
|
|
23
34
|
script: |
|
|
35
|
+
// Pure burn-down/health logic lives in a real, unit-tested module
|
|
36
|
+
// (see .github/scripts/auto-close-sprint.js and
|
|
37
|
+
// test/auto-close-sprint.test.js) rather than inline here, so
|
|
38
|
+
// it isn't only verified by hand whenever it changes.
|
|
39
|
+
const {
|
|
40
|
+
parseParentSprintNumber,
|
|
41
|
+
parseSprintDates,
|
|
42
|
+
computeTimePercent,
|
|
43
|
+
computeProgress,
|
|
44
|
+
computeHealthEmoji,
|
|
45
|
+
renderBurnDown,
|
|
46
|
+
updateSprintBody,
|
|
47
|
+
} = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/auto-close-sprint.js`);
|
|
48
|
+
|
|
24
49
|
const issue = context.payload.issue;
|
|
25
50
|
const body = issue.body || "";
|
|
26
51
|
|
|
27
|
-
const
|
|
28
|
-
if (!
|
|
29
|
-
|
|
30
|
-
const sprintNumber = parseInt(parentMatch[1]);
|
|
52
|
+
const sprintNumber = parseParentSprintNumber(body);
|
|
53
|
+
if (!sprintNumber) return;
|
|
31
54
|
|
|
32
55
|
const sprintIssue = await github.rest.issues.get({
|
|
33
56
|
owner: context.repo.owner,
|
|
@@ -37,31 +60,27 @@ jobs:
|
|
|
37
60
|
|
|
38
61
|
const sprintBody = sprintIssue.data.body || "";
|
|
39
62
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const dateMatch2a = sprintBody.match(/Sprint Start[\s\S]*?(\d{4}-\d{2}-\d{2})/i);
|
|
43
|
-
const dateMatch2b = sprintBody.match(/Sprint End[\s\S]*?(\d{4}-\d{2}-\d{2})/i);
|
|
44
|
-
if (dateMatch1) {
|
|
45
|
-
startDate = new Date(dateMatch1[1]);
|
|
46
|
-
endDate = new Date(dateMatch1[2]);
|
|
47
|
-
} else if (dateMatch2a && dateMatch2b) {
|
|
48
|
-
startDate = new Date(dateMatch2a[1]);
|
|
49
|
-
endDate = new Date(dateMatch2b[1]);
|
|
50
|
-
}
|
|
51
|
-
if (!startDate || !endDate) {
|
|
63
|
+
const dates = parseSprintDates(sprintBody);
|
|
64
|
+
if (!dates) {
|
|
52
65
|
console.log("Sprint dates not found.");
|
|
53
66
|
return;
|
|
54
67
|
}
|
|
55
|
-
const now = new Date();
|
|
56
|
-
|
|
57
|
-
const totalDuration = endDate - startDate;
|
|
58
|
-
const elapsed = now - startDate;
|
|
59
|
-
const timePercent = Math.max(0, Math.min(100, Math.round((elapsed / totalDuration) * 100)));
|
|
60
68
|
|
|
61
|
-
const
|
|
69
|
+
const timePercent = computeTimePercent(dates.startDate, dates.endDate, new Date());
|
|
70
|
+
|
|
71
|
+
// Paginate: a plain listForRepo call caps at one page (100 issues) and
|
|
72
|
+
// would silently undercount children in repos with more open+closed
|
|
73
|
+
// issues than that, skewing the burn-down. Scoped to `sprint-active`
|
|
74
|
+
// (the label every child issue gets on creation) instead of every
|
|
75
|
+
// issue in the repo, so this stays cheap in repos with a lot of
|
|
76
|
+
// unrelated issue history — this workflow now runs serialized per
|
|
77
|
+
// repo, so an unscoped full-repo fetch on every child close would
|
|
78
|
+
// add up fast.
|
|
79
|
+
const allIssues = await github.paginate(github.rest.issues.listForRepo, {
|
|
62
80
|
owner: context.repo.owner,
|
|
63
81
|
repo: context.repo.repo,
|
|
64
82
|
state: "all",
|
|
83
|
+
labels: "sprint-active",
|
|
65
84
|
per_page: 100
|
|
66
85
|
});
|
|
67
86
|
|
|
@@ -69,31 +88,10 @@ jobs:
|
|
|
69
88
|
i.body && i.body.includes(`Parent Sprint: #${sprintNumber}`)
|
|
70
89
|
);
|
|
71
90
|
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
: Math.round((closedChildren.length / children.length) * 100);
|
|
77
|
-
|
|
78
|
-
let healthEmoji = "🟢";
|
|
79
|
-
if (progressPercent < timePercent - 10) healthEmoji = "🔴";
|
|
80
|
-
else if (progressPercent < timePercent) healthEmoji = "🟡";
|
|
81
|
-
|
|
82
|
-
const totalBars = 20;
|
|
83
|
-
const filledBars = Math.round((progressPercent / 100) * totalBars);
|
|
84
|
-
const burnDown =
|
|
85
|
-
"█".repeat(filledBars) + "░".repeat(totalBars - filledBars);
|
|
86
|
-
|
|
87
|
-
let updatedBody = sprintBody;
|
|
88
|
-
updatedBody = updatedBody.replace(/## 🚦 Sprint Status[\s\S]*?---/g, "");
|
|
89
|
-
|
|
90
|
-
updatedBody +=
|
|
91
|
-
`\n\n---\n\n## 🚦 Sprint Status\n\n` +
|
|
92
|
-
`Progress: **${progressPercent}%**\n` +
|
|
93
|
-
`Time Elapsed: **${timePercent}%**\n\n` +
|
|
94
|
-
`Health: ${healthEmoji}\n\n` +
|
|
95
|
-
`### 📉 Burn-down\n` +
|
|
96
|
-
`\`${burnDown}\` ${progressPercent}%\n\n---`;
|
|
91
|
+
const { progressPercent } = computeProgress(children);
|
|
92
|
+
const healthEmoji = computeHealthEmoji(progressPercent, timePercent);
|
|
93
|
+
const burnDown = renderBurnDown(progressPercent);
|
|
94
|
+
const updatedBody = updateSprintBody(sprintBody, { progressPercent, timePercent, healthEmoji, burnDown });
|
|
97
95
|
|
|
98
96
|
await github.rest.issues.update({
|
|
99
97
|
owner: context.repo.owner,
|
|
@@ -6,6 +6,7 @@ on:
|
|
|
6
6
|
|
|
7
7
|
permissions:
|
|
8
8
|
issues: write
|
|
9
|
+
contents: read
|
|
9
10
|
|
|
10
11
|
jobs:
|
|
11
12
|
create-child-issues:
|
|
@@ -13,25 +14,30 @@ jobs:
|
|
|
13
14
|
runs-on: ubuntu-latest
|
|
14
15
|
permissions:
|
|
15
16
|
issues: write
|
|
17
|
+
contents: read # needed for actions/checkout, so the parser script can be required from the repo
|
|
16
18
|
|
|
17
19
|
steps:
|
|
20
|
+
- name: Checkout
|
|
21
|
+
uses: actions/checkout@v6
|
|
22
|
+
|
|
18
23
|
- name: Create Child Issues
|
|
19
24
|
uses: actions/github-script@v7
|
|
20
25
|
with:
|
|
21
26
|
script: |
|
|
27
|
+
// Pure parsing logic lives in a real, unit-tested module (see
|
|
28
|
+
// .github/scripts/sprint-child-creator.js and
|
|
29
|
+
// test/sprint-child-creator.test.js) rather than inline here, so
|
|
30
|
+
// it isn't only verified by hand whenever it changes.
|
|
31
|
+
const { parseFeatures, buildChildBody } = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/sprint-child-creator.js`);
|
|
32
|
+
|
|
22
33
|
const issue = context.payload.issue;
|
|
23
34
|
const body = issue.body || "";
|
|
24
35
|
const parentNumber = issue.number;
|
|
25
36
|
|
|
26
|
-
const
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
const features = featuresMatch[0]
|
|
30
|
-
.split('\n')
|
|
31
|
-
.map(line => line.trim())
|
|
32
|
-
.filter(line => line && !line.startsWith('###'));
|
|
37
|
+
const features = parseFeatures(body);
|
|
38
|
+
if (features.length === 0) return;
|
|
33
39
|
|
|
34
|
-
const bodyContent =
|
|
40
|
+
const bodyContent = buildChildBody(parentNumber);
|
|
35
41
|
|
|
36
42
|
for (const title of features) {
|
|
37
43
|
await github.rest.issues.create({
|
|
@@ -100,10 +100,17 @@ jobs:
|
|
|
100
100
|
URL="${ISSUE_URL}"
|
|
101
101
|
COMMENT=$(echo "$COMMENT_BODY" | head -c 300)
|
|
102
102
|
|
|
103
|
-
|
|
103
|
+
# Case-insensitive actor comparison: GitHub logins are case-insensitive,
|
|
104
|
+
# but a plain bash == is not, so a RELEASE_APPROVER var with different
|
|
105
|
+
# casing than the actual login would otherwise never match here.
|
|
106
|
+
# Anchored to the start of a line, matching the same keyword set and
|
|
107
|
+
# convention as authorize-deployment.yml's DECLINE_RE, so this alert
|
|
108
|
+
# can't fire for a comment that workflow wouldn't actually treat as a
|
|
109
|
+
# decline (e.g. "not approved" appearing mid-sentence).
|
|
110
|
+
if [[ "${ACTOR,,}" == "${RELEASE_APPROVER,,}" ]] && echo "$COMMENT_BODY" | grep -iqE "^(declined|rejected|reject|not approved)"; then
|
|
104
111
|
MESSAGE="🔴🛑 RELEASE DECLINED%0A$TITLE%0A$URL%0A---%0A👤 $ACTOR%0A🕒 $TIMESTAMP"
|
|
105
112
|
|
|
106
|
-
elif [[ "$ACTOR" == "$RELEASE_APPROVER" ]] && echo "$COMMENT_BODY" | grep -iq "^approved"; then
|
|
113
|
+
elif [[ "${ACTOR,,}" == "${RELEASE_APPROVER,,}" ]] && echo "$COMMENT_BODY" | grep -iq "^approved"; then
|
|
107
114
|
MESSAGE="🟢🛡️ RELEASE APPROVED%0A$TITLE%0A$URL%0A---%0A👤 $ACTOR%0A🕒 $TIMESTAMP"
|
|
108
115
|
|
|
109
116
|
elif [[ "${{ contains(github.event.issue.labels.*.name || fromJSON('[]'), 'bug') }}" == "true" ]]; then
|
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ The **GitHub Delivery Operating System (Delivery OS)** embeds structured intake,
|
|
|
30
30
|
npx github-delivery-os install --with-templates .
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
From your repo root. Add `--with-labels` to create labels via `gh` CLI (requires `gh auth`). Use `--dry-run` to preview first.
|
|
33
|
+
From your repo root. Add `--with-labels` to create labels via `gh` CLI (requires `gh auth`). Add `--with-skill` to also drop in a [Claude Code](https://claude.com/claude-code) skill for operating this repo's Delivery OS from Claude Code — creating sprint/release/QA issues, commenting as an approver, checking status. Use `--dry-run` to preview first.
|
|
34
34
|
|
|
35
35
|
**Alternative — clone and run script:**
|
|
36
36
|
|
|
@@ -52,12 +52,15 @@ cd github-delivery-operating-system
|
|
|
52
52
|
|
|
53
53
|
**Other commands:**
|
|
54
54
|
```bash
|
|
55
|
-
npx github-delivery-os status . # Show what's installed
|
|
55
|
+
npx github-delivery-os status . # Show what's installed + installed version
|
|
56
|
+
npx github-delivery-os status --offline . # Same, without checking npm for updates
|
|
56
57
|
npx github-delivery-os uninstall . # Remove workflows
|
|
57
58
|
npx github-delivery-os uninstall --with-templates . # Remove workflows + templates
|
|
58
59
|
npx github-delivery-os uninstall --dry-run . # Preview (no changes)
|
|
59
60
|
```
|
|
60
61
|
|
|
62
|
+
`status` also checks npm for a newer release and tells you if you're behind (e.g. `⬆️ Update available: 1.0.3 → 1.1.0`), along with the exact command to update. That check is silent and non-fatal if you're offline — use `--offline` to skip it outright (e.g. in CI).
|
|
63
|
+
|
|
61
64
|
**What gets installed:**
|
|
62
65
|
|
|
63
66
|
| Workflow | Purpose |
|
|
@@ -72,6 +75,10 @@ npx github-delivery-os uninstall --dry-run . # Preview (no changes)
|
|
|
72
75
|
|
|
73
76
|
Workflows and templates are **copied directly** into your repo. No `workflow_call` or external references.
|
|
74
77
|
|
|
78
|
+
Installing via `npx github-delivery-os` (not the `scripts/install.sh` clone path) also writes `.github/delivery-os.json`, a small manifest recording the installed version — this is what powers the update check in `status`. It's only written when the files it describes are actually current (a fresh install, or `--overwrite`); a skip-mode install over existing files leaves it untouched rather than claiming a version that isn't really on disk. `uninstall` removes it.
|
|
79
|
+
|
|
80
|
+
With `--with-skill`, a `.claude/skills/delivery-ops/SKILL.md` file is also written — a Claude Code skill scoped to this repo, so anyone working here with Claude Code can create issues that correctly trigger the workflows above, comment as an approver, and check status without knowing the underlying `gh` commands or issue-body formats by heart. It's opt-in and retroactive: `--with-skill` on any later `install` call adds it if it isn't there yet.
|
|
81
|
+
|
|
75
82
|
---
|
|
76
83
|
|
|
77
84
|
## Quick Start (After Install)
|
|
@@ -102,6 +109,8 @@ When you open an issue using the **Sprint Planning** template with a title like
|
|
|
102
109
|
| Document | Description |
|
|
103
110
|
|----------|-------------|
|
|
104
111
|
| **[Landing page & quick start](https://phaneroo.github.io/github-delivery-operating-system/)** | Overview, one-command install, features |
|
|
112
|
+
| [PRFAQ](docs/PRFAQ.md) | Product overview and FAQs for all audiences (npm launch, install, governance) |
|
|
113
|
+
| [Press release (npm)](docs/press-release.md) | Formal announcement: Delivery OS on npm |
|
|
105
114
|
| [Consumer Setup](docs/consumer-setup.md) | Installation, configuration, variables, labels, Telegram, uninstall |
|
|
106
115
|
| [How To](docs/how-to.md) | Create sprints, request releases, approve, report bugs, QA requests |
|
|
107
116
|
| [Architecture](docs/architecture.md) | Workflows, templates, data flow |
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "github-delivery-os",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
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": {
|
|
7
7
|
"delivery-os": "./bin/delivery-os.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"install:local": "node bin/delivery-os.js install --with-templates ."
|
|
10
|
+
"install:local": "node bin/delivery-os.js install --with-templates .",
|
|
11
|
+
"test": "node test/run.js"
|
|
11
12
|
},
|
|
12
13
|
"keywords": [
|
|
13
14
|
"github",
|
|
@@ -37,8 +38,15 @@
|
|
|
37
38
|
"files": [
|
|
38
39
|
"bin",
|
|
39
40
|
"src",
|
|
40
|
-
".github/workflows",
|
|
41
|
-
".github/
|
|
41
|
+
".github/workflows/sprint-child-creator.yml",
|
|
42
|
+
".github/workflows/auto-close-sprint.yml",
|
|
43
|
+
".github/workflows/notify-release-approver.yml",
|
|
44
|
+
".github/workflows/authorize-deployment.yml",
|
|
45
|
+
".github/workflows/auto-assign-qa.yml",
|
|
46
|
+
".github/workflows/telegram-issues.yml",
|
|
47
|
+
".github/workflows/setup-labels.yml",
|
|
48
|
+
".github/ISSUE_TEMPLATE",
|
|
49
|
+
".claude/skills/delivery-ops"
|
|
42
50
|
],
|
|
43
51
|
"engines": {
|
|
44
52
|
"node": ">=14.0.0"
|
package/src/cli.js
CHANGED
|
@@ -20,6 +20,7 @@ program
|
|
|
20
20
|
.description('Install workflows and templates into a repository')
|
|
21
21
|
.option('-t, --with-templates', 'Copy issue templates (sprint, task, bug, QA, production release)')
|
|
22
22
|
.option('-l, --with-labels', 'Create labels via gh CLI (requires gh auth)')
|
|
23
|
+
.option('-s, --with-skill', 'Add the delivery-ops Claude Code skill (.claude/skills/delivery-ops/SKILL.md)')
|
|
23
24
|
.option('-o, --overwrite', 'Replace existing workflow/template files')
|
|
24
25
|
.option('--no-overwrite', 'Skip existing files (default)')
|
|
25
26
|
.option('-d, --dry-run', 'Show what would happen without changing files')
|
|
@@ -29,6 +30,7 @@ program
|
|
|
29
30
|
targetDir,
|
|
30
31
|
withTemplates: options.withTemplates ?? false,
|
|
31
32
|
withLabels: options.withLabels ?? false,
|
|
33
|
+
withSkill: options.withSkill ?? false,
|
|
32
34
|
overwrite: options.overwrite ?? false,
|
|
33
35
|
dryRun: options.dryRun ?? false,
|
|
34
36
|
});
|
|
@@ -37,24 +39,33 @@ program
|
|
|
37
39
|
program
|
|
38
40
|
.command('status [target]')
|
|
39
41
|
.description('Show which workflows and templates are installed')
|
|
40
|
-
.
|
|
41
|
-
|
|
42
|
+
.option('--offline', 'Skip checking npm for the latest published version')
|
|
43
|
+
.action(async (target, options) => {
|
|
44
|
+
await runStatus({ targetDir: target || '.', checkUpdates: !options.offline });
|
|
42
45
|
});
|
|
43
46
|
|
|
44
47
|
program
|
|
45
48
|
.command('uninstall [target]')
|
|
46
49
|
.description('Remove Delivery OS workflows (and optionally templates)')
|
|
47
50
|
.option('-t, --with-templates', 'Also remove issue templates')
|
|
51
|
+
.option('-s, --with-skill', 'Also remove the delivery-ops Claude Code skill')
|
|
48
52
|
.option('-d, --dry-run', 'Show what would be removed without deleting')
|
|
49
53
|
.action((target, options) => {
|
|
50
54
|
runUninstall({
|
|
51
55
|
targetDir: target || '.',
|
|
52
56
|
withTemplates: options.withTemplates ?? false,
|
|
57
|
+
withSkill: options.withSkill ?? false,
|
|
53
58
|
dryRun: options.dryRun ?? false,
|
|
54
59
|
});
|
|
55
60
|
});
|
|
56
61
|
|
|
57
|
-
|
|
62
|
+
// parseAsync (not parse) because the `status` action is async — with plain
|
|
63
|
+
// parse(), an error thrown inside it becomes an unhandled rejection that
|
|
64
|
+
// Node <15 does not treat as fatal, so a real failure could exit 0.
|
|
65
|
+
program.parseAsync().catch((err) => {
|
|
66
|
+
console.error(err && err.message ? err.message : err);
|
|
67
|
+
process.exitCode = 1;
|
|
68
|
+
});
|
|
58
69
|
|
|
59
70
|
// Show help if no command
|
|
60
71
|
if (!process.argv.slice(2).length) {
|
package/src/install.js
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
2
|
const fs = require('fs');
|
|
3
|
+
const https = require('https');
|
|
3
4
|
const { execFileSync } = require('child_process');
|
|
4
5
|
|
|
6
|
+
const MANIFEST_FILE = 'delivery-os.json'; // written to .github/delivery-os.json in the target repo
|
|
7
|
+
const SKILL_REL_PATH = path.join('.claude', 'skills', 'delivery-ops', 'SKILL.md'); // opt-in via --with-skill
|
|
8
|
+
|
|
9
|
+
// If you add/remove/rename an entry here, also update package.json's "files"
|
|
10
|
+
// array — it lists these paths explicitly (not the whole .github/workflows
|
|
11
|
+
// directory) so this package's own maintainer workflows (ci.yml, release.yml,
|
|
12
|
+
// pages.yml) don't get bundled into what ships to consumers.
|
|
5
13
|
const WORKFLOWS = [
|
|
6
14
|
'sprint-child-creator',
|
|
7
15
|
'auto-close-sprint',
|
|
@@ -30,6 +38,69 @@ const LABELS = [
|
|
|
30
38
|
['risk', 'B60205'],
|
|
31
39
|
];
|
|
32
40
|
|
|
41
|
+
function manifestPath(targetAbs) {
|
|
42
|
+
return path.join(targetAbs, '.github', MANIFEST_FILE);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function skillPath(targetAbs) {
|
|
46
|
+
return path.join(targetAbs, SKILL_REL_PATH);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readManifest(targetAbs) {
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(fs.readFileSync(manifestPath(targetAbs), 'utf8'));
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function writeManifest(targetAbs, version) {
|
|
58
|
+
const dest = manifestPath(targetAbs);
|
|
59
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
60
|
+
fs.writeFileSync(
|
|
61
|
+
dest,
|
|
62
|
+
JSON.stringify({ version, installedAt: new Date().toISOString() }, null, 2) + '\n'
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Best-effort check against the npm registry. Never throws or rejects —
|
|
67
|
+
// resolves null on any failure (offline, registry down, timeout) so callers
|
|
68
|
+
// can treat "unknown" and "couldn't check" identically with no extra
|
|
69
|
+
// error-handling of their own.
|
|
70
|
+
function fetchLatestVersion(timeoutMs = 3000) {
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
let settled = false;
|
|
73
|
+
const done = (value) => {
|
|
74
|
+
if (!settled) {
|
|
75
|
+
settled = true;
|
|
76
|
+
resolve(value);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const req = https.get(
|
|
80
|
+
'https://registry.npmjs.org/github-delivery-os/latest',
|
|
81
|
+
{ headers: { 'User-Agent': 'github-delivery-os-cli' } },
|
|
82
|
+
(res) => {
|
|
83
|
+
if (res.statusCode !== 200) {
|
|
84
|
+
res.resume();
|
|
85
|
+
done(null);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
let data = '';
|
|
89
|
+
res.on('data', (chunk) => (data += chunk));
|
|
90
|
+
res.on('end', () => {
|
|
91
|
+
try {
|
|
92
|
+
done(JSON.parse(data).version || null);
|
|
93
|
+
} catch {
|
|
94
|
+
done(null);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
);
|
|
99
|
+
req.setTimeout(timeoutMs, () => req.destroy());
|
|
100
|
+
req.on('error', () => done(null));
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
33
104
|
function getPackageRoot() {
|
|
34
105
|
// When installed via npm, __dirname is node_modules/github-delivery-os/src
|
|
35
106
|
const possibleRoots = [
|
|
@@ -50,6 +121,7 @@ function runInstall(options) {
|
|
|
50
121
|
targetDir = '.',
|
|
51
122
|
withTemplates = false,
|
|
52
123
|
withLabels = false,
|
|
124
|
+
withSkill = false,
|
|
53
125
|
overwrite = false,
|
|
54
126
|
dryRun = false,
|
|
55
127
|
} = options;
|
|
@@ -57,6 +129,7 @@ function runInstall(options) {
|
|
|
57
129
|
const pkgRoot = getPackageRoot();
|
|
58
130
|
const workflowsSrc = path.join(pkgRoot, '.github', 'workflows');
|
|
59
131
|
const templatesSrc = path.join(pkgRoot, '.github', 'ISSUE_TEMPLATE');
|
|
132
|
+
const skillSrc = path.join(pkgRoot, SKILL_REL_PATH);
|
|
60
133
|
const targetAbs = path.resolve(process.cwd(), targetDir);
|
|
61
134
|
|
|
62
135
|
console.log('=== GitHub Delivery Operating System ===');
|
|
@@ -86,6 +159,10 @@ function runInstall(options) {
|
|
|
86
159
|
|
|
87
160
|
let workflowsCopied = 0;
|
|
88
161
|
let templatesCopied = 0;
|
|
162
|
+
let skillCopied = 0;
|
|
163
|
+
let workflowsSkipped = 0;
|
|
164
|
+
let templatesSkipped = 0;
|
|
165
|
+
let skillSkipped = 0;
|
|
89
166
|
|
|
90
167
|
// Copy workflows
|
|
91
168
|
for (const wf of WORKFLOWS) {
|
|
@@ -99,6 +176,7 @@ function runInstall(options) {
|
|
|
99
176
|
|
|
100
177
|
if (fs.existsSync(dest) && !overwrite) {
|
|
101
178
|
console.log(` Skipped (exists): ${wf}.yml`);
|
|
179
|
+
workflowsSkipped++;
|
|
102
180
|
} else if (dryRun) {
|
|
103
181
|
console.log(` [dry-run] Would create: ${wf}.yml`);
|
|
104
182
|
workflowsCopied++;
|
|
@@ -120,6 +198,7 @@ function runInstall(options) {
|
|
|
120
198
|
|
|
121
199
|
if (fs.existsSync(dest) && !overwrite) {
|
|
122
200
|
console.log(` Skipped (exists): ${name}`);
|
|
201
|
+
templatesSkipped++;
|
|
123
202
|
} else if (dryRun) {
|
|
124
203
|
console.log(` [dry-run] Would create template: ${name}`);
|
|
125
204
|
templatesCopied++;
|
|
@@ -131,6 +210,25 @@ function runInstall(options) {
|
|
|
131
210
|
}
|
|
132
211
|
}
|
|
133
212
|
|
|
213
|
+
// Copy the delivery-ops Claude Code skill (opt-in — most consumer repos
|
|
214
|
+
// aren't using Claude Code, so this is never written unless asked for)
|
|
215
|
+
if (withSkill && fs.existsSync(skillSrc)) {
|
|
216
|
+
const skillDest = skillPath(targetAbs);
|
|
217
|
+
|
|
218
|
+
if (fs.existsSync(skillDest) && !overwrite) {
|
|
219
|
+
console.log(` Skipped (exists): ${SKILL_REL_PATH}`);
|
|
220
|
+
skillSkipped++;
|
|
221
|
+
} else if (dryRun) {
|
|
222
|
+
console.log(` [dry-run] Would create: ${SKILL_REL_PATH}`);
|
|
223
|
+
skillCopied++;
|
|
224
|
+
} else {
|
|
225
|
+
fs.mkdirSync(path.dirname(skillDest), { recursive: true });
|
|
226
|
+
fs.copyFileSync(skillSrc, skillDest);
|
|
227
|
+
console.log(` Created: ${SKILL_REL_PATH}`);
|
|
228
|
+
skillCopied++;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
134
232
|
// Create labels via gh
|
|
135
233
|
let labelsCreated = 0;
|
|
136
234
|
let labelsSkipReason = '';
|
|
@@ -192,15 +290,35 @@ function runInstall(options) {
|
|
|
192
290
|
}
|
|
193
291
|
}
|
|
194
292
|
|
|
293
|
+
// 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: an
|
|
295
|
+
// overwrite, or a fresh install where nothing had to be skipped. A partial
|
|
296
|
+
// skip-mode install would leave older file content on disk, so don't
|
|
297
|
+
// overwrite a previously recorded (possibly accurate, possibly newer)
|
|
298
|
+
// version with a number that isn't actually true on disk yet.
|
|
299
|
+
const cleanInstall = overwrite || (workflowsSkipped === 0 && templatesSkipped === 0 && skillSkipped === 0);
|
|
300
|
+
if (!dryRun && cleanInstall) {
|
|
301
|
+
const pkgVersion = require(path.join(pkgRoot, 'package.json')).version;
|
|
302
|
+
writeManifest(targetAbs, pkgVersion);
|
|
303
|
+
}
|
|
304
|
+
|
|
195
305
|
// Summary
|
|
196
306
|
console.log('');
|
|
197
|
-
if (
|
|
307
|
+
if (!dryRun && !cleanInstall) {
|
|
308
|
+
console.log(' Note: some files already existed and were skipped, so the recorded');
|
|
309
|
+
console.log(' Delivery OS version was not updated. Re-run with --overwrite to sync');
|
|
310
|
+
console.log(' all files (and the recorded version) to the latest release.');
|
|
311
|
+
console.log('');
|
|
312
|
+
}
|
|
313
|
+
if (workflowsCopied > 0 || templatesCopied > 0 || skillCopied > 0 || labelsCreated > 0) {
|
|
198
314
|
if (dryRun) {
|
|
199
315
|
if (workflowsCopied > 0) console.log(`Would install ${workflowsCopied} workflow(s).`);
|
|
200
316
|
if (templatesCopied > 0) console.log(`Would copy ${templatesCopied} issue template(s).`);
|
|
317
|
+
if (skillCopied > 0) console.log('Would add the Claude Code delivery-ops skill.');
|
|
201
318
|
} else {
|
|
202
319
|
if (workflowsCopied > 0) console.log(`Installed ${workflowsCopied} workflow(s).`);
|
|
203
320
|
if (templatesCopied > 0) console.log(`Copied ${templatesCopied} issue template(s).`);
|
|
321
|
+
if (skillCopied > 0) console.log('Added the Claude Code delivery-ops skill.');
|
|
204
322
|
if (labelsCreated > 0) console.log(`Created ${labelsCreated} label(s).`);
|
|
205
323
|
}
|
|
206
324
|
console.log('');
|
|
@@ -212,8 +330,14 @@ function runInstall(options) {
|
|
|
212
330
|
console.log(' - QA_APPROVER: GitHub username of QA approver');
|
|
213
331
|
console.log(' - QA_ASSIGNEES: Comma-separated usernames for QA assignment');
|
|
214
332
|
console.log(' 3. Add secrets (optional, for Telegram): TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID');
|
|
333
|
+
let nextStep = 4;
|
|
215
334
|
if (!withTemplates) {
|
|
216
|
-
console.log(
|
|
335
|
+
console.log(` ${nextStep}. Copy templates: re-run with --with-templates`);
|
|
336
|
+
nextStep++;
|
|
337
|
+
}
|
|
338
|
+
if (!withSkill) {
|
|
339
|
+
console.log(` ${nextStep}. Add the Claude Code delivery-ops skill (optional, for Claude Code users): re-run with --with-skill`);
|
|
340
|
+
nextStep++;
|
|
217
341
|
}
|
|
218
342
|
console.log('');
|
|
219
343
|
console.log('See https://phaneroo.github.io/github-delivery-operating-system/ for full docs.');
|
|
@@ -238,8 +362,8 @@ const TEMPLATES = [
|
|
|
238
362
|
'bug_report.yml',
|
|
239
363
|
];
|
|
240
364
|
|
|
241
|
-
function runStatus(options) {
|
|
242
|
-
const { targetDir = '.' } = options;
|
|
365
|
+
async function runStatus(options) {
|
|
366
|
+
const { targetDir = '.', checkUpdates = true } = options;
|
|
243
367
|
const targetAbs = path.resolve(process.cwd(), targetDir);
|
|
244
368
|
const workflowsDest = path.join(targetAbs, '.github', 'workflows');
|
|
245
369
|
const templatesDest = path.join(targetAbs, '.github', 'ISSUE_TEMPLATE');
|
|
@@ -254,6 +378,33 @@ function runStatus(options) {
|
|
|
254
378
|
const installedTemplates = TEMPLATES.filter((t) =>
|
|
255
379
|
fs.existsSync(path.join(templatesDest, t))
|
|
256
380
|
);
|
|
381
|
+
const skillInstalled = fs.existsSync(skillPath(targetAbs));
|
|
382
|
+
|
|
383
|
+
if (installedWorkflows.length > 0 || installedTemplates.length > 0) {
|
|
384
|
+
const manifest = readManifest(targetAbs);
|
|
385
|
+
if (manifest && manifest.version) {
|
|
386
|
+
const installedOn = manifest.installedAt ? ` (installed ${manifest.installedAt.slice(0, 10)})` : '';
|
|
387
|
+
console.log(`Installed version: ${manifest.version}${installedOn}`);
|
|
388
|
+
} else {
|
|
389
|
+
console.log('Installed version: unknown (installed before version tracking was added)');
|
|
390
|
+
console.log(' Run install with --overwrite to record the current version.');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (checkUpdates) {
|
|
394
|
+
const latest = await fetchLatestVersion();
|
|
395
|
+
if (!latest) {
|
|
396
|
+
console.log(' (Could not check npm for the latest version — offline or registry unreachable.)');
|
|
397
|
+
} else if (manifest && manifest.version === latest) {
|
|
398
|
+
console.log(`✓ Up to date (latest is ${latest})`);
|
|
399
|
+
} else if (manifest && manifest.version) {
|
|
400
|
+
console.log(`⬆️ Update available: ${manifest.version} → ${latest}`);
|
|
401
|
+
console.log(' Run: npx github-delivery-os@latest install --overwrite .');
|
|
402
|
+
} else {
|
|
403
|
+
console.log(`Latest published version: ${latest}`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
console.log('');
|
|
407
|
+
}
|
|
257
408
|
|
|
258
409
|
if (installedWorkflows.length > 0) {
|
|
259
410
|
console.log('Workflows:');
|
|
@@ -273,18 +424,29 @@ function runStatus(options) {
|
|
|
273
424
|
console.log('');
|
|
274
425
|
}
|
|
275
426
|
|
|
427
|
+
if (installedWorkflows.length > 0 || installedTemplates.length > 0) {
|
|
428
|
+
console.log('Claude Code skill:');
|
|
429
|
+
console.log(
|
|
430
|
+
skillInstalled
|
|
431
|
+
? ' ✓ delivery-ops'
|
|
432
|
+
: ' ○ delivery-ops (not installed — re-run install with --with-skill)'
|
|
433
|
+
);
|
|
434
|
+
console.log('');
|
|
435
|
+
}
|
|
436
|
+
|
|
276
437
|
if (installedWorkflows.length === 0 && installedTemplates.length === 0) {
|
|
277
438
|
console.log('Delivery OS is not installed in this repository.');
|
|
278
439
|
console.log('Run: npx github-delivery-os install --with-templates .');
|
|
279
440
|
} else {
|
|
280
|
-
|
|
281
|
-
|
|
441
|
+
console.log(
|
|
442
|
+
`Summary: ${installedWorkflows.length}/${WORKFLOWS.length} workflows, ${installedTemplates.length}/${TEMPLATES.length} templates, skill: ${skillInstalled ? 'yes' : 'no'}`
|
|
443
|
+
);
|
|
282
444
|
}
|
|
283
445
|
console.log('');
|
|
284
446
|
}
|
|
285
447
|
|
|
286
448
|
function runUninstall(options) {
|
|
287
|
-
const { targetDir = '.', withTemplates = false, dryRun = false } = options;
|
|
449
|
+
const { targetDir = '.', withTemplates = false, withSkill = false, dryRun = false } = options;
|
|
288
450
|
const targetAbs = path.resolve(process.cwd(), targetDir);
|
|
289
451
|
const workflowsDest = path.join(targetAbs, '.github', 'workflows');
|
|
290
452
|
const templatesDest = path.join(targetAbs, '.github', 'ISSUE_TEMPLATE');
|
|
@@ -325,15 +487,47 @@ function runUninstall(options) {
|
|
|
325
487
|
}
|
|
326
488
|
}
|
|
327
489
|
|
|
490
|
+
let skillRemoved = 0;
|
|
491
|
+
if (withSkill) {
|
|
492
|
+
const skillDest = skillPath(targetAbs);
|
|
493
|
+
if (fs.existsSync(skillDest)) {
|
|
494
|
+
if (dryRun) {
|
|
495
|
+
console.log(` [dry-run] Would remove: ${SKILL_REL_PATH}`);
|
|
496
|
+
} else {
|
|
497
|
+
fs.unlinkSync(skillDest);
|
|
498
|
+
console.log(` Removed: ${SKILL_REL_PATH}`);
|
|
499
|
+
}
|
|
500
|
+
skillRemoved++;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Remove the version manifest too — it has no meaning once Delivery OS is
|
|
505
|
+
// gone, and leaving it behind would make a later install/status think a
|
|
506
|
+
// stale version is still installed.
|
|
507
|
+
const manifestDest = manifestPath(targetAbs);
|
|
508
|
+
if (fs.existsSync(manifestDest)) {
|
|
509
|
+
if (dryRun) {
|
|
510
|
+
console.log(' [dry-run] Would remove: delivery-os.json');
|
|
511
|
+
} else {
|
|
512
|
+
fs.unlinkSync(manifestDest);
|
|
513
|
+
console.log(' Removed: delivery-os.json');
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
328
517
|
console.log('');
|
|
329
|
-
if (workflowsRemoved > 0 || templatesRemoved > 0) {
|
|
518
|
+
if (workflowsRemoved > 0 || templatesRemoved > 0 || skillRemoved > 0) {
|
|
519
|
+
const templateNote = withTemplates ? `, ${templatesRemoved} template(s)` : '';
|
|
520
|
+
const skillNote = withSkill ? `, ${skillRemoved} skill file(s)` : '';
|
|
330
521
|
if (dryRun) {
|
|
331
|
-
console.log(`Would remove ${workflowsRemoved} workflow(s)${
|
|
522
|
+
console.log(`Would remove ${workflowsRemoved} workflow(s)${templateNote}${skillNote}.`);
|
|
332
523
|
} else {
|
|
333
|
-
console.log(`Removed ${workflowsRemoved} workflow(s)${
|
|
524
|
+
console.log(`Removed ${workflowsRemoved} workflow(s)${templateNote}${skillNote}.`);
|
|
334
525
|
if (!withTemplates) {
|
|
335
526
|
console.log('Templates were kept. Re-run with --with-templates to remove them.');
|
|
336
527
|
}
|
|
528
|
+
if (!withSkill) {
|
|
529
|
+
console.log('Claude Code skill (if installed) was kept. Re-run with --with-skill to remove it.');
|
|
530
|
+
}
|
|
337
531
|
}
|
|
338
532
|
} else {
|
|
339
533
|
console.log('No Delivery OS files found to remove.');
|
|
@@ -342,4 +536,19 @@ function runUninstall(options) {
|
|
|
342
536
|
console.log('=== Uninstall complete ===');
|
|
343
537
|
}
|
|
344
538
|
|
|
345
|
-
module.exports = {
|
|
539
|
+
module.exports = {
|
|
540
|
+
runInstall,
|
|
541
|
+
runStatus,
|
|
542
|
+
runUninstall,
|
|
543
|
+
// Exposed for tests only — not part of the CLI's public API.
|
|
544
|
+
__test__: {
|
|
545
|
+
manifestPath,
|
|
546
|
+
readManifest,
|
|
547
|
+
writeManifest,
|
|
548
|
+
fetchLatestVersion,
|
|
549
|
+
skillPath,
|
|
550
|
+
SKILL_REL_PATH,
|
|
551
|
+
WORKFLOWS,
|
|
552
|
+
TEMPLATES,
|
|
553
|
+
},
|
|
554
|
+
};
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
# Deploy docs/ to GitHub Pages
|
|
2
|
-
# Requires: Settings → Pages → Source: GitHub Actions
|
|
3
|
-
|
|
4
|
-
name: Deploy to GitHub Pages
|
|
5
|
-
|
|
6
|
-
on:
|
|
7
|
-
push:
|
|
8
|
-
branches: [main]
|
|
9
|
-
workflow_dispatch:
|
|
10
|
-
|
|
11
|
-
permissions:
|
|
12
|
-
contents: read
|
|
13
|
-
pages: write
|
|
14
|
-
id-token: write
|
|
15
|
-
|
|
16
|
-
concurrency:
|
|
17
|
-
group: pages
|
|
18
|
-
cancel-in-progress: false
|
|
19
|
-
|
|
20
|
-
env:
|
|
21
|
-
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
22
|
-
|
|
23
|
-
jobs:
|
|
24
|
-
deploy:
|
|
25
|
-
runs-on: ubuntu-latest
|
|
26
|
-
environment:
|
|
27
|
-
name: github-pages
|
|
28
|
-
url: ${{ steps.deployment.outputs.page_url }}
|
|
29
|
-
steps:
|
|
30
|
-
- name: Opt into Node.js 24
|
|
31
|
-
run: echo "FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true" >> $GITHUB_ENV
|
|
32
|
-
|
|
33
|
-
- name: Checkout
|
|
34
|
-
uses: actions/checkout@v6
|
|
35
|
-
|
|
36
|
-
- name: Setup Pages
|
|
37
|
-
uses: actions/configure-pages@v5
|
|
38
|
-
|
|
39
|
-
- name: Upload artifact
|
|
40
|
-
uses: actions/upload-pages-artifact@v4
|
|
41
|
-
with:
|
|
42
|
-
path: ./docs
|
|
43
|
-
|
|
44
|
-
- name: Deploy to GitHub Pages
|
|
45
|
-
id: deployment
|
|
46
|
-
uses: actions/deploy-pages@v4
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
# Create GitHub release when a version tag is pushed
|
|
2
|
-
# Usage: git tag v1.0.2 && git push origin v1.0.2
|
|
3
|
-
|
|
4
|
-
name: Release
|
|
5
|
-
|
|
6
|
-
on:
|
|
7
|
-
push:
|
|
8
|
-
tags:
|
|
9
|
-
- 'v*'
|
|
10
|
-
|
|
11
|
-
permissions:
|
|
12
|
-
contents: write
|
|
13
|
-
|
|
14
|
-
jobs:
|
|
15
|
-
release:
|
|
16
|
-
runs-on: ubuntu-latest
|
|
17
|
-
steps:
|
|
18
|
-
- uses: actions/checkout@v6
|
|
19
|
-
with:
|
|
20
|
-
fetch-depth: 0
|
|
21
|
-
|
|
22
|
-
- name: Create Release
|
|
23
|
-
uses: softprops/action-gh-release@v2
|
|
24
|
-
with:
|
|
25
|
-
generate_release_notes: true
|
|
26
|
-
env:
|
|
27
|
-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|