Skip to content

Commit 20131f3

Browse files
committed
chore: configure scoped npm publishing
1 parent f26f824 commit 20131f3

8 files changed

Lines changed: 287 additions & 3 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
name: Publish npm dev
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
8+
permissions:
9+
contents: read
10+
id-token: write
11+
12+
concurrency:
13+
group: npm-publish-dev-${{ github.ref }}
14+
cancel-in-progress: true
15+
16+
jobs:
17+
publish-dev:
18+
runs-on: ubuntu-latest
19+
defaults:
20+
run:
21+
working-directory: repos/imgbin
22+
23+
steps:
24+
- name: Checkout repository
25+
uses: actions/checkout@v4
26+
27+
- name: Setup Node.js
28+
uses: actions/setup-node@v4
29+
with:
30+
node-version: 20
31+
cache: npm
32+
cache-dependency-path: repos/imgbin/package-lock.json
33+
registry-url: https://registry.npmjs.org
34+
35+
- name: Install dependencies
36+
run: npm ci
37+
38+
- name: Build package
39+
run: npm run build
40+
41+
- name: Run tests
42+
run: npm test
43+
44+
- name: Prepare dev version
45+
id: version
46+
run: |
47+
version="$(npm run --silent publish:prepare-dev-version)"
48+
echo "version=$version" >> "$GITHUB_OUTPUT"
49+
echo "Prepared dev version: $version"
50+
51+
- name: Verify packed files
52+
run: npm run pack:check
53+
54+
- name: Publish to npm dev dist-tag
55+
run: npm publish --tag dev --provenance --access public
56+
57+
- name: Summarize publish result
58+
run: |
59+
{
60+
echo "## npm dev publish"
61+
echo
62+
echo "- Version: ${{ steps.version.outputs.version }}"
63+
echo "- Dist-tag: dev"
64+
echo "- Registry: npmjs.org"
65+
} >> "$GITHUB_STEP_SUMMARY"
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
name: Publish npm release
2+
3+
on:
4+
push:
5+
tags:
6+
- v*
7+
8+
permissions:
9+
contents: read
10+
id-token: write
11+
12+
concurrency:
13+
group: npm-publish-release-${{ github.ref }}
14+
cancel-in-progress: false
15+
16+
jobs:
17+
publish-release:
18+
runs-on: ubuntu-latest
19+
defaults:
20+
run:
21+
working-directory: repos/imgbin
22+
23+
steps:
24+
- name: Checkout repository
25+
uses: actions/checkout@v4
26+
27+
- name: Setup Node.js
28+
uses: actions/setup-node@v4
29+
with:
30+
node-version: 20
31+
cache: npm
32+
cache-dependency-path: repos/imgbin/package-lock.json
33+
registry-url: https://registry.npmjs.org
34+
35+
- name: Verify release tag matches package version
36+
id: version
37+
run: |
38+
version="$(npm run --silent publish:verify-release -- "$GITHUB_REF_NAME")"
39+
echo "version=$version" >> "$GITHUB_OUTPUT"
40+
echo "Validated release version: $version"
41+
42+
- name: Install dependencies
43+
run: npm ci
44+
45+
- name: Build package
46+
run: npm run build
47+
48+
- name: Run tests
49+
run: npm test
50+
51+
- name: Verify packed files
52+
run: npm run pack:check
53+
54+
- name: Publish to npm latest dist-tag
55+
run: npm publish --tag latest --provenance --access public
56+
57+
- name: Summarize publish result
58+
run: |
59+
{
60+
echo "## npm stable publish"
61+
echo
62+
echo "- Version: ${{ steps.version.outputs.version }}"
63+
echo "- Dist-tag: latest"
64+
echo "- Tag: ${{ github.ref_name }}"
65+
echo "- Registry: npmjs.org"
66+
} >> "$GITHUB_STEP_SUMMARY"

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,38 @@ You can bootstrap local configuration from the checked-in example:
2727
cp .env.example .env
2828
```
2929

30+
## Release automation
31+
32+
ImgBin includes a GitHub Actions based npm publishing flow for both prerelease and stable channels.
33+
34+
### Publishing channels
35+
36+
- Pushes to `main` publish a unique prerelease build to the npm `dev` dist-tag.
37+
- Stable releases publish only from Git tags in the `vX.Y.Z` format and target the npm `latest` dist-tag.
38+
- The stable release workflow fails if the Git tag version does not exactly match `repos/imgbin/package.json`.
39+
40+
### Trusted publishing prerequisites
41+
42+
Before the workflows can publish successfully:
43+
44+
1. configure npm trusted publishing for the `HagiCode-org/imgbin` GitHub repository,
45+
2. ensure the publishing package owner has access to the `@hagicode/imgbin` package on npm, and
46+
3. keep GitHub Actions enabled for the repository.
47+
48+
The workflows are designed to publish with GitHub OIDC identity and provenance, not a long-lived `NPM_TOKEN`.
49+
50+
### Local release verification
51+
52+
Before pushing a release tag, run the same checks used by CI:
53+
54+
```bash
55+
npm run build
56+
npm test
57+
npm run pack:check
58+
```
59+
60+
For a stable release, update `package.json` to the target version first and then push a matching tag such as `v0.1.0`.
61+
3062
## Usage guide
3163

3264
### Quick start for the current HagiCode site workflow

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
{
2-
"name": "imgbin",
2+
"name": "@hagicode/imgbin",
33
"version": "0.1.0",
44
"description": "CLI tooling for generating, annotating, and indexing image assets.",
5+
"homepage": "https://github.com/HagiCode-org/imgbin#readme",
6+
"bugs": {
7+
"url": "https://github.com/HagiCode-org/imgbin/issues"
8+
},
9+
"repository": {
10+
"type": "git",
11+
"url": "https://github.com/HagiCode-org/imgbin.git"
12+
},
513
"type": "module",
614
"bin": {
715
"imgbin": "dist/cli.js"
@@ -15,6 +23,9 @@
1523
"build": "tsc -p tsconfig.json",
1624
"clean": "rm -rf dist .vitest-temp .tmp coverage",
1725
"dev": "tsx src/cli.ts",
26+
"pack:check": "node scripts/verify-package.mjs",
27+
"publish:prepare-dev-version": "node scripts/prepare-dev-version.mjs",
28+
"publish:verify-release": "node scripts/verify-release-version.mjs",
1829
"test": "vitest run",
1930
"test:watch": "vitest"
2031
},
@@ -27,6 +38,11 @@
2738
"engines": {
2839
"node": ">=20"
2940
},
41+
"publishConfig": {
42+
"access": "public",
43+
"provenance": true,
44+
"registry": "https://registry.npmjs.org/"
45+
},
3046
"dependencies": {
3147
"commander": "^14.0.1",
3248
"dotenv": "^17.2.3",

scripts/prepare-dev-version.mjs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#!/usr/bin/env node
2+
3+
import fs from "node:fs";
4+
import path from "node:path";
5+
import process from "node:process";
6+
7+
const packageJsonPath = path.resolve(process.argv[2] ?? "package.json");
8+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
9+
10+
const match = String(packageJson.version).match(
11+
/^(?<major>0|[1-9]\d*)\.(?<minor>0|[1-9]\d*)\.(?<patch>0|[1-9]\d*)(?:[-+].*)?$/,
12+
);
13+
14+
if (!match?.groups) {
15+
throw new Error(`Unsupported package version: ${packageJson.version}`);
16+
}
17+
18+
const baseVersion = `${match.groups.major}.${match.groups.minor}.${match.groups.patch}`;
19+
const timestamp = new Date()
20+
.toISOString()
21+
.replace(/[-:]/g, "")
22+
.replace("T", "")
23+
.replace(/\.\d{3}Z$/, "");
24+
const runNumber = process.env.GITHUB_RUN_NUMBER ?? "0";
25+
const runAttempt = process.env.GITHUB_RUN_ATTEMPT ?? "0";
26+
const shortSha = (process.env.GITHUB_SHA ?? "local").slice(0, 7).toLowerCase();
27+
const devVersion = `${baseVersion}-dev.${timestamp}.${runNumber}.${runAttempt}.${shortSha}`;
28+
29+
packageJson.version = devVersion;
30+
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
31+
32+
process.stdout.write(devVersion);

scripts/verify-package.mjs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#!/usr/bin/env node
2+
3+
import fs from "node:fs";
4+
import path from "node:path";
5+
import process from "node:process";
6+
import { execFileSync } from "node:child_process";
7+
8+
const repoRoot = path.resolve(process.argv[2] ?? ".");
9+
const packageJsonPath = path.join(repoRoot, "package.json");
10+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
11+
const binPath = packageJson.bin?.imgbin;
12+
13+
if (!binPath) {
14+
throw new Error("package.json must define bin.imgbin before publishing.");
15+
}
16+
17+
const resolvedBinPath = path.join(repoRoot, binPath);
18+
if (!fs.existsSync(resolvedBinPath)) {
19+
throw new Error(`Missing built CLI entrypoint: ${binPath}`);
20+
}
21+
22+
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
23+
const output = execFileSync(npmCommand, ["pack", "--dry-run", "--json"], {
24+
cwd: repoRoot,
25+
encoding: "utf8",
26+
});
27+
const [packSummary] = JSON.parse(output);
28+
const packedFiles = new Set((packSummary?.files ?? []).map((file) => file.path));
29+
const requiredFiles = [binPath, "README.md", "prompts/default-analysis-prompt.txt"];
30+
const missingFiles = requiredFiles.filter((file) => !packedFiles.has(file));
31+
32+
if (missingFiles.length > 0) {
33+
throw new Error(
34+
`npm pack is missing required publish files: ${missingFiles.join(", ")}`,
35+
);
36+
}
37+
38+
process.stdout.write(
39+
`Verified ${packSummary.name} with ${packSummary.files.length} packed files.\n`,
40+
);

scripts/verify-release-version.mjs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#!/usr/bin/env node
2+
3+
import fs from "node:fs";
4+
import path from "node:path";
5+
import process from "node:process";
6+
7+
const tagName = process.argv[2] ?? process.env.GITHUB_REF_NAME;
8+
9+
if (!tagName) {
10+
throw new Error("Missing release tag. Pass a tag name or set GITHUB_REF_NAME.");
11+
}
12+
13+
const match = String(tagName).match(
14+
/^v(?<major>0|[1-9]\d*)\.(?<minor>0|[1-9]\d*)\.(?<patch>0|[1-9]\d*)$/,
15+
);
16+
17+
if (!match?.groups) {
18+
throw new Error(
19+
`Release tags must use the stable vX.Y.Z format. Received: ${tagName}`,
20+
);
21+
}
22+
23+
const expectedVersion = `${match.groups.major}.${match.groups.minor}.${match.groups.patch}`;
24+
const packageJsonPath = path.resolve(process.argv[3] ?? "package.json");
25+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
26+
27+
if (packageJson.version !== expectedVersion) {
28+
throw new Error(
29+
`Tag ${tagName} does not match package.json version ${packageJson.version}.`,
30+
);
31+
}
32+
33+
process.stdout.write(expectedVersion);

0 commit comments

Comments
 (0)