diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml new file mode 100644 index 000000000..c44314c18 --- /dev/null +++ b/.github/workflows/build-and-test.yml @@ -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 diff --git a/.github/workflows/main-build.yml b/.github/workflows/main-build.yml index 25ae8a369..61fb09a0f 100644 --- a/.github/workflows/main-build.yml +++ b/.github/workflows/main-build.yml @@ -1,4 +1,6 @@ -# 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: @@ -6,43 +8,11 @@ on: 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca8b26447..038c4b470 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,25 +1,34 @@ # 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: @@ -27,12 +36,16 @@ jobs: 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 @@ -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 @@ -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 diff --git a/AGENTS.md b/AGENTS.md index e534b3729..0b122489c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | @@ -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 && git push origin v` -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