Skip to content

Commit 8e9069b

Browse files
committed
improve github workflows
1 parent 000b4aa commit 8e9069b

File tree

2 files changed

+289
-18
lines changed

2 files changed

+289
-18
lines changed

.github/workflows/release.yml

Lines changed: 135 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,153 @@
11
name: Release
22

33
on:
4-
release:
5-
types: [published]
4+
workflow_dispatch:
5+
inputs:
6+
version:
7+
description: "Version to publish"
8+
required: true
9+
dry_run:
10+
description: "Perform a dry run without publishing"
11+
type: boolean
12+
required: false
13+
default: true
14+
15+
concurrency:
16+
group: npm-publish-${{ github.repository }}
17+
cancel-in-progress: false
618

719
jobs:
820
release:
921
name: Release workflow
10-
1122
runs-on: ubuntu-latest
1223

24+
permissions:
25+
contents: read
26+
id-token: write # Required for OIDC trusted publishing
27+
1328
steps:
14-
- uses: actions/checkout@v4
29+
- name: Validate GitHub release and tag exists
30+
env:
31+
GH_TOKEN: ${{ github.token }}
32+
run: |
33+
TAG="v${{ inputs.version }}"
34+
echo "Looking for release with tag $TAG..."
35+
36+
RELEASE=$(gh release view "$TAG" --repo ${{ github.repository }} --json tagName,name 2>/dev/null)
37+
if [ $? -ne 0 ]; then
38+
echo "❌ No GitHub release found with tag $TAG"
39+
exit 1
40+
fi
41+
42+
RELEASE_NAME=$(echo "$RELEASE" | jq -r '.name')
43+
if [ "$RELEASE_NAME" != "$TAG" ]; then
44+
echo "❌ Release name '$RELEASE_NAME' does not match expected '$TAG'"
45+
exit 1
46+
fi
47+
48+
echo "✅ GitHub release '$RELEASE_NAME' confirmed"
49+
50+
- name: Checkout tag
51+
uses: actions/checkout@v4
52+
with:
53+
ref: "v${{ inputs.version }}"
54+
fetch-depth: 0
55+
56+
- name: Ensure tag commit is on master
57+
run: |
58+
if ! git branch -r --contains "$(git rev-parse HEAD)" | grep -q "origin/master"; then
59+
echo "❌ Tag is not based on master branch"
60+
exit 1
61+
fi
62+
echo "✅ Tag commit is on master"
1563
1664
- name: Setup Node.js
1765
uses: actions/setup-node@v4
1866
with:
19-
node-version: '22.x'
20-
registry-url: 'https://registry.npmjs.org/'
67+
node-version: "lts/*"
68+
registry-url: "https://registry.npmjs.org/"
2169

