Skip to content
Merged
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
4 changes: 3 additions & 1 deletion .github/workflows/_verify.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
# committed lockfile and type-check cleanly with the public module defaults
# - the exact environment every fresh checkout gets.
#
# Called from two places:
# Called from three places:
# - pull-request.yaml, where it gates every pull request (including forks;
# see the security notes there - this workflow uses no secrets and only
# ever needs `contents: read`)
# - publish-ghcr-platform.yaml, where it gates image publication on `next`
# pushes, which land directly without a pull request
# - release.yaml, where it gates source tags and GitHub Releases on the
# exact main commit, including manual release retries

name: Verify

Expand Down
244 changes: 244 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
name: Platform Release

on:
pull_request:
branches:
- main
# @note every main update is a source release, including documentation-only
# promotions; filtering paths would silently omit those snapshots
push:
branches:
- main
- next
workflow_dispatch:

permissions:
contents: read

env:
# pinned to the devcontainer version, as in _verify.yaml
REQUIRED_NODE_VERSION: 24.20.0

# @note separate commits must not cancel each other's release; a retry of the
# same commit is safe because the workflow verifies existing tags
concurrency:
group: platform-source-release-${{ github.sha }}
cancel-in-progress: false

jobs:
# @note pull requests are unguarded so forks get the same check as in
# pull-request.yaml; pushes and dispatches are limited to the canonical
# repository and its `platform-*` siblings so a fork's main gets a clean skip
# instead of a failed protection check
check:
name: Check source release
if: >-
github.event_name == 'pull_request' ||
(
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next') &&
(github.repository == 'chatbotkit/platform' || startsWith(github.repository, 'chatbotkit/platform-'))
)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Require protected main for releases
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
env:
REF_PROTECTED: ${{ github.ref_protected }}
run: |
if [ "$REF_PROTECTED" != 'true' ]; then
echo 'Source releases require branch protection or an active ruleset on main.' >&2
exit 1
fi

- uses: actions/checkout@v7
with:
# @note tags are all the release checks need; full history is not
fetch-tags: true
persist-credentials: false

- uses: actions/setup-node@v7
with:
node-version: ${{ env.REQUIRED_NODE_VERSION }}

- name: Validate release metadata
run: |
node --input-type=commonjs <<'NODE'
const { execFileSync } = require('node:child_process')
const { readFileSync } = require('node:fs')
const { version, private: isPrivate } = JSON.parse(readFileSync('package.json', 'utf8'))

if (typeof version !== 'string' || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) {
throw new Error('package.json must define a stable major.minor.patch version')
}
if (isPrivate !== true) {
throw new Error('The platform workspace must remain private')
}

const sections = readFileSync('CHANGELOG.md', 'utf8')
.replace(/\r\n/g, '\n').split(/^## /m).slice(1)
.map(section => {
const [heading, ...body] = section.split('\n')
return { heading, notes: body.join('\n').trim() }
})
const latest = sections[1]
const heading = latest?.heading.match(/^\[([^\]]+)\] - (\d{4}-\d{2}-\d{2})$/)

if (sections[0]?.heading !== '[Unreleased]' || heading?.[1] !== version) {
throw new Error(`CHANGELOG.md must start with [Unreleased], then [${version}] - YYYY-MM-DD`)
}
const date = new Date(`${heading[2]}T00:00:00Z`)
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== heading[2]) {
throw new Error('The release date must be a valid calendar date')
}
if (sections.filter(section => section.heading.startsWith(`[${version}]`)).length !== 1) {
throw new Error(`CHANGELOG.md contains duplicate entries for ${version}`)
}
if (!/^- \S/m.test(latest.notes)) {
throw new Error(`Release ${version} must contain changelog entries`)
}

const tag = `platform/v${version}`
if (process.env.GITHUB_EVENT_NAME === 'pull_request') {
const existing = execFileSync('git', ['tag', '--list', tag], { encoding: 'utf8' }).trim()
if (existing) {
throw new Error(`${tag} already exists. Update package.json and CHANGELOG.md before promoting to main.`)
}
}
console.log(`Release metadata valid: ${tag}`)
NODE

# @note validate the exact main commit in a fresh checkout; next's checks
# alone do not validate the resulting merge, and dispatches need the same gate
verify_release:
name: Verify release
needs: check
if: >-
github.event_name != 'pull_request' &&
github.ref == 'refs/heads/main' &&
(github.repository == 'chatbotkit/platform' || startsWith(github.repository, 'chatbotkit/platform-'))
uses: ./.github/workflows/_verify.yaml

snapshot:
name: Publish GitHub release
needs:
- check
- verify_release
if: >-
needs.check.result == 'success' &&
needs.verify_release.result == 'success' &&
github.event_name != 'pull_request' &&
github.ref == 'refs/heads/main' &&
(github.repository == 'chatbotkit/platform' || startsWith(github.repository, 'chatbotkit/platform-'))
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
# @note tag the triggering merge, even if main has advanced since
ref: ${{ github.sha }}
fetch-tags: true

