-
Notifications
You must be signed in to change notification settings - Fork 113
ci: move the npm release into GitHub Actions #137
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 ?? ""}`, | ||
| "", | ||
| ].join("\n"), | ||
| ) | ||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Track the expected release state before the npm write. If no npm publication remains but tags or the release are absent, skip 🤖 Prompt for AI Agents |
||
|
|
||
| # 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" | ||
There was a problem hiding this comment.
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:
Repository: agentcommercekit/ack
Length of output: 725
🌐 Web query:
For@changesets/cli2.31.0, does alinkedgroup publish every member on each release? Contrastlinkedandfixedpackage groups using version-specific documentation.💡 Result:
For
@changesets/cli2.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:
Repository: agentcommercekit/ack
Length of output: 16114
Use the version of the packages being published.
A Changesets
linkedgroup does not publish every member on each release. Packages in a linked group are versioned only when they have active changesets, so theagentcommercekitversion can stay stale while a scoped package publishes a newer version..github/scripts/unpublished-packages.mjs#L70-L80: derive the release version from theunpublishedpackage records and fail if one run contains multiple release versions. Use afixedgroup 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