github-delivery-os 1.0.3 → 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.
@@ -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
- let releaseApproved = false;
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
- if (
44
- comment.user.login === releaseApprover &&
45
- /(declined|reject|not approved)/i.test(body)
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
- await github.rest.issues.addLabels({
49
- owner,
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
- try {
56
- await github.rest.issues.removeLabel({
57
- owner,
58
- repo,
59
- issue_number: issueNumber,
60
- name: "ready-for-deploy"
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
- await github.rest.issues.createComment({
75
+ try {
76
+ await github.rest.issues.removeLabel({
65
77
  owner,
66
78
  repo,
67
79
  issue_number: issueNumber,
68
- body: "🔴 **Release Declined**\n\nThis release requires additional fixes before production deployment."
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
- return;
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 (releaseApproved && qaApproved) {
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.addLabels({
98
+ await github.rest.issues.removeLabel({
93
99
  owner,
94
100
  repo,
95
101
  issue_number: issueNumber,
96
- labels: ["ready-for-deploy"]
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 parentMatch = body.match(/Parent Sprint:\s*#(\d+)/);
28
- if (!parentMatch) return;
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
- let startDate, endDate;
41
- const dateMatch1 = sprintBody.match(/Sprint Dates[\s\S]*?(\d{4}-\d{2}-\d{2})\s*to\s*(\d{4}-\d{2}-\d{2})/i);
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 { data: allIssues } = await github.rest.issues.listForRepo({
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 closedChildren = children.filter(i => i.state === "closed");
73
-
74
- const progressPercent = children.length === 0
75
- ? 0
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
@@ -19,6 +19,17 @@ jobs:
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 featuresMatch = body.match(/### Sprint Features[\s\S]*?(?=###|$)/);
27
- if (!featuresMatch) return;
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 = `Parent Sprint: #${parentNumber}\n\n---\n*Created by Delivery OS Sprint Child Creator*`;
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
- if [[ "$ACTOR" == "$RELEASE_APPROVER" ]] && echo "$COMMENT_BODY" | grep -iq "declined\|reject\|not approved"; then
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
@@ -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,8 @@ 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
+
75
80
  ---
76
81
 
77
82
  ## Quick Start (After Install)
@@ -102,6 +107,8 @@ When you open an issue using the **Sprint Planning** template with a title like
102
107
  | Document | Description |
103
108
  |----------|-------------|
104
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 |
105
112
  | [Consumer Setup](docs/consumer-setup.md) | Installation, configuration, variables, labels, Telegram, uninstall |
106
113
  | [How To](docs/how-to.md) | Create sprints, request releases, approve, report bugs, QA requests |
107
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",
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",
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
- .action((target) => {
41
- runStatus({ targetDir: target || '.' });
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
- program.parse();
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).`);
@@ -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 = { runInstall, runStatus, runUninstall };
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
+ };