- uses: actions/setup-node@v7
with:
node-version: ${{ env.REQUIRED_NODE_VERSION }}

- name: Create and push the source snapshot
run: |
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
node --input-type=commonjs <<'NODE'
const { execFileSync } = require('node:child_process')
const { readFileSync } = require('node:fs')
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim()
const { version } = JSON.parse(readFileSync('package.json', 'utf8'))
const tag = `platform/v${version}`
const commit = git('rev-parse', 'HEAD')

if (commit !== process.env.GITHUB_SHA) {
throw new Error('The checkout must identify the triggering merge commit')
}
if (git('status', '--porcelain', '--untracked-files=all')) {
throw new Error('The release checkout must be clean')
}

if (git('tag', '--list', tag)) {
if (git('cat-file', '-t', tag) !== 'tag' || git('rev-parse', `${tag}^{commit}`) !== commit) {
throw new Error(`${tag} already exists for another snapshot. Update package.json and CHANGELOG.md.`)
}
console.log(`Snapshot ${tag} already identifies ${commit}`)
} else {
// @note the check job validated the notes at this same commit
const section = readFileSync('CHANGELOG.md', 'utf8')
.replace(/\r\n/g, '\n').split(/^## /m)[2]
const notes = section.split('\n').slice(1).join('\n').trim()
execFileSync('git', ['tag', '-a', tag, commit, '-F', '-'], {
input: `Platform ${version}\n\n${notes}\n`,
stdio: ['pipe', 'inherit', 'inherit'],
})
}

execFileSync('git', ['push', 'origin', `refs/tags/${tag}:refs/tags/${tag}`], {
stdio: 'inherit',
})
NODE

- name: Publish release with changelog notes
uses: actions/github-script@v9
with:
script: |
const { readFileSync } = require('node:fs')
const { version } = JSON.parse(readFileSync('package.json', 'utf8'))
const tag = `platform/v${version}`
const name = `Platform ${version}`
// @note the check job validated this version's section at the same commit
const section = readFileSync('CHANGELOG.md', 'utf8')
.replace(/\r\n/g, '\n').split(/^## /m)[2]
const body = section.split('\n').slice(1).join('\n').trim()

// @note require the pushed annotated tag to still identify the verified commit
const { data: ref } = await github.rest.git.getRef({
...context.repo, ref: `tags/${tag}`,
})
if (ref.object.type !== 'tag') {
throw new Error(`${tag} must be an annotated tag`)
}
const { data: annotation } = await github.rest.git.getTag({
...context.repo, tag_sha: ref.object.sha,
})
if (annotation.object.type !== 'commit' || annotation.object.sha !== context.sha) {
throw new Error(`${tag} does not identify the verified release commit`)
}

let existing
try {
const response = await github.rest.repos.getReleaseByTag({
...context.repo, tag,
})
existing = response.data
} catch (error) {
if (error.status !== 404) throw error
}

if (existing) {
if (existing.draft || existing.prerelease || existing.name !== name ||
(existing.body ?? '').replace(/\r\n/g, '\n').trim() !== body) {
throw new Error(`Existing release ${tag} differs from the verified release metadata`)
}
core.info(`Release already published: ${existing.html_url}`)
return
}

const { data: release } = await github.rest.repos.createRelease({
...context.repo,
tag_name: tag,
target_commitish: context.sha,
name,
body,
draft: false,
prerelease: false,
make_latest: 'legacy',
})
core.info(`Published release: ${release.html_url}`)
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Changelog

Notable changes to the platform application and shared packages are recorded
here. The release version is defined in the workspace root `package.json`.

## [Unreleased]

## [0.1.0] - 2026-09-08

### Added

- Establish the first versioned platform source snapshot as the baseline for
future releases.
- Add release metadata validation and annotated Git snapshot tags, with
`package.json` as the authoritative platform version.
- Automatically create and push the source snapshot when changes merge into
`main`, with release metadata checks before promotion.
- Publish a GitHub Release for each snapshot with its matching changelog notes
and GitHub's source archive downloads.
- Require protected `main` and successful verification of the exact release
commit before the workflow can create a source snapshot.

This release records the existing platform as a baseline. Earlier development
history remains available in Git.
68 changes: 68 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,74 @@ for why).
Inside `platform/`, the narrower loops are `pnpm check`, `pnpm lint`,
`pnpm test:unit path/to/file.utest.js`, and `pnpm storybook`.

## Source releases

The workspace root `package.json` holds the platform release version. It covers
the application and shared packages as one source snapshot; individual package
versions and Studio's version are independent. The workspace stays private.

Add notable changes to [CHANGELOG.md](./CHANGELOG.md) under `Unreleased`, using
`Added`, `Changed`, `Fixed`, `Removed`, or `Security` as appropriate. Include any
required database migrations, configuration changes, and upgrade steps.

To prepare a release:

1. Choose the next `major.minor.patch` version and update the root `package.json`.
Use patch releases for compatible fixes, minor releases for features, and
major releases for breaking changes after 1.0. Before 1.0, use minor releases
for breaking changes and call them out in the notes.
2. Move the accumulated notes into `## [VERSION] - YYYY-MM-DD`, keeping an empty
`## [Unreleased]` section above it.
3. Complete the quality checks for the changes being released. The source
release workflow validates the version and changelog on pushes to `next`.
4. Commit and push the release changes to `next`, then open the promotion pull
request to `main`. The **Check source release** job checks the changelog and
rejects a version whose tag already exists.
5. Merge the promotion pull request. The **Platform Release** workflow
requires protected `main` and runs the full verification workflow on that
exact merge commit. Only after it passes does the workflow create and push
an annotated `platform/vVERSION` tag, using the changelog entry as its
annotation. It then publishes a GitHub Release titled `Platform VERSION`
against that tag, with the same changelog entry as its release notes.
The release page includes GitHub's source ZIP and tarball downloads.

Every promotion to `main` needs an unused version and dated changelog entry.
Version bumps and notes are prepared in the source before promotion; CI does
not write commits back to `main` or `next`. Configure **Check source release**
as a required check in the repository's branch rules to enforce it before merge.
The release workflow is independent of container image publication.

Release verification installs from the frozen lockfile, builds the shared
packages, and runs package and application lint, type checks, and tests in a
fresh checkout. Failed, cancelled, or skipped verification cannot produce a
tag or GitHub Release. The publishing job also requires a clean checkout at
the triggering commit. Only that job has repository write permission. Application image builds
and distribution smoke tests remain in the container publication workflow.

The same flow applies when a subtree manager pushes this workspace to `next`
and opens the promotion pull request: the workflow travels inside this
workspace's `.github/workflows` and runs in the destination repository.

For a failed run, rerun **Platform Release**, or dispatch it on `main`.
Manual dispatches pass the same protection and verification gates.
An existing annotated tag on the same commit is accepted, so retries are safe.
If the tag was pushed but release creation failed, the retry creates the missing
GitHub Release. An already published release with matching title and notes is
accepted; conflicting release metadata fails without overwriting it.
A tag pointing elsewhere fails the release and is never moved. A manual dispatch
uses the selected `main` commit; rerun the original job to retry an older merge.

All release validation and tagging logic lives in the GitHub workflow. There
are no local release scripts or package commands. The workflow publishes the
release tag and GitHub Release with its changelog notes. The source downloads
contain no installed dependencies, compiled application, databases, or container
images. Package and container publishing remain separate.

Workflow checks govern tags created by this automation. To restrict direct tag
pushes as well, configure a repository tag ruleset for `platform/v*`, limiting
creation to the release identity and preventing updates and deletion. Branch
protection alone does not enforce those tag restrictions.

## Pull requests

- Explain the problem and the outcome, not just the diff.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,4 +101,5 @@ storage configuration and the first model connection.
- [Architecture and repository map](./docs/architecture.md)
- [Licensing](./LICENSING.md)
- [Contributing](./CONTRIBUTING.md)
- [Changelog](./CHANGELOG.md) and [source releases](./CONTRIBUTING.md#source-releases)
- [Security](./SECURITY.md)
6 changes: 4 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ dependency resolution across the application and its packages.

The `next` branch is the development branch, and contributor pull requests
target it. The `main` branch is the stable release branch and advances through
a reviewed promotion from `next` after the required checks pass. See
`CONTRIBUTING.md` for the current contribution workflow.
a reviewed promotion from `next` after the required checks pass; each
promotion produces a `platform/v*` source snapshot and a GitHub Release with
the matching changelog notes. See `CONTRIBUTING.md`
for the current contribution workflow and release steps.

## Swappable modules

Expand Down
11 changes: 7 additions & 4 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,10 +268,13 @@ production infrastructure. A production deployment still needs:
[module defaults](./module-defaults.md)
- monitoring, restore testing and an upgrade and rollback procedure

The repository does not yet publish versioned releases, SBOMs or signed
provenance, so branch and commit images remain pre-release artifacts. This
status concerns release provenance and compatibility, not whether Compose
should provision the operator-owned infrastructure listed above.
Promotions to `main` produce versioned source snapshots (`platform/v*`) and
GitHub Releases with changelog notes and source downloads (see
[CONTRIBUTING.md](../CONTRIBUTING.md#source-releases)), but images are
still published only by branch and commit, without SBOMs or signed provenance,
so they remain pre-release artifacts. This status concerns release provenance
and compatibility, not whether Compose should provision the operator-owned
infrastructure listed above.

The experimental Dockerfile builds with `.env.example`; Compose attaches the
optional operator `.env` file only to the running container. Configuration
Expand Down
Loading