Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
25 changes: 21 additions & 4 deletions docs/pr-pipelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -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
)
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Secrets / config for pipelines (locked)

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
127 changes: 127 additions & 0 deletions scripts/github-releases.mjs
Original file line number Diff line number Diff line change
@@ -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 };
}
99 changes: 99 additions & 0 deletions scripts/github-releases.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
8 changes: 6 additions & 2 deletions scripts/release-changed.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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();
Loading