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
54 changes: 54 additions & 0 deletions .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Reusable workflow: build + browser-matrix test on a checked-out tree.
#
# Called by:
# - main-build.yml (push to main, workflow_dispatch)
# - release.yml (publish gate)
#
# Single source of truth for "build + matrix test". Other workflows
# express dependency via `needs:` on the calling job, so cross-workflow
# coordination uses standard GitHub Actions semantics — no polling.
name: Build and Test

on:
workflow_call:

jobs:
test:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.59.1-noble

steps:
- name: Checkout code
uses: actions/checkout@v6

# Install Bun directly — avoids `apt-get update` (9+ min in this
# container) which setup-bun needs for unzip.
- name: Install Bun
run: python3 tools/install-bun

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Run Build
run: bun run build

- name: Verify ESM extensions
run: bun run verify:esm

- name: Run tests (browser matrix)
run: bunx vitest run --project browser
env:
HOME: /root
VITEST_BROWSERS: chromium,firefox,webkit

- name: Run tests (cli-happy-dom)
run: bunx vitest run --project cli-happy-dom
env:
HOME: /root

- name: Upload build artifacts
uses: actions/upload-artifact@v7
with:
name: tko
path: builds/knockout/dist
50 changes: 10 additions & 40 deletions .github/workflows/main-build.yml
Original file line number Diff line number Diff line change
@@ -1,48 +1,18 @@
# workflow for build and deploy
# Push-to-main + manual-dispatch entry point for the build-and-test
# reusable workflow. Real logic lives in build-and-test.yml so
# release.yml can depend on it via `needs:`.
name: Build

on:
push:
branches: ["main"]
workflow_dispatch:

jobs:
test:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.59.1-noble

steps:
- name: Checkout code
uses: actions/checkout@v6

# Install Bun directly — avoids `apt-get update` (9+ min in this
# container) which setup-bun needs for unzip.
- name: Install Bun
run: python3 tools/install-bun

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Run Build
run: bun run build
# Serialize main-build runs against themselves so two near-simultaneous
# main pushes do not waste a Playwright runner racing the same SHA.
concurrency: ${{ github.workflow }}-${{ github.ref }}

- name: Verify ESM extensions
run: bun run verify:esm

- name: Run tests (browser matrix)
run: bunx vitest run --project browser
env:
HOME: /root
VITEST_BROWSERS: chromium,firefox,webkit

- name: Run tests (cli-happy-dom)
run: bunx vitest run --project cli-happy-dom
env:
HOME: /root

- name: Upload build artifacts
uses: actions/upload-artifact@v7
with:
name: tko
path: builds/knockout/dist
jobs:
build-and-test:
uses: ./.github/workflows/build-and-test.yml
secrets: inherit
166 changes: 91 additions & 75 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,38 +1,51 @@
# Automated release via Changesets.
#
# On tagging:
# - If there are unreleased changesets, opens/updates a release version
# PR that bumps versions and updates changelogs.
# - If there are no unreleased changesets, builds, tests, and publishes
# to npm from a separate least-privilege job.
# Trigger: push to main.
#
# Requires npm trusted publisher configuration for the @tko packages.
# Publish auth comes from GitHub Actions OIDC, not a long-lived npm token.
# Flow:
# - prepare: invoke changesets/action to open or update the
# "chore: version packages" PR. This is the only entry point for
# opening that PR.
# - publish-and-tag: runs on any main push that finds no pending
# changesets (covers both the version-PR merge and incidental
# no-changeset pushes like docs/plans). Builds, runs the browser
# matrix gate, npm-publishes via OIDC, and — only if the publish
# actually shipped new versions — creates the repo-wide vX.Y.Z
# tag + GitHub release.
#
# Single human action: merge the version PR. Everything else runs
# unattended.
name: Release

on:
push:
tags:
- 'v*'
branches: [main]

# Serialize releases against themselves. Two near-simultaneous main
# pushes (e.g. version-PR merge + a doc PR merge) cannot race two
# parallel publishes or tag creations.
concurrency: ${{ github.workflow }}-${{ github.ref }}

jobs:
prepare-release:
name: Prepare Release
prepare:
name: Open or update version PR
runs-on: ubuntu-latest

