Skip to content
Open
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
84 changes: 84 additions & 0 deletions .github/scripts/unpublished-packages.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#!/usr/bin/env node

// Lists workspace packages whose current version is not yet on the npm
// registry, i.e. exactly what `changeset publish` would push.
//
// Used by the publish workflow as a pre-flight gate: dispatching Publish with
// nothing to publish should fail loudly and early rather than end in a green
// run that shipped nothing. Queries the registry directly over HTTPS instead of
// shelling out to `npm view` 8 times -- these are public packages, so no auth is
// involved, and one fetch per package keeps the failure mode obvious.
//
// Writes `count`, `packages` and `version` to $GITHUB_OUTPUT when running under
// Actions; always prints a human-readable table to stdout.

import { appendFileSync, readdirSync, readFileSync } from "node:fs"
import { join } from "node:path"

const PACKAGES_DIR = "packages"
const REGISTRY = "https://registry.npmjs.org"

function readWorkspacePackages() {
return readdirSync(PACKAGES_DIR, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => join(PACKAGES_DIR, entry.name, "package.json"))
.map((path) => JSON.parse(readFileSync(path, "utf8")))
.filter((pkg) => !pkg.private && typeof pkg.name === "string")
}

async function publishedVersions(name) {
const response = await fetch(`${REGISTRY}/${name.replace("/", "%2f")}`, {
headers: { accept: "application/vnd.npm.install-v1+json" },
})

// A package that has never been published 404s. Anything else is a registry
// problem, and guessing "not published" there would let the workflow publish
// over a version that already exists.
if (response.status === 404) {
return []
}

if (!response.ok) {
throw new Error(
`Registry lookup for ${name} failed: ${response.status} ${response.statusText}`,
)
}

const body = await response.json()
return Object.keys(body.versions ?? {})
}

const packages = readWorkspacePackages()

const results = await Promise.all(
packages.map(async (pkg) => ({
name: pkg.name,
version: pkg.version,
published: (await publishedVersions(pkg.name)).includes(pkg.version),
})),
)

const unpublished = results.filter((result) => !result.published)

for (const result of results) {
const status = result.published ? "already on npm" : "TO PUBLISH"
console.log(
`${result.name.padEnd(32)} ${result.version.padEnd(12)} ${status}`,
)
}

// Every package is version-locked by the `linked` group in
// .changeset/config.json, so the umbrella package's version names the release.
const umbrella = results.find((result) => result.name === "agentcommercekit")

if (process.env.GITHUB_OUTPUT) {
appendFileSync(
process.env.GITHUB_OUTPUT,
[
`count=${unpublished.length}`,
`packages=${unpublished.map((result) => result.name).join(" ")}`,
`version=${umbrella?.version ?? ""}`,
Comment on lines +70 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

jq '{fixed, linked}' .changeset/config.json

fd -0 '^package\.json$' packages | while IFS= read -r -d '' manifest; do
  jq -r --arg manifest "$manifest" \
    '[$manifest, .name, .version] | `@tsv`' "$manifest"
done

Repository: agentcommercekit/ack

Length of output: 725


🌐 Web query:

For @changesets/cli2.31.0, does alinkedgroup publish every member on each release? Contrastlinkedandfixed package groups using version-specific documentation.

💡 Result:

For @changesets/cli 2.31.0, a linked group does not publish every member on each release [1][2]. The behavior of linked and fixed package groups is contrasted as follows: Linked Packages: The linked configuration option allows you to specify groups of packages that should share a version [3][1]. Packages within a linked group are only version-bumped and published if they have associated changesets (either direct changes or as a result of being a dependent) [1][2]. When a package in a linked group is released, it is versioned to the highest current version in that group plus the highest bump type from the changesets within the group [1][2]. There is no guarantee that all packages in the group will be published together; only those with active changesets are processed [1][2]. Fixed Packages: The fixed configuration option declares that packages must be version-bumped and published together, regardless of whether individual members have received changes [4][5]. Unlike linked packages, all packages in a fixed group are updated to the same version whenever a release is triggered for the group, ensuring that they remain in sync even if some members have no new changes [4][5]. In summary, linked packages synchronize versioning logic for packages that have changes, while fixed packages enforce a strict, all-or-nothing release requirement for the entire group [1][4].

Citations:


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== script outline =="
ast-grep outline .github/scripts/unpublished-packages.mjs --view compact || true

echo
echo "== relevant script lines =="
cat -n .github/scripts/unpublished-packages.mjs | sed -n '1,140p'

echo
echo "== workflow publish relevant lines =="
cat -n .github/workflows/publish.yaml | sed -n '180,250p'

echo
echo "== changeset files =="
git ls-files '.changeset/*' | sort | while IFS= read -r f; do
  echo "--- $f"
  cat -n "$f"
done

echo
echo "== package change logs/changelog mentions =="
rg -n "CHANGELOG|agentcommercekit|`@agentcommercekit`" -S packages .changeset 2>/dev/null | head -n 80

Repository: agentcommercekit/ack

Length of output: 16114


Use the version of the packages being published.

A Changesets linked group does not publish every member on each release. Packages in a linked group are versioned only when they have active changesets, so the agentcommercekit version can stay stale while a scoped package publishes a newer version.

  • .github/scripts/unpublished-packages.mjs#L70-L80: derive the release version from the unpublished package records and fail if one run contains multiple release versions. Use a fixed group if every package must always publish together.
  • .github/workflows/publish.yaml#L208-234: create release metadata from the verified published-package version, not the umbrella manifest version.
📍 Affects 2 files
  • .github/scripts/unpublished-packages.mjs#L70-L80 (this comment)
  • .github/workflows/publish.yaml#L208-L234
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/scripts/unpublished-packages.mjs around lines 70 - 80, Update
.github/scripts/unpublished-packages.mjs lines 70-80 to derive the release
version from the unpublished package records, verify that all records share one
version, and fail when multiple versions are present; do not use the umbrella
package version. Update .github/workflows/publish.yaml lines 208-234 to generate
release metadata from this verified published-package version, with no direct
change required to other sites.

"",
].join("\n"),
)
}
251 changes: 251 additions & 0 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
name: Publish

