diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index fb42f6996..234c715b6 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -1,10 +1,21 @@ name: Canary -# Nightly canary publishes the current tip of `main` to npm under @nightly. +# Two-stage release pipeline (nightly canary side). +# +# Nightly canary creates snapshot versions, tags, and a GitHub prerelease. +# npm publishing is handled by a private cron job (AO) that polls GitHub +# releases and publishes when a new prerelease tag is ahead of the current +# npm nightly version. +# +# No NPM_TOKEN or publisher dispatch secrets are needed in this repo. +# The only secret used is GITHUB_TOKEN (automatic). +# # Cron schedule: 23:30 IST = 18:00 UTC, on Fri,Sat,Sun,Mon,Tue # (DOW 5,6,0,1,2). Wed/Thu are the bake window — no scheduled publishes. -# `workflow_dispatch` lets the release captain re-cut a nightly during bake -# when a fix lands and the Discord cohort should test the patched candidate. +# `workflow_dispatch` lets the release captain re-cut a nightly during +# bake when a fix lands and the Discord cohort should test the patched +# candidate. + on: schedule: - cron: "0 18 * * 5,6,0,1,2" @@ -15,38 +26,86 @@ concurrency: cancel-in-progress: false permissions: - contents: read - id-token: write + contents: write jobs: canary: name: Publish canary runs-on: ubuntu-latest - environment: release steps: - uses: actions/checkout@v4 with: ref: main fetch-depth: 0 + # Skip-if-unchanged guard: if the most recent prerelease in this + # repo was cut from the current tip of main, there's nothing new + # to publish — short-circuit the whole job. Prevents republishing + # the same SHA on every cron tick during quiet stretches. + # + # We filter to prereleases only (`isPrerelease == true`). If the + # most recent release were a stable cut from `release.yml`, an + # explicit `workflow_dispatch` nightly right after the stable + # release would otherwise be suppressed — which we don't want. + # + # The previous-nightly tag points at the snapshot commit created + # by the "Commit snapshot version bumps" step below. The snapshot + # commit's first parent is the source main HEAD, so `${LAST_TAG}^` + # is the right anchor to compare against `GITHUB_SHA` (main HEAD + # at this run). + # + # `workflow_dispatch` always proceeds — recovery path for partial + # failures. A human triggered the run, they want it to run. + - name: Check if main has new commits since the last nightly + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ "$GITHUB_EVENT_NAME" = 'workflow_dispatch' ]; then + echo 'Manual trigger via workflow_dispatch — bypassing skip guard.' + echo 'skip=false' >> "$GITHUB_OUTPUT" + exit 0 + fi + + LAST_TAG=$(gh release list --json tagName,isPrerelease --jq '[.[] | select(.isPrerelease)][0].tagName // empty') + LAST_SHA="" + if [ -n "$LAST_TAG" ]; then + LAST_SHA=$(git rev-parse "${LAST_TAG}^" 2>/dev/null || echo "") + fi + if [ -n "$LAST_SHA" ] && [ "$LAST_SHA" = "$GITHUB_SHA" ]; then + echo "Last prerelease ($LAST_TAG) was cut from $GITHUB_SHA — skipping." + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + - uses: pnpm/action-setup@v4 + if: steps.check.outputs.skip != 'true' - uses: actions/setup-node@v4 + if: steps.check.outputs.skip != 'true' with: node-version: 20 cache: pnpm - registry-url: https://registry.npmjs.org + # No `registry-url`: this workflow does not publish to npm. - - run: echo "HUSKY=0" >> $GITHUB_ENV + - if: steps.check.outputs.skip != 'true' + run: echo "HUSKY=0" >> $GITHUB_ENV - - run: pnpm install --frozen-lockfile + - if: steps.check.outputs.skip != 'true' + run: pnpm install --frozen-lockfile - - run: pnpm -r build + - if: steps.check.outputs.skip != 'true' + run: pnpm -r build - # Pre-publish guard: same as release.yml — catches workspace:* deps on - # private packages before they'd silently break a published install. - - run: node scripts/check-publishable-deps.mjs + # Pre-publish guard: same as release.yml — catches workspace:* deps + # on private packages before they'd silently break a published + # install. Still runs here so the public repo blocks a malformed + # snapshot before it reaches npm. + - if: steps.check.outputs.skip != 'true' + run: node scripts/check-publishable-deps.mjs - name: Create snapshot versions + if: steps.check.outputs.skip != 'true' run: | # If no changesets exist (e.g. right after a Version Packages merge), # create a minimal one. `changeset version --snapshot` consumes and @@ -58,8 +117,52 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Publish canary - run: pnpm changeset publish --tag nightly --no-git-tag + # `pnpm changeset version --snapshot` modifies package.json files + # on disk but does NOT create a git commit. Without this commit, + # the umbrella tag we push below would point at the pre-snapshot + # HEAD, so when the npm publisher checks out the tag it would see + # the un-bumped versions and either publish wrong version numbers + # or fail trying to republish an existing version. + # + # The commit is NEVER pushed to main — only the tag below is + # pushed, and the snapshot commit travels with it as part of the + # tag's reachable history. `[skip ci]` is defensive in case any + # future change ever pushes this commit to a branch. + - name: Commit snapshot version bumps + if: steps.check.outputs.skip != 'true' + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add packages/*/package.json packages/plugins/*/package.json .changeset/ + git diff --cached --quiet || git commit -m 'chore: snapshot version bump [skip ci]' + + # Push the umbrella `vX.Y.Z` tag pointing at the snapshot commit. + # The npm publisher resolves this tag to the snapshot commit with + # the bumped package.json versions. + # + # We deliberately skip `pnpm changeset tag`: it would create one + # tag per publishable package (~27 here) every night, which adds + # up fast on the nightly cadence. The npm publisher only consumes + # the umbrella tag, so the per-package tags are pure decoration. + - name: Tag snapshot versions + id: tag + if: steps.check.outputs.skip != 'true' + run: | + version=$(node -p "require('./packages/ao/package.json').version") + git tag "v$version" + git push origin "v$version" + echo "version=$version" >> "$GITHUB_OUTPUT" + + # Public-facing GitHub prerelease. `--prerelease` flags it as such + # so consumers and tooling that filter by stability stay correct. + # No `--target`: the tag was just pushed and GitHub resolves the + # commitish from it (avoids the race where another commit lands + # on main between the tag push and this step). + - name: Create GitHub prerelease + if: steps.check.outputs.skip != 'true' env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - NPM_CONFIG_PROVENANCE: "true" + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "v${{ steps.tag.outputs.version }}" \ + --prerelease \ + --generate-notes diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cee557905..eb2c322cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,17 @@ name: Release -# Stable @latest publishes when CI passes on `main` and a Version Packages PR -# is merged. The changesets/action below opens/updates that PR automatically; -# merging it runs `changeset publish` and pushes to npm. +# Two-stage release pipeline. +# +# This public repo is responsible for version bumps, tagging, and creating +# the GitHub release. npm publishing is handled by a private cron job (AO) +# that polls GitHub releases and publishes when a new tag is ahead of the +# current npm version. +# +# No NPM_TOKEN or publisher dispatch secrets are needed in this repo. +# The only secret used is GITHUB_TOKEN (automatic). +# +# See CONTRIBUTING.md → "Release architecture" for the full picture. + on: workflow_run: # Depends on the workflow named "CI" in .github/workflows/ci.yml — if @@ -20,13 +29,11 @@ concurrency: permissions: contents: write pull-requests: write - id-token: write jobs: release: name: Release runs-on: ubuntu-latest - environment: release if: >- github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && @@ -41,24 +48,105 @@ jobs: with: node-version: 20 cache: pnpm - registry-url: https://registry.npmjs.org + # No `registry-url`: this workflow does not publish to npm. - run: echo "HUSKY=0" >> $GITHUB_ENV - run: pnpm install --frozen-lockfile - run: pnpm -r build # Pre-publish guard: catches the case where a publishable package has a # workspace:* runtime dep on a `private: true` package — pnpm would # rewrite the dep on publish to a version that doesn't exist on npm, - # breaking `npm install -g @aoagents/ao`. + # breaking `npm install -g @aoagents/ao`. Still runs here so the + # public repo blocks a malformed version bump before it reaches npm. - run: node scripts/check-publishable-deps.mjs + + # changesets/action manages the "Version Packages" PR — when there + # are pending changesets it opens/updates the PR; when there are + # none (i.e. that PR was just merged) it would normally invoke the + # `publish:` command. We deliberately omit `publish:` so the action + # never runs `changeset publish`. npm publishing is handled by a + # private cron that detects the GitHub release. - uses: changesets/action@v1 + id: changesets with: - publish: pnpm changeset publish version: pnpm changeset version - # Creates/updates a PR titled "chore: version packages" when changesets - # are pending. Merging that PR publishes to npm under @latest. title: "chore: version packages" commit: "chore: version packages" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - NPM_CONFIG_PROVENANCE: "true" + + # Determine release state. Each downstream step (tag push, GH + # release creation) is gated on its own piece of state so the + # workflow is idempotent and recovers cleanly on re-run after a + # partial failure. + # + # `is_release_commit` is the crucial signal: it filters out + # regular commits to main (which also have `hasChangesets == + # 'false'` but should NOT trigger a publish). We detect a + # version-bump commit by comparing the umbrella package's + # version against its value in the parent commit — a Version + # Packages merge changes that version, a regular commit does not. + # The umbrella `@aoagents/ao` is the canonical version source for + # the `vX.Y.Z` tag scheme and is part of the linked group, so + # any release that bumps the cohort will bump this file. + - name: Determine release state + id: state + if: steps.changesets.outputs.hasChangesets == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + version=$(node -p "require('./packages/ao/package.json').version") + echo "version=$version" >> "$GITHUB_OUTPUT" + + prev_version="" + if git rev-parse HEAD^ >/dev/null 2>&1; then + prev_version=$(git show HEAD^:packages/ao/package.json 2>/dev/null | jq -r .version 2>/dev/null || echo "") + fi + if [ -n "$prev_version" ] && [ "$prev_version" != "$version" ]; then + echo "is_release_commit=true" >> "$GITHUB_OUTPUT" + else + echo "is_release_commit=false" >> "$GITHUB_OUTPUT" + fi + + if git ls-remote --tags --exit-code origin "refs/tags/v$version" >/dev/null 2>&1; then + echo "tag_on_remote=true" >> "$GITHUB_OUTPUT" + else + echo "tag_on_remote=false" >> "$GITHUB_OUTPUT" + fi + + if gh release view "v$version" --json tagName >/dev/null 2>&1; then + echo "release_exists=true" >> "$GITHUB_OUTPUT" + else + echo "release_exists=false" >> "$GITHUB_OUTPUT" + fi + + # Push the umbrella `vX.Y.Z` tag only if this is a fresh + # version-bump commit AND the tag isn't already on the remote. + # Skipped on re-runs where the tag was pushed on a prior run + # (idempotent recovery). + # + # We deliberately skip `pnpm changeset tag`: it would create one + # tag per publishable package (~27 here) on every release, which + # creates partial-recovery conflicts on re-run when `git push --tags` + # tries to re-push existing per-package tags. The npm publisher + # only consumes the umbrella tag, so the per-package tags add no + # value. + - name: Tag versioned packages + if: steps.state.outputs.is_release_commit == 'true' && steps.state.outputs.tag_on_remote == 'false' + run: | + git tag "v${{ steps.state.outputs.version }}" + git push origin "v${{ steps.state.outputs.version }}" + + # Public-facing GitHub release. Stable channel — not a prerelease. + # No `--target`: the `vX.Y.Z` tag is on the remote at the + # version-bump commit, and `gh release create` resolves the + # commitish from the existing tag. Avoids the race where another + # commit lands on main between the tag push and this step, + # pulling unrelated commits into the auto-generated release notes. + # Skipped if a release for this version already exists. + - name: Create GitHub release + if: steps.state.outputs.is_release_commit == 'true' && steps.state.outputs.release_exists == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "v${{ steps.state.outputs.version }}" \ + --generate-notes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1df81037..825003c56 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,13 +70,57 @@ ao update `ao update` fast-forwards the local install repo, reinstalls dependencies, clean-rebuilds `@aoagents/ao-core`, `@aoagents/ao-cli`, and `@aoagents/ao-web`, refreshes the global launcher with `npm link`, and finishes with CLI smoke tests. Use `ao update --skip-smoke` when you only need the rebuild step, or `ao update --smoke-only` when validating an existing install. -## Release Setup (maintainers only) +## Release Architecture (maintainers only) -The canary and stable release workflows require one secret configured in GitHub repo settings: +AO uses a **two-stage release pipeline**. This public repo handles version bumps, git tags, and GitHub releases. npm publishing runs on a private server (AO cron job) that polls GitHub releases and publishes when a new tag is ahead of the current npm version. Org compliance forbids npm publish credentials in public repositories, so `NPM_TOKEN` never enters this repo. -- `NPM_TOKEN` — an npm automation token with publish access to the `@aoagents` org. Add it at **Settings → Secrets and variables → Actions → New repository secret**. +### Where things happen -Without this secret, both `release.yml` and `canary.yml` will fail at the publish step. +| Stage | Where | Responsibility | +| ------------------------ | ------------------------------ | ------------------------------------------------------------------------ | +| Versioning + GitHub release | This repo (public, CI) | Changesets version bumps, git tags, `gh release create` | +| npm publish | Private server (AO cron) | Detects new GitHub releases → builds → `pnpm changeset publish` | + +The flow on every release: + +``` +This repo (public CI) Private server (AO cron) +────────────────────── ───────────────────────── +release.yml: Polls gh release list + changeset version Detects new vX.Y.Z tag + push vX.Y.Z tag Compare to npm @latest/@nightly + gh release create vX.Y.Z If behind → checkout tag → build → publish + +canary.yml: Same cron, detects prereleases + changeset version --snapshot Publishes with --tag nightly + commit snapshot bump + tag + gh release create --prerelease +``` + +Each release pushes a single umbrella `vX.Y.Z` git tag pointing at the version-bump commit. We deliberately do **not** run `pnpm changeset tag`, which would emit one tag per publishable package (~27) every release — fine for stable's monthly cadence, noisy on the nightly cadence (~7 000 tags/year). The npm publisher only consumes the umbrella tag, so the per-package tags add no value. + +### Secrets + +This repo requires **no additional secrets** beyond the automatic `GITHUB_TOKEN`. `NPM_TOKEN` lives only on the private server. + +### How releases are cut + +- **Stable**: merge the "chore: version packages" PR opened by `changesets/action`. `release.yml` tags the bumped packages and creates a `vX.Y.Z` GitHub release. The AO cron detects the new release and publishes to npm `@latest`. +- **Nightly**: `canary.yml` runs on cron (23:30 IST Fri–Tue) or via `workflow_dispatch`. It snapshots versions to `X.Y.Z-nightly-` format (e.g., `0.6.1-nightly-7c46dc92`), tags, and creates a prerelease GitHub release. The AO cron detects the new prerelease and publishes to npm `@nightly`. + +There is no path from this repo that calls `npm publish` directly. + +### Idempotency + +`release.yml` is idempotent: each step (tag push, GitHub release creation) is gated on whether that piece of state already exists, so a re-run after a partial failure picks up only the missing steps. + +The AO cron is also idempotent — `pnpm changeset publish` skips packages whose current version is already on the registry, so re-running after a partial publish is safe. + +### Recovery + +If `release.yml` fails after the GitHub release was created, **re-run the failed workflow**: the state-detection step will see that the tag and release already exist and skip those steps. + +If the AO cron fails to publish, it will retry on the next poll cycle (every 15 minutes). No manual intervention needed for transient failures. For persistent issues, check the cron logs on the private server. ## Testing your changes