permissions:
contents: write
pull-requests: write

outputs:
has_changesets: ${{ steps.changesets.outputs.hasChangesets }}
should_publish: ${{ steps.changesets.outputs.hasChangesets == 'false' }}

steps:
- name: Checkout code
uses: actions/checkout@v6
with:
# changesets/action commits version bumps via the GitHub API
# (commitMode: github-api) so we deliberately disable
# persisted credentials — there is no `git push` from this
# job.
persist-credentials: false

- name: Setup Bun
Expand All @@ -49,35 +62,51 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile

- name: Create or update version PR
- name: Open or update version PR
id: changesets
uses: changesets/action@v1
with:
version: npx changeset version
version: bunx changeset version
title: 'chore: version packages'
commit: 'chore: version packages'
# Required when persist-credentials: false — without this the
# action falls back to git-cli, which has no remote auth and
# fails the push. github-api also produces a verified commit
# authored by github-actions[bot].
commitMode: github-api
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

publish:
name: Publish to npm
needs: prepare-release
if: needs.prepare-release.outputs.has_changesets == 'false'
# Browser matrix gate. Shares the build-and-test reusable workflow
# with main-build.yml so we exercise the same Playwright container,
# same browsers, same vitest projects on the same SHA. publish-and-tag
# depends on this via `needs:`, so a red browser matrix blocks
# publish without polling.
build-and-test:
needs: prepare
if: needs.prepare.outputs.should_publish == 'true'
uses: ./.github/workflows/build-and-test.yml
secrets: inherit

publish-and-tag:
name: Publish to npm + tag repo
needs: [prepare, build-and-test]
if: needs.prepare.outputs.should_publish == 'true'
runs-on: ubuntu-latest

outputs:
release_version: ${{ steps.version.outputs.version }}

permissions:
contents: read
id-token: write
# contents:write is required to push the repo-wide vX.Y.Z tag via
# gh release create. PRs are not modified from this job.
contents: write
id-token: write # npm OIDC trusted publishing

steps:
- name: Checkout code
uses: actions/checkout@v6
with:
persist-credentials: false
# persist-credentials defaults to true so `changeset publish`
# can push per-package git tags (@tko/utils@X.Y.Z, etc.) via
# the runner's git remote auth. The repo-wide vX.Y.Z tag is
# created later by `gh release create` using GH_TOKEN.

- name: Setup Bun
uses: oven-sh/setup-bun@v2
Expand All @@ -94,72 +123,59 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile

# Build locally for the publish artifacts. The build-and-test
# job already built + tested the same SHA in its Playwright
# container; that result is the gate. This second build produces
# the npm-publishable dists in this runner's worktree.
- name: Build all packages
run: bun run build

- name: Determine release version
id: version
run: |
version="$(node tools/release-version.cjs)"
echo "version=$version" >> "$GITHUB_OUTPUT"

- name: Validate tag matches release version
env:
TAG_VERSION: ${{ github.ref_name }}
RELEASE_VERSION: ${{ steps.version.outputs.version }}
run: |
tag_version="${TAG_VERSION#v}"

if [ "$tag_version" != "$RELEASE_VERSION" ]; then
echo "Tag version $tag_version does not match release version $RELEASE_VERSION." >&2
exit 1
fi

- name: Build all packages
run: bun run build

# Use changesets/action's publish path so we get its `published`
# output as the authoritative "did anything actually go to npm"
# signal. The action parses `New tag:` lines from
# `changeset publish` stdout to set `outputs.published`, so we
# MUST let `changeset publish` create its per-package git tags
# (i.e. no `--no-git-tag`) — otherwise `published` always
# reports false and the post-publish tag step never fires.
#
# The per-package tags (@tko/utils@X.Y.Z, etc., 27 of them per
# release given the fixed group) are noisy but harmless. The
# canonical release marker is the repo-wide vX.Y.Z tag created
# by the next step.
#
# npm generates provenance attestations automatically during
# trusted publishing from GitHub Actions.
- name: Publish packages
# npm generates provenance attestations automatically during trusted
# publishing from GitHub Actions.
run: npx changeset publish

