github-delivery-os 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/ci.yml +23 -0
- package/.github/workflows/pages.yml +46 -0
- package/.github/workflows/release.yml +12 -1
- package/.github/workflows/sprint-child-creator.yml +14 -8
- package/.github/workflows/telegram-issues.yml +9 -2
- package/README.md +12 -4
- package/package.json +6 -5
- package/src/cli.js +10 -3
- package/src/install.js +133 -4
|
@@ -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,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v6
|
|
13
|
+
|
|
14
|
+
- name: Set up Node.js
|
|
15
|
+
uses: actions/setup-node@v4
|
|
16
|
+
with:
|
|
17
|
+
node-version: '20'
|
|
18
|
+
|
|
19
|
+
- name: Install dependencies
|
|
20
|
+
run: npm ci
|
|
21
|
+
|
|
22
|
+
- name: Run tests
|
|
23
|
+
run: npm test
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
|
@@ -15,10 +15,21 @@ jobs:
|
|
|
15
15
|
release:
|
|
16
16
|
runs-on: ubuntu-latest
|
|
17
17
|
steps:
|
|
18
|
-
- uses: actions/checkout@
|
|
18
|
+
- uses: actions/checkout@v6
|
|
19
19
|
with:
|
|
20
20
|
fetch-depth: 0
|
|
21
21
|
|
|
22
|
+
- name: Set up Node.js
|
|
23
|
+
uses: actions/setup-node@v4
|
|
24
|
+
with:
|
|
25
|
+
node-version: '20'
|
|
26
|
+
|
|
27
|
+
- name: Install dependencies
|
|
28
|
+
run: npm ci
|
|
29
|
+
|
|
30
|
+
- name: Run tests
|
|
31
|
+
run: npm test
|
|
32
|
+
|
|
22
33
|
- name: Create Release
|
|
23
34
|
uses: softprops/action-gh-release@v2
|
|
24
35
|
with:
|
|
@@ -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
|
@@ -35,7 +35,7 @@ From your repo root. Add `--with-labels` to create labels via `gh` CLI (requires
|
|
|
35
35
|
**Alternative — clone and run script:**
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
git clone https://github.com/
|
|
38
|
+
git clone https://github.com/Phaneroo/github-delivery-operating-system
|
|
39
39
|
cd github-delivery-operating-system
|
|
40
40
|
|
|
41
41
|
# New install or repo with existing workflows — adds only missing files (safe)
|
|
@@ -52,11 +52,15 @@ cd github-delivery-operating-system
|
|
|
52
52
|
|
|
53
53
|
**Other commands:**
|
|
54
54
|
```bash
|
|
55
|
-
npx github-delivery-os status .
|
|
56
|
-
npx github-delivery-os
|
|
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
|
|
57
|
+
npx github-delivery-os uninstall . # Remove workflows
|
|
57
58
|
npx github-delivery-os uninstall --with-templates . # Remove workflows + templates
|
|
59
|
+
npx github-delivery-os uninstall --dry-run . # Preview (no changes)
|
|
58
60
|
```
|
|
59
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
|
+
|
|
60
64
|
**What gets installed:**
|
|
61
65
|
|
|
62
66
|
| Workflow | Purpose |
|
|
@@ -71,6 +75,8 @@ npx github-delivery-os uninstall --with-templates . # Remove workflows + templa
|
|
|
71
75
|
|
|
72
76
|
Workflows and templates are **copied directly** into your repo. No `workflow_call` or external references.
|
|
73
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
|
+
|
|
74
80
|
---
|
|
75
81
|
|
|
76
82
|
## Quick Start (After Install)
|
|
@@ -100,7 +106,9 @@ When you open an issue using the **Sprint Planning** template with a title like
|
|
|
100
106
|
|
|
101
107
|
| Document | Description |
|
|
102
108
|
|----------|-------------|
|
|
103
|
-
| **[Landing page & quick start](https://
|
|
109
|
+
| **[Landing page & quick start](https://phaneroo.github.io/github-delivery-operating-system/)** | Overview, one-command install, features |
|
|
110
|
+
| [PRFAQ](docs/PRFAQ.md) | Product overview and FAQs for all audiences (npm launch, install, governance) |
|
|
111
|
+
| [Press release (npm)](docs/press-release.md) | Formal announcement: Delivery OS on npm |
|
|
104
112
|
| [Consumer Setup](docs/consumer-setup.md) | Installation, configuration, variables, labels, Telegram, uninstall |
|
|
105
113
|
| [How To](docs/how-to.md) | Create sprints, request releases, approve, report bugs, QA requests |
|
|
106
114
|
| [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.1.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",
|
|
@@ -28,11 +29,11 @@
|
|
|
28
29
|
"license": "MIT",
|
|
29
30
|
"repository": {
|
|
30
31
|
"type": "git",
|
|
31
|
-
"url": "https://github.com/
|
|
32
|
+
"url": "https://github.com/Phaneroo/github-delivery-operating-system"
|
|
32
33
|
},
|
|
33
|
-
"homepage": "https://
|
|
34
|
+
"homepage": "https://phaneroo.github.io/github-delivery-operating-system/",
|
|
34
35
|
"bugs": {
|
|
35
|
-
"url": "https://github.com/
|
|
36
|
+
"url": "https://github.com/Phaneroo/github-delivery-operating-system/issues"
|
|
36
37
|
},
|
|
37
38
|
"files": [
|
|
38
39
|
"bin",
|
package/src/cli.js
CHANGED
|
@@ -37,8 +37,9 @@ program
|
|
|
37
37
|
program
|
|
38
38
|
.command('status [target]')
|
|
39
39
|
.description('Show which workflows and templates are installed')
|
|
40
|
-
.
|
|
41
|
-
|
|
40
|
+
.option('--offline', 'Skip checking npm for the latest published version')
|
|
41
|
+
.action(async (target, options) => {
|
|
42
|
+
await runStatus({ targetDir: target || '.', checkUpdates: !options.offline });
|
|
42
43
|
});
|
|
43
44
|
|
|
44
45
|
program
|
|
@@ -54,7 +55,13 @@ program
|
|
|
54
55
|
});
|
|
55
56
|
});
|
|
56
57
|
|
|
57
|
-
|
|
58
|
+
// parseAsync (not parse) because the `status` action is async — with plain
|
|
59
|
+
// parse(), an error thrown inside it becomes an unhandled rejection that
|
|
60
|
+
// Node <15 does not treat as fatal, so a real failure could exit 0.
|
|
61
|
+
program.parseAsync().catch((err) => {
|
|
62
|
+
console.error(err && err.message ? err.message : err);
|
|
63
|
+
process.exitCode = 1;
|
|
64
|
+
});
|
|
58
65
|
|
|
59
66
|
// Show help if no command
|
|
60
67
|
if (!process.argv.slice(2).length) {
|
package/src/install.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
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
|
+
|
|
5
8
|
const WORKFLOWS = [
|
|
6
9
|
'sprint-child-creator',
|
|
7
10
|
'auto-close-sprint',
|
|
@@ -30,6 +33,65 @@ const LABELS = [
|
|
|
30
33
|
['risk', 'B60205'],
|
|
31
34
|
];
|
|
32
35
|
|
|
36
|
+
function manifestPath(targetAbs) {
|
|
37
|
+
return path.join(targetAbs, '.github', MANIFEST_FILE);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readManifest(targetAbs) {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(fs.readFileSync(manifestPath(targetAbs), 'utf8'));
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writeManifest(targetAbs, version) {
|
|
49
|
+
const dest = manifestPath(targetAbs);
|
|
50
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
51
|
+
fs.writeFileSync(
|
|
52
|
+
dest,
|
|
53
|
+
JSON.stringify({ version, installedAt: new Date().toISOString() }, null, 2) + '\n'
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Best-effort check against the npm registry. Never throws or rejects —
|
|
58
|
+
// resolves null on any failure (offline, registry down, timeout) so callers
|
|
59
|
+
// can treat "unknown" and "couldn't check" identically with no extra
|
|
60
|
+
// error-handling of their own.
|
|
61
|
+
function fetchLatestVersion(timeoutMs = 3000) {
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
let settled = false;
|
|
64
|
+
const done = (value) => {
|
|
65
|
+
if (!settled) {
|
|
66
|
+
settled = true;
|
|
67
|
+
resolve(value);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const req = https.get(
|
|
71
|
+
'https://registry.npmjs.org/github-delivery-os/latest',
|
|
72
|
+
{ headers: { 'User-Agent': 'github-delivery-os-cli' } },
|
|
73
|
+
(res) => {
|
|
74
|
+
if (res.statusCode !== 200) {
|
|
75
|
+
res.resume();
|
|
76
|
+
done(null);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
let data = '';
|
|
80
|
+
res.on('data', (chunk) => (data += chunk));
|
|
81
|
+
res.on('end', () => {
|
|
82
|
+
try {
|
|
83
|
+
done(JSON.parse(data).version || null);
|
|
84
|
+
} catch {
|
|
85
|
+
done(null);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
);
|
|
90
|
+
req.setTimeout(timeoutMs, () => req.destroy());
|
|
91
|
+
req.on('error', () => done(null));
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
33
95
|
function getPackageRoot() {
|
|
34
96
|
// When installed via npm, __dirname is node_modules/github-delivery-os/src
|
|
35
97
|
const possibleRoots = [
|
|
@@ -86,6 +148,8 @@ function runInstall(options) {
|
|
|
86
148
|
|
|
87
149
|
let workflowsCopied = 0;
|
|
88
150
|
let templatesCopied = 0;
|
|
151
|
+
let workflowsSkipped = 0;
|
|
152
|
+
let templatesSkipped = 0;
|
|
89
153
|
|
|
90
154
|
// Copy workflows
|
|
91
155
|
for (const wf of WORKFLOWS) {
|
|
@@ -99,6 +163,7 @@ function runInstall(options) {
|
|
|
99
163
|
|
|
100
164
|
if (fs.existsSync(dest) && !overwrite) {
|
|
101
165
|
console.log(` Skipped (exists): ${wf}.yml`);
|
|
166
|
+
workflowsSkipped++;
|
|
102
167
|
} else if (dryRun) {
|
|
103
168
|
console.log(` [dry-run] Would create: ${wf}.yml`);
|
|
104
169
|
workflowsCopied++;
|
|
@@ -120,6 +185,7 @@ function runInstall(options) {
|
|
|
120
185
|
|
|
121
186
|
if (fs.existsSync(dest) && !overwrite) {
|
|
122
187
|
console.log(` Skipped (exists): ${name}`);
|
|
188
|
+
templatesSkipped++;
|
|
123
189
|
} else if (dryRun) {
|
|
124
190
|
console.log(` [dry-run] Would create template: ${name}`);
|
|
125
191
|
templatesCopied++;
|
|
@@ -192,8 +258,26 @@ function runInstall(options) {
|
|
|
192
258
|
}
|
|
193
259
|
}
|
|
194
260
|
|
|
261
|
+
// Record what got installed so `status` can report a version and detect
|
|
262
|
+
// drift. Only claim a version when the on-disk files actually match it: an
|
|
263
|
+
// overwrite, or a fresh install where nothing had to be skipped. A partial
|
|
264
|
+
// skip-mode install would leave older file content on disk, so don't
|
|
265
|
+
// overwrite a previously recorded (possibly accurate, possibly newer)
|
|
266
|
+
// version with a number that isn't actually true on disk yet.
|
|
267
|
+
const cleanInstall = overwrite || (workflowsSkipped === 0 && templatesSkipped === 0);
|
|
268
|
+
if (!dryRun && cleanInstall) {
|
|
269
|
+
const pkgVersion = require(path.join(pkgRoot, 'package.json')).version;
|
|
270
|
+
writeManifest(targetAbs, pkgVersion);
|
|
271
|
+
}
|
|
272
|
+
|
|
195
273
|
// Summary
|
|
196
274
|
console.log('');
|
|
275
|
+
if (!dryRun && !cleanInstall) {
|
|
276
|
+
console.log(' Note: some files already existed and were skipped, so the recorded');
|
|
277
|
+
console.log(' Delivery OS version was not updated. Re-run with --overwrite to sync');
|
|
278
|
+
console.log(' all files (and the recorded version) to the latest release.');
|
|
279
|
+
console.log('');
|
|
280
|
+
}
|
|
197
281
|
if (workflowsCopied > 0 || templatesCopied > 0 || labelsCreated > 0) {
|
|
198
282
|
if (dryRun) {
|
|
199
283
|
if (workflowsCopied > 0) console.log(`Would install ${workflowsCopied} workflow(s).`);
|
|
@@ -216,7 +300,7 @@ function runInstall(options) {
|
|
|
216
300
|
console.log(' 4. Copy templates: re-run with --with-templates');
|
|
217
301
|
}
|
|
218
302
|
console.log('');
|
|
219
|
-
console.log('See https://
|
|
303
|
+
console.log('See https://phaneroo.github.io/github-delivery-operating-system/ for full docs.');
|
|
220
304
|
} else {
|
|
221
305
|
if (dryRun) {
|
|
222
306
|
console.log('Dry run complete. No files were changed.');
|
|
@@ -238,8 +322,8 @@ const TEMPLATES = [
|
|
|
238
322
|
'bug_report.yml',
|
|
239
323
|
];
|
|
240
324
|
|
|
241
|
-
function runStatus(options) {
|
|
242
|
-
const { targetDir = '.' } = options;
|
|
325
|
+
async function runStatus(options) {
|
|
326
|
+
const { targetDir = '.', checkUpdates = true } = options;
|
|
243
327
|
const targetAbs = path.resolve(process.cwd(), targetDir);
|
|
244
328
|
const workflowsDest = path.join(targetAbs, '.github', 'workflows');
|
|
245
329
|
const templatesDest = path.join(targetAbs, '.github', 'ISSUE_TEMPLATE');
|
|
@@ -255,6 +339,32 @@ function runStatus(options) {
|
|
|
255
339
|
fs.existsSync(path.join(templatesDest, t))
|
|
256
340
|
);
|
|
257
341
|
|
|
342
|
+
if (installedWorkflows.length > 0 || installedTemplates.length > 0) {
|
|
343
|
+
const manifest = readManifest(targetAbs);
|
|
344
|
+
if (manifest && manifest.version) {
|
|
345
|
+
const installedOn = manifest.installedAt ? ` (installed ${manifest.installedAt.slice(0, 10)})` : '';
|
|
346
|
+
console.log(`Installed version: ${manifest.version}${installedOn}`);
|
|
347
|
+
} else {
|
|
348
|
+
console.log('Installed version: unknown (installed before version tracking was added)');
|
|
349
|
+
console.log(' Run install with --overwrite to record the current version.');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (checkUpdates) {
|
|
353
|
+
const latest = await fetchLatestVersion();
|
|
354
|
+
if (!latest) {
|
|
355
|
+
console.log(' (Could not check npm for the latest version — offline or registry unreachable.)');
|
|
356
|
+
} else if (manifest && manifest.version === latest) {
|
|
357
|
+
console.log(`✓ Up to date (latest is ${latest})`);
|
|
358
|
+
} else if (manifest && manifest.version) {
|
|
359
|
+
console.log(`⬆️ Update available: ${manifest.version} → ${latest}`);
|
|
360
|
+
console.log(' Run: npx github-delivery-os@latest install --overwrite .');
|
|
361
|
+
} else {
|
|
362
|
+
console.log(`Latest published version: ${latest}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
console.log('');
|
|
366
|
+
}
|
|
367
|
+
|
|
258
368
|
if (installedWorkflows.length > 0) {
|
|
259
369
|
console.log('Workflows:');
|
|
260
370
|
installedWorkflows.forEach((wf) => console.log(` ✓ ${wf}.yml`));
|
|
@@ -325,6 +435,19 @@ function runUninstall(options) {
|
|
|
325
435
|
}
|
|
326
436
|
}
|
|
327
437
|
|
|
438
|
+
// Remove the version manifest too — it has no meaning once Delivery OS is
|
|
439
|
+
// gone, and leaving it behind would make a later install/status think a
|
|
440
|
+
// stale version is still installed.
|
|
441
|
+
const manifestDest = manifestPath(targetAbs);
|
|
442
|
+
if (fs.existsSync(manifestDest)) {
|
|
443
|
+
if (dryRun) {
|
|
444
|
+
console.log(' [dry-run] Would remove: delivery-os.json');
|
|
445
|
+
} else {
|
|
446
|
+
fs.unlinkSync(manifestDest);
|
|
447
|
+
console.log(' Removed: delivery-os.json');
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
328
451
|
console.log('');
|
|
329
452
|
if (workflowsRemoved > 0 || templatesRemoved > 0) {
|
|
330
453
|
if (dryRun) {
|
|
@@ -342,4 +465,10 @@ function runUninstall(options) {
|
|
|
342
465
|
console.log('=== Uninstall complete ===');
|
|
343
466
|
}
|
|
344
467
|
|
|
345
|
-
module.exports = {
|
|
468
|
+
module.exports = {
|
|
469
|
+
runInstall,
|
|
470
|
+
runStatus,
|
|
471
|
+
runUninstall,
|
|
472
|
+
// Exposed for tests only — not part of the CLI's public API.
|
|
473
|
+
__test__: { manifestPath, readManifest, writeManifest, fetchLatestVersion, WORKFLOWS, TEMPLATES },
|
|
474
|
+
};
|