From 78d156f9e5292a4a38179b345afb69146863656d Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Mon, 4 May 2026 09:18:38 -0400 Subject: [PATCH 1/3] chore(release): collapse release flow to single human action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the plan in #373: trigger release.yml on push to main instead of tag push, collapse three jobs into two, and make "merge the version PR" the only human step. release.yml: - Trigger flips from `push: tags: v*` to `push: branches: [main]`. - Two jobs (`prepare` / `publish-and-tag`) preserve least-privilege: prepare holds contents+pull-requests (no OIDC); publish-and-tag holds contents+id-token (no PR-write). - prepare uses `persist-credentials: false` paired with `commitMode: github-api` so changesets/action commits the version bump via the GitHub API (verified, github-actions[bot]-authored). - publish-and-tag is gated on `hasChangesets == 'false'` so feature PRs with changesets do not trigger the publish path; their changesets accumulate in the version PR. - Build + test run inside publish-and-tag, gated by the outer job condition, so no-changeset paths skip them. - Publish uses changesets/action's publish input so its `published` output is the authoritative "did anything go to npm" signal, closing the trap where every doc-only / plan-only main push would otherwise hit `gh release create` for an existing tag. - Per-package git tags suppressed via `--no-git-tag`; the repo-wide `vX.Y.Z` is the single tag. - `gh release create` creates tag ref + release in one API call. - Concurrency block (`workflow-ref`) serializes against itself. - Prerelease detection anchored on canonical suffixes (`*-alpha`, `*-alpha.*`, etc.) — no false positives like `1.0.0-alphabet`. AGENTS.md: - § Release Process rewritten around the single-action flow. Drops force-push tag instructions. - Workflows table row for release.yml updated (trigger column: "Push to main"). Adversarial review: fresh subagent against this branch's diff flagged 11 issues. Critical (gating logic conflated "no pending changesets" with "version PR just merged") fixed by switching to `changesets/action`'s `published` output. Plan-file reference in workflow header dropped (plan lives on PR #373, not yet on main). AGENTS.md "re-runs" wording sharpened. Remaining items accepted as documented tradeoffs (concurrency latency on doc pushes, broader credential scope on publish-and-tag, fetch-depth default). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 154 ++++++++++++++++++---------------- AGENTS.md | 16 ++-- 2 files changed, 89 insertions(+), 81 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca8b26447..9b13ba48b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,25 +1,32 @@ # 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: when no pending changesets remain (i.e. the +# version PR was just merged), build, test, npm-publish via OIDC, +# then create the repo-wide vX.Y.Z tag + GitHub release in one +# gh release create call. +# +# 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 +34,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 +60,38 @@ 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 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' + publish-and-tag: + name: Publish to npm + tag repo + needs: prepare + 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 — required for the + # post-publish tag/release step to authenticate. - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -94,72 +108,64 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + # Build is gated on the publish path so doc-only / plan-only main + # pushes do not pay the cost. + - name: Build all packages + run: bun run build + + # Tests run before publish so a regression caught only in the + # browser matrix cannot ship to npm. main-build.yml runs in + # parallel and is not a gate; this is the gate. + - name: Run tests + run: bun run test + - 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. Without this, a plan-only / doc-only main push (which + # also has hasChangesets == 'false') would no-op `changeset + # publish` and then fail noisily on `gh release create` for a + # tag that already exists. + # + # changeset publish creates per-package git tags by default + # (e.g. @tko/utils@4.1.0). With the .changeset/config.json `fixed` + # group all 27 @tko/* packages share one version, so the + # per-package tags carry no information beyond the repo-wide + # vX.Y.Z. We suppress them via --no-git-tag and rely solely on + # the post-publish step below for the single tag. + # + # 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: npx changeset publish --no-git-tag + 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 From 7aefbc4514405bdfff2c2e4e5b48423b1a22bc30 Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Mon, 4 May 2026 10:25:43 -0400 Subject: [PATCH 2/3] chore(release): gate publish on browser matrix via reusable workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the in-job `bun run test` (which only ran headless because publish-and-tag's runner has no Playwright browsers) with a real browser-matrix gate. - New `.github/workflows/build-and-test.yml` reusable workflow (`on: workflow_call`) holds the Playwright-container build + browser matrix + cli-happy-dom + ESM-extension verification + artifact upload. - `main-build.yml` becomes a thin shim that calls the reusable on push to main / workflow_dispatch. - `release.yml` adds a `build-and-test` job that calls the same reusable, gated on `should_publish == 'true'`. `publish-and-tag` now `needs: [prepare, build-and-test]`, so a red browser matrix blocks publish through standard GitHub Actions semantics — no polling. Both callers pass `secrets: inherit` so future secret-using steps in the reusable don't silently get nothing. main-build.yml gains a concurrency block matching release.yml's so two rapid main pushes cannot run two parallel Playwright runners against the same SHA. publish-and-tag still runs `bun run build` locally to produce the publishable dists in its worktree. The reusable is the gate, not an artifact source. Adversarial review: fresh subagent against this diff flagged 10 items. Two actionable (secrets: inherit, main-build concurrency) — fixed. Remaining items accepted as documented tradeoffs (double build per push, cosmetic name on shim). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/build-and-test.yml | 54 ++++++++++++++++++++++++++++ .github/workflows/main-build.yml | 50 ++++++-------------------- .github/workflows/release.yml | 25 ++++++++----- 3 files changed, 80 insertions(+), 49 deletions(-) create mode 100644 .github/workflows/build-and-test.yml 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 9b13ba48b..f8118b5c1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -75,9 +75,20 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # 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 + needs: [prepare, build-and-test] if: needs.prepare.outputs.should_publish == 'true' runs-on: ubuntu-latest @@ -108,17 +119,13 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - # Build is gated on the publish path so doc-only / plan-only main - # pushes do not pay the cost. + # 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 - # Tests run before publish so a regression caught only in the - # browser matrix cannot ship to npm. main-build.yml runs in - # parallel and is not a gate; this is the gate. - - name: Run tests - run: bun run test - - name: Determine release version id: version run: | From 29d8dec8734170e8343fa193ec5252d3447f818e Mon Sep 17 00:00:00 2001 From: Brian M Hunt Date: Mon, 4 May 2026 11:27:04 -0400 Subject: [PATCH 3/3] fix(release): drop --no-git-tag so action's published output works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P1: changesets/action infers `outputs.published` by parsing `New tag:` lines from `changeset publish` stdout. Those lines are only emitted when changeset publish creates per-package git tags. With `--no-git-tag`, `published` always reports false → the post-publish tag step never fires → release tag never created. Drop the flag and let changeset publish create its 27 per-package tags. Noisy but harmless; the canonical release marker remains the repo-wide vX.Y.Z tag created by `gh release create` after publish. Also addresses Copilot review: - Standardize on `bunx` for CLI invocations (was `npx`); matches the rest of the repo's tooling per AGENTS.md. - Reword the workflow header to acknowledge that publish-and-tag runs on any no-pending-changeset main push (docs/plans included), not only after a version-PR merge — the published-output gate is what makes the path safe. - Sharpen the persist-credentials note: the post-publish gh release create authenticates via GH_TOKEN; persist-credentials: true is needed for `changeset publish`'s per-package tag pushes via git remote. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 39 +++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f8118b5c1..038c4b470 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,10 +6,12 @@ # - 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: when no pending changesets remain (i.e. the -# version PR was just merged), build, test, npm-publish via OIDC, -# then create the repo-wide vX.Y.Z tag + GitHub release in one -# gh release create call. +# - 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. @@ -64,7 +66,7 @@ jobs: 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 @@ -101,8 +103,10 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v6 - # persist-credentials defaults to true — required for the - # post-publish tag/release step to authenticate. + # 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 @@ -134,17 +138,16 @@ jobs: # Use changesets/action's publish path so we get its `published` # output as the authoritative "did anything actually go to npm" - # signal. Without this, a plan-only / doc-only main push (which - # also has hasChangesets == 'false') would no-op `changeset - # publish` and then fail noisily on `gh release create` for a - # tag that already exists. + # 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. # - # changeset publish creates per-package git tags by default - # (e.g. @tko/utils@4.1.0). With the .changeset/config.json `fixed` - # group all 27 @tko/* packages share one version, so the - # per-package tags carry no information beyond the repo-wide - # vX.Y.Z. We suppress them via --no-git-tag and rely solely on - # the post-publish step below for the single tag. + # 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. @@ -152,7 +155,7 @@ jobs: id: publish uses: changesets/action@v1 with: - publish: npx changeset publish --no-git-tag + publish: bunx changeset publish env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}