@intelmesh/sdk 0.1.0 → 0.1.1
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.
|
@@ -108,6 +108,7 @@ jobs:
|
|
|
108
108
|
name: Publish to npm
|
|
109
109
|
needs: test
|
|
110
110
|
runs-on: ubuntu-24.04
|
|
111
|
+
environment: npm-publish
|
|
111
112
|
permissions:
|
|
112
113
|
contents: read
|
|
113
114
|
id-token: write
|
|
@@ -122,7 +123,7 @@ jobs:
|
|
|
122
123
|
- name: Setup Node
|
|
123
124
|
uses: actions/setup-node@v5
|
|
124
125
|
with:
|
|
125
|
-
node-version: '
|
|
126
|
+
node-version: '24'
|
|
126
127
|
registry-url: 'https://registry.npmjs.org'
|
|
127
128
|
cache: 'npm'
|
|
128
129
|
|
|
@@ -170,7 +171,7 @@ jobs:
|
|
|
170
171
|
- name: Setup Node
|
|
171
172
|
uses: actions/setup-node@v5
|
|
172
173
|
with:
|
|
173
|
-
node-version: '
|
|
174
|
+
node-version: '24'
|
|
174
175
|
cache: 'npm'
|
|
175
176
|
|
|
176
177
|
- name: Install dependencies
|
package/package.json
CHANGED
|
@@ -1,798 +0,0 @@
|
|
|
1
|
-
# Release Pipeline Implementation Plan
|
|
2
|
-
|
|
3
|
-
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
-
|
|
5
|
-
**Goal:** Ship a GitHub Actions workflow that publishes `@intelmesh/sdk` to npm (with provenance) and creates a GitHub Release on every signed semver tag, with signature verification of both the commit and the annotated tag object as a hard gate.
|
|
6
|
-
|
|
7
|
-
**Architecture:** Single workflow file (`.github/workflows/release.yml`) with four sequential jobs chained via `needs:` — `verify-signature` → `test` (Node 20/22/24 matrix) → `publish-npm` → `github-release`. The npm dist-tag logic is extracted into a standalone bash script (`.github/scripts/compute-disttag.sh`) with vitest unit tests, so the trickiest piece of the pipeline is deterministically covered before any tag is pushed.
|
|
8
|
-
|
|
9
|
-
**Tech Stack:** GitHub Actions, `gh` CLI (pre-installed on runners), `npm publish --provenance`, actionlint (binary, downloaded ad-hoc for local validation), vitest (already in the repo), bash.
|
|
10
|
-
|
|
11
|
-
**Reference spec:** `docs/superpowers/specs/2026-04-10-release-pipeline-design.md` (all design decisions live there; this plan implements them literally).
|
|
12
|
-
|
|
13
|
-
**User preference — important:** Do NOT `git add` or commit anything under `docs/`. The plan document itself is not committed. Code changes outside `docs/` are committed normally.
|
|
14
|
-
|
|
15
|
-
---
|
|
16
|
-
|
|
17
|
-
## File Structure
|
|
18
|
-
|
|
19
|
-
| File | State | Responsibility |
|
|
20
|
-
|---|---|---|
|
|
21
|
-
| `package.json` | modify | Add missing `repository` field (required for npm provenance). |
|
|
22
|
-
| `.gitignore` | modify | Ignore `.tools/` directory where local actionlint binary is downloaded. |
|
|
23
|
-
| `.github/scripts/compute-disttag.sh` | create | Pure bash: read `GITHUB_REF_NAME`, emit `version`, `disttag`, `is_prerelease` to `GITHUB_OUTPUT`. No dependencies. Testable. |
|
|
24
|
-
| `tests/scripts/compute-disttag.test.ts` | create | Vitest test that execs the bash script in a temp dir and asserts outputs for stable/beta/rc/alpha/next and no-dot-suffix cases. |
|
|
25
|
-
| `.github/workflows/release.yml` | create | Single-file workflow: trigger, permissions, concurrency, and four jobs. |
|
|
26
|
-
|
|
27
|
-
---
|
|
28
|
-
|
|
29
|
-
## Task 1: Prerequisites — `repository` field and `.gitignore` entry
|
|
30
|
-
|
|
31
|
-
**Context:** `npm publish --provenance` requires `package.json` to have a `repository` field pointing to the GitHub repo — the published package's provenance attestation links back to this URL. We also prep `.gitignore` for the actionlint binary we'll download in Task 3.
|
|
32
|
-
|
|
33
|
-
**Files:**
|
|
34
|
-
- Modify: `package.json`
|
|
35
|
-
- Modify: `.gitignore`
|
|
36
|
-
|
|
37
|
-
- [ ] **Step 1.1: Read current `package.json`**
|
|
38
|
-
|
|
39
|
-
Run: `cat package.json` (using Read tool in agent).
|
|
40
|
-
|
|
41
|
-
Confirm: no `"repository"` key exists at the top level.
|
|
42
|
-
|
|
43
|
-
- [ ] **Step 1.2: Add `repository` field to `package.json`**
|
|
44
|
-
|
|
45
|
-
Add this block right after the `"license": "MIT",` line:
|
|
46
|
-
|
|
47
|
-
```json
|
|
48
|
-
"repository": {
|
|
49
|
-
"type": "git",
|
|
50
|
-
"url": "git+https://github.com/intelmesh/intelmesh-sdk-ts.git"
|
|
51
|
-
},
|
|
52
|
-
"homepage": "https://github.com/intelmesh/intelmesh-sdk-ts#readme",
|
|
53
|
-
"bugs": {
|
|
54
|
-
"url": "https://github.com/intelmesh/intelmesh-sdk-ts/issues"
|
|
55
|
-
},
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
The final ordering should place `repository`, `homepage`, `bugs` right after `license` and before `devDependencies`. Preserve existing formatting (2-space indent, trailing comma style of surrounding code).
|
|
59
|
-
|
|
60
|
-
- [ ] **Step 1.3: Validate JSON is still parseable**
|
|
61
|
-
|
|
62
|
-
Run: `node -e "JSON.parse(require('fs').readFileSync('package.json','utf8')); console.log('ok')"`
|
|
63
|
-
Expected output: `ok`
|
|
64
|
-
|
|
65
|
-
- [ ] **Step 1.4: Append `.tools/` to `.gitignore`**
|
|
66
|
-
|
|
67
|
-
Edit `.gitignore`, add a new block at the end:
|
|
68
|
-
|
|
69
|
-
```
|
|
70
|
-
# Local tooling binaries (actionlint, etc)
|
|
71
|
-
.tools/
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
- [ ] **Step 1.5: Commit**
|
|
75
|
-
|
|
76
|
-
```bash
|
|
77
|
-
git add package.json .gitignore
|
|
78
|
-
git commit -m "chore: add repository metadata for npm provenance
|
|
79
|
-
|
|
80
|
-
Adds repository, homepage, and bugs fields required by
|
|
81
|
-
npm publish --provenance to attach Sigstore attestation,
|
|
82
|
-
and ignores .tools/ where actionlint will be downloaded
|
|
83
|
-
for local workflow validation."
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
---
|
|
87
|
-
|
|
88
|
-
## Task 2: Extract `compute-disttag.sh` with TDD
|
|
89
|
-
|
|
90
|
-
**Context:** The dist-tag computation (`v1.2.3` → `latest`, `v1.2.3-beta.1` → `beta`, etc.) is the most logic-heavy piece of the workflow. Extract it to a standalone bash script so it can be unit-tested with vitest before being wired into the YAML. The script reads `GITHUB_REF_NAME` and writes to `GITHUB_OUTPUT` — identical ABI to an inline workflow step, just testable.
|
|
91
|
-
|
|
92
|
-
**Files:**
|
|
93
|
-
- Create: `.github/scripts/compute-disttag.sh`
|
|
94
|
-
- Create: `tests/scripts/compute-disttag.test.ts`
|
|
95
|
-
|
|
96
|
-
- [ ] **Step 2.1: Write the failing test**
|
|
97
|
-
|
|
98
|
-
Create `tests/scripts/compute-disttag.test.ts`:
|
|
99
|
-
|
|
100
|
-
```typescript
|
|
101
|
-
import { describe, expect, it } from 'vitest';
|
|
102
|
-
// eslint-disable-next-line security/detect-child-process
|
|
103
|
-
import { spawnSync } from 'node:child_process';
|
|
104
|
-
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
105
|
-
import { tmpdir } from 'node:os';
|
|
106
|
-
import { join } from 'node:path';
|
|
107
|
-
|
|
108
|
-
interface ScriptOutput {
|
|
109
|
-
version: string;
|
|
110
|
-
disttag: string;
|
|
111
|
-
is_prerelease: string;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function parseOutput(raw: string): ScriptOutput {
|
|
115
|
-
const map: Record<string, string> = {};
|
|
116
|
-
for (const line of raw.split('\n')) {
|
|
117
|
-
const idx = line.indexOf('=');
|
|
118
|
-
if (idx === -1) continue;
|
|
119
|
-
map[line.slice(0, idx)] = line.slice(idx + 1);
|
|
120
|
-
}
|
|
121
|
-
return {
|
|
122
|
-
version: map.version ?? '',
|
|
123
|
-
disttag: map.disttag ?? '',
|
|
124
|
-
is_prerelease: map.is_prerelease ?? '',
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function runScript(refName: string): ScriptOutput {
|
|
129
|
-
const dir = mkdtempSync(join(tmpdir(), 'disttag-'));
|
|
130
|
-
const outputFile = join(dir, 'github_output');
|
|
131
|
-
writeFileSync(outputFile, '');
|
|
132
|
-
const result = spawnSync('bash', ['.github/scripts/compute-disttag.sh'], {
|
|
133
|
-
env: {
|
|
134
|
-
...process.env,
|
|
135
|
-
GITHUB_REF_NAME: refName,
|
|
136
|
-
GITHUB_OUTPUT: outputFile,
|
|
137
|
-
},
|
|
138
|
-
encoding: 'utf-8',
|
|
139
|
-
});
|
|
140
|
-
if (result.status !== 0) {
|
|
141
|
-
throw new Error(
|
|
142
|
-
`Script failed (status=${String(result.status)}): ${result.stderr}`,
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
return parseOutput(readFileSync(outputFile, 'utf-8'));
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
describe('compute-disttag.sh', () => {
|
|
149
|
-
it('stable v1.2.3 resolves to latest', () => {
|
|
150
|
-
const out = runScript('v1.2.3');
|
|
151
|
-
expect(out.version).toBe('1.2.3');
|
|
152
|
-
expect(out.disttag).toBe('latest');
|
|
153
|
-
expect(out.is_prerelease).toBe('false');
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it('v1.2.3-beta.1 resolves to beta dist-tag', () => {
|
|
157
|
-
const out = runScript('v1.2.3-beta.1');
|
|
158
|
-
expect(out.version).toBe('1.2.3-beta.1');
|
|
159
|
-
expect(out.disttag).toBe('beta');
|
|
160
|
-
expect(out.is_prerelease).toBe('true');
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it('v1.2.3-rc.2 resolves to rc dist-tag', () => {
|
|
164
|
-
const out = runScript('v1.2.3-rc.2');
|
|
165
|
-
expect(out.disttag).toBe('rc');
|
|
166
|
-
expect(out.is_prerelease).toBe('true');
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it('v2.0.0-alpha.7 resolves to alpha dist-tag', () => {
|
|
170
|
-
const out = runScript('v2.0.0-alpha.7');
|
|
171
|
-
expect(out.disttag).toBe('alpha');
|
|
172
|
-
expect(out.is_prerelease).toBe('true');
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it('v2.0.0-next.0 resolves to next dist-tag', () => {
|
|
176
|
-
const out = runScript('v2.0.0-next.0');
|
|
177
|
-
expect(out.disttag).toBe('next');
|
|
178
|
-
expect(out.is_prerelease).toBe('true');
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it('v1.0.0-beta without dot suffix resolves to beta', () => {
|
|
182
|
-
const out = runScript('v1.0.0-beta');
|
|
183
|
-
expect(out.version).toBe('1.0.0-beta');
|
|
184
|
-
expect(out.disttag).toBe('beta');
|
|
185
|
-
expect(out.is_prerelease).toBe('true');
|
|
186
|
-
});
|
|
187
|
-
});
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
- [ ] **Step 2.2: Run the test — verify it fails because the script does not exist**
|
|
191
|
-
|
|
192
|
-
Run: `npm test -- tests/scripts/compute-disttag.test.ts`
|
|
193
|
-
|
|
194
|
-
Expected: all 6 tests fail with an error like `Script failed (status=127): bash: .github/scripts/compute-disttag.sh: No such file or directory`.
|
|
195
|
-
|
|
196
|
-
- [ ] **Step 2.3: Create the script**
|
|
197
|
-
|
|
198
|
-
Create `.github/scripts/compute-disttag.sh`:
|
|
199
|
-
|
|
200
|
-
```bash
|
|
201
|
-
#!/usr/bin/env bash
|
|
202
|
-
# Compute npm dist-tag and prerelease flag from a git tag name.
|
|
203
|
-
#
|
|
204
|
-
# Reads (env):
|
|
205
|
-
# GITHUB_REF_NAME git tag name, e.g. "v1.2.3" or "v1.2.3-beta.1"
|
|
206
|
-
# GITHUB_OUTPUT path to GitHub Actions step output file
|
|
207
|
-
#
|
|
208
|
-
# Writes to $GITHUB_OUTPUT:
|
|
209
|
-
# version semver without leading "v"
|
|
210
|
-
# disttag npm dist-tag ("latest" for stable, suffix prefix otherwise)
|
|
211
|
-
# is_prerelease "true" for any tag with a "-" suffix, else "false"
|
|
212
|
-
|
|
213
|
-
set -euo pipefail
|
|
214
|
-
|
|
215
|
-
tag="${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}"
|
|
216
|
-
: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}"
|
|
217
|
-
|
|
218
|
-
version="${tag#v}"
|
|
219
|
-
|
|
220
|
-
if [[ "$version" == *-* ]]; then
|
|
221
|
-
suffix="${version#*-}"
|
|
222
|
-
disttag="${suffix%%.*}"
|
|
223
|
-
is_prerelease=true
|
|
224
|
-
else
|
|
225
|
-
disttag="latest"
|
|
226
|
-
is_prerelease=false
|
|
227
|
-
fi
|
|
228
|
-
|
|
229
|
-
{
|
|
230
|
-
echo "version=$version"
|
|
231
|
-
echo "disttag=$disttag"
|
|
232
|
-
echo "is_prerelease=$is_prerelease"
|
|
233
|
-
} >> "$GITHUB_OUTPUT"
|
|
234
|
-
|
|
235
|
-
echo "Resolved: tag=$tag version=$version disttag=$disttag prerelease=$is_prerelease"
|
|
236
|
-
```
|
|
237
|
-
|
|
238
|
-
Make it executable: `chmod +x .github/scripts/compute-disttag.sh`
|
|
239
|
-
|
|
240
|
-
- [ ] **Step 2.4: Run the test — verify it passes**
|
|
241
|
-
|
|
242
|
-
Run: `npm test -- tests/scripts/compute-disttag.test.ts`
|
|
243
|
-
|
|
244
|
-
Expected: all 6 tests pass.
|
|
245
|
-
|
|
246
|
-
- [ ] **Step 2.5: Run full test suite — verify nothing else broke**
|
|
247
|
-
|
|
248
|
-
Run: `npm test`
|
|
249
|
-
|
|
250
|
-
Expected: all tests across the repo pass.
|
|
251
|
-
|
|
252
|
-
- [ ] **Step 2.6: Run lint — verify the test file passes ESLint strict**
|
|
253
|
-
|
|
254
|
-
Run: `npm run lint`
|
|
255
|
-
|
|
256
|
-
Expected: no errors. If ESLint flags anything in the new test file (e.g., security plugin or jsdoc), fix inline with targeted disable comments scoped to the minimum line, re-run, confirm clean.
|
|
257
|
-
|
|
258
|
-
- [ ] **Step 2.7: Commit**
|
|
259
|
-
|
|
260
|
-
```bash
|
|
261
|
-
git add .github/scripts/compute-disttag.sh tests/scripts/compute-disttag.test.ts
|
|
262
|
-
git commit -m "feat(ci): add compute-disttag script with tests
|
|
263
|
-
|
|
264
|
-
Extracts the tag-to-disttag mapping logic into a
|
|
265
|
-
standalone bash script so it can be unit-tested before
|
|
266
|
-
being wired into the release workflow. Handles stable
|
|
267
|
-
releases (-> latest), prereleases with .N suffix
|
|
268
|
-
(beta.1 -> beta), and prereleases without a dot
|
|
269
|
-
(beta -> beta)."
|
|
270
|
-
```
|
|
271
|
-
|
|
272
|
-
---
|
|
273
|
-
|
|
274
|
-
## Task 3: Workflow skeleton and `verify-signature` job
|
|
275
|
-
|
|
276
|
-
**Context:** Create the workflow file with trigger, workflow-level permissions, concurrency, and the first job (`verify-signature`). After this task the file is a syntactically valid workflow that *only* verifies signatures — the remaining jobs are added in subsequent tasks. Also download actionlint for local validation.
|
|
277
|
-
|
|
278
|
-
**Files:**
|
|
279
|
-
- Create: `.github/workflows/release.yml`
|
|
280
|
-
- Create: `.tools/actionlint` (gitignored, not committed)
|
|
281
|
-
|
|
282
|
-
- [ ] **Step 3.1: Download actionlint binary into `.tools/`**
|
|
283
|
-
|
|
284
|
-
Run:
|
|
285
|
-
|
|
286
|
-
```bash
|
|
287
|
-
mkdir -p .tools
|
|
288
|
-
cd .tools
|
|
289
|
-
bash <(curl -sSf https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) 1.7.7 .
|
|
290
|
-
cd ..
|
|
291
|
-
ls -la .tools/actionlint
|
|
292
|
-
```
|
|
293
|
-
|
|
294
|
-
Expected: `.tools/actionlint` exists and is executable. If the download script fails, fall back to:
|
|
295
|
-
|
|
296
|
-
```bash
|
|
297
|
-
curl -sSL -o /tmp/actionlint.tgz \
|
|
298
|
-
https://github.com/rhysd/actionlint/releases/download/v1.7.7/actionlint_1.7.7_linux_amd64.tar.gz
|
|
299
|
-
tar -xzf /tmp/actionlint.tgz -C .tools actionlint
|
|
300
|
-
chmod +x .tools/actionlint
|
|
301
|
-
```
|
|
302
|
-
|
|
303
|
-
Confirm: `.tools/actionlint --version` prints `1.7.7`.
|
|
304
|
-
|
|
305
|
-
- [ ] **Step 3.2: Create `.github/workflows/release.yml` with skeleton and verify-signature job**
|
|
306
|
-
|
|
307
|
-
Create the file with exactly this content:
|
|
308
|
-
|
|
309
|
-
```yaml
|
|
310
|
-
name: release
|
|
311
|
-
|
|
312
|
-
on:
|
|
313
|
-
push:
|
|
314
|
-
tags:
|
|
315
|
-
- 'v[0-9]+.[0-9]+.[0-9]+'
|
|
316
|
-
- 'v[0-9]+.[0-9]+.[0-9]+-*'
|
|
317
|
-
|
|
318
|
-
permissions:
|
|
319
|
-
contents: write
|
|
320
|
-
id-token: write
|
|
321
|
-
|
|
322
|
-
concurrency:
|
|
323
|
-
group: release-${{ github.ref }}
|
|
324
|
-
cancel-in-progress: false
|
|
325
|
-
|
|
326
|
-
jobs:
|
|
327
|
-
verify-signature:
|
|
328
|
-
name: Verify commit and tag are signed
|
|
329
|
-
runs-on: ubuntu-24.04
|
|
330
|
-
steps:
|
|
331
|
-
- name: Verify commit signature
|
|
332
|
-
env:
|
|
333
|
-
GH_TOKEN: ${{ github.token }}
|
|
334
|
-
run: |
|
|
335
|
-
set -euo pipefail
|
|
336
|
-
commit_sha="${GITHUB_SHA}"
|
|
337
|
-
repo="${GITHUB_REPOSITORY}"
|
|
338
|
-
|
|
339
|
-
verified=$(gh api "repos/${repo}/commits/${commit_sha}" \
|
|
340
|
-
--jq '.commit.verification.verified')
|
|
341
|
-
reason=$(gh api "repos/${repo}/commits/${commit_sha}" \
|
|
342
|
-
--jq '.commit.verification.reason')
|
|
343
|
-
|
|
344
|
-
echo "commit=${commit_sha} verified=${verified} reason=${reason}"
|
|
345
|
-
|
|
346
|
-
if [ "${verified}" != "true" ]; then
|
|
347
|
-
echo "::error title=Unsigned commit::Commit ${commit_sha} is NOT verified by GitHub. Reason: ${reason}. Refusing to release."
|
|
348
|
-
exit 1
|
|
349
|
-
fi
|
|
350
|
-
|
|
351
|
-
- name: Verify tag is a signed annotated tag
|
|
352
|
-
env:
|
|
353
|
-
GH_TOKEN: ${{ github.token }}
|
|
354
|
-
run: |
|
|
355
|
-
set -euo pipefail
|
|
356
|
-
tag="${GITHUB_REF_NAME}"
|
|
357
|
-
repo="${GITHUB_REPOSITORY}"
|
|
358
|
-
|
|
359
|
-
ref_json=$(gh api "repos/${repo}/git/refs/tags/${tag}")
|
|
360
|
-
obj_type=$(echo "${ref_json}" | jq -r '.object.type')
|
|
361
|
-
obj_sha=$(echo "${ref_json}" | jq -r '.object.sha')
|
|
362
|
-
|
|
363
|
-
if [ "${obj_type}" != "tag" ]; then
|
|
364
|
-
echo "::error title=Lightweight tag::Tag ${tag} is a lightweight tag (object.type=${obj_type}). Use 'git tag -s ${tag}' to create a signed annotated tag. Refusing to release."
|
|
365
|
-
exit 1
|
|
366
|
-
fi
|
|
367
|
-
|
|
368
|
-
verified=$(gh api "repos/${repo}/git/tags/${obj_sha}" \
|
|
369
|
-
--jq '.verification.verified')
|
|
370
|
-
reason=$(gh api "repos/${repo}/git/tags/${obj_sha}" \
|
|
371
|
-
--jq '.verification.reason')
|
|
372
|
-
|
|
373
|
-
echo "tag=${tag} object_sha=${obj_sha} verified=${verified} reason=${reason}"
|
|
374
|
-
|
|
375
|
-
if [ "${verified}" != "true" ]; then
|
|
376
|
-
echo "::error title=Unsigned tag::Tag ${tag} signature is NOT verified by GitHub. Reason: ${reason}. Refusing to release."
|
|
377
|
-
exit 1
|
|
378
|
-
fi
|
|
379
|
-
```
|
|
380
|
-
|
|
381
|
-
- [ ] **Step 3.3: Validate with actionlint**
|
|
382
|
-
|
|
383
|
-
Run: `./.tools/actionlint .github/workflows/release.yml`
|
|
384
|
-
|
|
385
|
-
Expected: no output (success). If actionlint reports issues, fix them inline and re-run until clean. Common issues:
|
|
386
|
-
- `shellcheck` warnings on the `run:` blocks — fix the bash per shellcheck's guidance.
|
|
387
|
-
- `github.token` should work; if flagged use `${{ secrets.GITHUB_TOKEN }}` instead.
|
|
388
|
-
|
|
389
|
-
- [ ] **Step 3.4: Verify YAML is parseable with Node**
|
|
390
|
-
|
|
391
|
-
Run:
|
|
392
|
-
|
|
393
|
-
```bash
|
|
394
|
-
node -e "
|
|
395
|
-
const fs = require('node:fs');
|
|
396
|
-
const content = fs.readFileSync('.github/workflows/release.yml', 'utf8');
|
|
397
|
-
// naive sanity: starts with 'name:' and contains 'jobs:'
|
|
398
|
-
if (!content.startsWith('name: release')) throw new Error('bad header');
|
|
399
|
-
if (!content.includes('jobs:\n verify-signature:')) throw new Error('missing job');
|
|
400
|
-
console.log('ok');
|
|
401
|
-
"
|
|
402
|
-
```
|
|
403
|
-
|
|
404
|
-
Expected output: `ok`.
|
|
405
|
-
|
|
406
|
-
- [ ] **Step 3.5: Commit**
|
|
407
|
-
|
|
408
|
-
```bash
|
|
409
|
-
git add .github/workflows/release.yml
|
|
410
|
-
git commit -m "feat(ci): add release workflow skeleton with verify-signature job
|
|
411
|
-
|
|
412
|
-
Adds the release workflow entry point triggered by semver
|
|
413
|
-
tag pushes (v*.*.* and v*.*.*-*) with workflow-level
|
|
414
|
-
permissions (contents:write, id-token:write) and a
|
|
415
|
-
concurrency group per tag ref with cancel-in-progress
|
|
416
|
-
disabled.
|
|
417
|
-
|
|
418
|
-
The first job, verify-signature, queries the GitHub API
|
|
419
|
-
to confirm both the commit the tag points to and the
|
|
420
|
-
annotated tag object itself are cryptographically
|
|
421
|
-
verified. Lightweight tags, unsigned commits, and any
|
|
422
|
-
non-verified tag objects cause the job to fail hard with
|
|
423
|
-
a ::error:: annotation, blocking the rest of the
|
|
424
|
-
pipeline via needs: in subsequent tasks."
|
|
425
|
-
```
|
|
426
|
-
|
|
427
|
-
Note: `.tools/actionlint` is NOT staged — it is in `.gitignore` (Task 1.4).
|
|
428
|
-
|
|
429
|
-
---
|
|
430
|
-
|
|
431
|
-
## Task 4: Add `test` job with Node version matrix
|
|
432
|
-
|
|
433
|
-
**Context:** Add the quality gate: lint, typecheck, tests, and build, running in parallel across Node 20, 22, 24. This job `needs: verify-signature` so it only runs if the signature check passed. All three matrix cells must succeed for downstream jobs to unblock.
|
|
434
|
-
|
|
435
|
-
**Files:**
|
|
436
|
-
- Modify: `.github/workflows/release.yml`
|
|
437
|
-
|
|
438
|
-
- [ ] **Step 4.1: Append the `test` job to `.github/workflows/release.yml`**
|
|
439
|
-
|
|
440
|
-
Add this block after the `verify-signature` job, inside the `jobs:` mapping, maintaining 2-space YAML indent:
|
|
441
|
-
|
|
442
|
-
```yaml
|
|
443
|
-
test:
|
|
444
|
-
name: Test on Node ${{ matrix.node-version }}
|
|
445
|
-
needs: verify-signature
|
|
446
|
-
runs-on: ubuntu-24.04
|
|
447
|
-
strategy:
|
|
448
|
-
fail-fast: true
|
|
449
|
-
matrix:
|
|
450
|
-
node-version: ['20', '22', '24']
|
|
451
|
-
steps:
|
|
452
|
-
- name: Checkout
|
|
453
|
-
uses: actions/checkout@v5
|
|
454
|
-
|
|
455
|
-
- name: Setup Node ${{ matrix.node-version }}
|
|
456
|
-
uses: actions/setup-node@v5
|
|
457
|
-
with:
|
|
458
|
-
node-version: ${{ matrix.node-version }}
|
|
459
|
-
cache: 'npm'
|
|
460
|
-
|
|
461
|
-
- name: Install dependencies
|
|
462
|
-
run: npm ci
|
|
463
|
-
|
|
464
|
-
- name: Lint
|
|
465
|
-
run: npm run lint
|
|
466
|
-
|
|
467
|
-
- name: Typecheck
|
|
468
|
-
run: npm run typecheck
|
|
469
|
-
|
|
470
|
-
- name: Test
|
|
471
|
-
run: npm test
|
|
472
|
-
|
|
473
|
-
- name: Build
|
|
474
|
-
run: npm run build
|
|
475
|
-
```
|
|
476
|
-
|
|
477
|
-
- [ ] **Step 4.2: Validate with actionlint**
|
|
478
|
-
|
|
479
|
-
Run: `./.tools/actionlint .github/workflows/release.yml`
|
|
480
|
-
|
|
481
|
-
Expected: no output. Fix any reported issues before continuing.
|
|
482
|
-
|
|
483
|
-
- [ ] **Step 4.3: Commit**
|
|
484
|
-
|
|
485
|
-
```bash
|
|
486
|
-
git add .github/workflows/release.yml
|
|
487
|
-
git commit -m "feat(ci): add test matrix job to release workflow
|
|
488
|
-
|
|
489
|
-
Adds a test job that runs lint, typecheck, tests, and
|
|
490
|
-
build across Node 20, 22, and 24 in parallel. Chained
|
|
491
|
-
after verify-signature via needs: so unsigned tags never
|
|
492
|
-
burn CI minutes on tests. fail-fast is enabled: the
|
|
493
|
-
first matrix cell to fail aborts the others."
|
|
494
|
-
```
|
|
495
|
-
|
|
496
|
-
---
|
|
497
|
-
|
|
498
|
-
## Task 5: Add `publish-npm` job
|
|
499
|
-
|
|
500
|
-
**Context:** Add the npm publish job. Uses the bash script from Task 2 to compute the dist-tag, includes the version-mismatch guard, and publishes with `--provenance --access public`. This job `needs: test` so it only runs if all three Node matrix cells passed.
|
|
501
|
-
|
|
502
|
-
**Files:**
|
|
503
|
-
- Modify: `.github/workflows/release.yml`
|
|
504
|
-
|
|
505
|
-
- [ ] **Step 5.1: Append the `publish-npm` job**
|
|
506
|
-
|
|
507
|
-
Add this block after the `test` job, inside the `jobs:` mapping:
|
|
508
|
-
|
|
509
|
-
```yaml
|
|
510
|
-
publish-npm:
|
|
511
|
-
name: Publish to npm
|
|
512
|
-
needs: test
|
|
513
|
-
runs-on: ubuntu-24.04
|
|
514
|
-
outputs:
|
|
515
|
-
version: ${{ steps.disttag.outputs.version }}
|
|
516
|
-
disttag: ${{ steps.disttag.outputs.disttag }}
|
|
517
|
-
is_prerelease: ${{ steps.disttag.outputs.is_prerelease }}
|
|
518
|
-
steps:
|
|
519
|
-
- name: Checkout
|
|
520
|
-
uses: actions/checkout@v5
|
|
521
|
-
|
|
522
|
-
- name: Setup Node
|
|
523
|
-
uses: actions/setup-node@v5
|
|
524
|
-
with:
|
|
525
|
-
node-version: '22'
|
|
526
|
-
registry-url: 'https://registry.npmjs.org'
|
|
527
|
-
cache: 'npm'
|
|
528
|
-
|
|
529
|
-
- name: Install dependencies
|
|
530
|
-
run: npm ci
|
|
531
|
-
|
|
532
|
-
- name: Build
|
|
533
|
-
run: npm run build
|
|
534
|
-
|
|
535
|
-
- name: Compute dist-tag from git tag
|
|
536
|
-
id: disttag
|
|
537
|
-
run: bash .github/scripts/compute-disttag.sh
|
|
538
|
-
|
|
539
|
-
- name: Verify package.json version matches git tag
|
|
540
|
-
run: |
|
|
541
|
-
set -euo pipefail
|
|
542
|
-
pkg_version=$(node -p "require('./package.json').version")
|
|
543
|
-
git_version="${{ steps.disttag.outputs.version }}"
|
|
544
|
-
if [ "${pkg_version}" != "${git_version}" ]; then
|
|
545
|
-
echo "::error title=Version mismatch::package.json version (${pkg_version}) does not match git tag (${git_version}). Bump package.json before tagging."
|
|
546
|
-
exit 1
|
|
547
|
-
fi
|
|
548
|
-
echo "package.json and git tag both at ${pkg_version}"
|
|
549
|
-
|
|
550
|
-
- name: Publish to npm
|
|
551
|
-
env:
|
|
552
|
-
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
553
|
-
run: |
|
|
554
|
-
set -euo pipefail
|
|
555
|
-
npm publish \
|
|
556
|
-
--provenance \
|
|
557
|
-
--access public \
|
|
558
|
-
--tag "${{ steps.disttag.outputs.disttag }}"
|
|
559
|
-
```
|
|
560
|
-
|
|
561
|
-
- [ ] **Step 5.2: Validate with actionlint**
|
|
562
|
-
|
|
563
|
-
Run: `./.tools/actionlint .github/workflows/release.yml`
|
|
564
|
-
|
|
565
|
-
Expected: no output. Fix any issues before continuing.
|
|
566
|
-
|
|
567
|
-
- [ ] **Step 5.3: Verify the script is referenced correctly**
|
|
568
|
-
|
|
569
|
-
Run:
|
|
570
|
-
|
|
571
|
-
```bash
|
|
572
|
-
test -x .github/scripts/compute-disttag.sh && echo "script exists and is executable"
|
|
573
|
-
grep -q 'bash .github/scripts/compute-disttag.sh' .github/workflows/release.yml && echo "workflow references script"
|
|
574
|
-
```
|
|
575
|
-
|
|
576
|
-
Expected:
|
|
577
|
-
```
|
|
578
|
-
script exists and is executable
|
|
579
|
-
workflow references script
|
|
580
|
-
```
|
|
581
|
-
|
|
582
|
-
- [ ] **Step 5.4: Commit**
|
|
583
|
-
|
|
584
|
-
```bash
|
|
585
|
-
git add .github/workflows/release.yml
|
|
586
|
-
git commit -m "feat(ci): add publish-npm job with provenance
|
|
587
|
-
|
|
588
|
-
Adds the npm publish job chained after the test matrix.
|
|
589
|
-
Computes the npm dist-tag from the git tag via the
|
|
590
|
-
compute-disttag script, verifies package.json version
|
|
591
|
-
matches the git tag (fails hard on mismatch), then
|
|
592
|
-
publishes with --provenance --access public. Exposes
|
|
593
|
-
version, disttag, and is_prerelease as job outputs for
|
|
594
|
-
the github-release job to consume."
|
|
595
|
-
```
|
|
596
|
-
|
|
597
|
-
---
|
|
598
|
-
|
|
599
|
-
## Task 6: Add `github-release` job
|
|
600
|
-
|
|
601
|
-
**Context:** Final job: creates a GitHub Release with the built tarball attached. Split into two mutually exclusive steps gated by `if:` on `needs.publish-npm.outputs.is_prerelease` — one for stable releases, one for prereleases (adds `--prerelease` flag). Auto-generates release notes from commits since the last tag.
|
|
602
|
-
|
|
603
|
-
**Files:**
|
|
604
|
-
- Modify: `.github/workflows/release.yml`
|
|
605
|
-
|
|
606
|
-
- [ ] **Step 6.1: Append the `github-release` job**
|
|
607
|
-
|
|
608
|
-
Add this block after the `publish-npm` job, inside the `jobs:` mapping:
|
|
609
|
-
|
|
610
|
-
```yaml
|
|
611
|
-
github-release:
|
|
612
|
-
name: Create GitHub Release
|
|
613
|
-
needs: publish-npm
|
|
614
|
-
runs-on: ubuntu-24.04
|
|
615
|
-
steps:
|
|
616
|
-
- name: Checkout
|
|
617
|
-
uses: actions/checkout@v5
|
|
618
|
-
with:
|
|
619
|
-
fetch-depth: 0
|
|
620
|
-
|
|
621
|
-
- name: Setup Node
|
|
622
|
-
uses: actions/setup-node@v5
|
|
623
|
-
with:
|
|
624
|
-
node-version: '22'
|
|
625
|
-
cache: 'npm'
|
|
626
|
-
|
|
627
|
-
- name: Install dependencies
|
|
628
|
-
run: npm ci
|
|
629
|
-
|
|
630
|
-
- name: Build
|
|
631
|
-
run: npm run build
|
|
632
|
-
|
|
633
|
-
- name: Pack tarball
|
|
634
|
-
run: npm pack
|
|
635
|
-
|
|
636
|
-
- name: Create GitHub Release (stable)
|
|
637
|
-
if: needs.publish-npm.outputs.is_prerelease == 'false'
|
|
638
|
-
env:
|
|
639
|
-
GH_TOKEN: ${{ github.token }}
|
|
640
|
-
run: |
|
|
641
|
-
set -euo pipefail
|
|
642
|
-
gh release create "${GITHUB_REF_NAME}" \
|
|
643
|
-
./intelmesh-sdk-*.tgz \
|
|
644
|
-
--title "${GITHUB_REF_NAME}" \
|
|
645
|
-
--generate-notes
|
|
646
|
-
|
|
647
|
-
- name: Create GitHub Release (prerelease)
|
|
648
|
-
if: needs.publish-npm.outputs.is_prerelease == 'true'
|
|
649
|
-
env:
|
|
650
|
-
GH_TOKEN: ${{ github.token }}
|
|
651
|
-
run: |
|
|
652
|
-
set -euo pipefail
|
|
653
|
-
gh release create "${GITHUB_REF_NAME}" \
|
|
654
|
-
./intelmesh-sdk-*.tgz \
|
|
655
|
-
--title "${GITHUB_REF_NAME}" \
|
|
656
|
-
--generate-notes \
|
|
657
|
-
--prerelease
|
|
658
|
-
```
|
|
659
|
-
|
|
660
|
-
- [ ] **Step 6.2: Validate with actionlint**
|
|
661
|
-
|
|
662
|
-
Run: `./.tools/actionlint .github/workflows/release.yml`
|
|
663
|
-
|
|
664
|
-
Expected: no output. Fix any issues before continuing.
|
|
665
|
-
|
|
666
|
-
- [ ] **Step 6.3: Sanity check — count jobs in final file**
|
|
667
|
-
|
|
668
|
-
Run:
|
|
669
|
-
|
|
670
|
-
```bash
|
|
671
|
-
grep -cE '^ [a-z][a-z-]+:$' .github/workflows/release.yml
|
|
672
|
-
```
|
|
673
|
-
|
|
674
|
-
Expected: `4` (four jobs: verify-signature, test, publish-npm, github-release).
|
|
675
|
-
|
|
676
|
-
Also verify the needs chain:
|
|
677
|
-
|
|
678
|
-
```bash
|
|
679
|
-
grep -n 'needs:' .github/workflows/release.yml
|
|
680
|
-
```
|
|
681
|
-
|
|
682
|
-
Expected: three `needs:` lines — `verify-signature` (on test), `test` (on publish-npm), `publish-npm` (on github-release).
|
|
683
|
-
|
|
684
|
-
- [ ] **Step 6.4: Commit**
|
|
685
|
-
|
|
686
|
-
```bash
|
|
687
|
-
git add .github/workflows/release.yml
|
|
688
|
-
git commit -m "feat(ci): add github-release job completing the pipeline
|
|
689
|
-
|
|
690
|
-
Adds the final job that npm-packs the built SDK and
|
|
691
|
-
creates a GitHub Release with the .tgz attached. Split
|
|
692
|
-
into two mutually exclusive steps gated by if: on the
|
|
693
|
-
publish-npm.is_prerelease output — stable releases use
|
|
694
|
-
default flags, prereleases add --prerelease so they
|
|
695
|
-
don't pollute /releases/latest. Release notes are
|
|
696
|
-
auto-generated from commits since the previous tag via
|
|
697
|
-
gh release --generate-notes, which needs fetch-depth: 0
|
|
698
|
-
on checkout."
|
|
699
|
-
```
|
|
700
|
-
|
|
701
|
-
---
|
|
702
|
-
|
|
703
|
-
## Task 7: Final full-workflow actionlint + manual review
|
|
704
|
-
|
|
705
|
-
**Context:** The workflow is complete. Run the linter one last time on the full file, then do a manual sanity sweep against the spec.
|
|
706
|
-
|
|
707
|
-
**Files:**
|
|
708
|
-
- Read only: `.github/workflows/release.yml`, `docs/superpowers/specs/2026-04-10-release-pipeline-design.md`
|
|
709
|
-
|
|
710
|
-
- [ ] **Step 7.1: Run actionlint with verbose output**
|
|
711
|
-
|
|
712
|
-
Run: `./.tools/actionlint -verbose .github/workflows/release.yml`
|
|
713
|
-
|
|
714
|
-
Expected: no output (success). If anything is flagged, fix it and re-run until clean.
|
|
715
|
-
|
|
716
|
-
- [ ] **Step 7.2: Spec-to-implementation mapping check**
|
|
717
|
-
|
|
718
|
-
Manually walk through the spec sections and confirm each corresponds to something in the final workflow. Create a checklist in your head:
|
|
719
|
-
|
|
720
|
-
| Spec section | Where implemented |
|
|
721
|
-
|---|---|
|
|
722
|
-
| §3 Trigger (`v*.*.*` + `v*.*.*-*`) | `on.push.tags` in workflow |
|
|
723
|
-
| §4 Permissions + concurrency | workflow-level `permissions:` and `concurrency:` |
|
|
724
|
-
| §5.1 verify-signature (commit + tag object checks) | `verify-signature` job |
|
|
725
|
-
| §5.2 test matrix (Node 20/22/24, lint+typecheck+test+build) | `test` job with strategy.matrix |
|
|
726
|
-
| §5.3 publish-npm (dist-tag, version guard, provenance) | `publish-npm` job + `compute-disttag.sh` |
|
|
727
|
-
| §5.4 github-release (two steps, prerelease flag, generate-notes) | `github-release` job |
|
|
728
|
-
| §6 Secrets (NPM_TOKEN usage) | referenced in publish-npm step |
|
|
729
|
-
|
|
730
|
-
If any row has a gap, STOP and fix the workflow before the smoke test task.
|
|
731
|
-
|
|
732
|
-
- [ ] **Step 7.3: Dry-run the dist-tag script against a few tag names to build confidence**
|
|
733
|
-
|
|
734
|
-
Run:
|
|
735
|
-
|
|
736
|
-
```bash
|
|
737
|
-
mkdir -p /tmp/disttag-manual
|
|
738
|
-
for tag in v1.0.0 v1.0.0-beta.1 v2.0.0-rc.3 v3.0.0-alpha.0; do
|
|
739
|
-
out=/tmp/disttag-manual/$tag.out
|
|
740
|
-
: > "$out"
|
|
741
|
-
GITHUB_REF_NAME="$tag" GITHUB_OUTPUT="$out" bash .github/scripts/compute-disttag.sh
|
|
742
|
-
echo "--- $tag ---"
|
|
743
|
-
cat "$out"
|
|
744
|
-
done
|
|
745
|
-
```
|
|
746
|
-
|
|
747
|
-
Expected output groups like:
|
|
748
|
-
```
|
|
749
|
-
--- v1.0.0 ---
|
|
750
|
-
version=1.0.0
|
|
751
|
-
disttag=latest
|
|
752
|
-
is_prerelease=false
|
|
753
|
-
--- v1.0.0-beta.1 ---
|
|
754
|
-
version=1.0.0-beta.1
|
|
755
|
-
disttag=beta
|
|
756
|
-
is_prerelease=true
|
|
757
|
-
...
|
|
758
|
-
```
|
|
759
|
-
|
|
760
|
-
- [ ] **Step 7.4: Final full test suite run**
|
|
761
|
-
|
|
762
|
-
Run: `npm test`
|
|
763
|
-
|
|
764
|
-
Expected: all tests pass, including `tests/scripts/compute-disttag.test.ts`.
|
|
765
|
-
|
|
766
|
-
- [ ] **Step 7.5: No commit needed for this task**
|
|
767
|
-
|
|
768
|
-
Task 7 is verification only. No files were modified. If anything needed fixing in Step 7.1, those fixes were already committed as targeted "fix(ci): ..." commits during the fix loop.
|
|
769
|
-
|
|
770
|
-
---
|
|
771
|
-
|
|
772
|
-
## Post-implementation: smoke test checklist (manual, operator-run)
|
|
773
|
-
|
|
774
|
-
**Not part of the automated plan — this is a runbook for the human operator once the code is merged.**
|
|
775
|
-
|
|
776
|
-
Before the first real release:
|
|
777
|
-
|
|
778
|
-
1. Ensure `NPM_TOKEN` secret is set in the repo (Settings → Secrets → Actions → `NPM_TOKEN`, Granular Access Token with `Read and write` on `@intelmesh` packages).
|
|
779
|
-
2. Ensure the npm account has the package linked to this repo (required for `--provenance`).
|
|
780
|
-
3. On a throwaway branch, bump `package.json` to a prerelease like `0.1.0-alpha.0`.
|
|
781
|
-
4. Sign-tag: `git tag -s v0.1.0-alpha.0 -m "pipeline smoke test"`.
|
|
782
|
-
5. Push: `git push origin v0.1.0-alpha.0`.
|
|
783
|
-
6. Watch the Actions run. Expected outcomes:
|
|
784
|
-
- `verify-signature` passes (if it fails, fix local GPG/SSH signing config before anything else).
|
|
785
|
-
- `test` passes on all three Node versions.
|
|
786
|
-
- `publish-npm` publishes to npm under dist-tag `alpha`.
|
|
787
|
-
- `github-release` creates a prerelease on GitHub with the `.tgz` attached.
|
|
788
|
-
7. Cleanup (optional): `npm dist-tag rm @intelmesh/sdk alpha` and delete the GitHub release.
|
|
789
|
-
|
|
790
|
-
---
|
|
791
|
-
|
|
792
|
-
## Self-review notes (author's own)
|
|
793
|
-
|
|
794
|
-
- **Spec coverage:** every requirement in the spec maps to a specific task or file created. §9 (smoke test) is represented in the post-implementation runbook above since it's a manual step.
|
|
795
|
-
- **No placeholders:** every step has actual content — code blocks, commands, and expected output.
|
|
796
|
-
- **Type consistency:** the bash script writes `version`, `disttag`, `is_prerelease` to `$GITHUB_OUTPUT`; the vitest test parses those same three keys; the workflow step `id: disttag` consumes `steps.disttag.outputs.{version,disttag,is_prerelease}`; the `publish-npm` job exposes the same three names as job outputs; `github-release` reads `needs.publish-npm.outputs.is_prerelease`. Names are consistent end-to-end.
|
|
797
|
-
- **Commits are frequent:** Task 1 = 1 commit, Task 2 = 1 commit, Tasks 3-6 = 1 commit each, Task 7 = 0 commits. Total: 6 commits, each self-contained and reversible.
|
|
798
|
-
- **User feedback honored:** nothing under `docs/` is ever staged or committed. The plan document itself is not committed.
|
|
@@ -1,309 +0,0 @@
|
|
|
1
|
-
# Release Pipeline Design — `@intelmesh/sdk`
|
|
2
|
-
|
|
3
|
-
**Date:** 2026-04-10
|
|
4
|
-
**Status:** Draft (pending user review)
|
|
5
|
-
**Scope:** GitHub Actions workflow to publish `@intelmesh/sdk` to the public npm registry and create a GitHub Release on every signed semver tag.
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## 1. Goals
|
|
10
|
-
|
|
11
|
-
Create a single, auditable GitHub Actions workflow that:
|
|
12
|
-
|
|
13
|
-
1. Triggers on semver tag push (`v*.*.*` and `v*.*.*-*`).
|
|
14
|
-
2. Refuses to publish unless **both** the commit the tag points to **and** the tag object itself are cryptographically signed and verified by GitHub.
|
|
15
|
-
3. Runs the full quality gate (lint, typecheck, tests, build) on a Node version matrix (20, 22, 24) before any publish action.
|
|
16
|
-
4. Publishes to the public npm registry with Sigstore provenance.
|
|
17
|
-
5. Automatically derives the npm `dist-tag` from the tag suffix (stable → `latest`, prerelease → `beta`/`rc`/`alpha`/etc).
|
|
18
|
-
6. Creates a GitHub Release with the built tarball attached and auto-generated changelog, marked as `prerelease` when appropriate.
|
|
19
|
-
7. Fails hard at the earliest failing step — no partial state (no published version without a release, no release without a successful publish).
|
|
20
|
-
|
|
21
|
-
## 2. Non-goals
|
|
22
|
-
|
|
23
|
-
- PR CI workflow (only release-on-tag is in scope).
|
|
24
|
-
- `workflow_dispatch` with `dry_run` flag (deferred, YAGNI).
|
|
25
|
-
- Integration tests hitting a live API (no such tests exist today).
|
|
26
|
-
- Publishing to GitHub Packages or any registry other than public npm.
|
|
27
|
-
- Automatic version bumping, changelog generation outside `gh release --generate-notes`, or release-please style automation.
|
|
28
|
-
- Unpublish / rollback automation (stays manual).
|
|
29
|
-
|
|
30
|
-
## 3. Trigger
|
|
31
|
-
|
|
32
|
-
```yaml
|
|
33
|
-
on:
|
|
34
|
-
push:
|
|
35
|
-
tags:
|
|
36
|
-
- 'v[0-9]+.[0-9]+.[0-9]+'
|
|
37
|
-
- 'v[0-9]+.[0-9]+.[0-9]+-*'
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
Two explicit glob patterns. GitHub filters these *before* scheduling runners, so malformed tags like `v-test` or `release-1` never spend CI minutes. The first pattern matches stable releases (`v1.2.3`); the second matches prereleases (`v1.2.3-beta.1`, `v1.2.3-rc.2`, `v2.0.0-alpha.0`, etc).
|
|
41
|
-
|
|
42
|
-
## 4. Workflow-level permissions and concurrency
|
|
43
|
-
|
|
44
|
-
```yaml
|
|
45
|
-
permissions:
|
|
46
|
-
contents: write # github-release job creates releases
|
|
47
|
-
id-token: write # publish-npm job generates OIDC token for --provenance
|
|
48
|
-
|
|
49
|
-
concurrency:
|
|
50
|
-
group: release-${{ github.ref }}
|
|
51
|
-
cancel-in-progress: false
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
**Permissions** are declared at the workflow level (not per job) because both are needed and `contents: read` alone is insufficient for the release step. No other permissions granted — principle of least privilege.
|
|
55
|
-
|
|
56
|
-
**Concurrency** groups runs by tag ref with `cancel-in-progress: false`. Same tag cannot run twice in parallel (defense against accidental double-push), but distinct tags run independently. `cancel-in-progress` is explicitly `false` because a publish-in-flight must never be cancelled mid-flight (would leave npm and GitHub in inconsistent states).
|
|
57
|
-
|
|
58
|
-
## 5. Job graph
|
|
59
|
-
|
|
60
|
-
Four jobs, chained strictly via `needs:`. Any failure aborts the entire chain.
|
|
61
|
-
|
|
62
|
-
```
|
|
63
|
-
verify-signature → test (matrix 20/22/24) → publish-npm → github-release
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
### 5.1 Job: `verify-signature`
|
|
67
|
-
|
|
68
|
-
**Runs-on:** `ubuntu-24.04`
|
|
69
|
-
**Responsibility:** Refuse to proceed unless both the commit and the annotated tag object are verified.
|
|
70
|
-
|
|
71
|
-
**Algorithm:**
|
|
72
|
-
|
|
73
|
-
1. **Commit check**
|
|
74
|
-
- `GET /repos/{owner}/{repo}/commits/{github.sha}`
|
|
75
|
-
- Read `.commit.verification.verified`
|
|
76
|
-
- If not `true`: log `.commit.verification.reason` (examples: `unsigned`, `unknown_key`, `bad_email`, `unverified_email`, `not_signing_key`, `expired_key`) and `exit 1`.
|
|
77
|
-
|
|
78
|
-
2. **Resolve tag object**
|
|
79
|
-
- `GET /repos/{owner}/{repo}/git/refs/tags/{github.ref_name}`
|
|
80
|
-
- Read `.object.type`
|
|
81
|
-
- If `"commit"`: the tag is a lightweight tag (no signature). Emit error telling the user to use `git tag -s` and `exit 1`.
|
|
82
|
-
- If `"tag"`: read `.object.sha` (the SHA of the tag object itself).
|
|
83
|
-
|
|
84
|
-
3. **Tag signature check**
|
|
85
|
-
- `GET /repos/{owner}/{repo}/git/tags/{tag_object_sha}`
|
|
86
|
-
- Read `.verification.verified`
|
|
87
|
-
- If not `true`: log `.verification.reason` and `exit 1`.
|
|
88
|
-
|
|
89
|
-
**Implementation tool:** `gh api` (pre-installed on GitHub-hosted runners, authenticated via `GH_TOKEN=${{ github.token }}`). JSON parsing via `--jq`.
|
|
90
|
-
|
|
91
|
-
**Error output:** uses `::error::` workflow command so failures surface as annotations on the tag's commit in the Actions UI.
|
|
92
|
-
|
|
93
|
-
**Why both checks?**
|
|
94
|
-
- Commit-only: misses a lightweight tag created on top of a verified commit — you'd publish without ever signing the release itself.
|
|
95
|
-
- Tag-only: misses the case where the tag is signed but the underlying commit came from an unverified push.
|
|
96
|
-
- Both: guarantees a complete cryptographic chain from commit → tag → release.
|
|
97
|
-
|
|
98
|
-
### 5.2 Job: `test`
|
|
99
|
-
|
|
100
|
-
**Runs-on:** `ubuntu-24.04`
|
|
101
|
-
**Needs:** `verify-signature`
|
|
102
|
-
**Strategy:** Matrix on `node-version: [20, 22, 24]`, `fail-fast: true`.
|
|
103
|
-
|
|
104
|
-
**Steps:**
|
|
105
|
-
|
|
106
|
-
1. `actions/checkout@v5`
|
|
107
|
-
2. `actions/setup-node@v5` with `node-version: ${{ matrix.node-version }}` and `cache: 'npm'`
|
|
108
|
-
3. `npm ci`
|
|
109
|
-
4. `npm run lint`
|
|
110
|
-
5. `npm run typecheck`
|
|
111
|
-
6. `npm run test` (vitest)
|
|
112
|
-
7. `npm run build`
|
|
113
|
-
|
|
114
|
-
All three matrix cells must succeed for `needs: test` downstream to unblock.
|
|
115
|
-
|
|
116
|
-
**Rationale for matrix:** `package.json` declares `engines.node: ">=20"`. The release must prove the SDK works on every LTS-or-current supported runtime before going to npm. Runtime is cheap (three parallel jobs) and it catches runtime-specific regressions that only manifest on newer Node versions (e.g., changes to `fetch`, `URL`, or `AbortController` semantics).
|
|
117
|
-
|
|
118
|
-
### 5.3 Job: `publish-npm`
|
|
119
|
-
|
|
120
|
-
**Runs-on:** `ubuntu-24.04`
|
|
121
|
-
**Needs:** `test`
|
|
122
|
-
**Outputs:** `is_prerelease`, `disttag`, `version` (consumed by `github-release`).
|
|
123
|
-
|
|
124
|
-
**Steps:**
|
|
125
|
-
|
|
126
|
-
1. `actions/checkout@v5`
|
|
127
|
-
2. `actions/setup-node@v5` with `node-version: 22`, `registry-url: 'https://registry.npmjs.org'`, `cache: 'npm'`
|
|
128
|
-
3. `npm ci`
|
|
129
|
-
4. `npm run build`
|
|
130
|
-
5. **Compute dist-tag and version (step id: `disttag`)**
|
|
131
|
-
|
|
132
|
-
```bash
|
|
133
|
-
set -euo pipefail
|
|
134
|
-
tag="${GITHUB_REF_NAME}" # e.g. "v1.2.3-beta.1"
|
|
135
|
-
version="${tag#v}" # strip leading v → "1.2.3-beta.1"
|
|
136
|
-
if [[ "$version" == *-* ]]; then
|
|
137
|
-
suffix="${version#*-}" # "beta.1"
|
|
138
|
-
disttag="${suffix%%.*}" # "beta"
|
|
139
|
-
is_prerelease=true
|
|
140
|
-
else
|
|
141
|
-
disttag="latest"
|
|
142
|
-
is_prerelease=false
|
|
143
|
-
fi
|
|
144
|
-
echo "version=$version" >> "$GITHUB_OUTPUT"
|
|
145
|
-
echo "disttag=$disttag" >> "$GITHUB_OUTPUT"
|
|
146
|
-
echo "is_prerelease=$is_prerelease" >> "$GITHUB_OUTPUT"
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
6. **Version mismatch guard**
|
|
150
|
-
|
|
151
|
-
```bash
|
|
152
|
-
pkg_version=$(node -p "require('./package.json').version")
|
|
153
|
-
if [ "$pkg_version" != "${{ steps.disttag.outputs.version }}" ]; then
|
|
154
|
-
echo "::error::package.json version ($pkg_version) does not match git tag (${{ steps.disttag.outputs.version }})"
|
|
155
|
-
exit 1
|
|
156
|
-
fi
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
7. **Publish**
|
|
160
|
-
|
|
161
|
-
```bash
|
|
162
|
-
npm publish --provenance --access public --tag "${{ steps.disttag.outputs.disttag }}"
|
|
163
|
-
```
|
|
164
|
-
|
|
165
|
-
With `env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}`.
|
|
166
|
-
|
|
167
|
-
**Tag-to-dist-tag mapping (reference):**
|
|
168
|
-
|
|
169
|
-
| Tag pushed | `version` | `disttag` | `is_prerelease` |
|
|
170
|
-
|-----------------------|-------------------|-----------|-----------------|
|
|
171
|
-
| `v1.2.3` | `1.2.3` | `latest` | `false` |
|
|
172
|
-
| `v1.2.3-beta.1` | `1.2.3-beta.1` | `beta` | `true` |
|
|
173
|
-
| `v1.2.3-beta.4` | `1.2.3-beta.4` | `beta` | `true` |
|
|
174
|
-
| `v1.2.3-rc.1` | `1.2.3-rc.1` | `rc` | `true` |
|
|
175
|
-
| `v2.0.0-alpha.7` | `2.0.0-alpha.7` | `alpha` | `true` |
|
|
176
|
-
| `v2.0.0-next.0` | `2.0.0-next.0` | `next` | `true` |
|
|
177
|
-
|
|
178
|
-
**Why `--access public`?** `@intelmesh/sdk` is a scoped package; npm defaults scoped packages to private. The flag makes it explicit and prevents accidental private publishes.
|
|
179
|
-
|
|
180
|
-
**Why `--provenance`?** Generates a Sigstore attestation linking the published tarball to the GitHub Actions run that built it. Consumers see a "provenance" badge on `npmjs.com/package/@intelmesh/sdk`. Requires `id-token: write` permission (already declared) and the npm account to have the package linked to this repo (via `package.json` `repository` field — must be verified present during implementation).
|
|
181
|
-
|
|
182
|
-
**Why use the tag as the source of truth, not `package.json`?** If the developer tags `v1.2.3-beta.1` but forgets to bump `package.json` from `1.2.3`, we want a loud failure, not silent guessing. The version mismatch guard enforces this.
|
|
183
|
-
|
|
184
|
-
### 5.4 Job: `github-release`
|
|
185
|
-
|
|
186
|
-
**Runs-on:** `ubuntu-24.04`
|
|
187
|
-
**Needs:** `publish-npm`
|
|
188
|
-
|
|
189
|
-
**Steps:**
|
|
190
|
-
|
|
191
|
-
1. `actions/checkout@v5` with `fetch-depth: 0` (needed for `gh release --generate-notes` to compute since-last-tag)
|
|
192
|
-
2. `actions/setup-node@v5` with `node-version: 22`, `cache: 'npm'`
|
|
193
|
-
3. `npm ci`
|
|
194
|
-
4. `npm run build`
|
|
195
|
-
5. `npm pack` (produces `intelmesh-sdk-<version>.tgz`)
|
|
196
|
-
6. **Create release** — split into two steps, gated by `if:` on the `is_prerelease` output, to keep each command readable:
|
|
197
|
-
|
|
198
|
-
```yaml
|
|
199
|
-
- name: Create GitHub Release (stable)
|
|
200
|
-
if: needs.publish-npm.outputs.is_prerelease == 'false'
|
|
201
|
-
env:
|
|
202
|
-
GH_TOKEN: ${{ github.token }}
|
|
203
|
-
run: |
|
|
204
|
-
gh release create "$GITHUB_REF_NAME" \
|
|
205
|
-
./intelmesh-sdk-*.tgz \
|
|
206
|
-
--title "$GITHUB_REF_NAME" \
|
|
207
|
-
--generate-notes
|
|
208
|
-
|
|
209
|
-
- name: Create GitHub Release (prerelease)
|
|
210
|
-
if: needs.publish-npm.outputs.is_prerelease == 'true'
|
|
211
|
-
env:
|
|
212
|
-
GH_TOKEN: ${{ github.token }}
|
|
213
|
-
run: |
|
|
214
|
-
gh release create "$GITHUB_REF_NAME" \
|
|
215
|
-
./intelmesh-sdk-*.tgz \
|
|
216
|
-
--title "$GITHUB_REF_NAME" \
|
|
217
|
-
--generate-notes \
|
|
218
|
-
--prerelease
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
Two mutually exclusive steps are clearer than one step with a dynamic flag inlined via GHA expression syntax.
|
|
222
|
-
|
|
223
|
-
**Why rebuild here instead of reusing `publish-npm` artifacts?** GitHub Actions jobs have isolated file systems. The alternatives are (a) `actions/upload-artifact` + `actions/download-artifact` between jobs, or (b) rebuild. Rebuild is simpler, deterministic, and the build is fast (<30s for this SDK). If build time grows, switch to upload/download. YAGNI for now.
|
|
224
|
-
|
|
225
|
-
**Why `--generate-notes`?** GitHub auto-generates release notes from commits and PRs since the previous tag. No need to maintain a manual CHANGELOG for the MVP — the git log and PR titles are the source of truth. Manual CHANGELOG can be added later if needed.
|
|
226
|
-
|
|
227
|
-
**Why create release *after* npm publish (not before)?** If npm publish fails, we don't want a dangling GitHub release pointing at a version that isn't in the registry. Publish first, release second.
|
|
228
|
-
|
|
229
|
-
## 6. Secrets
|
|
230
|
-
|
|
231
|
-
| Secret | Source | Used in | Required |
|
|
232
|
-
|----------------|---------------------------------|---------------------------------|----------|
|
|
233
|
-
| `NPM_TOKEN` | Repo Settings → Secrets → Actions | `publish-npm` | Yes |
|
|
234
|
-
| `GITHUB_TOKEN` | Automatic (injected) | `verify-signature`, `github-release` | Auto |
|
|
235
|
-
|
|
236
|
-
**`NPM_TOKEN` provisioning (one-time, manual):**
|
|
237
|
-
|
|
238
|
-
1. On `npmjs.com`, log in with the owner account for `@intelmesh`.
|
|
239
|
-
2. Settings → Access Tokens → Generate New Token → Granular Access Token.
|
|
240
|
-
3. Scope: `Read and write` on packages under `@intelmesh`.
|
|
241
|
-
4. Expiration: 1 year (document renewal in team runbook).
|
|
242
|
-
5. Copy the token.
|
|
243
|
-
6. In the GitHub repo: Settings → Secrets and variables → Actions → New repository secret, name `NPM_TOKEN`.
|
|
244
|
-
|
|
245
|
-
**Provenance prerequisite check:** During implementation, verify `package.json` has a `repository` field pointing to `github.com/intelmesh/intelmesh-sdk-ts`. If missing, add it — `npm publish --provenance` uses it to link the package to the source repo.
|
|
246
|
-
|
|
247
|
-
## 7. Failure modes
|
|
248
|
-
|
|
249
|
-
| Scenario | Failing job | User-visible message |
|
|
250
|
-
|-----------------------------------------|-------------------|---------------------------------------------------------------------------------------|
|
|
251
|
-
| Push of lightweight tag | `verify-signature` | `Tag vX.Y.Z is a lightweight tag. Use 'git tag -s' to create a signed annotated tag.` |
|
|
252
|
-
| Commit not signed | `verify-signature` | `Commit <sha> is NOT verified. Reason: unsigned` |
|
|
253
|
-
| Signing key not registered on GitHub | `verify-signature` | `Reason: unknown_key` |
|
|
254
|
-
| Expired signing key | `verify-signature` | `Reason: expired_key` |
|
|
255
|
-
| Signer email not verified on GitHub | `verify-signature` | `Reason: unverified_email` |
|
|
256
|
-
| Lint / typecheck / test / build failure | `test` | ESLint / tsc / vitest annotations on the run |
|
|
257
|
-
| `package.json` version ≠ tag | `publish-npm` | `package.json version (X) does not match git tag (Y)` |
|
|
258
|
-
| Version already published | `publish-npm` | npm 403; step fails; no release created |
|
|
259
|
-
| `NPM_TOKEN` missing/expired | `publish-npm` | npm 401 |
|
|
260
|
-
| Provenance OIDC fails | `publish-npm` | npm publish error mentioning `id-token` |
|
|
261
|
-
|
|
262
|
-
All failures propagate via `needs:` so no downstream job runs. There is no partial state: a failed pipeline leaves the npm registry and GitHub Releases untouched by downstream jobs.
|
|
263
|
-
|
|
264
|
-
## 8. Re-running a failed release
|
|
265
|
-
|
|
266
|
-
**Supported:** GitHub Actions "Re-run failed jobs" works because the trigger is `push: tags` and the tag continues to point to the same commit. Use this for flaky failures (npm registry 502, runner network blip, etc).
|
|
267
|
-
|
|
268
|
-
**Not supported by the workflow (manual process):**
|
|
269
|
-
|
|
270
|
-
- Unpublishing a broken release from npm: `npm unpublish @intelmesh/sdk@X.Y.Z` within the 72-hour window, or contact npm support after.
|
|
271
|
-
- Removing a release from GitHub: `gh release delete vX.Y.Z`.
|
|
272
|
-
- Deleting/moving a tag: `git tag -d vX.Y.Z && git push --delete origin vX.Y.Z && git tag -s vX.Y.Z <new-sha> && git push origin vX.Y.Z`.
|
|
273
|
-
|
|
274
|
-
These stay manual because they are operational rollbacks, not part of a normal release flow, and automating them would be footgun territory.
|
|
275
|
-
|
|
276
|
-
## 9. Testing the workflow itself
|
|
277
|
-
|
|
278
|
-
Before the first real release, validate the pipeline end-to-end on a throwaway version:
|
|
279
|
-
|
|
280
|
-
1. Create a branch with a signed commit.
|
|
281
|
-
2. Bump `package.json` to a harmless prerelease version like `0.1.0-alpha.0`.
|
|
282
|
-
3. Sign-tag: `git tag -s v0.1.0-alpha.0 -m "pipeline smoke test"`.
|
|
283
|
-
4. Push tag: `git push origin v0.1.0-alpha.0`.
|
|
284
|
-
5. Watch the Actions run:
|
|
285
|
-
- `verify-signature` must pass (if not: fix GPG/SSH setup first).
|
|
286
|
-
- `test` must pass on all three Node versions.
|
|
287
|
-
- `publish-npm` will actually publish to npm under dist-tag `alpha`. This is fine — `alpha` dist-tag doesn't affect default installs.
|
|
288
|
-
- `github-release` creates a prerelease on GitHub.
|
|
289
|
-
6. After success: `npm dist-tag rm @intelmesh/sdk alpha` (cleanup, optional) and delete the GitHub release if desired.
|
|
290
|
-
|
|
291
|
-
Alternatively, if contaminating the npm registry with a test version is undesirable, a future `workflow_dispatch` with `dry_run` input can be added (explicitly deferred — see Non-goals).
|
|
292
|
-
|
|
293
|
-
## 10. File layout
|
|
294
|
-
|
|
295
|
-
```
|
|
296
|
-
.github/
|
|
297
|
-
workflows/
|
|
298
|
-
release.yml ← this design's output
|
|
299
|
-
```
|
|
300
|
-
|
|
301
|
-
Single file. All four jobs live in one workflow so the full release pipeline is visible in one place.
|
|
302
|
-
|
|
303
|
-
## 11. Open implementation questions
|
|
304
|
-
|
|
305
|
-
None at spec time. Items to verify during implementation:
|
|
306
|
-
|
|
307
|
-
- `package.json` `repository` field is present and points to the correct GitHub URL (required for `--provenance`).
|
|
308
|
-
- The npm account owning `@intelmesh` exists and `NPM_TOKEN` has been provisioned before merging the workflow (otherwise the first tag push will fail at `publish-npm`).
|
|
309
|
-
- GitHub org `intelmesh` has `Actions` enabled with `write` permissions for workflow tokens (Settings → Actions → Workflow permissions).
|