diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2c9fa29 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,80 @@ +name: Release + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + id-token: write + +jobs: + release: + name: Version, changelog, and publish + runs-on: ubuntu-latest + + steps: + - name: Create or finalize release + id: release + uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + - name: Check out repository + if: ${{ steps.release.outputs.release_created == 'true' }} + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Node.js + if: ${{ steps.release.outputs.release_created == 'true' }} + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + package-manager-cache: false + + - name: Install dependencies + if: ${{ steps.release.outputs.release_created == 'true' }} + run: npm ci + + - name: Test package + if: ${{ steps.release.outputs.release_created == 'true' }} + run: npm test + + - name: Generate OpenCode release diff summary + if: ${{ steps.release.outputs.release_created == 'true' }} + continue-on-error: true + env: + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + OPENCODE_MODEL: ${{ vars.OPENCODE_MODEL }} + RELEASE_TAG_NAME: ${{ steps.release.outputs.tag_name }} + run: node scripts/summarize-release-diff.mjs + + - name: Append OpenCode summary to GitHub release + if: ${{ steps.release.outputs.release_created == 'true' }} + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG_NAME: ${{ steps.release.outputs.tag_name }} + run: | + if [ ! -s opencode-release-summary.md ]; then + echo "No OpenCode release summary was generated." + exit 0 + fi + + gh release view "$RELEASE_TAG_NAME" --json body --jq '.body // ""' > release-notes.md + { + cat release-notes.md + printf '\n\n## OpenCode Diff Summary\n\n' + cat opencode-release-summary.md + } > release-notes-with-opencode.md + gh release edit "$RELEASE_TAG_NAME" --notes-file release-notes-with-opencode.md + + - name: Publish to npm + if: ${{ steps.release.outputs.release_created == 'true' }} + run: npm publish --access public diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..e18ee07 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} diff --git a/AGENTS.md b/AGENTS.md index c3f68f1..6dc9205 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,3 +7,5 @@ The role of this file is to describe common mistakes and confusion points that a If you encounter something surprising, tell the developer and add the lesson here so future agents do not rediscover the same failure. - For number-based MIL-STD-2525D/APP-6D SIDCs rendered by `milsymbol`, digits 9-10 are the echelon/mobility field and digits 11-20 are the function ID. Keep curated land-unit fixtures aligned with `milsymbol`'s number-SIDC metadata and land-unit function table. +- Release automation uses Release Please plus npm trusted publishing in `.github/workflows/release.yml`. Keep release-driving commits in Conventional Commit format so version bumps and `CHANGELOG.md` stay automated. +- The release workflow optionally uses `OPENCODE_API_KEY` through `scripts/summarize-release-diff.mjs` to append an OpenCode-generated diff summary to GitHub release notes. Keep that step non-blocking so npm publishing is not blocked by LLM availability. diff --git a/README.md b/README.md index 34b4b5e..0e30ef7 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,13 @@ V0 intentionally supports only a tiny curated set of 30-digit SIDCs. It does not ## Install -This package is private while the V0 API is being validated. Clone the repository and install dependencies locally: +Install from npm after the first release is published: + +```sh +npm install sidc-kit +``` + +For local development, clone the repository and install dependencies: ```sh npm install @@ -69,3 +75,24 @@ Renders a curated SIDC with `milsymbol` and returns SVG plus anchor and size met The current curated set includes a few common land-unit examples such as friendly infantry platoon, hostile infantry platoon, armor platoon, artillery platoon, reconnaissance platoon, and infantry company. Image-based reverse lookup is intentionally deferred. + +## Release Automation + +Releases are managed by Release Please. Commits merged to `main` should use Conventional Commit prefixes: + +- `fix:` creates a patch release +- `feat:` creates a minor release +- `feat!:` or `fix!:` creates a major release + +On `main`, the release workflow opens or updates a release PR that contains the version bump and `CHANGELOG.md`. Merging that release PR creates the GitHub release and publishes the package to npm. + +Npm publishing uses trusted publishing with GitHub Actions OIDC. Configure the package on npm with these trusted publisher values: + +- owner: `the-Drunken-coder` +- repository: `sidc-kit` +- workflow filename: `release.yml` +- allowed action: `npm publish` + +No long-lived `NPM_TOKEN` is required for the release workflow. + +If `OPENCODE_API_KEY` is configured as a GitHub Actions secret, the release workflow also asks OpenCode to summarize the diff between the new release tag and the previous release tag, then appends that summary to the GitHub release notes. Set the optional repository variable `OPENCODE_MODEL` to override the default `opencode/kimi-k2` model. diff --git a/package.json b/package.json index b8ebdde..5ab16a3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,6 @@ { "name": "sidc-kit", "version": "0.0.0", - "private": true, "description": "A small TypeScript toolkit for searching, explaining, building, and rendering curated SIDCs.", "type": "module", "main": "./dist/index.js", @@ -16,6 +15,14 @@ "dist", "README.md" ], + "repository": { + "type": "git", + "url": "git+https://github.com/the-Drunken-coder/sidc-kit.git" + }, + "bugs": { + "url": "https://github.com/the-Drunken-coder/sidc-kit/issues" + }, + "homepage": "https://github.com/the-Drunken-coder/sidc-kit#readme", "scripts": { "build": "tsc -p tsconfig.json", "test": "npm run build && node --test" @@ -28,6 +35,9 @@ "milsymbol" ], "license": "MIT", + "publishConfig": { + "access": "public" + }, "dependencies": { "milsymbol": "^3.0.4" }, diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..35d0854 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,9 @@ +{ + "packages": { + ".": { + "release-type": "node", + "package-name": "sidc-kit", + "include-component-in-tag": false + } + } +} diff --git a/scripts/summarize-release-diff.mjs b/scripts/summarize-release-diff.mjs new file mode 100644 index 0000000..083a802 --- /dev/null +++ b/scripts/summarize-release-diff.mjs @@ -0,0 +1,114 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; + +const tagName = process.env.RELEASE_TAG_NAME; +const openCodeApiKey = process.env.OPENCODE_API_KEY; +const model = process.env.OPENCODE_MODEL || "opencode/kimi-k2"; +const contextPath = ".release-diff-context.md"; +const outputPath = "opencode-release-summary.md"; +const maxPatchBytes = 120_000; + +if (!tagName) { + console.log("No release tag was provided; skipping OpenCode release summary."); + process.exit(0); +} + +if (!openCodeApiKey) { + console.log("OPENCODE_API_KEY is not configured; skipping OpenCode release summary."); + process.exit(0); +} + +/** + * Runs a git command and returns trimmed stdout. + */ +function git(args) { + return execFileSync("git", args, { encoding: "utf8" }).trim(); +} + +/** + * Runs a git command that may validly fail for first-release history. + */ +function optionalGit(args) { + try { + return git(args); + } catch { + return ""; + } +} + +const previousTag = optionalGit(["describe", "--tags", "--abbrev=0", `${tagName}^`]); +const baseRef = previousTag || git(["rev-list", "--max-parents=0", tagName]); +const range = `${baseRef}..${tagName}`; +const diffStat = optionalGit(["diff", "--stat", range]); +const nameStatus = optionalGit(["diff", "--name-status", range]); +const commits = optionalGit(["log", "--oneline", "--no-decorate", range]); +const patch = optionalGit(["diff", "--no-ext-diff", "--unified=40", range, "--", ".", ":(exclude)package-lock.json"]); +const truncatedPatch = + Buffer.byteLength(patch, "utf8") > maxPatchBytes + ? `${Buffer.from(patch).subarray(0, maxPatchBytes).toString("utf8")}\n\n[Diff truncated at ${maxPatchBytes} bytes.]` + : patch; + +writeFileSync( + contextPath, + [ + `# Release Diff Context`, + ``, + `Current tag: ${tagName}`, + `Previous tag: ${previousTag || "(none; comparing from initial commit)"}`, + `Compared range: ${range}`, + ``, + `## Commits`, + commits || "(none)", + ``, + `## Changed Files`, + nameStatus || "(none)", + ``, + `## Diff Stat`, + diffStat || "(none)", + ``, + `## Patch`, + "```diff", + truncatedPatch || "(none)", + "```", + "" + ].join("\n") +); + +const prompt = [ + "Summarize this release diff for maintainers and npm users.", + "Use concise Markdown.", + "Focus on user-visible changes, migration notes, packaging changes, and risks.", + "Do not invent changes that are not supported by the attached diff context.", + "Do not include secrets, token values, or operational speculation.", + "Return only the summary body." +].join(" "); + +const result = spawnSync( + "npx", + ["-y", "opencode-ai@1.17.9", "run", "--pure", "--model", model, "--file", contextPath, prompt], + { + encoding: "utf8", + env: process.env, + killSignal: "SIGTERM", + timeout: 120_000 + } +); + +if (result.error?.code === "ETIMEDOUT") { + console.error("OpenCode summary generation timed out."); + process.exit(1); +} + +if (result.status !== 0) { + console.error(result.stderr || result.stdout || "OpenCode summary generation failed."); + process.exit(result.status ?? 1); +} + +const summary = result.stdout.trim(); +if (!summary) { + console.log("OpenCode returned an empty summary."); + process.exit(0); +} + +writeFileSync(outputPath, `${summary}\n`); +console.log(`Wrote OpenCode release summary to ${outputPath}.`);