Skip to content

Commit c150d6f

Browse files
committed
ci: add npm publish workflow and backfill script
- .github/workflows/npm-publish.yml — publishes to npm on every v* tag push - gates on test suite pass - verifies package.json version matches the pushed tag - uses npm provenance attestation - creates a GitHub Release with auto-generated notes - scripts/backfill-npm.sh — retroactively publish pre-workflow tags - dry-run mode by default (--publish to execute) - assigns dist-tag 'legacy' to all versions except v1.5.0 (latest) - skips already-published versions automatically - supports --from <tag> to start from a specific version - README: new '📦 Publishing to npm' section with step-by-step setup
1 parent f287c72 commit c150d6f

3 files changed

Lines changed: 339 additions & 0 deletions

File tree

.github/workflows/npm-publish.yml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: Publish to npm
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*'
7+
8+
permissions:
9+
contents: write # needed for creating GitHub Releases
10+
id-token: write # needed for npm provenance
11+
12+
jobs:
13+
test:
14+
name: Run tests
15+
runs-on: ubuntu-latest
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- name: Setup Node.js
20+
uses: actions/setup-node@v4
21+
with:
22+
node-version: '20'
23+
24+
- name: Run test suite
25+
run: node test/run.js
26+
27+
publish:
28+
name: Publish to npm
29+
needs: test
30+
runs-on: ubuntu-latest
31+
steps:
32+
- uses: actions/checkout@v4
33+
34+
- name: Setup Node.js
35+
uses: actions/setup-node@v4
36+
with:
37+
node-version: '20'
38+
registry-url: 'https://registry.npmjs.org'
39+
40+
- name: Verify package version matches tag
41+
run: |
42+
PKG_VERSION="v$(node -p "require('./package.json').version")"
43+
TAG_VERSION="${GITHUB_REF_NAME}"
44+
if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then
45+
echo "ERROR: package.json version ($PKG_VERSION) does not match tag ($TAG_VERSION)"
46+
exit 1
47+
fi
48+
echo "Version check passed: $PKG_VERSION"
49+
50+
- name: Publish to npm
51+
run: npm publish --provenance --access public
52+
env:
53+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
54+
55+
- name: Create GitHub Release
56+
uses: softprops/action-gh-release@v2
57+
with:
58+
generate_release_notes: true
59+
make_latest: true

README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,61 @@ context-forge/
514514

515515
---
516516