22-
- name: Install
23-
run: yarn --frozen-lockfile --non-interactive
70+
- name: Enable Corepack
71+
run: corepack enable
72+
73+
- name: Detect Yarn version
74+
id: yarn-version
75+
run: |
76+
# Resolve the Yarn major version from the packageManager field in
77+
# package.json if present, otherwise fall back to the installed version.
78+
YARN_VERSION=$(node -p "
79+
try {
80+
const pm = require('./package.json').packageManager ?? '';
81+
const match = pm.match(/^yarn@(\d+)/);
82+
match ? match[1] : '';
83+
} catch { '' }
84+
")
85+
if [ -z "$YARN_VERSION" ]; then
86+
YARN_VERSION=$(yarn --version | cut -d. -f1)
87+
fi
88+
echo "major=$YARN_VERSION" >> "$GITHUB_OUTPUT"
89+
echo "Detected Yarn major version: $YARN_VERSION"
90+
91+
- name: Validate version matches package.json
92+
run: |
93+
PKG_VERSION=$(node -p "require('./package.json').version")
94+
INPUT_VERSION="${{ inputs.version }}"
95+
if [ "$PKG_VERSION" != "$INPUT_VERSION" ]; then
96+
echo "❌ Version mismatch: package.json has $PKG_VERSION but input was $INPUT_VERSION"
97+
exit 1
98+
fi
99+
echo "✅ Version $PKG_VERSION confirmed"
100+
101+
- name: Install dependencies
102+
run: |
103+
if [ "${{ steps.yarn-version.outputs.major }}" = "1" ]; then
104+
yarn install --frozen-lockfile --non-interactive
105+
else
106+
yarn install --immutable
107+
fi
108+
109+
- name: Build package (if build script exists)
110+
run: |
111+
if node -e "process.exit(require('./package.json').scripts?.build ? 0 : 1)" 2>/dev/null; then
112+
yarn build
113+
else
114+
echo "No build script found — skipping build step"
115+
fi
116+
117+
- name: Publish (dry run)
118+
if: ${{ inputs.dry_run }}
119+
env:
120+
# The npm CLI automatically detects the OIDC environment via
121+
# ACTIONS_ID_TOKEN_REQUEST_URL / ACTIONS_ID_TOKEN_REQUEST_TOKEN and
122+
# handles the token exchange itself. NODE_AUTH_TOKEN must still be set
123+
# (even if empty) to satisfy the .npmrc written by actions/setup-node,
124+
# otherwise npm errors before it reaches OIDC auth.
125+
NODE_AUTH_TOKEN: ""
126+
run: npm publish --provenance --access public --dry-run
24127

25128
- name: Publish
26-
run: yarn publish
129+
if: ${{ !inputs.dry_run }}
27130
env:
28-
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
131+
NODE_AUTH_TOKEN: ""
132+
run: npm publish --provenance --access public
133+
134+
- name: Verify published version
135+
if: ${{ !inputs.dry_run }}
136+
run: |
137+
PACKAGE_NAME=$(node -p "require('./package.json').name")
138+
EXPECTED_VERSION="${{ inputs.version }}"
139+
140+
echo "Waiting for npm propagation..."
141+
142+
for i in {1..10}; do
143+
PUBLISHED_VERSION=$(npm view "$PACKAGE_NAME" version 2>/dev/null)
144+
if [ "$PUBLISHED_VERSION" = "$EXPECTED_VERSION" ]; then
145+
echo "✅ Version $PUBLISHED_VERSION confirmed on npm"
146+
exit 0
147+
fi
148+
echo "Not visible yet (attempt $i)..."
149+
sleep 15
150+
done
151+
152+
echo "❌ Published version not visible after waiting"
153+
exit 1

.github/workflows/tests.yml

Lines changed: 154 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,136 @@ name: Tests
33
on: [push, pull_request]
44

55
jobs:
6-
build:
6+
resolve-node-versions:
7+
runs-on: ubuntu-latest
8+
outputs:
9+
matrix: ${{ steps.set-matrix.outputs.matrix }}
10+
steps:
11+
- id: set-matrix
12+
run: |
13+
node <<'EOF' >> $GITHUB_OUTPUT
14+
const https = require('https');
15+
const fs = require('fs');
16+
17+
function compareSemver(a, b) {
18+
const pa = a.split('.').map(Number);
19+
const pb = b.split('.').map(Number);
20+
for (let i = 0; i < 3; i++) {
21+
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pb[i] ?? 0) - (pa[i] ?? 0);
22+
}
23+
return 0;
24+
}
25+
26+
// Returns { major, minor, patch, minorSpecified, patchSpecified } or null
27+
function parseEnginesVersion(enginesNode) {
28+
if (!enginesNode) return null;
29+
const match = enginesNode.match(/>=\s*(\d+)(?:\.(\d+)(?:\.(\d+))?)?/);
30+
if (!match) return null;
31+
return {
32+
major: parseInt(match[1], 10),
33+
minor: match[2] !== undefined ? parseInt(match[2], 10) : null,
34+
patch: match[3] !== undefined ? parseInt(match[3], 10) : null,
35+
minorSpecified: match[2] !== undefined,
36+
patchSpecified: match[3] !== undefined,
37+
};
38+
}
39+
40+
// Build the version spec to pass to setup-node for the minimum version.
41+
// We let setup-node resolve the latest patch when patch is omitted.
42+
// e.g. ">= 18" -> "18"
43+
// ">= 18.18" -> "18.18"
44+
// ">= 18.18.2" -> "18.18.2"
45+
function minVersionSpec(minVersion) {
46+
if (minVersion.patchSpecified) return `${minVersion.major}.${minVersion.minor}.${minVersion.patch}`;
47+
if (minVersion.minorSpecified) return `${minVersion.major}.${minVersion.minor}`;
48+
return `${minVersion.major}`;
49+
}
50+
51+
let enginesNode = null;
52+
try {
53+
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
54+
enginesNode = pkg?.engines?.node ?? null;
55+
} catch {}
56+
57+
const minVersion = parseEnginesVersion(enginesNode);
58+
59+
https.get('https://nodejs.org/dist/index.json', res => {
60+
let data = '';
61+
res.on('data', chunk => data += chunk);
62+
res.on('end', () => {
63+
const releases = JSON.parse(data);
64+
65+
const lts = releases
66+
.filter(r => r.lts)
67+
.map(r => r.version.replace(/^v/, ''));
68+
69+
// Group by major, pick highest semver per major
70+
const byMajor = {};
71+
for (const v of lts) {
72+
const major = parseInt(v.split('.')[0], 10);
73+
if (!byMajor[major]) byMajor[major] = [];
74+
byMajor[major].push(v);
75+
}
76+
77+
const latestPerMajor = Object.entries(byMajor)
78+
.map(([major, versions]) => {
79+
versions.sort(compareSemver);
80+
return { major: parseInt(major, 10), version: versions[0] };
81+
});
82+
83+
latestPerMajor.sort((a, b) => b.major - a.major);
84+
85+
// Take latest 4 LTS majors (these are exact resolved versions)
86+
const top4 = latestPerMajor.slice(0, 4);
87+
const top4Majors = new Set(top4.map(e => e.major));
88+
89+
const extra = [];
790
91+
if (minVersion !== null) {
92+
const spec = minVersionSpec(minVersion);
93+
94+
// Check if this spec is already fully covered:
95+
// - If only major specified and that major is in top4, it's covered.
96+
// - If minor or patch specified, we always add it — even if the major
97+
// is in top4, because the top4 entry is the *latest* of that major,
98+
// which may differ from the minimum spec.
99+
const alreadyCovered =
100+
!minVersion.minorSpecified && top4Majors.has(minVersion.major);
101+
102+
if (!alreadyCovered) {
103+
// Check for exact duplicate against resolved top4 versions
104+
const alreadyExact = top4.some(e => e.version === spec);
105+
if (!alreadyExact) {
106+
extra.push({ major: minVersion.major, version: spec });
107+
}
108+
}
109+
110+
// Also ensure the latest of the min major is in the list,
111+
// in case it wasn't in top4 (e.g. engines requires an older major)
112+
const latestOfMinMajor = latestPerMajor.find(e => e.major === minVersion.major);
113+
if (latestOfMinMajor && !top4Majors.has(minVersion.major)) {
114+
extra.push(latestOfMinMajor);
115+
}
116+
}
117+
118+
// Merge, sort ascending by semver (oldest -> newest)
119+
const all = [...top4, ...extra];
120+
all.sort((a, b) => compareSemver(b.version, a.version));
121+
122+
const matrix = { "node-version": all.map(e => e.version) };
123+
console.log(`matrix=${JSON.stringify(matrix)}`);
124+
});
125+
});
126+
EOF
127+
128+
test:
129+
needs: resolve-node-versions
8130
runs-on: ubuntu-latest
9131

