@nanobpm/nano-workforce 0.205.2 → 0.206.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/release.yml +18 -3
- package/.releaserc.json +7 -0
- package/CHANGELOG.md +6 -0
- package/app/contracts.ts +1 -1
- package/app/version.test.ts +204 -0
- package/app/version.ts +64 -4
- package/build-info.json +6 -0
- package/docs/agent-guide.md +5 -0
- package/openapi.yaml +38 -0
- package/operations/getVersion.test.ts +3 -0
- package/operations/listActivePrs.test.ts +15 -0
- package/operations/listActivePrs.ts +5 -2
- package/package.json +2 -1
- package/scripts/write-build-info.test.ts +87 -0
- package/scripts/write-build-info.ts +126 -0
|
@@ -197,8 +197,11 @@ jobs:
|
|
|
197
197
|
# token, still OIDC + provenance via the job's id-token. This step is a NO-OP
|
|
198
198
|
# when the release step succeeded or when the version is already on npm, so it
|
|
199
199
|
# can never double-publish. If OIDC stays down through every retry, fail loudly:
|
|
200
|
-
# a human must `npm publish` the pushed tag locally
|
|
201
|
-
#
|
|
200
|
+
# a human must `npm publish` the pushed tag locally — restoring the build identity first,
|
|
201
|
+
# because a fresh tag checkout has no build-info.json and plain `npm publish` skips the
|
|
202
|
+
# prepare hook (npm login with 2FA, then `git checkout vX.Y.Z && npm ci &&
|
|
203
|
+
# node --experimental-strip-types scripts/write-build-info.ts X.Y.Z --sha "$(git rev-parse
|
|
204
|
+
# vX.Y.Z^)" && npm publish --provenance --access public`; see the ::error:: message below).
|
|
202
205
|
- name: Retry OIDC publish if the release publish failed
|
|
203
206
|
if: steps.release.outcome == 'failure'
|
|
204
207
|
env:
|
|
@@ -213,6 +216,18 @@ jobs:
|
|
|
213
216
|
exit 0
|
|
214
217
|
fi
|
|
215
218
|
echo "semantic-release prepared + tagged ${PKG}@${VERSION} but the OIDC publish failed; retrying."
|
|
219
|
+
# Reuse the build-info.json that semantic-release's prepare step already wrote (prepareCmd
|
|
220
|
+
# in .releaserc.json) — do NOT regenerate it here. By the time this retry runs,
|
|
221
|
+
# @semantic-release/git has already created the release commit, so HEAD is that
|
|
222
|
+
# metadata-only `chore(release)` commit, NOT the source HEAD the release was cut from.
|
|
223
|
+
# Re-running write-build-info.ts would call `git rev-parse HEAD` and overwrite the recorded
|
|
224
|
+
# source SHA with the release commit's SHA (and replace builtAt), making the documented
|
|
225
|
+
# identity depend on whether OIDC publishing failed. The prepared file is already present
|
|
226
|
+
# in this checkout with the correct source HEAD; this step re-packs via plain `npm publish`
|
|
227
|
+
# (which runs OUTSIDE semantic-release, so the prepare hook does not re-run), so the baked
|
|
228
|
+
# file is packed as-is. Validate it is present and well-formed (fail loudly if a future
|
|
229
|
+
# change ever drops it) rather than regenerate it from the changed HEAD (issue #925).
|
|
230
|
+
node --experimental-strip-types scripts/write-build-info.ts --check
|
|
216
231
|
published=false
|
|
217
232
|
for attempt in 1 2 3; do
|
|
218
233
|
echo "OIDC publish attempt ${attempt}/3 for ${PKG}@${VERSION}"
|
|
@@ -224,7 +239,7 @@ jobs:
|
|
|
224
239
|
sleep $((attempt * 30))
|
|
225
240
|
done
|
|
226
241
|
if [ "$published" != true ]; then
|
|
227
|
-
echo "::error::OIDC publish of ${PKG}@${VERSION} still failing after retries. The git tag v${VERSION} is pushed but npm has no tarball. Publish it locally: 'git fetch --tags && git checkout v${VERSION} && npm ci && npm publish --provenance --access public' (npm
|
|
242
|
+
echo "::error::OIDC publish of ${PKG}@${VERSION} still failing after retries. The git tag v${VERSION} is pushed but npm has no tarball. Publish it locally (npm login with 2FA first): 'git fetch --tags && git checkout v${VERSION} && npm ci && node --experimental-strip-types scripts/write-build-info.ts ${VERSION} --sha \"\$(git rev-parse v${VERSION}^)\" && node --experimental-strip-types scripts/write-build-info.ts --check && npm publish --provenance --access public'. The write-build-info step is REQUIRED: a fresh tag checkout has no build-info.json (it is an uncommitted build artifact) and plain npm publish does not run the semantic-release prepare hook, so without it the package ships with gitSha: null. Passing --sha \"\$(git rev-parse v${VERSION}^)\" records the SOURCE HEAD (the tag's parent), not the metadata-only release commit the tag points at."
|
|
228
243
|
exit 1
|
|
229
244
|
fi
|
|
230
245
|
# semantic-release aborts before @semantic-release/github when the npm
|
package/.releaserc.json
CHANGED
|
@@ -40,6 +40,13 @@
|
|
|
40
40
|
"@semantic-release/changelog",
|
|
41
41
|
"@semantic-release/npm",
|
|
42
42
|
"@semantic-release/github",
|
|
43
|
+
[
|
|
44
|
+
"@semantic-release/exec",
|
|
45
|
+
{
|
|
46
|
+
"//": "Bake the build identity into the package (issue #925). prepareCmd runs in semantic-release's prepare step — AFTER @semantic-release/npm bumps package.json to ${nextRelease.version} and BEFORE it packs/publishes the tarball — so the published package ships build-info.json and an npm-installed instance (no .git, no env override) can still report its gitSha. The recorded gitSha is the SOURCE HEAD the release is cut from; the tag commit is created afterwards by @semantic-release/git (see scripts/write-build-info.ts). There is deliberately NO verifyConditionsCmd: semantic-release runs verifyConditions BEFORE it computes nextRelease, so ${nextRelease.version} interpolates empty there and the command would fail with EVERIFYCONDITIONS, blocking every release. The workflow's OIDC-retry step re-runs plain `npm publish` (which re-packs) in the same checkout — where prepare already wrote build-info.json — and validates + reuses that prepared file (it does NOT regenerate it: by then HEAD is the release commit, so re-running the writer would overwrite the source SHA with the release commit's SHA; see release.yml), so the retry never publishes a tarball with no or wrong build-info.json.",
|
|
47
|
+
"prepareCmd": "node --experimental-strip-types scripts/write-build-info.ts ${nextRelease.version}"
|
|
48
|
+
}
|
|
49
|
+
],
|
|
43
50
|
[
|
|
44
51
|
"@semantic-release/git",
|
|
45
52
|
{
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.206.0](https://github.com/nanobpm/nano-workforce/compare/v0.205.2...v0.206.0) (2026-11-08)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **version:** bake the git SHA into release packages and report identity on /status ([#926](https://github.com/nanobpm/nano-workforce/issues/926)) ([e5eedca](https://github.com/nanobpm/nano-workforce/commit/e5eedca0ee28db1f54167b4bb962ce4c06776e9f)), closes [#925](https://github.com/nanobpm/nano-workforce/issues/925) [#925](https://github.com/nanobpm/nano-workforce/issues/925) [#925](https://github.com/nanobpm/nano-workforce/issues/925) [#925](https://github.com/nanobpm/nano-workforce/issues/925)
|
|
6
|
+
|
|
1
7
|
## [0.205.2](https://github.com/nanobpm/nano-workforce/compare/v0.205.1...v0.205.2) (2026-11-08)
|
|
2
8
|
|
|
3
9
|
### Bug Fixes
|
package/app/contracts.ts
CHANGED
|
@@ -301,7 +301,7 @@ export const ENV_CONTRACTS = {
|
|
|
301
301
|
name: "NANO_WORKFORCE_GIT_SHA",
|
|
302
302
|
owner: "app/version.ts",
|
|
303
303
|
semantics:
|
|
304
|
-
"Explicit git SHA override for version reporting on deploys shipped without a .git directory; version derivation reads .git when unset.",
|
|
304
|
+
"Explicit git SHA override for version reporting on deploys shipped without a .git directory; version derivation reads .git when unset, then falls back to the release-baked build-info.json (issue #925).",
|
|
305
305
|
},
|
|
306
306
|
NANO_AUTO_RETRO: {
|
|
307
307
|
category: "env",
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Tests for app/version.ts identity resolution (issue #925).
|
|
2
|
+
//
|
|
3
|
+
// The module probes the working tree ONCE at import time (STATIC), so each scenario imports a
|
|
4
|
+
// cache-busted copy (`?scenario=…`) to re-run the probe under a controlled environment:
|
|
5
|
+
// - env override (NANO_WORKFORCE_GIT_SHA) always wins;
|
|
6
|
+
// - a live .git checkout beats the baked build-info.json;
|
|
7
|
+
// - with neither env nor .git, the release-baked build-info.json is the fallback (the npm-install
|
|
8
|
+
// case — the whole point of #925);
|
|
9
|
+
// - gitTag/builtAt are exposed from build-info.json when known, null on a plain checkout.
|
|
10
|
+
//
|
|
11
|
+
// The "no .git" scenarios copy the app's identity inputs (package.json + build-info.json) into a
|
|
12
|
+
// temp dir WITHOUT a .git directory and load a thin re-export fixture from there — the fixture's
|
|
13
|
+
// own location is what `version.ts` derives REPO_ROOT from, so loading it via the temp path
|
|
14
|
+
// reproduces an npm-installed layout faithfully.
|
|
15
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { test } from "node:test";
|
|
19
|
+
import { assert, assertEquals } from "#test-assert";
|
|
20
|
+
import { buildVersionInfo } from "./version.ts";
|
|
21
|
+
|
|
22
|
+
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
23
|
+
// A build-info.json the release pipeline would have baked (scripts/write-build-info.ts shape).
|
|
24
|
+
const BAKED = {
|
|
25
|
+
version: "9.9.9",
|
|
26
|
+
gitSha: "0123456789abcdef0123456789abcdef01234567",
|
|
27
|
+
gitTag: "v9.9.9",
|
|
28
|
+
builtAt: "2026-01-02T03:04:05.000Z",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Materialise an npm-install-like layout (package.json + build-info.json, NO .git) in a temp dir. */
|
|
32
|
+
function npmInstallLayout(): { dir: string; cleanup: () => void } {
|
|
33
|
+
const dir = mkdtempSync(join(tmpdir(), "nwf-version-"));
|
|
34
|
+
writeFileSync(
|
|
35
|
+
join(dir, "package.json"),
|
|
36
|
+
// "type": "module" so the copied version.ts loads as ESM without a MODULE_TYPELESS_PACKAGE_JSON warning.
|
|
37
|
+
JSON.stringify({ name: "@nanobpm/nano-workforce", version: BAKED.version, type: "module" }),
|
|
38
|
+
);
|
|
39
|
+
writeFileSync(join(dir, "build-info.json"), JSON.stringify(BAKED));
|
|
40
|
+
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Import version.ts AS IF it lived under `dir` (a copy whose REPO_ROOT resolves to `dir`).
|
|
45
|
+
* version.ts derives REPO_ROOT as dirname(dirname(import.meta.url)) — it lives in `app/` — so the
|
|
46
|
+
* copy must sit at the SAME depth (`<dir>/app/version-copy.ts`) for the derivation to land on `dir`.
|
|
47
|
+
*/
|
|
48
|
+
async function importFrom(dir: string) {
|
|
49
|
+
const { mkdirSync, readFileSync } = await import("node:fs");
|
|
50
|
+
const src = readFileSync(new URL("./version.ts", import.meta.url), "utf8");
|
|
51
|
+
mkdirSync(join(dir, "app"), { recursive: true });
|
|
52
|
+
const dest = join(dir, "app", `version-copy-${Date.now()}-${Math.random().toString(36).slice(2)}.ts`);
|
|
53
|
+
writeFileSync(dest, src);
|
|
54
|
+
return (await import(dest)) as typeof import("./version.ts");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function withEnv<T>(key: string, value: string | undefined, fn: () => Promise<T>): Promise<T> {
|
|
58
|
+
const prev = process.env[key];
|
|
59
|
+
if (value === undefined) delete process.env[key];
|
|
60
|
+
else process.env[key] = value;
|
|
61
|
+
try {
|
|
62
|
+
return await fn();
|
|
63
|
+
} finally {
|
|
64
|
+
if (prev === undefined) delete process.env[key];
|
|
65
|
+
else process.env[key] = value;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test("a live .git checkout resolves gitSha from HEAD (the dev-clone case)", () => {
|
|
70
|
+
// This test itself runs from a real checkout, so the plain import sees .git.
|
|
71
|
+
const info = buildVersionInfo();
|
|
72
|
+
assert(info.gitSha == null || SHA_RE.test(info.gitSha), `gitSha is null or 40-hex, got ${info.gitSha}`);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a live .git checkout WINS over a competing baked build-info.json (checkout > baked)", async () => {
|
|
76
|
+
// Precedence guard (issue #925): env > .git checkout > build-info.json. Lay out BOTH a live
|
|
77
|
+
// (detached) .git whose HEAD SHA differs from the baked one AND a build-info.json, with no env
|
|
78
|
+
// override, and assert HEAD wins. Without a competing baked file this could not catch the baked
|
|
79
|
+
// fallback incorrectly taking precedence over the checkout.
|
|
80
|
+
const dir = mkdtempSync(join(tmpdir(), "nwf-version-precedence-"));
|
|
81
|
+
try {
|
|
82
|
+
const checkoutSha = "cccccccccccccccccccccccccccccccccccccccc";
|
|
83
|
+
writeFileSync(
|
|
84
|
+
join(dir, "package.json"),
|
|
85
|
+
JSON.stringify({ name: "@nanobpm/nano-workforce", version: BAKED.version, type: "module" }),
|
|
86
|
+
);
|
|
87
|
+
// A live checkout: .git is a directory whose HEAD is a detached commit SHA distinct from the baked one.
|
|
88
|
+
mkdirSync(join(dir, ".git"), { recursive: true });
|
|
89
|
+
writeFileSync(join(dir, ".git", "HEAD"), `${checkoutSha}\n`);
|
|
90
|
+
// A competing baked file whose gitSha must LOSE to the checkout above.
|
|
91
|
+
writeFileSync(join(dir, "build-info.json"), JSON.stringify(BAKED));
|
|
92
|
+
await withEnv("NANO_WORKFORCE_GIT_SHA", undefined, async () => {
|
|
93
|
+
const mod = await importFrom(dir);
|
|
94
|
+
const info = mod.buildVersionInfo();
|
|
95
|
+
assertEquals(info.gitSha, checkoutSha, "the live .git checkout beats the baked build-info.json");
|
|
96
|
+
});
|
|
97
|
+
} finally {
|
|
98
|
+
rmSync(dir, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("env override NANO_WORKFORCE_GIT_SHA wins over .git and build-info.json", async () => {
|
|
103
|
+
const envSha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
104
|
+
await withEnv("NANO_WORKFORCE_GIT_SHA", envSha, async () => {
|
|
105
|
+
const mod = (await import(`./version.ts?env-wins=${Date.now()}`)) as typeof import("./version.ts");
|
|
106
|
+
assertEquals(mod.buildVersionInfo().gitSha, envSha);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("env override wins even in an npm-install layout (env > build-info.json)", async () => {
|
|
111
|
+
const { dir, cleanup } = npmInstallLayout();
|
|
112
|
+
try {
|
|
113
|
+
const envSha = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
|
114
|
+
await withEnv("NANO_WORKFORCE_GIT_SHA", envSha, async () => {
|
|
115
|
+
const mod = await importFrom(dir);
|
|
116
|
+
assertEquals(mod.buildVersionInfo().gitSha, envSha, "env beats the baked build-info.json");
|
|
117
|
+
});
|
|
118
|
+
} finally {
|
|
119
|
+
cleanup();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("with no env and no .git, gitSha falls back to the baked build-info.json (the npm-install case)", async () => {
|
|
124
|
+
const { dir, cleanup } = npmInstallLayout();
|
|
125
|
+
try {
|
|
126
|
+
await withEnv("NANO_WORKFORCE_GIT_SHA", undefined, async () => {
|
|
127
|
+
const mod = await importFrom(dir);
|
|
128
|
+
const info = mod.buildVersionInfo();
|
|
129
|
+
assertEquals(info.gitSha, BAKED.gitSha, "build-info.json is the fallback source of gitSha");
|
|
130
|
+
assertEquals(info.gitTag, BAKED.gitTag, "gitTag is exposed from build-info.json");
|
|
131
|
+
assertEquals(info.builtAt, BAKED.builtAt, "builtAt is exposed from build-info.json");
|
|
132
|
+
assertEquals(info.gitBranch, null, "no .git → no branch");
|
|
133
|
+
assertEquals(info.version, BAKED.version, "version still comes from package.json");
|
|
134
|
+
});
|
|
135
|
+
} finally {
|
|
136
|
+
cleanup();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("a malformed build-info.json (non-string gitSha) is ignored, not fatal", async () => {
|
|
141
|
+
const dir = mkdtempSync(join(tmpdir(), "nwf-version-bad-"));
|
|
142
|
+
try {
|
|
143
|
+
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "@nanobpm/nano-workforce", version: "1.0.0", type: "module" }));
|
|
144
|
+
writeFileSync(join(dir, "build-info.json"), JSON.stringify({ version: "1.0.0", gitSha: 42 }));
|
|
145
|
+
await withEnv("NANO_WORKFORCE_GIT_SHA", undefined, async () => {
|
|
146
|
+
const mod = await importFrom(dir);
|
|
147
|
+
const info = mod.buildVersionInfo();
|
|
148
|
+
assertEquals(info.gitSha, null);
|
|
149
|
+
assertEquals(info.gitTag, null);
|
|
150
|
+
assertEquals(info.builtAt, null);
|
|
151
|
+
});
|
|
152
|
+
} finally {
|
|
153
|
+
rmSync(dir, { recursive: true, force: true });
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("a build-info.json whose gitSha is a non-blank but non-40-hex string is rejected (fail closed)", async () => {
|
|
158
|
+
// Regression for the malformed-baked-SHA finding: a payload like `gitSha: "not-a-sha"` must NOT
|
|
159
|
+
// be reported as the commit — the getter fails closed and reports null rather than surfacing an
|
|
160
|
+
// invalid commit on the identity endpoints.
|
|
161
|
+
const dir = mkdtempSync(join(tmpdir(), "nwf-version-badsha-"));
|
|
162
|
+
try {
|
|
163
|
+
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "@nanobpm/nano-workforce", version: "1.0.0", type: "module" }));
|
|
164
|
+
writeFileSync(
|
|
165
|
+
join(dir, "build-info.json"),
|
|
166
|
+
JSON.stringify({ version: "1.0.0", gitSha: "not-a-sha", gitTag: "v1.0.0", builtAt: "2026-01-02T03:04:05.000Z" }),
|
|
167
|
+
);
|
|
168
|
+
await withEnv("NANO_WORKFORCE_GIT_SHA", undefined, async () => {
|
|
169
|
+
const mod = await importFrom(dir);
|
|
170
|
+
const info = mod.buildVersionInfo();
|
|
171
|
+
assertEquals(info.gitSha, null, "a malformed baked SHA is ignored, not reported");
|
|
172
|
+
assertEquals(info.gitTag, null, "the whole baked payload is dropped when its gitSha is invalid");
|
|
173
|
+
assertEquals(info.builtAt, null);
|
|
174
|
+
});
|
|
175
|
+
} finally {
|
|
176
|
+
rmSync(dir, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("gitTag/builtAt are always present in the payload (null on a plain checkout)", () => {
|
|
181
|
+
// The committed repo carries no build-info.json, so a dev checkout reports the release-only
|
|
182
|
+
// fields as null; the assertion keys off the SAME getter the endpoint uses, so it documents the
|
|
183
|
+
// contract either way.
|
|
184
|
+
const info = buildVersionInfo();
|
|
185
|
+
assert("gitTag" in info && "builtAt" in info, "the fields are always present in the payload");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("a build-info.json with a valid SHA but an unparseable builtAt is rejected (shared validator)", async () => {
|
|
189
|
+
// Regression: the reader once validated only gitSha, so `builtAt: "not-a-date"` reached /version
|
|
190
|
+
// even though the writer's buildInfoProblems() rejects it. Reader + writer now share one validator.
|
|
191
|
+
const dir = mkdtempSync(join(tmpdir(), "nwf-version-baddate-"));
|
|
192
|
+
try {
|
|
193
|
+
writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "@nanobpm/nano-workforce", version: "1.0.0", type: "module" }));
|
|
194
|
+
writeFileSync(join(dir, "build-info.json"), JSON.stringify({ ...BAKED, builtAt: "not-a-date" }));
|
|
195
|
+
await withEnv("NANO_WORKFORCE_GIT_SHA", undefined, async () => {
|
|
196
|
+
const mod = await importFrom(dir);
|
|
197
|
+
const info = mod.buildVersionInfo();
|
|
198
|
+
assertEquals(info.gitSha, null, "the whole baked payload is dropped when builtAt is invalid");
|
|
199
|
+
assertEquals(info.builtAt, null);
|
|
200
|
+
});
|
|
201
|
+
} finally {
|
|
202
|
+
rmSync(dir, { recursive: true, force: true });
|
|
203
|
+
}
|
|
204
|
+
});
|
package/app/version.ts
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
// inspecting the working tree at runtime. This module gathers that identity — the app's package
|
|
6
6
|
// version, the resolved `@nanobpm/urban` version, the git commit (read from `.git`, handling both
|
|
7
7
|
// an ordinary `.git` directory and the `gitdir:` file pointer used by worktrees/submodules, with
|
|
8
|
-
// an env override for detached deploys
|
|
9
|
-
//
|
|
8
|
+
// an env override for detached deploys AND a `build-info.json` fallback baked into the release
|
|
9
|
+
// package — an npm install has neither `.git` nor the env var, issue #925), plus the Node runtime,
|
|
10
|
+
// pid and start time — so an operator debugging a stuck instance can confirm the process is on the
|
|
11
|
+
// code they think it is.
|
|
10
12
|
//
|
|
11
13
|
// Every probe is best-effort: a missing file or unavailable `.git` yields `null` for that field
|
|
12
14
|
// rather than throwing, so `/app/api/version` never fails just because one source is absent.
|
|
@@ -98,17 +100,68 @@ function urbanVersion(): string | null {
|
|
|
98
100
|
return typeof pkg?.version === "string" ? pkg.version : null;
|
|
99
101
|
}
|
|
100
102
|
|
|
103
|
+
/**
|
|
104
|
+
* The build identity baked into the release package (`build-info.json` at the package root) by
|
|
105
|
+
* `scripts/write-build-info.ts` during semantic-release's `prepare` step (issue #925). An npm
|
|
106
|
+
* install ships this file but no `.git`, so it is the ONLY source of `gitSha` there. Its `gitSha`
|
|
107
|
+
* is the SOURCE HEAD the release was cut from (the tag commit does not exist yet at prepare time);
|
|
108
|
+
* its `gitTag` names the tag placed on the release commit, whose tree matches that source HEAD
|
|
109
|
+
* except for the version-bump metadata. Returns null on a dev checkout, which has no such file.
|
|
110
|
+
*/
|
|
111
|
+
export const SHA_RE = /^[0-9a-f]{40}$/;
|
|
112
|
+
|
|
113
|
+
/** Validate a parsed build-info payload; returns a list of problems (empty = valid). The single
|
|
114
|
+
* validator shared by the writer (scripts/write-build-info.ts) and the reader below. */
|
|
115
|
+
export function buildInfoProblems(info: unknown): string[] {
|
|
116
|
+
const problems: string[] = [];
|
|
117
|
+
if (typeof info !== "object" || info == null) return ["build-info.json is not a JSON object"];
|
|
118
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape (parsed JSON)
|
|
119
|
+
const bi = info as Record<string, unknown>;
|
|
120
|
+
if (typeof bi.version !== "string" || !bi.version.trim()) problems.push("version must be a non-blank string");
|
|
121
|
+
if (typeof bi.gitSha !== "string" || !SHA_RE.test(bi.gitSha)) problems.push("gitSha must be a 40-hex string");
|
|
122
|
+
if (typeof bi.gitTag !== "string" || !bi.gitTag.trim()) problems.push("gitTag must be a non-blank string");
|
|
123
|
+
if (typeof bi.builtAt !== "string" || Number.isNaN(Date.parse(bi.builtAt)))
|
|
124
|
+
problems.push("builtAt must be an ISO-8601 timestamp");
|
|
125
|
+
return problems;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
function buildInfo(): { version: string; gitSha: string; gitTag: string; builtAt: string } | null {
|
|
130
|
+
const raw = readJson(join(REPO_ROOT, "build-info.json"));
|
|
131
|
+
if (raw == null) return null;
|
|
132
|
+
// Fail closed on ANY malformed field (bad SHA, unparseable builtAt, blank tag/version): a corrupt
|
|
133
|
+
// or tampered build-info.json is ignored wholesale rather than partially surfaced. Uses the SAME
|
|
134
|
+
// validator the writer's --check uses, so reader and writer can never drift.
|
|
135
|
+
if (buildInfoProblems(raw).length > 0) return null;
|
|
136
|
+
return {
|
|
137
|
+
version: String(raw.version),
|
|
138
|
+
gitSha: String(raw.gitSha),
|
|
139
|
+
gitTag: String(raw.gitTag),
|
|
140
|
+
builtAt: String(raw.builtAt),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
101
144
|
/**
|
|
102
145
|
* The git commit the working tree is on, read straight from `.git` so it reflects the ACTUAL
|
|
103
146
|
* checked-out code — not a value baked at some earlier build. Resolves a symbolic `HEAD`
|
|
104
147
|
* (`ref: refs/heads/…`) via the loose ref file (checking both the per-worktree and common dirs),
|
|
105
|
-
* falling back to `packed-refs`.
|
|
106
|
-
* that ship without a `.git` directory)
|
|
148
|
+
* falling back to `packed-refs`. Resolution order (issue #925): an explicit
|
|
149
|
+
* `NANO_WORKFORCE_GIT_SHA` env var wins (for deploys that ship without a `.git` directory), then
|
|
150
|
+
* the live `.git` checkout, then the release-baked `build-info.json` (the only source available to
|
|
151
|
+
* an npm-installed instance).
|
|
107
152
|
*/
|
|
108
153
|
function gitSha(): string | null {
|
|
109
154
|
const env = envVar("NANO_WORKFORCE_GIT_SHA");
|
|
110
155
|
if (env) return env;
|
|
111
156
|
|
|
157
|
+
const fromCheckout = gitShaFromCheckout();
|
|
158
|
+
if (fromCheckout != null) return fromCheckout;
|
|
159
|
+
|
|
160
|
+
return buildInfo()?.gitSha ?? null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The commit SHA read from the live `.git` checkout, or null when there is none (npm install). */
|
|
164
|
+
function gitShaFromCheckout(): string | null {
|
|
112
165
|
const dirs = resolveGitDirs();
|
|
113
166
|
if (dirs == null) return null;
|
|
114
167
|
const head = readText(join(dirs.gitDir, "HEAD"));
|
|
@@ -160,6 +213,10 @@ export interface VersionInfo {
|
|
|
160
213
|
urbanVersion: string | null;
|
|
161
214
|
gitSha: string | null;
|
|
162
215
|
gitBranch: string | null;
|
|
216
|
+
/** The release tag (e.g. "v0.206.0") from the baked `build-info.json`; null on a dev checkout. */
|
|
217
|
+
gitTag: string | null;
|
|
218
|
+
/** ISO-8601 time the release package was built (from `build-info.json`); null on a dev checkout. */
|
|
219
|
+
builtAt: string | null;
|
|
163
220
|
runtime: string;
|
|
164
221
|
pid: number | null;
|
|
165
222
|
startedAt: string;
|
|
@@ -172,12 +229,15 @@ export interface VersionInfo {
|
|
|
172
229
|
*/
|
|
173
230
|
const STATIC: Omit<VersionInfo, "uptimeSeconds"> = (() => {
|
|
174
231
|
const pkg = appPackage();
|
|
232
|
+
const build = buildInfo();
|
|
175
233
|
return Object.freeze({
|
|
176
234
|
name: appName(pkg),
|
|
177
235
|
version: appVersion(pkg),
|
|
178
236
|
urbanVersion: urbanVersion(),
|
|
179
237
|
gitSha: gitSha(),
|
|
180
238
|
gitBranch: gitBranch(),
|
|
239
|
+
gitTag: build?.gitTag || null,
|
|
240
|
+
builtAt: build?.builtAt || null,
|
|
181
241
|
runtime: runtime(),
|
|
182
242
|
pid: typeof process.pid === "number" ? process.pid : null,
|
|
183
243
|
startedAt: STARTED_AT.toISOString(),
|
package/build-info.json
ADDED
package/docs/agent-guide.md
CHANGED
|
@@ -26,6 +26,11 @@ curl -sS __BASE__/version | jq # tool: getVersion: app/urban version, git sha,
|
|
|
26
26
|
curl -sS __BASE__/status | jq # tool: listActivePrs: every PR in flight
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
+
`/status` also carries the running app's build identity as a top-level `version` object
|
|
30
|
+
(`{ version, gitSha }`, the same source as `/version` — env override, then the live `.git`
|
|
31
|
+
checkout, then the release-baked `build-info.json` an npm install ships), so the endpoint
|
|
32
|
+
everyone polls answers "is the fix live?" in one call.
|
|
33
|
+
|
|
29
34
|
Each `/status` entry carries `prKey` (`owner/repo#123`), `status`, `round`, `processKey`
|
|
30
35
|
(the **engine process-instance key**, the bridge to §5), `openEscalation`,
|
|
31
36
|
`activeWorker`/`leaseUntil` (an agent holds the round, or the job is just queued), and
|
package/openapi.yaml
CHANGED
|
@@ -195,6 +195,7 @@ components:
|
|
|
195
195
|
- count
|
|
196
196
|
- prs
|
|
197
197
|
- deliveries
|
|
198
|
+
- version
|
|
198
199
|
properties:
|
|
199
200
|
count:
|
|
200
201
|
type: integer
|
|
@@ -209,6 +210,26 @@ components:
|
|
|
209
210
|
first (issue #889 §1 — the opt-in is shown for each PR AND each delivery).
|
|
210
211
|
items:
|
|
211
212
|
$ref: "#/components/schemas/ActiveDelivery"
|
|
213
|
+
version:
|
|
214
|
+
type: object
|
|
215
|
+
additionalProperties: false
|
|
216
|
+
required:
|
|
217
|
+
- version
|
|
218
|
+
- gitSha
|
|
219
|
+
description: >-
|
|
220
|
+
The running app's build identity (issue #925), derived from the same app/version.ts
|
|
221
|
+
getter as GET /version — so the endpoint everyone polls answers "is the fix live?" in
|
|
222
|
+
one call. gitSha resolves from NANO_WORKFORCE_GIT_SHA, then the .git checkout, then the
|
|
223
|
+
release-baked build-info.json (the only source an npm install has).
|
|
224
|
+
properties:
|
|
225
|
+
version:
|
|
226
|
+
type: string
|
|
227
|
+
nullable: true
|
|
228
|
+
description: The app's package version.
|
|
229
|
+
gitSha:
|
|
230
|
+
type: string
|
|
231
|
+
nullable: true
|
|
232
|
+
description: The commit the instance runs; null only when no source (env, .git, build-info.json) knows it.
|
|
212
233
|
ActiveDelivery:
|
|
213
234
|
type: object
|
|
214
235
|
description: A delivery-graph run currently in flight, as reported by the status endpoint.
|
|
@@ -1561,6 +1582,8 @@ components:
|
|
|
1561
1582
|
- urbanVersion
|
|
1562
1583
|
- gitSha
|
|
1563
1584
|
- gitBranch
|
|
1585
|
+
- gitTag
|
|
1586
|
+
- builtAt
|
|
1564
1587
|
- runtime
|
|
1565
1588
|
- pid
|
|
1566
1589
|
- startedAt
|
|
@@ -1577,9 +1600,24 @@ components:
|
|
|
1577
1600
|
gitSha:
|
|
1578
1601
|
type: string
|
|
1579
1602
|
nullable: true
|
|
1603
|
+
description: >-
|
|
1604
|
+
The commit the instance runs. Resolution order (issue #925): NANO_WORKFORCE_GIT_SHA env,
|
|
1605
|
+
then the live .git checkout, then the release-baked build-info.json (the only source an
|
|
1606
|
+
npm install has — this is the source HEAD the release was cut from).
|
|
1580
1607
|
gitBranch:
|
|
1581
1608
|
type: string
|
|
1582
1609
|
nullable: true
|
|
1610
|
+
gitTag:
|
|
1611
|
+
type: string
|
|
1612
|
+
nullable: true
|
|
1613
|
+
description: >-
|
|
1614
|
+
The release tag (e.g. "v0.206.0") naming the release commit, from the baked
|
|
1615
|
+
build-info.json; null on a dev checkout. The tag commit's tree matches gitSha's tree
|
|
1616
|
+
except for the version-bump metadata.
|
|
1617
|
+
builtAt:
|
|
1618
|
+
type: string
|
|
1619
|
+
nullable: true
|
|
1620
|
+
description: ISO-8601 time the release package was built (from build-info.json); null on a dev checkout.
|
|
1583
1621
|
runtime:
|
|
1584
1622
|
type: string
|
|
1585
1623
|
pid:
|
|
@@ -34,6 +34,9 @@ test("returns 200 with the app identity", async () => {
|
|
|
34
34
|
assert("urbanVersion" in r.body);
|
|
35
35
|
assert("gitSha" in r.body);
|
|
36
36
|
assert("gitBranch" in r.body);
|
|
37
|
+
// Release-baked identity fields (issue #925): always present, null on a dev checkout.
|
|
38
|
+
assert("gitTag" in r.body);
|
|
39
|
+
assert("builtAt" in r.body);
|
|
37
40
|
assert(typeof r.body.runtime === "string" && r.body.runtime.length > 0);
|
|
38
41
|
assert(typeof r.body.startedAt === "string");
|
|
39
42
|
assert(typeof r.body.uptimeSeconds === "number");
|
|
@@ -66,6 +66,21 @@ test("returns 200 with a count + projected active PRs", async () => {
|
|
|
66
66
|
assertEquals(r.body.prs[0].processKey, "9");
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
+
test("carries the running app's identity as a top-level version object (issue #925)", async () => {
|
|
70
|
+
// `/status` is the endpoint everyone polls, so it must answer "is the fix live?" on its own:
|
|
71
|
+
// version.version + version.gitSha, derived from the SAME app/version.ts getter /version uses.
|
|
72
|
+
const app = memApp([]);
|
|
73
|
+
const res = (await handler(input(), app)) as any;
|
|
74
|
+
assertEquals(res.status, 200);
|
|
75
|
+
assert("version" in res.body, "the status payload carries a top-level version object");
|
|
76
|
+
assert("version" in res.body.version, "version.version is present (string|null)");
|
|
77
|
+
assert("gitSha" in res.body.version, "version.gitSha is present (string|null)");
|
|
78
|
+
const { buildVersionInfo } = await import("../app/version.ts");
|
|
79
|
+
const identity = buildVersionInfo();
|
|
80
|
+
assertEquals(res.body.version.version, identity.version, "derived from the app/version.ts getter, not re-read");
|
|
81
|
+
assertEquals(res.body.version.gitSha, identity.gitSha, "derived from the app/version.ts getter, not re-read");
|
|
82
|
+
});
|
|
83
|
+
|
|
69
84
|
test("surfaces the structured openEscalation from the user_tasks read model (both loops)", async () => {
|
|
70
85
|
// Issue #666: openEscalation is derived from the ONE `user_tasks` read model (the same surface
|
|
71
86
|
// `listEscalations` and the Convergence page consume), so `/status` carries the completable
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// callers must present it via the x-hook-secret header. Unset → open (unchanged default).
|
|
9
9
|
|
|
10
10
|
import { activeDeliveries, activePrs } from "../app/service.ts";
|
|
11
|
-
import { envVar } from "../app/version.ts";
|
|
11
|
+
import { buildVersionInfo, envVar } from "../app/version.ts";
|
|
12
12
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
13
13
|
|
|
14
14
|
// The optional shared-secret guard: when NANO_PR_WEBHOOK_SECRET is set, callers must present it via
|
|
@@ -21,5 +21,8 @@ export default defineOperation("listActivePrs", async ({ req }, app) => {
|
|
|
21
21
|
return { status: 401, body: { error: "unauthorized" } };
|
|
22
22
|
}
|
|
23
23
|
const [prs, deliveries] = await Promise.all([activePrs(app.data, app.engine), activeDeliveries(app.data)]);
|
|
24
|
-
|
|
24
|
+
// The running app's identity, derived from the SAME app/version.ts getter /version uses (never
|
|
25
|
+
// re-read), so the endpoint everyone polls answers "is the fix live?" in one call (issue #925).
|
|
26
|
+
const { version, gitSha } = buildVersionInfo();
|
|
27
|
+
return { status: 200, body: { count: prs.length, prs, deliveries, version: { version, gitSha } } };
|
|
25
28
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.206.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
"@nanobpm/urban-testkit": "^1.6.0",
|
|
75
75
|
"@nanobpm/workflow": "^0.15.0",
|
|
76
76
|
"@semantic-release/changelog": "^7.0.0",
|
|
77
|
+
"@semantic-release/exec": "^7.1.0",
|
|
77
78
|
"@semantic-release/git": "^11.0.0",
|
|
78
79
|
"@semantic-release/npm": "^13.1.5",
|
|
79
80
|
"@types/node": "^24.0.0",
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Tests for scripts/write-build-info.ts — the release-time bake of `build-info.json` (issue #925).
|
|
2
|
+
//
|
|
3
|
+
// The acceptance criterion is end-to-end: an `npm pack` of a tree whose build-info.json was written
|
|
4
|
+
// by this script CONTAINS the file, with a 40-hex gitSha — i.e. the published tarball carries the
|
|
5
|
+
// identity an npm-installed instance falls back to. Plus unit coverage of the payload the script
|
|
6
|
+
// composes (source-HEAD sha, `v<version>` tag, ISO builtAt) and its validation.
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { test } from "node:test";
|
|
13
|
+
import { assert, assertEquals, assertThrows } from "#test-assert";
|
|
14
|
+
import { buildInfoProblems, composeBuildInfo } from "./write-build-info.ts";
|
|
15
|
+
|
|
16
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
+
const BUILD_INFO = join(REPO_ROOT, "build-info.json");
|
|
18
|
+
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
19
|
+
|
|
20
|
+
test("composeBuildInfo records the checkout HEAD (40-hex), the v<version> tag, and an ISO builtAt", () => {
|
|
21
|
+
const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: REPO_ROOT, encoding: "utf8" }).trim();
|
|
22
|
+
const info = composeBuildInfo("1.2.3", new Date("2026-01-02T03:04:05.000Z"));
|
|
23
|
+
assertEquals(info.gitSha, head);
|
|
24
|
+
assert(SHA_RE.test(info.gitSha));
|
|
25
|
+
assertEquals(info.gitTag, "v1.2.3");
|
|
26
|
+
assertEquals(info.builtAt, "2026-01-02T03:04:05.000Z");
|
|
27
|
+
assertEquals(buildInfoProblems(info), []);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("composeBuildInfo honours an explicit source SHA instead of the checkout HEAD", () => {
|
|
31
|
+
// The manual OIDC-recovery path checks out the release TAG, whose HEAD is the metadata-only
|
|
32
|
+
// release commit — so it passes the source HEAD explicitly. The explicit SHA must win over
|
|
33
|
+
// `git rev-parse HEAD` and must be validated against the 40-hex contract.
|
|
34
|
+
const sourceHead = "b".repeat(40);
|
|
35
|
+
const info = composeBuildInfo("2.0.0", new Date("2026-01-02T03:04:05.000Z"), sourceHead);
|
|
36
|
+
assertEquals(info.gitSha, sourceHead);
|
|
37
|
+
assertEquals(info.gitTag, "v2.0.0");
|
|
38
|
+
assertEquals(buildInfoProblems(info), []);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("composeBuildInfo rejects a malformed explicit source SHA (fail closed)", () => {
|
|
42
|
+
assertThrows(() => composeBuildInfo("2.0.0", new Date(), "not-a-sha"), Error, "non-SHA");
|
|
43
|
+
assertThrows(() => composeBuildInfo("2.0.0", new Date(), "abc123"), Error, "non-SHA");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("buildInfoProblems rejects malformed payloads", () => {
|
|
47
|
+
assert(buildInfoProblems(null).length > 0);
|
|
48
|
+
assert(buildInfoProblems({ version: "1.0.0", gitSha: "not-a-sha", gitTag: "v1.0.0", builtAt: "2026-01-02T03:04:05.000Z" }).length > 0);
|
|
49
|
+
assert(buildInfoProblems({ version: "1.0.0", gitSha: "a".repeat(40), gitTag: "", builtAt: "2026-01-02T03:04:05.000Z" }).length > 0);
|
|
50
|
+
assert(buildInfoProblems({ version: "1.0.0", gitSha: "a".repeat(40), gitTag: "v1.0.0", builtAt: "not-a-date" }).length > 0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("an npm pack of the package contains build-info.json with a 40-hex SHA", () => {
|
|
54
|
+
// Write the file exactly as the release pipeline does (prepare step), pack, inspect, then clean
|
|
55
|
+
// up — build-info.json is a build artifact and MUST NOT be committed (a stale committed copy
|
|
56
|
+
// would mislabel every later dev checkout), so the test restores the prior state in `finally`.
|
|
57
|
+
const prior = (() => {
|
|
58
|
+
try {
|
|
59
|
+
return readFileSync(BUILD_INFO, "utf8");
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
})();
|
|
64
|
+
const packDir = mkdtempSync(join(tmpdir(), "nwf-pack-"));
|
|
65
|
+
try {
|
|
66
|
+
execFileSync("node", ["--experimental-strip-types", "scripts/write-build-info.ts", "0.0.0-packtest"], {
|
|
67
|
+
cwd: REPO_ROOT,
|
|
68
|
+
stdio: "pipe",
|
|
69
|
+
});
|
|
70
|
+
const out = execFileSync("npm", ["pack", "--pack-destination", packDir], { cwd: REPO_ROOT, encoding: "utf8" });
|
|
71
|
+
const tarball = join(packDir, out.trim().split("\n").pop() ?? "");
|
|
72
|
+
const listing = execFileSync("tar", ["-tzf", tarball], { encoding: "utf8" });
|
|
73
|
+
assert(
|
|
74
|
+
listing.split("\n").some((f) => f === "package/build-info.json" || f === "build-info.json"),
|
|
75
|
+
`the tarball must contain build-info.json; got:\n${listing.split("\n").slice(0, 5).join("\n")}…`,
|
|
76
|
+
);
|
|
77
|
+
const raw = execFileSync("tar", ["-xzOf", tarball, "package/build-info.json"], { encoding: "utf8" });
|
|
78
|
+
const packed: unknown = JSON.parse(raw);
|
|
79
|
+
assertEquals(buildInfoProblems(packed), [], `packed build-info.json must be well-formed: ${raw}`);
|
|
80
|
+
const gitSha = (packed as { gitSha: string }).gitSha;
|
|
81
|
+
assert(SHA_RE.test(gitSha), `packed gitSha must be 40-hex, got ${gitSha}`);
|
|
82
|
+
} finally {
|
|
83
|
+
if (prior == null) rmSync(BUILD_INFO, { force: true });
|
|
84
|
+
else writeFileSync(BUILD_INFO, prior);
|
|
85
|
+
rmSync(packDir, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// write-build-info — bake the release's build identity into `build-info.json` at the package root
|
|
2
|
+
// (issue #925).
|
|
3
|
+
//
|
|
4
|
+
// WHY: an npm-installed instance has no `.git` directory and no `NANO_WORKFORCE_GIT_SHA` env var,
|
|
5
|
+
// so `app/version.ts` reported `gitSha: null` on exactly the deploys an operator most needs to
|
|
6
|
+
// identify ("is the fix live on merlin?"). The release pipeline writes this file during
|
|
7
|
+
// semantic-release's `prepare` step — AFTER @semantic-release/npm bumps `package.json` to the
|
|
8
|
+
// release version and BEFORE it packs/publishes the tarball — so every published package carries
|
|
9
|
+
// its own identity and `app/version.ts` can fall back to it (env → .git → build-info.json).
|
|
10
|
+
//
|
|
11
|
+
// WHAT IT RECORDS (and why): `gitSha` is the SOURCE HEAD the release was cut from
|
|
12
|
+
// (`git rev-parse HEAD` in the release checkout), NOT the `chore(release): X.Y.Z` commit
|
|
13
|
+
// @semantic-release/git creates afterwards — that commit does not exist yet at prepare time, so it
|
|
14
|
+
// cannot be recorded here. The release TAG `vX.Y.Z` is later placed on that release commit, whose
|
|
15
|
+
// tree is identical to the source HEAD's tree except for the version-bump metadata (CHANGELOG.md,
|
|
16
|
+
// package.json, package-lock.json). `gitTag` carries the tag name so an operator can resolve the
|
|
17
|
+
// exact release commit (`git rev-list -1 <tag>`) when they need it; `gitSha` answers the
|
|
18
|
+
// day-to-day question "which source code is running".
|
|
19
|
+
//
|
|
20
|
+
// USAGE:
|
|
21
|
+
// node --experimental-strip-types scripts/write-build-info.ts <version> [--sha <40-hex>] # release pipeline
|
|
22
|
+
// node --experimental-strip-types scripts/write-build-info.ts --check # test: validate on disk
|
|
23
|
+
//
|
|
24
|
+
// `<version>` alone records the current checkout HEAD — correct at semantic-release `prepare` time.
|
|
25
|
+
// The manual OIDC-recovery path runs from a fresh checkout of the release TAG, whose HEAD is the
|
|
26
|
+
// metadata-only `chore(release)` commit, NOT the source HEAD; it passes `--sha "$(git rev-parse
|
|
27
|
+
// v<version>^")"` so the recorded gitSha still names the source code (see release.yml's recovery
|
|
28
|
+
// instructions).
|
|
29
|
+
//
|
|
30
|
+
// `--check` validates the committed/generated `build-info.json` (present, well-formed, 40-hex
|
|
31
|
+
// gitSha) — used by the pack test to assert the release artifact contract without cutting a
|
|
32
|
+
// release.
|
|
33
|
+
import { execFileSync } from "node:child_process";
|
|
34
|
+
import { writeFileSync } from "node:fs";
|
|
35
|
+
import { dirname, join } from "node:path";
|
|
36
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
37
|
+
|
|
38
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
39
|
+
export const BUILD_INFO_PATH = join(REPO_ROOT, "build-info.json");
|
|
40
|
+
|
|
41
|
+
export interface BuildInfo {
|
|
42
|
+
version: string;
|
|
43
|
+
gitSha: string;
|
|
44
|
+
gitTag: string;
|
|
45
|
+
builtAt: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The ONE payload validator is shared with the reader (app/version.ts) so writer and reader can
|
|
49
|
+
// never disagree on what a valid build-info.json is.
|
|
50
|
+
export { buildInfoProblems } from "../app/version.ts";
|
|
51
|
+
|
|
52
|
+
import { buildInfoProblems, SHA_RE } from "../app/version.ts";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Compose the build-info payload for a release of `version`. `gitSha` is the SOURCE HEAD the
|
|
56
|
+
* release was cut from. By default it is read from the current checkout HEAD (`git rev-parse HEAD`)
|
|
57
|
+
* — correct at semantic-release `prepare` time, when HEAD is still the source commit. Callers that
|
|
58
|
+
* run LATER (the manual OIDC-recovery path, which checks out the release TAG) must pass `sha`
|
|
59
|
+
* explicitly as the source HEAD (`git rev-parse v<version>^`): by then HEAD is the metadata-only
|
|
60
|
+
* `chore(release)` commit, and recording it would mislabel the package with a commit whose only
|
|
61
|
+
* change is the version bump. An explicit `sha` is validated against the 40-hex contract.
|
|
62
|
+
*/
|
|
63
|
+
export function composeBuildInfo(version: string, now: Date = new Date(), sha?: string): BuildInfo {
|
|
64
|
+
const gitSha = (
|
|
65
|
+
sha ?? execFileSync("git", ["rev-parse", "HEAD"], { cwd: REPO_ROOT, encoding: "utf8" })
|
|
66
|
+
).trim();
|
|
67
|
+
if (!SHA_RE.test(gitSha)) throw new Error(`git rev-parse HEAD returned a non-SHA: ${JSON.stringify(gitSha)}`);
|
|
68
|
+
return {
|
|
69
|
+
version,
|
|
70
|
+
gitSha,
|
|
71
|
+
// The tag semantic-release will place on the release commit for this version.
|
|
72
|
+
gitTag: `v${version}`,
|
|
73
|
+
builtAt: now.toISOString(),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function main(argv: string[]): void {
|
|
78
|
+
const arg = argv[2];
|
|
79
|
+
if (arg === "--check") {
|
|
80
|
+
// Lazy import so the write path never needs fs/readFileSync beyond writeFileSync.
|
|
81
|
+
import("node:fs").then(({ readFileSync }) => {
|
|
82
|
+
let parsed: unknown;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(readFileSync(BUILD_INFO_PATH, "utf8"));
|
|
85
|
+
} catch (err) {
|
|
86
|
+
// biome-ignore lint/plugin: narrowing a caught unknown to Error for its message
|
|
87
|
+
console.error(`build-info.json is missing or invalid JSON: ${(err as Error).message}`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
const problems = buildInfoProblems(parsed);
|
|
91
|
+
if (problems.length > 0) {
|
|
92
|
+
console.error(`build-info.json failed validation:\n - ${problems.join("\n - ")}`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
console.log(`build-info.json OK: ${JSON.stringify(parsed)}`);
|
|
96
|
+
});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const version = arg?.trim();
|
|
100
|
+
if (!version) {
|
|
101
|
+
console.error("usage: write-build-info.ts <version> [--sha <40-hex>] | --check");
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
// Optional explicit source SHA (see composeBuildInfo). The manual OIDC-recovery path checks out
|
|
105
|
+
// the release TAG, whose HEAD is the release commit — so it passes the source HEAD
|
|
106
|
+
// (`git rev-parse v<version>^`) explicitly to keep gitSha pointing at the source code.
|
|
107
|
+
let sha: string | undefined;
|
|
108
|
+
const shaIdx = argv.indexOf("--sha");
|
|
109
|
+
if (shaIdx !== -1) {
|
|
110
|
+
sha = argv[shaIdx + 1]?.trim();
|
|
111
|
+
if (!sha || !SHA_RE.test(sha)) {
|
|
112
|
+
console.error(`--sha must be a 40-hex commit SHA, got ${JSON.stringify(argv[shaIdx + 1])}`);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const info = composeBuildInfo(version, new Date(), sha);
|
|
117
|
+
writeFileSync(BUILD_INFO_PATH, `${JSON.stringify(info, null, 2)}\n`);
|
|
118
|
+
console.log(`wrote build-info.json: version=${info.version} gitSha=${info.gitSha} gitTag=${info.gitTag}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Run the CLI only when executed directly — importing this module (e.g. from the test) must not
|
|
122
|
+
// parse argv or write files.
|
|
123
|
+
const invokedAs = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
|
|
124
|
+
if (invokedAs === import.meta.url) {
|
|
125
|
+
main(process.argv);
|
|
126
|
+
}
|