517+
## 📦 Publishing to npm
518+
519+
Releases are published automatically via GitHub Actions whenever a version tag is pushed.
520+
521+
### One-time setup
522+
523+
1. **Create an npm account** at [npmjs.com](https://www.npmjs.com) (if you haven't already).
524+
525+
2. **Generate an npm access token**:
526+
- npmjs.com → Account → Access Tokens → Generate New Token → **Granular Access Token** (or Classic Automation token)
527+
- Scope: `context-forge` package, permission: **Read and Write**
528+
529+
3. **Add the secret to GitHub**:
530+
```
531+
GitHub repo → Settings → Secrets and variables → Actions → New repository secret
532+
Name: NPM_TOKEN
533+
Value: <paste token>
534+
```
535+
536+
### Releasing a new version
537+
538+
```bash
539+
# 1. Bump version in package.json
540+
npm version patch # or minor / major
541+
542+
# 2. Push the commit AND the new tag
543+
git push && git push --tags
544+
```
545+
546+
The [npm-publish workflow](.github/workflows/npm-publish.yml) will:
547+
1. Run the full test suite
548+
2. Verify `package.json` version matches the pushed tag
549+
3. Publish to npm with provenance attestation
550+
4. Create a GitHub Release with auto-generated notes
551+
552+
### Backfilling historical versions
553+
554+
Tags that existed before the workflow was set up can be published retroactively:
555+
556+
```bash
557+
# Dry run first — see what would be published
558+
./scripts/backfill-npm.sh
559+
560+
# Actually publish all historical tags
561+
export NPM_TOKEN=npm_xxxxxxxxxxxx
562+
./scripts/backfill-npm.sh --publish
563+
564+
# Start from a specific tag
565+
./scripts/backfill-npm.sh --publish --from v0.5.0
566+
```
567+
568+
The script assigns `dist-tag: legacy` to all versions except `v1.5.0` (which gets `latest`), so `npm install context-forge` always resolves to the current release.
569+
570+
---
571+
517572
## 🤝 Contributing
518573

519574
See [CONTRIBUTING.md](CONTRIBUTING.md) for how to add a language extractor or new feature.

scripts/backfill-npm.sh

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
#!/usr/bin/env bash
2+
# backfill-npm.sh — Publish all historical git-tagged versions to npm
3+
#
4+
# This script checks out each tagged version and publishes it to npm.
5+
# Versions prior to the current latest are published with --tag legacy
6+
# so they don't overwrite the `latest` dist-tag.
7+
#
8+
# Prerequisites:
9+
# - npm logged in, or NPM_TOKEN exported:
10+
# export NPM_TOKEN=npm_xxxxxxxxxxxx
11+
# - All tags must exist locally:
12+
# git fetch --tags
13+
#
14+
# Usage:
15+
# ./scripts/backfill-npm.sh # dry run (no publish)
16+
# ./scripts/backfill-npm.sh --publish # actually publish
17+
# ./scripts/backfill-npm.sh --publish --from v0.5.0 # start from a specific tag
18+
#
19+
# Safety:
20+
# - Always does a dry run first (npm publish --dry-run) before the real publish.
21+
# - Leaves your working tree on the original branch when done.
22+
# - Skips any tag whose package.json version doesn't match the tag name.
23+
24+
set -euo pipefail
25+
26+
# ── Configuration ─────────────────────────────────────────────────────────────
27+
28+
PACKAGE_NAME="context-forge"
29+
LATEST_TAG="v1.5.0" # The tag that should occupy the `latest` dist-tag
30+
31+
# Tags in ascending order — adjust if you add more historical tags.
32+
ALL_TAGS=(
33+
v0.1.0 v0.2.0 v0.3.0 v0.4.0 v0.5.0
34+
v0.6.0 v0.7.0 v0.8.0 v0.9.0
35+
v1.0.0 v1.2.0 v1.3.0 v1.4.0
36+
v1.5.0
37+
)
38+
# Note: v1.1.0 was never tagged in git — intentionally omitted.
39+
40+
# ── Argument parsing ───────────────────────────────────────────────────────────
41+
42+
DRY_RUN=true
43+
FROM_TAG=""
44+
45+
for arg in "$@"; do
46+
case "$arg" in
47+
--publish) DRY_RUN=false ;;
48+
--from=*) FROM_TAG="${arg#--from=}" ;;
49+
--from) shift; FROM_TAG="${1:-}" ;;
50+
--help|-h)
51+
grep '^#' "$0" | head -20 | sed 's/^# \?//'
52+
exit 0
53+
;;
54+
esac
55+
done
56+
57+
# ── Setup ─────────────────────────────────────────────────────────────────────
58+
59+
if [ -n "${NPM_TOKEN:-}" ]; then
60+
# Write .npmrc so npm picks up the token without an interactive login
61+
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
62+
echo "[backfill] NPM_TOKEN applied to ~/.npmrc"
63+
fi
64+
65+
ORIGINAL_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")
66+
ORIGINAL_REF=$(git rev-parse HEAD)
67+
68+
restore_state() {
69+
echo ""
70+
echo "[backfill] Restoring working tree to original state..."
71+
if [ -n "$ORIGINAL_BRANCH" ]; then
72+
git checkout "$ORIGINAL_BRANCH" --quiet 2>/dev/null || git checkout "$ORIGINAL_REF" --quiet
73+
else
74+
git checkout "$ORIGINAL_REF" --quiet
75+
fi
76+
echo "[backfill] Done."
77+
}
78+
trap restore_state EXIT
79+
80+
# ── Filtering by --from ────────────────────────────────────────────────────────
81+
82+
TAGS_TO_PUBLISH=()
83+
SKIP=true
84+
85+
for tag in "${ALL_TAGS[@]}"; do
86+
if [ -z "$FROM_TAG" ]; then
87+
SKIP=false
88+
fi
89+
if [ "$tag" = "$FROM_TAG" ]; then
90+
SKIP=false
91+
fi
92+
if [ "$SKIP" = false ]; then
93+
TAGS_TO_PUBLISH+=("$tag")
94+
fi
95+
done
96+
97+
if [ ${#TAGS_TO_PUBLISH[@]} -eq 0 ]; then
98+
echo "[backfill] No tags to publish (FROM_TAG '$FROM_TAG' not found or list is empty)"
99+
exit 1
100+
fi
101+
102+
# ── Summary ───────────────────────────────────────────────────────────────────
103+
104+
echo ""
105+
echo "╔══════════════════════════════════════════════════════════╗"
106+
echo "║ context-forge — npm backfill publisher ║"
107+
echo "╚══════════════════════════════════════════════════════════╝"
108+
echo ""
109+
echo " Package : $PACKAGE_NAME"
110+
echo " Latest : $LATEST_TAG (will use dist-tag: latest)"
111+
echo " Others : all other tags (will use dist-tag: legacy)"
112+
echo " Dry run : $DRY_RUN"
113+
echo ""
114+
echo " Tags to publish (${#TAGS_TO_PUBLISH[@]}):"
115+
for tag in "${TAGS_TO_PUBLISH[@]}"; do
116+
marker=""
117+
[ "$tag" = "$LATEST_TAG" ] && marker=" ← latest"
118+
echo " $tag$marker"
119+
done
120+
echo ""
121+
122+
if [ "$DRY_RUN" = true ]; then
123+
echo " ⚠ DRY RUN — pass --publish to actually publish."
124+
echo ""
125+
fi
126+
127+
# ── Publish loop ──────────────────────────────────────────────────────────────
128+
129+
PASS=()
130+
FAIL=()
131+
SKIP_LIST=()
132+
133+
for tag in "${TAGS_TO_PUBLISH[@]}"; do
134+
echo "──────────────────────────────────────────────────────────"
135+
echo "[backfill] Processing $tag"
136+
137+
# Check the tag exists locally
138+
if ! git rev-parse "$tag" > /dev/null 2>&1; then
139+
echo "[backfill] SKIP — tag '$tag' not found locally (run: git fetch --tags)"
140+
SKIP_LIST+=("$tag")
141+
continue
142+
fi
143+
144+
# Checkout the tag
145+
git checkout "$tag" --quiet
146+
147+
# Verify package.json version matches the tag
148+
PKG_VERSION="v$(node -p "require('./package.json').version" 2>/dev/null || echo 'unknown')"
149+
if [ "$PKG_VERSION" != "$tag" ]; then
150+
echo "[backfill] SKIP — package.json version ($PKG_VERSION) doesn't match tag ($tag)"
151+
SKIP_LIST+=("$tag")
152+
continue
153+
fi
154+
155+
echo "[backfill] package.json version OK: $PKG_VERSION"
156+
157+
# Choose dist-tag
158+
if [ "$tag" = "$LATEST_TAG" ]; then
159+
DIST_TAG="latest"
160+
else
161+
DIST_TAG="legacy"
162+
fi
163+
164+
# Check if this version is already published
165+
PUBLISHED=$(npm view "${PACKAGE_NAME}@${PKG_VERSION#v}" version 2>/dev/null || echo "")
166+
if [ -n "$PUBLISHED" ]; then
167+
echo "[backfill] SKIP — ${PACKAGE_NAME}@${PKG_VERSION#v} already published on npm"
168+
SKIP_LIST+=("$tag")
169+
continue
170+
fi
171+
172+
# Always do a dry run first to catch packaging errors
173+
echo "[backfill] Running dry-run for $tag"
174+
if ! npm publish --dry-run --access public 2>&1 | tail -5; then
175+
echo "[backfill] FAIL — dry run failed for $tag"
176+
FAIL+=("$tag")
177+
continue
178+
fi
179+
180+
if [ "$DRY_RUN" = true ]; then
181+
echo "[backfill] DRY RUN — would publish: npm publish --tag $DIST_TAG --access public"
182+
PASS+=("$tag (dry)")
183+
else
184+
echo "[backfill] Publishing $tag with dist-tag '$DIST_TAG'…"
185+
if npm publish --tag "$DIST_TAG" --access public; then
186+
echo "[backfill] ✓ Published $tag"
187+
PASS+=("$tag")
188+
else
189+
echo "[backfill] ✗ FAILED to publish $tag"
190+
FAIL+=("$tag")
191+
fi
192+
fi
193+
done
194+
195+
# ── Result summary ────────────────────────────────────────────────────────────
196+
197+
echo ""
198+
echo "══════════════════════════════════════════════════════════"
199+
echo " Results"
200+
echo "══════════════════════════════════════════════════════════"
201+
echo ""
202+
203+
if [ ${#PASS[@]} -gt 0 ]; then
204+
echo " ✓ Published (${#PASS[@]}):"
205+
for t in "${PASS[@]}"; do echo " $t"; done
206+
echo ""
207+
fi
208+
209+
if [ ${#SKIP_LIST[@]} -gt 0 ]; then
210+
echo " ○ Skipped (${#SKIP_LIST[@]}):"
211+
for t in "${SKIP_LIST[@]}"; do echo " $t"; done
212+
echo ""
213+
fi
214+
215+
if [ ${#FAIL[@]} -gt 0 ]; then
216+
echo " ✗ Failed (${#FAIL[@]}):"
217+
for t in "${FAIL[@]}"; do echo " $t"; done
218+
echo ""
219+
exit 1
220+
fi
221+
222+
if [ "$DRY_RUN" = true ] && [ ${#PASS[@]} -gt 0 ]; then
223+
echo " Run with --publish to execute the above publishes."
224+
echo ""
225+
fi

0 commit comments

Comments
 (0)