10132
strategy:
11-
matrix:
12-
node-version: [18.x, 20.x, 22.x, 24.x]
133+
fail-fast: false
134+
matrix: ${{ fromJson(needs.resolve-node-versions.outputs.matrix) }}
135+
name: Node ${{ matrix.node-version }}
13136

14137
steps:
15138
- uses: actions/checkout@v4
@@ -18,13 +141,36 @@ jobs:
18141
uses: actions/setup-node@v4
19142
with:
20143
node-version: ${{ matrix.node-version }}
21-
cache: 'yarn'
22144

23-
- name: Install dependencies
24-
run: yarn --frozen-lockfile --non-interactive --prefer-offline
145+
- name: Enable Corepack
146+
run: corepack enable
25147

26-
- name: Test
27-
run: yarn test
148+
- name: Detect Yarn version
149+
id: yarn-version
150+
run: |
151+
YARN_VERSION=$(node -p "
152+
try {
153+
const pm = require('./package.json').packageManager ?? '';
154+
const match = pm.match(/^yarn@(\d+)/);
155+
match ? match[1] : '';
156+
} catch { '' }
157+
")
158+
if [ -z "$YARN_VERSION" ]; then
159+
YARN_VERSION=$(yarn --version | cut -d. -f1)
160+
fi
161+
echo "major=$YARN_VERSION" >> "$GITHUB_OUTPUT"
162+
echo "Detected Yarn major version: $YARN_VERSION"
163+
164+
- name: Install dependencies
165+
run: |
166+
if [ "${{ steps.yarn-version.outputs.major }}" = "1" ]; then
167+
yarn install --frozen-lockfile --non-interactive --prefer-offline
168+
else
169+
yarn install --immutable
170+
fi
28171
29172
- name: Lint
30173
run: yarn lint
174+
175+
- name: Test
176+
run: yarn test

0 commit comments

Comments
 (0)