refactor(release): split publish into two-repo model — GitHub release public, npm publish private (#1815)

* refactor(release): split publish into two-repo model

Org compliance forbids npm publish credentials in public repositories.
Move the npm publish step out of this repo and into a private
ComposioHQ/ao-publisher repo, triggered via repository_dispatch.

Public repo (this one) now only:
- Bumps versions via changesets/action (no `publish:` argument)
- Creates git tags via `pnpm changeset tag`
- Creates the GitHub release (stable or prerelease)
- Dispatches `publish-npm-stable` / `publish-npm-nightly` to ao-publisher

The only secret needed here is PUBLISHER_DISPATCH_TOKEN, a fine-grained
PAT scoped to ao-publisher with `repository_dispatch:write`. NPM_TOKEN
lives in ao-publisher's secret store and never enters this repo.

Removed from both workflows: NODE_AUTH_TOKEN env, NPM_CONFIG_PROVENANCE
env, and `registry-url` on setup-node — none are needed when no npm
publish happens here. Canary also gains a skip-if-unchanged guard so
cron ticks during quiet stretches don't republish identical SHAs.

CONTRIBUTING.md "Release Setup" → "Release Architecture": documents the
two-repo split, secret layout, rotation procedure, and the stable vs.
nightly flows.

Requires PUBLISHER_DISPATCH_TOKEN secret + ao-publisher repo setup by
maintainers (out of scope for this PR — see PR description).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(release): address Greptile review findings on two-repo split

Three review findings on PR #1815:

1. (P1) canary.yml missing git commit before tagging
   `pnpm changeset version --snapshot` modifies package.json but does
   not commit. `pnpm changeset tag` would then tag the pre-snapshot
   HEAD, so ao-publisher would check out un-bumped versions.

   Add a "Commit snapshot version bumps" step with `git diff --cached
   --quiet || git commit` (defensive: skip if nothing to commit) and
   the `[skip ci]` marker. The commit is never pushed to main — only
   the tags are pushed and the orphan commit travels with them.

2. (P2) release.yml `--target main` race condition
   If a commit lands on main between `git push --follow-tags` and
   `gh release create --target main`, the release commitish drifts
   and auto-generated notes pull in unrelated commits.

   Create an explicit `vX.Y.Z` git tag pointing at the version-bump
   commit, push it, and drop `--target`. `gh release create` then
   resolves the commitish from the existing tag — no race window.

3. (P2) canary.yml skip-guard suppresses post-stable nightlies
   `gh release list --limit 1` returns the most recent release by
   date, which could be a stable from `release.yml`. An explicit
   `workflow_dispatch` nightly right after a stable cut would be
   suppressed.

   Filter to prereleases only:
     gh release list --json tagName,isPrerelease \
       --jq '[.[] | select(.isPrerelease)][0].tagName // empty'

   If no prerelease exists yet, the jq returns empty and the guard
   falls through naturally (LAST_SHA empty → condition false →
   nightly proceeds).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: retrigger checks (event dropped on previous push)

* fix(release): make release.yml idempotent and dispatch reachable on re-run

The previous design used a single `released` output (based on `after >
before` tag count) to gate both `Create GitHub release` and
`Dispatch npm publish`. On a re-run, all tags are already on the
remote and `fetch-depth: 0` brings them down, so `pnpm changeset tag`
adds nothing, `after == before`, `released=false`, and both steps are
skipped — breaking the "re-run recovers cleanly" claim, especially
the common case where the first run failed only at dispatch because
`PUBLISHER_DISPATCH_TOKEN` was missing.

Refactor into a `Determine release state` step that emits three
independent signals:

- `is_release_commit` — version-bump signal. Detected by comparing
  `packages/ao/package.json` version against its value in HEAD^.
  A Version Packages merge changes this; a regular commit does not.
  This is the filter that prevents the dispatch from firing on every
  commit to main (which all have `hasChangesets == 'false'`).
- `tag_on_remote` — whether the `vX.Y.Z` tag exists at origin.
- `release_exists` — whether the matching GitHub release exists.

Each downstream step is gated on its own piece of state:

- Tag push: `is_release_commit && !tag_on_remote`
- Release create: `is_release_commit && !release_exists`
- Dispatch: `is_release_commit` (always fires on a release commit)

The dispatch fires unconditionally on a release commit, even when
tag and release already exist on the remote. That guarantees recovery
from the most common failure mode (first run succeeded everywhere
except dispatch). The publisher must be idempotent against already-
published versions for this to be safe — `pnpm changeset publish`
already has this property since it skips packages whose current
version is already on the registry.

Update CONTRIBUTING.md → "Release Architecture":
- Add "Idempotency contract for ao-publisher" section spelling out the
  no-op-on-already-published requirement.
- Add "Recovery" section explaining that re-running the failed
  workflow is the canonical recovery path, plus a manual `gh api`
  fallback for cases where re-running isn't practical.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(canary): skip-guard anchors on tag parent, not the orphan snapshot

The previous P1 fix added a "Commit snapshot version bumps" step before
`pnpm changeset tag`, which moved the snapshot tag onto an orphan
commit (the version-bump commit) rather than main HEAD. The
skip-if-unchanged guard still compared `git rev-list -n 1 "$LAST_TAG"`
against `GITHUB_SHA`, but `LAST_TAG` now resolves to the orphan
snapshot commit's SHA, never the main SHA. The two could never be
equal → `skip=true` was unreachable → every cron tick republished
regardless of whether main had advanced.

Use `${LAST_TAG}^` to anchor on the snapshot commit's first parent —
which is the main HEAD at the time the previous nightly ran — and
compare that against the current `GITHUB_SHA`. Now the guard fires
correctly when main hasn't advanced since the last nightly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(release): single umbrella tag per release, drop per-package tags

`pnpm changeset tag` creates one tag per publishable package (~27 here)
on every release. At 5 nightlies/week × 52 weeks × 27 packages that's
roughly 7 000 tags/year just from canary — pure decoration since
`ao-publisher` only consumes the umbrella `vX.Y.Z` tag. The per-
package tags also cause partial-recovery conflicts: `git push --tags`
on a re-run trips over tags that were pushed by the prior run.

Drop the `pnpm changeset tag` call from both workflows and replace
`git push origin --tags` with `git push origin "v$version"`. Push
exactly one umbrella tag per release.

CONTRIBUTING.md → "Release Architecture" updated:
- Flow diagram replaces "changeset tag → push" with "push vX.Y.Z tag"
- New paragraph spells out the single-tag-per-release policy and why
  we skip `pnpm changeset tag`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(canary): workflow_dispatch bypasses skip-guard for recovery

When a nightly pushes the tag but fails at `gh release create` or
dispatch (e.g. `PUBLISHER_DISPATCH_TOKEN` missing), a re-run via
`workflow_dispatch` was silently skipped: `LAST_TAG` resolves to the
just-pushed nightly, `${LAST_TAG}^` equals `GITHUB_SHA`, and the
binary skip guard fires before any of the downstream steps we want
to retry.

`release.yml` handles the equivalent case by gating each step on
independent state. Canary has a single binary gate, so it needs a
different escape hatch: `workflow_dispatch` always proceeds. The
human trigger is itself the signal that we want to run regardless
of whether the source SHA looks unchanged. Cron-driven runs keep
the SHA-equality dedup so quiet stretches don't republish the same
SHA on every tick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(release): replace ao-publisher dispatch with AO cron poll model

Remove the ao-publisher dispatch steps and PUBLISHER_DISPATCH_TOKEN from
both release.yml and canary.yml. npm publishing is now handled by an AO
cron job on a private server that polls GitHub releases and publishes
when a new tag is ahead of the current npm version.

Changes:
- release.yml: remove dispatch step, keep tag + GitHub release
- canary.yml: remove dispatch step, keep tag + GitHub prerelease
- CONTRIBUTING.md: rewrite Release Architecture for two-stage model
  (public CI → GitHub release, private cron → npm publish)

* fix(release): address review — remove env/id-token, scope git add, fix wording

- Remove environment: release from both workflows (no npm publish here,
  could block on required reviewers)
- Remove id-token: write permission (unused, no OIDC needed)
- Scope git add in canary to package.json and .changeset/ only (avoid
  staging build artifacts)
- Fix 'orphan commit' wording → 'snapshot commit' (not actually orphan)
- Fix nightly version format 0.0.0-* → X.Y.Z-nightly-<sha>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: i-trytoohard[bot] <1484917231245856808@users.noreply.github.com>
This commit is contained in:
suraj_markup 2026-05-13 03:29:04 +05:30 committed by GitHub
parent d95c9def10
commit 15158aa7b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 268 additions and 33 deletions

View File

@ -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

View File

@ -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

View File

@ -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 FriTue) or via `workflow_dispatch`. It snapshots versions to `X.Y.Z-nightly-<sha>` 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