From 98b8215d36bfea8d754b5e9898f793decb00c627 Mon Sep 17 00:00:00 2001 From: pdparchitect Date: Tue, 8 Sep 2026 12:10:45 +0000 Subject: [PATCH] feat(release): implement source release workflow and add changelog documentation --- .github/workflows/_verify.yaml | 4 +- .github/workflows/release.yaml | 244 +++++++++++++++++++++++++++++++++ CHANGELOG.md | 24 ++++ CONTRIBUTING.md | 68 +++++++++ README.md | 1 + docs/architecture.md | 6 +- docs/deployment.md | 11 +- package.json | 2 +- 8 files changed, 352 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/release.yaml create mode 100644 CHANGELOG.md diff --git a/.github/workflows/_verify.yaml b/.github/workflows/_verify.yaml index 7331682..558504d 100644 --- a/.github/workflows/_verify.yaml +++ b/.github/workflows/_verify.yaml @@ -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 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..1de9cec --- /dev/null +++ b/.github/workflows/release.yaml @@ -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}`) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3e6a415 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 07dc87f..9209969 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/README.md b/README.md index cc17200..96da97d 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/architecture.md b/docs/architecture.md index e4a77b2..7f5c9ca 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/deployment.md b/docs/deployment.md index 1295eec..9df5de8 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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 diff --git a/package.json b/package.json index c57b743..49e6259 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "platform", - "version": "0.0.0", + "version": "0.1.0", "private": true, "license": "Apache-2.0", "packageManager": "pnpm@11.24.0",