diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c13ce36..90fce28 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,4 +64,6 @@ jobs: echo "::error::Unexpected release:ci output (no release push and not empty)." exit 1 env: + # Used by `gh release create` after tags are pushed (see scripts/github-releases.mjs). + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/docs/pr-pipelines.md b/docs/pr-pipelines.md index 0b47eed..d7c906c 100644 --- a/docs/pr-pipelines.md +++ b/docs/pr-pipelines.md @@ -5,7 +5,7 @@ | Workflow | Triggers | Checks | | --- | --- | --- | | `ci.yml` | every pull request; every push to `main` | prettier check, eslint, worktree-path tests, PR automation tests, recursive package test/build | -| `release.yml` | push to **`main`** (skipped for `chore: Release` commits) | Path-aware bumps; commit + tags for `@singleton-sd/post-kit-*` packages | +| `release.yml` | push to **`main`** (skipped for `chore: Release` commits) | Path-aware bumps; commit + tags + GitHub Releases for `@singleton-sd/post-kit-*` packages (npm publish still disabled) | | `validate-email-domain-branding.yml` | daily 06:00 UTC; `workflow_dispatch`; pushes to `main` under `packages/post-kit-email/**`, this workflow file, root `package.json`, `pnpm-lock.yaml`, or `infra/appconfig-seed.json` | Live SPF/DKIM/DMARC/BIMI check. Reads `app:email:validation:*` from App Configuration with `--auth-mode login`. Skips (success) when Azure repository Variables are missing, the store is missing, or `app:email:validation:domain` is unset. Failed OIDC federation fails the job. Not required on PRs. | | `deploy-api.yml` | `main` path changes under `apps/api/**`, `packages/post-kit-email/**`, `infra/function-app.bicep`, `infra/appconfig-seed.json`, `.github/workflows/deploy-api.yml`; also `workflow_dispatch` | OIDC → bicep + App Config seed-if-absent + zip deploy; skips Azure if `AZURE_*` Variables are missing | @@ -23,9 +23,26 @@ matching worktree with `pnpm worktree:add` under the parent workspace require CI checks, **not** approving reviews (see `SETUP.md`). On **`main`**, `release.yml` bumps versions for changed public packages -(conventional commits: `fix`→patch, `feat`→minor, `BREAKING CHANGE`→major). -With an empty workspace (no `@singleton-sd/post-kit-*` packages yet) it logs -`Nothing to release` and exits 0. +(conventional commits: `fix`→patch, `feat`→minor, `BREAKING CHANGE`→major), +pushes one annotated git tag per package, then creates a matching **GitHub +Release** per tag (`scripts/github-releases.mjs`). npm publish remains +disabled until a later issue enables it. With an empty workspace (no +`@singleton-sd/post-kit-*` packages yet) it logs `Nothing to release` and +exits 0. + +### Backfill existing tags (one-shot) + +Tags created before GitHub Releases were enabled can be backfilled with: + +```bash +( + set -e + for tag in $(git tag -l '@singleton-sd/*'); do + gh release view "$tag" >/dev/null 2>&1 && continue + gh release create "$tag" --verify-tag --title "$tag" --notes "Backfilled from existing tag. See CHANGELOG.md." + done +) +``` ## Secrets / config for pipelines (locked) diff --git a/package.json b/package.json index e2f2e23..54aeb31 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "release:ci": "node ./scripts/release-changed.mjs --ci", "prepare": "husky", "pr:gate": "node scripts/pr-handoff-gate.mjs", - "test:pr-automation": "node scripts/pr-handoff-gate.test.mjs && node --test scripts/invoke-ps1.test.mjs", + "test:pr-automation": "node scripts/pr-handoff-gate.test.mjs && node --test scripts/invoke-ps1.test.mjs && node --test scripts/github-releases.test.mjs", "email:provision": "pnpm --filter @singleton-sd/post-kit-email provision", "validate:email-domain-branding": "pnpm --filter @singleton-sd/post-kit-email validate:domain-branding", "sync:skills": "npx --yes skills add singleton-sd/ai-plattform-skills --skill task-driven-development --skill \"Task-Driven Development\" --skill backend --skill frontend -a cursor -a claude-code -a grok -a codex --copy -y", diff --git a/scripts/github-releases.mjs b/scripts/github-releases.mjs new file mode 100644 index 0000000..74eb776 --- /dev/null +++ b/scripts/github-releases.mjs @@ -0,0 +1,127 @@ +/** + * Create GitHub Releases for package version tags. + * + * Used by `scripts/release-changed.mjs` after tags are pushed. npm publish + * remains disabled — this only creates GitHub Release entries. + */ +import { execFileSync } from 'node:child_process'; + +/** + * @typedef {'major' | 'minor' | 'patch'} Increment + * @typedef {{ name: string, version: string, next: string, increment: Increment, tag: string }} ReleaseSpec + */ + +/** + * @param {ReleaseSpec} release + * @returns {string} + */ +export function formatReleaseTitle(release) { + return `${release.name}@${release.next}`; +} + +/** + * @param {ReleaseSpec} release + * @returns {string} + */ +export function formatReleaseNotes(release) { + const bumpLine = + release.version === release.next + ? `## ${release.name} \`${release.next}\`` + : `## ${release.name} \`${release.version}\` → \`${release.next}\` (${release.increment})`; + + return [ + bumpLine, + '', + 'See [CHANGELOG.md](https://github.com/singleton-sd/post-kit/blob/main/CHANGELOG.md) for monorepo release notes.', + '', + 'npm publish is not enabled yet for this package scope.', + ].join('\n'); +} + +/** + * True when `gh release view` failed because the Release does not exist yet. + * @param {unknown} err + */ +export function isReleaseNotFoundError(err) { + const text = [ + err instanceof Error ? err.message : String(err), + err && typeof err === 'object' && 'stderr' in err ? String(err.stderr) : '', + ] + .join('\n') + .toLowerCase(); + + return ( + text.includes('release not found') || + text.includes('could not find') || + text.includes('not found') || + text.includes('http 404') + ); +} + +/** + * @param {(args: string[]) => string} runGh + * @param {string} tag + * @returns {boolean} + */ +export function githubReleaseExists(runGh, tag) { + try { + runGh(['release', 'view', tag, '--json', 'tagName']); + return true; + } catch (err) { + if (isReleaseNotFoundError(err)) { + return false; + } + throw err; + } +} + +/** + * @returns {(args: string[]) => string} + */ +function defaultRunGh() { + return (args) => + execFileSync('gh', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim(); +} + +/** + * Create one GitHub Release per package tag. Skips tags that already have a Release. + * + * @param {ReleaseSpec[]} releases + * @param {{ runGh?: (args: string[]) => string, log?: (msg: string) => void }} [options] + * @returns {{ created: string[], skipped: string[] }} + */ +export function createGitHubReleases(releases, options = {}) { + const runGh = options.runGh ?? defaultRunGh(); + const log = options.log ?? console.log; + + /** @type {string[]} */ + const created = []; + /** @type {string[]} */ + const skipped = []; + + for (const release of releases) { + if (githubReleaseExists(runGh, release.tag)) { + log(`GitHub Release already exists for ${release.tag}; skipping.`); + skipped.push(release.tag); + continue; + } + + runGh([ + 'release', + 'create', + release.tag, + '--verify-tag', + '--title', + formatReleaseTitle(release), + '--notes', + formatReleaseNotes(release), + ]); + log(`Created GitHub Release for ${release.tag}.`); + created.push(release.tag); + } + + return { created, skipped }; +} diff --git a/scripts/github-releases.test.mjs b/scripts/github-releases.test.mjs new file mode 100644 index 0000000..a76226e --- /dev/null +++ b/scripts/github-releases.test.mjs @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createGitHubReleases, + formatReleaseNotes, + formatReleaseTitle, + githubReleaseExists, + isReleaseNotFoundError, +} from './github-releases.mjs'; + +/** @type {import('./github-releases.mjs').ReleaseSpec} */ +const sample = { + name: '@singleton-sd/post-kit-publisher', + version: '0.1.0', + next: '0.2.0', + increment: 'minor', + tag: '@singleton-sd/post-kit-publisher@0.2.0', +}; + +test('formatReleaseTitle uses name@version', () => { + assert.equal(formatReleaseTitle(sample), '@singleton-sd/post-kit-publisher@0.2.0'); +}); + +test('formatReleaseNotes includes bump and npm-disabled note', () => { + const notes = formatReleaseNotes(sample); + assert.match(notes, /0\.1\.0.*→.*0\.2\.0.*minor/); + assert.match(notes, /CHANGELOG\.md/); + assert.match(notes, /npm publish is not enabled/); +}); + +test('isReleaseNotFoundError recognizes gh not-found output', () => { + assert.equal(isReleaseNotFoundError(new Error('release not found')), true); + assert.equal(isReleaseNotFoundError(new Error('HTTP 401: Bad credentials')), false); +}); + +test('githubReleaseExists is true when gh release view succeeds', () => { + const runGh = () => '{"tagName":"@singleton-sd/post-kit-publisher@0.2.0"}'; + assert.equal(githubReleaseExists(runGh, sample.tag), true); +}); + +test('githubReleaseExists is false when gh release view reports not found', () => { + const runGh = () => { + throw new Error('release not found'); + }; + assert.equal(githubReleaseExists(runGh, sample.tag), false); +}); + +test('githubReleaseExists rethrows non-not-found lookup failures', () => { + const runGh = () => { + throw new Error('HTTP 401: Bad credentials'); + }; + assert.throws(() => githubReleaseExists(runGh, sample.tag), /401/); +}); + +test('createGitHubReleases creates missing releases and skips existing', () => { + /** @type {string[][]} */ + const calls = []; + const existing = new Set(['@singleton-sd/post-kit-types@0.2.0']); + + const runGh = (/** @type {string[]} */ args) => { + calls.push(args); + if (args[0] === 'release' && args[1] === 'view') { + if (existing.has(args[2])) return JSON.stringify({ tagName: args[2] }); + throw new Error('HTTP 404: release not found'); + } + if (args[0] === 'release' && args[1] === 'create') { + return ''; + } + throw new Error(`unexpected gh args: ${args.join(' ')}`); + }; + + /** @type {string[]} */ + const logs = []; + const result = createGitHubReleases( + [ + sample, + { + name: '@singleton-sd/post-kit-types', + version: '0.1.0', + next: '0.2.0', + increment: 'minor', + tag: '@singleton-sd/post-kit-types@0.2.0', + }, + ], + { runGh, log: (m) => logs.push(m) }, + ); + + assert.deepEqual(result.created, ['@singleton-sd/post-kit-publisher@0.2.0']); + assert.deepEqual(result.skipped, ['@singleton-sd/post-kit-types@0.2.0']); + + const createCall = calls.find((a) => a[1] === 'create'); + assert.ok(createCall); + assert.equal(createCall[2], sample.tag); + assert.equal(createCall[3], '--verify-tag'); + assert.equal(createCall[createCall.indexOf('--title') + 1], sample.tag); + assert.match(createCall[createCall.indexOf('--notes') + 1], /0\.1\.0.*→.*0\.2\.0/); + assert.match(logs.join('\n'), /Created GitHub Release/); + assert.match(logs.join('\n'), /already exists/); +}); diff --git a/scripts/release-changed.mjs b/scripts/release-changed.mjs index 5b1a29a..54cef46 100755 --- a/scripts/release-changed.mjs +++ b/scripts/release-changed.mjs @@ -4,20 +4,22 @@ * * Bumps each workspace package whose name starts with `@singleton-sd/post-kit-` * that has releasable conventional commits since its last `@scope/name@version` - * tag (reachable from HEAD), then creates one git commit + per-package tags. + * tag (reachable from HEAD), then creates one git commit + per-package tags + * and matching GitHub Releases (npm publish stays disabled). * * Push order is commit-then-tags (never `--follow-tags`): a non-fast-forward * race must not publish tags without the release commit on main. * * Usage: * node scripts/release-changed.mjs # dry-run - * node scripts/release-changed.mjs --ci # bump, commit, tag, push + * node scripts/release-changed.mjs --ci # bump, commit, tag, push, GitHub Releases */ import { execFileSync, execSync } from 'node:child_process'; import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import semver from 'semver'; +import { createGitHubReleases } from './github-releases.mjs'; const ROOT = resolve(fileURLToPath(new URL('..', import.meta.url))); const CI = process.argv.includes('--ci'); @@ -419,6 +421,8 @@ function main() { pushReleaseCommit(); createAndPushTags(releases.map((r) => r.tag)); console.log('Release commit and tags pushed.'); + createGitHubReleases(releases); + console.log('GitHub Releases created (or already present).'); } main();