# Phase 2 of the two-phase changesets release: publish the versions already on
# main to npm. Phase 1 is release.yaml, which opens the version-bump PR.
#
# This is the release button: dispatch it from main after the version PR merges.
# Nothing publishes automatically on merge -- the `verify` job re-runs the full
# check suite and the `publish` job sits behind the `npm-publish` environment,
# so a human approves the actual registry write.
#
# npm auth is a granular access token (NPM_TOKEN), not OIDC trusted publishing.
# `changeset publish` shells out to `pnpm publish` in a pnpm workspace, and pnpm
# 11 supports neither trusted publishing nor --provenance (only --otp). Moving
# to OIDC would mean packing tarballs and publishing them with npm directly,
# bypassing changesets' publish path.
#
# Tags are created through the GitHub API rather than `git push`, so no
# push-capable git credential is ever persisted in the runner's checkout.

on:
workflow_dispatch: {}

permissions: {}

# Never cancel in flight: `changeset publish` walks the packages one at a time
# and a cancelled run leaves a partially published release behind.
concurrency:
group: publish
cancel-in-progress: false

jobs:
verify:
name: Verify
if: github.repository == 'agentcommercekit/ack'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
outputs:
version: ${{ steps.unpublished.outputs.version }}
count: ${{ steps.unpublished.outputs.count }}
packages: ${{ steps.unpublished.outputs.packages }}
steps:
- name: Refuse if not dispatched from main
env:
REF: ${{ github.ref }}
run: |
set -euo pipefail
if [ "$REF" != "refs/heads/main" ]; then
echo "Refusing: must dispatch from main, got $REF" >&2
exit 1
fi

- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
persist-credentials: false

- uses: ./.github/actions/setup

# Decide what would be published BEFORE spending 20 minutes on the check
# suite. Dispatching with nothing to publish is a mistake worth surfacing
# immediately -- usually it means the version PR was never merged.
- name: Determine packages to publish
id: unpublished
run: node .github/scripts/unpublished-packages.mjs

- name: Refuse if there is nothing to publish
env:
COUNT: ${{ steps.unpublished.outputs.count }}
run: |
set -euo pipefail
if [ "$COUNT" = "0" ]; then
echo "Refusing: every workspace package version is already on npm." >&2
echo "Merge the version PR from the Release workflow first." >&2
exit 1
fi
Comment on lines +68 to +77

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Allow recovery after npm publication succeeds.

If changeset publish succeeds and a later tag or release call fails, a rerun sees zero unpublished versions after npm registry visibility catches up. This gate then exits before it can create the missing tags or GitHub release.

Track the expected release state before the npm write. If no npm publication remains but tags or the release are absent, skip changeset publish and repair the remote release state. Reject only when both npm and GitHub release state are complete.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/publish.yaml around lines 68 - 77, Update the publish
workflow around the “Refuse if there is nothing to publish” gate to record the
expected release state before running changeset publish. When unpublished count
is zero, check whether the corresponding tags and GitHub release exist; skip npm
publication and continue with repair when either is missing, and reject only
when npm, tags, and the GitHub release are all complete.


# Verifies the tree that is about to ship, not a cached approximation of
# it. `check` runs build + lint (including type checking) + format + test.
- name: Run full check suite
env:
ANTHROPIC_API_KEY: secret
ISSUER_PRIVATE_KEY: "0xa45f5c566918ef954e8c200a96b14092cabcd69cb8a1a132804a2b8cbb8489a1"
VERIFIER_PRIVATE_KEY: "0xeeca8f89b2f5196126f7d9199e739153bd43a13f9cdd1099e7191a33143a2059"
run: pnpm run check