github-release:
name: Create GitHub Release
needs: [prepare-release, publish]
if: needs.prepare-release.outputs.has_changesets == 'false' && needs.publish.result == 'success'
runs-on: ubuntu-latest

permissions:
contents: write
id: publish
uses: changesets/action@v1
with:
publish: bunx changeset publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

steps:
# gh release create is a single API call that creates both the
# release and the underlying tag ref. Gated on the action's
# `published` output so plan-only / doc-only pushes that
# legitimately have nothing to publish skip tag creation.
- name: Create GitHub release
if: steps.publish.outputs.published == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.publish.outputs.release_version }}
VERSION: ${{ steps.version.outputs.version }}
TARGET_SHA: ${{ github.sha }}
run: |
tag="v${VERSION}"

if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
object_type="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag" --jq '.object.type')"
object_sha="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag" --jq '.object.sha')"

if [ "$object_type" = "tag" ]; then
existing_target="$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$object_sha" --jq '.object.sha')"
else
existing_target="$object_sha"
fi

if [ "$existing_target" = "$TARGET_SHA" ]; then
echo "GitHub release $tag already exists on $TARGET_SHA; skipping."
exit 0
fi

echo "GitHub release $tag already exists on $existing_target, expected $TARGET_SHA." >&2
exit 1
fi

# Anchor prerelease detection on canonical suffixes only —
# avoids false positives like 1.0.0-alphabet.
prerelease_flag=""
case "$VERSION" in
*-alpha*|*-beta*|*-rc*)
*-alpha|*-alpha.*|*-beta|*-beta.*|*-rc|*-rc.*)
prerelease_flag="--prerelease"
;;
esac
Expand Down
16 changes: 9 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ GitHub Actions workflows (`.github/workflows/`):
| `test-headless.yml` | PRs | Matrix test (Chrome, Firefox, jQuery) |
| `lint-and-typecheck.yml` | PRs | Biome + tsc (lint, format, typecheck) |
| `publish-check.yml` | PRs | Verify packages are publishable |
| `release.yml` | Tag push (`v*`) | Changeset version PRs + npm publish + GitHub release creation |
| `release.yml` | Push to main | Changeset version PRs + npm publish + GitHub release creation |
| `github-release.yml` | Manual fallback | Backfill a GitHub release/tag for a published `main` commit if automatic release creation needs a retry |
| `deploy-docs.yml` | Push to main | Deploy tko.io to GitHub Pages |
| `codeql-analysis.yml` | Weekly + main push | Security scanning |
Expand All @@ -151,12 +151,14 @@ bunx changeset add # Select affected packages, bump type, describe change
```
This creates a changeset file in `.changeset/` that gets committed with your PR.

**For maintainers** — releasing is handled by CI:
1. Merge the "Version Packages" PR (created by the Changesets action) into main
2. Tag the resulting commit: `git tag v<version> && git push origin v<version>`
3. The tag push triggers `.github/workflows/release.yml`, which builds, tests, and publishes to npm via OIDC trusted publishing
4. The same release workflow creates the matching GitHub Release
5. If GitHub release creation ever needs a retry after publish, run `github-release.yml` manually with the merged commit SHA
**For maintainers** — releasing is a single human action: **merge the version PR**.

1. Feature PRs with changesets merge to `main`. `.github/workflows/release.yml` opens or updates a "chore: version packages" PR that bumps versions and updates changelogs.
2. When the open version PR's batch feels release-worthy, merge it.
3. The merge fires the workflow again: builds, tests, publishes to npm via OIDC trusted publishing, then creates the repo-wide `vX.Y.Z` git tag and matching GitHub Release in one `gh release create` call. The tag step is gated on `changesets/action`'s `published` output so plan-only / doc-only main pushes that have no pending changesets do not create spurious releases.
4. If GitHub release creation ever needs a retry after publish, run `github-release.yml` manually with the merged commit SHA.

No tag-push entry point. No force-moving tags. The version PR is the single review surface.

Avoid manual workstation publishes. If release CI is unavailable, fix the
workflow or npm trusted publisher configuration rather than bypassing it with a
Expand Down
Loading