diff --git a/.github/actions/release-metadata/action.yaml b/.github/actions/release-metadata/action.yaml new file mode 100644 index 0000000..4d4825f --- /dev/null +++ b/.github/actions/release-metadata/action.yaml @@ -0,0 +1,89 @@ +# Validates the release metadata at the checked-out commit - the workspace root +# `package.json` version and the matching CHANGELOG.md section - and exposes +# the version, the `platform/v` tag and the section's notes for the +# jobs that tag images, push the Git tag and publish the GitHub Release. +# +# Shared by pull-request.yaml (the promotion gate) and +# publish-ghcr-platform.yaml (the release itself) so both read one definition +# of what a valid release looks like. + +name: Release metadata +description: Validate the platform release version and changelog notes + +inputs: + reject-existing-tag: + description: >- + Fail when the release tag already exists in the checkout. Requires tags + to be fetched. Used by the promotion gate, where an existing tag means + the version was not bumped. + default: 'false' + +outputs: + version: + description: The release version from package.json + value: ${{ steps.metadata.outputs.version }} + tag: + description: The Git tag for the release + value: ${{ steps.metadata.outputs.tag }} + notes-path: + description: A file holding the changelog notes for the release + value: ${{ steps.metadata.outputs.notes-path }} + +runs: + using: composite + steps: + - name: Validate release metadata + id: metadata + shell: bash + env: + REJECT_EXISTING_TAG: ${{ inputs.reject-existing-tag }} + run: | + node --input-type=commonjs <<'NODE' + const { execFileSync } = require('node:child_process') + const { appendFileSync, readFileSync, writeFileSync } = require('node:fs') + const { join } = require('node:path') + 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.REJECT_EXISTING_TAG === 'true') { + 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.`) + } + } + + const notesPath = join(process.env.RUNNER_TEMP, 'release-notes.md') + writeFileSync(notesPath, `${latest.notes}\n`) + appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\ntag=${tag}\nnotes-path=${notesPath}\n`) + console.log(`Release metadata valid: ${tag}`) + NODE diff --git a/.github/workflows/_verify.yaml b/.github/workflows/_verify.yaml index 558504d..7331682 100644 --- a/.github/workflows/_verify.yaml +++ b/.github/workflows/_verify.yaml @@ -2,14 +2,12 @@ # committed lockfile and type-check cleanly with the public module defaults # - the exact environment every fresh checkout gets. # -# Called from three places: +# Called from two 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/publish-ghcr-platform.yaml b/.github/workflows/publish-ghcr-platform.yaml index ebadfcc..e74f1f7 100644 --- a/.github/workflows/publish-ghcr-platform.yaml +++ b/.github/workflows/publish-ghcr-platform.yaml @@ -6,7 +6,8 @@ on: # @note no `paths` filter here on purpose: the verify job must run on every # push to next so its check lands on the SHA the promotion pull request # (next -> main) requires - pull-request.yaml skips its own run for that - # head branch. Path filtering for the image build lives in the changes job + # head branch, and every push to main is a source release whatever it + # touches. Path filtering for the image build lives in the changes job # below instead. push: branches: @@ -30,7 +31,29 @@ jobs: # skip rather than a queued build against runners and a registry it does # not have. # - # The trigger-level `paths` filter this job replaces could not coexist with + # A push to main is a source release: publish tags the images with the + # version from package.json and release tags the commit and publishes the + # GitHub Release. The metadata is validated here first so nothing is tagged + # from a version the changelog does not describe. On next the job is an + # early warning only; a stale changelog must not hold back next images. + metadata: + if: >- + (github.repository == 'chatbotkit/platform' || startsWith(github.repository, 'chatbotkit/platform-')) && + github.actor != 'github-actions[bot]' + name: Validate release metadata + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + version: ${{ steps.metadata.outputs.version }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: ./.github/actions/release-metadata + id: metadata + + # @note the trigger-level `paths` filter this job replaces could not coexist with # running verify on every next push, so the build-relevance decision is made # here from the actual diff of the push. Anything that prevents computing # that diff (a brand-new branch, a force push, a manual dispatch) falls back @@ -42,6 +65,12 @@ jobs: # publish already tagged as sha-. That tag is reused and retagged by # the publish job instead of spending two 8-core runners on an identical # image. Any tree that no published tag matches falls back to a full build. + # + # A push to main that touches no build input still publishes: the `main` + # channel images were built from a tree whose build inputs equal this one + # (every main push since that build was non-build-relevant, by this same + # test), so publish carries them under the release version tag without + # minting channel or sha- tags for a tree it did not build. changes: if: >- (github.repository == 'chatbotkit/platform' || startsWith(github.repository, 'chatbotkit/platform-')) && @@ -370,17 +399,26 @@ jobs: # leaves the build's untagged digests unreachable in the registry and # publishes nothing. !cancelled() + accepting the skipped verify is # required: the implicit success() looks at the whole needs chain, and - # verify is skipped on main. A skipped build is accepted only when the - # changes job found a published sha- tag of the same tree to retag. + # verify is skipped on main. A skipped build is accepted when the changes + # job found a published sha- tag of the same tree to retag, or on main + # when nothing build-relevant changed and the `main` channel images stand + # in (see the changes job). On main the release metadata must be valid, + # since the version becomes an image tag here. if: >- !cancelled() && (github.repository == 'chatbotkit/platform' || startsWith(github.repository, 'chatbotkit/platform-')) && - (needs.build.result == 'success' || (needs.build.result == 'skipped' && needs.changes.outputs.reuse != '')) && + ( + needs.build.result == 'success' || + (needs.build.result == 'skipped' && needs.changes.outputs.reuse != '') || + (needs.build.result == 'skipped' && needs.changes.outputs.build == 'false' && github.ref == 'refs/heads/main') + ) && (needs.verify.result == 'success' || needs.verify.result == 'skipped') && + (github.ref != 'refs/heads/main' || needs.metadata.result == 'success') && github.actor != 'github-actions[bot]' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next') name: Publish ${{ matrix.flavor.name }} needs: + - metadata - changes - build - verify @@ -439,17 +477,25 @@ jobs: # @note with a reused build the source is the already multi-platform # sha- manifest list, which imagetools copies under the new tags; a - # fresh build supplies one per-architecture digest per image instead + # fresh build supplies one per-architecture digest per image instead. + # A main push without build-relevant changes carries the `main` + # channel images under the release version tag only: channel and sha- + # tags name the tree an image was built from, and this is not it. - name: Create multi-platform image manifests id: manifest env: APPLICATION_IMAGE: ${{ steps.image.outputs.application }} + BUILD: ${{ needs.changes.outputs.build }} CHANNEL: ${{ github.ref_name }} DIGESTS_DIR: ${{ runner.temp }}/platform-digests INITIALIZER_IMAGE: ${{ steps.image.outputs.initializer }} + RELEASE_VERSION: ${{ needs.metadata.outputs.version }} REUSE: ${{ needs.changes.outputs.reuse }} run: | - if [ -n "$REUSE" ]; then + if [ "$BUILD" = "false" ]; then + application_sources=("${APPLICATION_IMAGE}:${CHANNEL}") + initializer_sources=("${INITIALIZER_IMAGE}:${CHANNEL}") + elif [ -n "$REUSE" ]; then application_sources=("${APPLICATION_IMAGE}:sha-${REUSE}") initializer_sources=("${INITIALIZER_IMAGE}:sha-${REUSE}") else @@ -472,19 +518,31 @@ jobs: fi short_sha="${GITHUB_SHA:0:7}" - application_tags=(--tag "${APPLICATION_IMAGE}:${CHANNEL}" --tag "${APPLICATION_IMAGE}:sha-${short_sha}") - initializer_tags=(--tag "${INITIALIZER_IMAGE}:${CHANNEL}" --tag "${INITIALIZER_IMAGE}:sha-${short_sha}") + application_tags=() + initializer_tags=() + + if [ "$BUILD" != "false" ]; then + application_tags+=(--tag "${APPLICATION_IMAGE}:${CHANNEL}" --tag "${APPLICATION_IMAGE}:sha-${short_sha}") + initializer_tags+=(--tag "${INITIALIZER_IMAGE}:${CHANNEL}" --tag "${INITIALIZER_IMAGE}:sha-${short_sha}") + fi if [ "$CHANNEL" = "main" ]; then - application_tags+=(--tag "${APPLICATION_IMAGE}:latest") - initializer_tags+=(--tag "${INITIALIZER_IMAGE}:latest") + if [ "$BUILD" != "false" ]; then + application_tags+=(--tag "${APPLICATION_IMAGE}:latest") + initializer_tags+=(--tag "${INITIALIZER_IMAGE}:latest") + fi + + # @note the release version tag is the one consumers pin; the + # release job below only runs once every flavor carries it + application_tags+=(--tag "${APPLICATION_IMAGE}:v${RELEASE_VERSION}") + initializer_tags+=(--tag "${INITIALIZER_IMAGE}:v${RELEASE_VERSION}") fi docker buildx imagetools create "${application_tags[@]}" "${application_sources[@]}" docker buildx imagetools create "${initializer_tags[@]}" "${initializer_sources[@]}" - application_digest=$(docker buildx imagetools inspect "${APPLICATION_IMAGE}:${CHANNEL}" --format '{{.Manifest.Digest}}') - initializer_digest=$(docker buildx imagetools inspect "${INITIALIZER_IMAGE}:${CHANNEL}" --format '{{.Manifest.Digest}}') + application_digest=$(docker buildx imagetools inspect "${application_sources[0]}" --format '{{.Manifest.Digest}}') + initializer_digest=$(docker buildx imagetools inspect "${initializer_sources[0]}" --format '{{.Manifest.Digest}}') echo "application_digest=${application_digest}" >> "$GITHUB_OUTPUT" echo "initializer_digest=${initializer_digest}" >> "$GITHUB_OUTPUT" @@ -495,11 +553,14 @@ jobs: # # docker compose -f oci://ghcr.io/chatbotkit/platform-community:latest up - name: Publish Compose distribution artifact + id: artifact env: APPLICATION_IMAGE: ${{ steps.image.outputs.application }}@${{ steps.manifest.outputs.application_digest }} + BUILD: ${{ needs.changes.outputs.build }} CHANNEL: ${{ github.ref_name }} FLAVOR: ${{ matrix.flavor.name }} INITIALIZER_IMAGE: ${{ steps.image.outputs.initializer }}@${{ steps.manifest.outputs.initializer_digest }} + RELEASE_VERSION: ${{ needs.metadata.outputs.version }} STACK_IMAGE: ${{ steps.image.outputs.stack }} run: | publish() { @@ -507,17 +568,156 @@ jobs: PLATFORM_INIT_IMAGE="$INITIALIZER_IMAGE" \ docker compose --file "docker/distro/${FLAVOR}/compose.yml" \ publish -y --resolve-image-digests "$1" + echo "reference=$1" >> "$GITHUB_OUTPUT" } - publish "${STACK_IMAGE}:${CHANNEL}" + if [ "$BUILD" != "false" ]; then + publish "${STACK_IMAGE}:${CHANNEL}" + fi if [ "$CHANNEL" = "main" ]; then - publish "${STACK_IMAGE}:latest" + if [ "$BUILD" != "false" ]; then + publish "${STACK_IMAGE}:latest" + fi + + publish "${STACK_IMAGE}:v${RELEASE_VERSION}" fi + # @note the last artifact published is the one checked: on main that is + # the release version, elsewhere the channel - name: Smoke-test published distribution artifact env: - CHANNEL: ${{ github.ref_name }} - STACK_IMAGE: ${{ steps.image.outputs.stack }} + REFERENCE: ${{ steps.artifact.outputs.reference }} + run: | + docker compose --file "oci://${REFERENCE}" config --quiet + + # @note the release exists only once every flavor's images and Compose + # artifact carry the version tag: a GitHub Release whose images are missing + # or still building would mislead operators pinning the version. The Git tag + # and the GitHub Release are created from the exact merge commit; on a + # retry an annotated tag already pointing at it is accepted, and a tag + # pointing elsewhere fails without being moved. The only write token in + # this workflow that reaches the repository lives here. + release: + name: Publish GitHub release + needs: + - metadata + - changes + - publish + if: >- + !cancelled() && + (github.repository == 'chatbotkit/platform' || startsWith(github.repository, 'chatbotkit/platform-')) && + needs.metadata.result == 'success' && + needs.publish.result == 'success' && + github.actor != 'github-actions[bot]' && + github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + steps: + - name: Require protected main for releases + 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 tag the triggering merge, even if main has advanced since + ref: ${{ github.sha }} + fetch-tags: true + + - uses: ./.github/actions/release-metadata + id: metadata + + - name: Create and push the source snapshot + env: + NOTES_PATH: ${{ steps.metadata.outputs.notes-path }} + TAG: ${{ steps.metadata.outputs.tag }} + VERSION: ${{ steps.metadata.outputs.version }} run: | - docker compose --file "oci://${STACK_IMAGE}:${CHANNEL}" config --quiet + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + + if [ "$(git rev-parse HEAD)" != "$GITHUB_SHA" ]; then + echo 'The checkout must identify the triggering merge commit' >&2 + exit 1 + fi + if [ -n "$(git status --porcelain --untracked-files=all)" ]; then + echo 'The release checkout must be clean' >&2 + exit 1 + fi + + if [ -n "$(git tag --list "$TAG")" ]; then + if [ "$(git cat-file -t "$TAG")" != 'tag' ] || [ "$(git rev-parse "${TAG}^{commit}")" != "$GITHUB_SHA" ]; then + echo "${TAG} already exists for another snapshot. Update package.json and CHANGELOG.md." >&2 + exit 1 + fi + echo "Snapshot ${TAG} already identifies ${GITHUB_SHA}" + else + { printf 'Platform %s\n\n' "$VERSION"; cat "$NOTES_PATH"; } | git tag -a "$TAG" "$GITHUB_SHA" -F - + fi + + git push origin "refs/tags/${TAG}:refs/tags/${TAG}" + + - name: Publish release with changelog notes + uses: actions/github-script@v9 + env: + NOTES_PATH: ${{ steps.metadata.outputs.notes-path }} + TAG: ${{ steps.metadata.outputs.tag }} + VERSION: ${{ steps.metadata.outputs.version }} + with: + script: | + const { readFileSync } = require('node:fs') + const { NOTES_PATH, TAG: tag, VERSION: version } = process.env + const name = `Platform ${version}` + const body = readFileSync(NOTES_PATH, 'utf8').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/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index b3ef4e4..6442b3a 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -2,7 +2,9 @@ # the committed lockfile and type-check cleanly with the public module defaults # - the exact environment every fresh checkout gets. The gate itself lives in # _verify.yaml so pushes to `next` can run the same checks before an image -# build. +# build. Pull requests into `main` additionally pass the release metadata +# gate: the promotion is a source release, so the version and changelog must +# be ready before it merges. # # SECURITY: this workflow is safe to run on code from strangers, and it is # designed to stay that way. Fork pull requests run through the plain @@ -50,3 +52,24 @@ jobs: # run. Every other head branch (forks included) still gets its own run. if: github.event_name != 'pull_request' || github.head_ref != 'next' uses: ./.github/workflows/_verify.yaml + + # @note the promotion is a release: publish-ghcr-platform.yaml tags the + # merge commit with the version in package.json, so the version must be + # unused and its changelog section written before the merge, not after. + # Configure this job as a required check on main. It reads no secrets and + # runs the same for forks, like verify above. + check_release: + name: Check source release + if: github.event_name == 'pull_request' && github.base_ref == 'main' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + with: + # @note the gate rejects a version whose tag already exists + fetch-tags: true + persist-credentials: false + + - uses: ./.github/actions/release-metadata + with: + reject-existing-tag: 'true' diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml deleted file mode 100644 index 1de9cec..0000000 --- a/.github/workflows/release.yaml +++ /dev/null @@ -1,244 +0,0 @@ -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 index 3e6a415..5f8915d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,20 +5,35 @@ here. The release version is defined in the workspace root `package.json`. ## [Unreleased] +## [0.2.0] - 2026-09-08 + +### Added + +- Add the OpenAI `gpt-image-2.5-flare` and `gpt-image-2.5-sunburst` image + models with their pricing and supported sizes. + +### Fixed + +- Allow plain-http image, script, style, font, media, frame and worker + sources in the Content Security Policy when `SITE_URL` itself is served + without TLS, matching the existing websocket allowance. The app shells on a + local http deployment load banners from the site or static origin, which the + browser refused before. + ## [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. + future releases, with `package.json` as the authoritative platform version. +- Validate the release version and changelog on promotion pull requests and + on every publication run, so a promotion to `main` needs an unused version + and a dated changelog entry. +- Tag the published images and Compose artifacts of each promotion with the + release version, alongside the channel and commit tags. +- Create an annotated Git snapshot tag and a GitHub Release with the changelog + notes and source downloads once every flavor's images carry the version. +- Require protected `main` 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 9209969..34a3745 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -124,50 +124,55 @@ To prepare a release: 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`. +3. Complete the quality checks for the changes being released. The + **Validate release metadata** job of the publication workflow checks the + version and changelog on every push to `next` as an early warning; it does + not hold back `next` images. 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. +5. Merge the promotion pull request. The **Publish GHCR Platform** workflow + publishes the images and Compose artifacts for that merge commit under the + `v` + `VERSION` tag alongside the channel tags, and only once every flavor + carries it 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 promotion pull request inherits the verification of its `next` head, which +runs on the push to `next`. The merge commit carries the same tree, so the +release does not verify it again; branch protection is what makes that hold, +and the release job refuses to run on an unprotected `main`. A promotion that +changes no build input still gets version-tagged images: the `main` channel +images were built from the same build inputs, so they are carried under the +version tag without minting new channel or commit tags. 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 +workspace's `.github` directory and runs in the destination repository. + +For a failed run, rerun **Publish GHCR Platform** on the failed run, or +dispatch it on `main`. Retries are safe: already published images are found by +their `sha-` tag and retagged, an existing annotated tag on the same commit is +accepted, and 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 +run to retry an older merge. + +All release validation, tagging and publication logic lives in the GitHub +workflows and the shared `release-metadata` action under `.github/actions`. +There are no local release scripts or package commands. The source downloads contain no installed dependencies, compiled application, databases, or container -images. Package and container publishing remain separate. +images; those are the version-tagged images and Compose artifacts. Only the +release job holds a repository write token. Workflow checks govern tags created by this automation. To restrict direct tag pushes as well, configure a repository tag ruleset for `platform/v*`, limiting diff --git a/docs/architecture.md b/docs/architecture.md index 7f5c9ca..d60a4df 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,9 +28,9 @@ 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; 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. +promotion publishes version-tagged images, 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 9df5de8..1f6cd32 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -34,8 +34,9 @@ CPU, memory and network cache state. Trusted pushes to `main` and `next` publish matching application and database initializer images. Names follow `platform--`; tags carry only the build: the moving `main` and `next` tags are channels (`latest` -follows `main`), and `sha-` tags identify an immutable source -revision. +follows `main`), `sha-` tags identify an immutable source revision, +and `v` tags identify a release - the same images as the +`platform/v` source snapshot and its GitHub Release. The publication workflow derives the registry owner and image name from the GitHub repository. In `chatbotkit/platform` this resolves to the official image @@ -83,8 +84,8 @@ stack. No checkout, no bind mounts: docker compose -f oci://ghcr.io/chatbotkit/platform-community:latest up ``` -The `latest` tag follows `main`; `next` follows the `next` branch. Compose -v2.34 or newer is required. On `up`, Compose shows the stack's variables - +The `latest` tag follows `main`; `next` follows the `next` branch; pin a +release with `v`. Compose v2.34 or newer is required. On `up`, Compose shows the stack's variables - site URL, secrets, optional provider keys - and their defaults before proceeding; set them in the shell, in a `.env` file in the directory the command runs from (picked up automatically), or via an explicit `--env-file`, @@ -268,13 +269,13 @@ production infrastructure. A production deployment still needs: [module defaults](./module-defaults.md) - monitoring, restore testing and an upgrade and rollback procedure -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. +Promotions to `main` produce versioned source snapshots (`platform/v*`), +GitHub Releases with changelog notes and source downloads, and `v` +image and Compose artifact tags (see +[CONTRIBUTING.md](../CONTRIBUTING.md#source-releases)). Images still ship +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 49e6259..f3c9f93 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "platform", - "version": "0.1.0", + "version": "0.2.0", "private": true, "license": "Apache-2.0", "packageManager": "pnpm@11.24.0", diff --git a/platform/AGENTS.md b/platform/AGENTS.md new file mode 100644 index 0000000..c4acf87 --- /dev/null +++ b/platform/AGENTS.md @@ -0,0 +1,7 @@ +# Platform Application + +Operator- or user-visible changes here or in the shared packages (models, +routing, security policy, config variables, migrations, behaviour fixes) need +a bullet under `Unreleased` in [../CHANGELOG.md](../CHANGELOG.md), in the +same batch as the code. Pure refactors do not. The release turns those notes +into the tag annotation and GitHub Release. diff --git a/platform/CLAUDE.md b/platform/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/platform/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/platform/config/models.ts b/platform/config/models.ts index 6470a88..ffc064d 100644 --- a/platform/config/models.ts +++ b/platform/config/models.ts @@ -7368,8 +7368,66 @@ export const visibleLanguageModels: Record = export const openaiImageModels: Record = WITH_OPENAI_MODELS ? { + 'gpt-image-2.5-flare': { + description: `GPT Image 2.5 Flare is OpenAI's image generation and editing model for fast creative workflows, with improved image fidelity, precise editing, and consistency across multiple edits.`, + + provider: 'openai', + + providerModel: 'gpt-image-2.5-flare', + + family: 'gpt-image', + + features: [], + + pricing: { + // @todo-by 2026-09-10 verify Flare pricing and replace temporary GPT Image 2 ratios + tokenRatio: 11666.6667, + inputTokenRatio: 714.2857, + outputTokenRatio: 11666.6667, + }, + + region: 'us', + availableRegions: ['us'], + + visible: true, + deprecated: false, + + tags: [], + + addedDate: '2026-09-08', + }, + + 'gpt-image-2.5-sunburst': { + description: `GPT Image 2.5 Sunburst is OpenAI's image generation and editing model for detailed creative work, offering greater precision and tighter control across edits with longer generation times.`, + + provider: 'openai', + + providerModel: 'gpt-image-2.5-sunburst', + + family: 'gpt-image', + + features: [], + + pricing: { + // @todo-by 2026-09-10 verify Sunburst pricing and replace temporary GPT Image 2 ratios + tokenRatio: 11666.6667, + inputTokenRatio: 714.2857, + outputTokenRatio: 11666.6667, + }, + + region: 'us', + availableRegions: ['us'], + + visible: true, + deprecated: false, + + tags: [], + + addedDate: '2026-09-08', + }, + 'gpt-image-2': { - description: `GPT Image 2 is OpenAI's latest image generation and editing model. It is a natively multimodal language model that accepts both text and image inputs, and produces image outputs with improved fidelity and editing capabilities.`, + description: `GPT Image 2 is OpenAI's image generation and editing model. It is a natively multimodal language model that accepts both text and image inputs, and produces image outputs with improved fidelity and editing capabilities.`, provider: 'openai', diff --git a/platform/lib/model.provider.openai.ts b/platform/lib/model.provider.openai.ts index ae7c2df..cf73e8e 100644 --- a/platform/lib/model.provider.openai.ts +++ b/platform/lib/model.provider.openai.ts @@ -3422,6 +3422,18 @@ export async function createImage( // config - the model name passed here is already the provider-side identifier const modelNameToSizeMap = { + 'gpt-image-2.5-flare': { + '1024x1024': '1024x1024', + '1024x1536': '1024x1536', + '1536x1024': '1536x1024', + }, + + 'gpt-image-2.5-sunburst': { + '1024x1024': '1024x1024', + '1024x1536': '1024x1536', + '1536x1024': '1536x1024', + }, + 'gpt-image-2': { '1024x1024': '1024x1024', '1024x1536': '1024x1536', @@ -3460,6 +3472,8 @@ export async function createImage( } const modelNameToResponseFormatMap = { + 'gpt-image-2.5-flare': undefined, + 'gpt-image-2.5-sunburst': undefined, 'gpt-image-2': undefined, 'gpt-image-1': undefined, 'gpt-image-1.5': undefined, @@ -3590,9 +3604,11 @@ export async function createImage( break } + case 'gpt-image-2.5-flare': + case 'gpt-image-2.5-sunburst': case 'gpt-image-2': { usage = { - model: 'gpt-image-2', + model, inputTokens: 0, outputTokens: urls.length || 1, } @@ -3724,6 +3740,18 @@ export async function editImage( // config - the model name passed here is already the provider-side identifier const modelNameToSizeMap = { + 'gpt-image-2.5-flare': { + '1024x1024': '1024x1024', + '1024x1536': '1024x1536', + '1536x1024': '1536x1024', + }, + + 'gpt-image-2.5-sunburst': { + '1024x1024': '1024x1024', + '1024x1536': '1024x1536', + '1536x1024': '1536x1024', + }, + 'gpt-image-2': { '1024x1024': '1024x1024', '1024x1536': '1024x1536', @@ -3750,6 +3778,8 @@ export async function editImage( } const modelNameToResponseFormatMap = { + 'gpt-image-2.5-flare': undefined, + 'gpt-image-2.5-sunburst': undefined, 'gpt-image-2': undefined, 'gpt-image-1': undefined, 'gpt-image-1.5': undefined, @@ -3867,9 +3897,11 @@ export async function editImage( let usage switch (model) { + case 'gpt-image-2.5-flare': + case 'gpt-image-2.5-sunburst': case 'gpt-image-2': { usage = { - model: 'gpt-image-2', + model, inputTokens: images.length, outputTokens: urls.length || 1, } diff --git a/platform/lib/security.headers.js b/platform/lib/security.headers.js index be5da1f..191154f 100644 --- a/platform/lib/security.headers.js +++ b/platform/lib/security.headers.js @@ -2,50 +2,57 @@ // @ts-check import { siteUrl } from '../config/site.js' +/** + * @note `http:` and `ws:` join the fetch directives only when the site itself + * is served without TLS. Such a deployment has no transport security for the + * policy to preserve, its shells load banners across plain-http site and + * static origins, and its local relay (RELAY_URL) speaks plain websockets. An + * https site never gets them - browsers refuse that mixed content anyway. + */ +const INSECURE_SITE = new URL(siteUrl).protocol === 'http:' + +/** @type {string[]} */ +const HTTP_SCHEMES = ['https:', ...(INSECURE_SITE ? ['http:'] : [])] + +/** @type {string[]} */ +const WS_SCHEMES = ['wss:', ...(INSECURE_SITE ? ['ws:'] : [])] + /** * @type {string} * @todo requires progressive hardening in the future */ -const ALLOWED_SCRIPTS = ['https:', 'blob:', 'data:'].join(' ') +const ALLOWED_SCRIPTS = [...HTTP_SCHEMES, 'blob:', 'data:'].join(' ') /** * @type {string} * @todo requires progressive hardening in the future */ -const ALLOWED_STYLES = ['https:', 'blob:', 'data:'].join(' ') +const ALLOWED_STYLES = [...HTTP_SCHEMES, 'blob:', 'data:'].join(' ') /** * @type {string} * @todo requires progressive hardening in the future */ -const ALLOWED_IMAGES = ['https:', 'blob:', 'data:'].join(' ') +const ALLOWED_IMAGES = [...HTTP_SCHEMES, 'blob:', 'data:'].join(' ') /** * @type {string} * @todo requires progressive hardening in the future */ -const ALLOWED_FONTS = ['https:', 'blob:', 'data:'].join(' ') +const ALLOWED_FONTS = [...HTTP_SCHEMES, 'blob:', 'data:'].join(' ') /** * @type {string} * @todo requires progressive hardening in the future */ -const ALLOWED_MEDIA = ['https:', 'blob:', 'data:'].join(' ') +const ALLOWED_MEDIA = [...HTTP_SCHEMES, 'blob:', 'data:'].join(' ') /** - * @note `http:` and `ws:` join the list only when the site itself is served - * without TLS. Such a deployment has no transport security for the policy to - * preserve, and its local relay (RELAY_URL) speaks plain websockets. An - * https site never gets them - browsers refuse that mixed content anyway. - * * @type {string} */ const ALLOWED_CONNECTS = [ - 'https:', - 'wss:', - - ...(new URL(siteUrl).protocol === 'http:' ? ['http:', 'ws:'] : []), - + ...HTTP_SCHEMES, + ...WS_SCHEMES, 'blob:', 'data:', ].join(' ') @@ -54,13 +61,13 @@ const ALLOWED_CONNECTS = [ * @type {string} * @todo requires progressive hardening in the future */ -const ALLOWED_FRAMES = ['https:', 'blob:', 'data:'].join(' ') +const ALLOWED_FRAMES = [...HTTP_SCHEMES, 'blob:', 'data:'].join(' ') /** * @type {string} * @todo requires progressive hardening in the future */ -const ALLOWED_WORKERS = ['https:', 'blob:', 'data:'].join(' ') +const ALLOWED_WORKERS = [...HTTP_SCHEMES, 'blob:', 'data:'].join(' ') /** * Ancestors that may frame embeddable surfaces. `*` only matches network diff --git a/platform/lib/security.headers.utest.js b/platform/lib/security.headers.utest.js index 71adf00..1476732 100644 --- a/platform/lib/security.headers.utest.js +++ b/platform/lib/security.headers.utest.js @@ -93,6 +93,52 @@ describe('Security Headers Configuration', () => { ) }) + it.each([ + ['http://cbk.localhost:3000', true], + ['https://cbk.example', false], + ])( + 'allows plain-http sources on %s only when the site has no TLS', + (url, insecure) => { + jest.isolateModules(() => { + const previous = process.env.SITE_URL + + process.env.SITE_URL = url + + try { + const { DEFAULT_SECURITY_HEADERS: headers } = jest.requireActual( + '@/lib/security.headers' + ) + + const csp = Object.fromEntries( + headers.contentSecurityPolicy.split(';').map((d) => { + const [name, ...values] = d.trim().split(/\s+/) + + return [name, values] + }) + ) + + for (const name of [ + 'script-src', + 'style-src', + 'img-src', + 'font-src', + 'media-src', + 'frame-src', + 'worker-src', + 'connect-src', + ]) { + expect(csp[name]).toContain('https:') + expect(csp[name].includes('http:')).toBe(insecure) + } + + expect(csp['connect-src'].includes('ws:')).toBe(insecure) + } finally { + process.env.SITE_URL = previous + } + }) + } + ) + it('constrains scripts, connections, forms and base URL', () => { expect(directives['default-src']).toBe("'self'") expect(directives['script-src']).toMatch(/^'self'/)