- name: Verify registry signatures of the dependency tree
run: pnpm audit signatures

- name: Summary
env:
VERSION: ${{ steps.unpublished.outputs.version }}
COUNT: ${{ steps.unpublished.outputs.count }}
PACKAGES: ${{ steps.unpublished.outputs.packages }}
run: |
{
echo "## Verify"
echo ""
echo "- Release version: **v$VERSION**"
echo "- Packages to publish: **$COUNT**"
echo ""
for package in $PACKAGES; do
echo " - \`$package\`"
done
echo ""
echo "Awaiting approval on the \`npm-publish\` environment."
} >> "$GITHUB_STEP_SUMMARY"

publish:
name: Publish to npm
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 20
# Approval gate. Configure `npm-publish` with required reviewers and limit
# its deployment branches to `main`; NPM_TOKEN lives here as an environment
# secret so no other workflow in the repo can reach it.
environment: npm-publish
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
persist-credentials: false
# changeset publish tags each released package; it needs the existing
# tags to know which ones already exist.
fetch-depth: 0
fetch-tags: true

- uses: ./.github/actions/setup

- name: Build packages
run: pnpm run build

# Written literally, with no token in the file: both npm and pnpm expand
# ${NPM_TOKEN} from the environment when reading a user-level .npmrc, so
# the secret exists only in the publish step's env and never on disk.
- name: Configure npm auth
run: |
set -euo pipefail
# shellcheck disable=SC2016 # npm and pnpm expand ${NPM_TOKEN} when they
# read the file; expanding it here would write the secret to disk.
printf '//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n' >> "$HOME/.npmrc"

# changesets creates annotated tags, which need an identity to sign off.
- name: Configure git identity
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

# Sorted explicitly: `comm` below assumes both inputs are in the same
# collation order, which git's default refname sort does not guarantee
# across locales.
- name: Record pre-existing tags
run: git tag --list | LC_ALL=C sort > /tmp/tags-before.txt

- name: Publish
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: pnpm exec changeset publish

# changeset publish tags locally. Create the refs through the API rather
# than pushing, so the job never holds a push-capable git credential.
- name: Push new tags
id: tags
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
SHA: ${{ github.sha }}
run: |
set -euo pipefail
git tag --list | LC_ALL=C sort > /tmp/tags-after.txt
new_tags=$(comm -13 /tmp/tags-before.txt /tmp/tags-after.txt)
if [ -z "$new_tags" ]; then
echo "Refusing: publish created no new tags; nothing was released." >&2
exit 1
fi
for tag in $new_tags; do
# Singular git/ref/ is the exact-match endpoint; the plural form
# falls back to prefix matching, which would report a hit for an
# unrelated longer tag.
if gh api "repos/$REPO/git/ref/tags/$tag" >/dev/null 2>&1; then
echo "Tag $tag already exists on the remote; skipping."
continue
fi
# Create an annotated tag object rather than a lightweight ref, to
# match every tag this repo has published so far and to leave
# something a future `git verify-tag` can check.
tag_sha=$(gh api -X POST "repos/$REPO/git/tags" \
-f "tag=$tag" \
-f "message=$tag" \
-f "object=$SHA" \
-f "type=commit" \
--jq .sha)
gh api -X POST "repos/$REPO/git/refs" \
-f "ref=refs/tags/$tag" \
-f "sha=$tag_sha" >/dev/null
echo "Created annotated tag $tag at $SHA"
done
{
echo "new_tags<<EOF"
printf '%s\n' "$new_tags"
echo "EOF"
} >> "$GITHUB_OUTPUT"

# One release per version, not per package: all eight packages are
# version-locked, so eight near-identical releases would be noise.
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
VERSION: ${{ needs.verify.outputs.version }}
PACKAGES: ${{ needs.verify.outputs.packages }}
run: |
set -euo pipefail
tag="agentcommercekit@$VERSION"
if gh release view "$tag" --repo "$REPO" >/dev/null 2>&1; then
echo "Release $tag already exists; skipping."
exit 0
fi
notes=$(
echo "Published to npm:"
echo ""
for package in $PACKAGES; do
echo "- [\`$package@$VERSION\`](https://www.npmjs.com/package/$package/v/$VERSION)"
done
echo ""
echo "See each package's \`CHANGELOG.md\` for the full set of changes."
)
gh release create "$tag" --repo "$REPO" \
--title "v$VERSION" \
--notes "$notes"

- name: Summary
env:
VERSION: ${{ needs.verify.outputs.version }}
NEW_TAGS: ${{ steps.tags.outputs.new_tags }}
run: |
{
echo "## Published"
echo ""
echo "Release **v$VERSION** is live on npm."
echo ""
echo "Tags created:"
echo ""
for tag in $NEW_TAGS; do
echo "- \`$tag\`"
done
} >> "$GITHUB_STEP_SUMMARY"
Loading
Loading