Skip to content

Commit af21446

Browse files
authored
Merge pull request #1026 from cipherstash/fix/release-publish-race
fix(release): wait for npm before changeset publish, and decide the EQL assets from npm and the tags
2 parents 7f75b37 + d4a9555 commit af21446

8 files changed

Lines changed: 1612 additions & 294 deletions

‎.github/workflows/release.yml‎

Lines changed: 109 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,10 @@ jobs:
220220
permissions:
221221
contents: write # the seven git tags and the GitHub release
222222
id-token: write # npm OIDC trusted publishing
223+
# `release` waits for npm to serve every one of these before it runs
224+
# `changeset publish`. Empty when this job is skipped.
225+
outputs:
226+
published: ${{ steps.publish.outputs.published }}
223227
steps:
224228
- uses: actions/download-artifact@v4
225229
with:
@@ -289,7 +293,10 @@ jobs:
289293
published+=("${name}@${version}")
290294
done
291295
printf '%s\n' "${published[@]}" > published.txt
292-
echo "version=$(meta "$wrapper" version)" >> "$GITHUB_OUTPUT"
296+
{
297+
echo "version=$(meta "$wrapper" version)"
298+
echo "published=${published[*]}"
299+
} >> "$GITHUB_OUTPUT"
293300
294301
# Changesets tags only what IT published — `tagPublish` receives
295302
# `publishedPackages.filter(p => p.result === "published")` — and it skips
@@ -369,6 +376,9 @@ jobs:
369376
permissions:
370377
contents: write # the seven git tags and the GitHub release
371378
id-token: write # npm OIDC trusted publishing
379+
# Waited for by `release`, as publish-ffi's is.
380+
outputs:
381+
published: ${{ steps.publish.outputs.published }}
372382
steps:
373383
- uses: actions/download-artifact@v4
374384
with:
@@ -420,7 +430,10 @@ jobs:
420430
published+=("${name}@${version}")
421431
done
422432
printf '%s\n' "${published[@]}" > published.txt
423-
echo "version=$(meta "$wrapper" version)" >> "$GITHUB_OUTPUT"
433+
{
434+
echo "version=$(meta "$wrapper" version)"
435+
echo "published=${published[*]}"
436+
} >> "$GITHUB_OUTPUT"
424437
425438
# Changesets tags only what it published itself, so without this an auth
426439
# release has no git tag and no GitHub release. The same idempotent tag
@@ -488,12 +501,12 @@ jobs:
488501
id-token: write # npm OIDC trusted publishing
489502
contents: write # changesets commits and pushes the Version Packages branch
490503
pull-requests: write # …and opens/updates the PR for it
491-
# `published` alone is not enough: this job publishes every unpublished JS
492-
# package, and a `@cipherstash/stack` release must not fire an EQL one.
504+
# Read by `eql-assets`. Straight from the step, with no step in between:
505+
# changesets/action sets it for whatever it published even when
506+
# `changeset publish` then fails, and a job output is evaluated when the
507+
# job ends, whatever its result.
493508
outputs:
494-
eql_published: ${{ steps.eql.outputs.eql_published }}
495-
eql_version: ${{ steps.eql.outputs.eql_version }}
496-
eql_prerelease: ${{ steps.eql.outputs.eql_prerelease }}
509+
published_packages: ${{ steps.changesets.outputs.publishedPackages }}
497510
steps:
498511
- name: Checkout Repo
499512
uses: actions/checkout@v6
@@ -591,6 +604,19 @@ jobs:
591604
add_shims_to_path: false
592605
env: false
593606

607+
# BEFORE `changeset publish`. npm accepts a publish minutes before its
608+
# package document lists the version, and `changeset publish` publishes
609+
# every version that document does not list — the native packages again,
610+
# from the workspace, with `restricted` access, which npm refuses with
611+
# E402. That failed this job for protect-ffi 0.33.0 and for
612+
# @cipherstash/auth 0.44.1. See scripts/wait-for-npm-versions.mjs.
613+
- name: Wait for npm to list the native packages
614+
timeout-minutes: 20
615+
env:
616+
FFI_PUBLISHED: ${{ needs.publish-ffi.outputs.published }}
617+
AUTH_PUBLISHED: ${{ needs.publish-auth.outputs.published }}
618+
run: node scripts/wait-for-npm-versions.mjs
619+
594620
- name: Publish to npm
595621
id: changesets
596622
uses: changesets/action@v1.9.0
@@ -620,84 +646,108 @@ jobs:
620646
# gh variable set STASH_POSTHOG_KEY --repo cipherstash/stack --body '<phc_...>'
621647
STASH_POSTHOG_KEY: ${{ vars.STASH_POSTHOG_KEY }}
622648

623-
# Read from `publishedPackages` rather than from the tree, so the SQL
624-
# release, docs bundle and image tag agree with what reached npm. A re-run
625-
# against an already-published version finds nothing and the EQL branch
626-
# skips, which is correct.
627-
- name: Resolve the published EQL version
628-
id: eql
629-
if: steps.changesets.outputs.published == 'true'
630-
env:
631-
PUBLISHED: ${{ steps.changesets.outputs.publishedPackages }}
632-
run: |
633-
set -euo pipefail
634-
version="$(node -e "const p=JSON.parse(process.env.PUBLISHED);const e=p.find(x=>x.name==='@cipherstash/eql');console.log(e ? e.version : '')")"
635-
if [ -z "$version" ]; then
636-
echo "@cipherstash/eql was not part of this release"
637-
exit 0
638-
fi
639-
if [[ "$version" == *-* ]]; then
640-
prerelease=true
641-
else
642-
prerelease=false
643-
fi
644-
echo "@cipherstash/eql@${version} published (prerelease=${prerelease})"
645-
{
646-
echo "eql_published=true"
647-
echo "eql_version=${version}"
648-
echo "eql_prerelease=${prerelease}"
649-
} >> "$GITHUB_OUTPUT"
650-
651649
# ---- The EQL release line: production ------------------------------------
652650
#
653651
# EQL ships as five artefacts at one version. Changesets publishes the npm
654652
# package (above) and release-plz.yml the crate on the same push; the rest are
655-
# built here, in the run that published, so they cannot drift from it.
653+
# built here.
654+
#
655+
# `eql-assets` decides from npm and the tags, not from this run, so a run
656+
# whose `changeset publish` failed still builds them, and so does the next
657+
# push to main if that run never got this far. See
658+
# scripts/eql-release-assets.mjs.
659+
#
660+
# `!cancelled()`, NOT the implicit `success()`, on all four: `success()`
661+
# is false when ANY job up the `needs:` chain was skipped or failed, and
662+
# `publish-ffi` and `publish-auth` are skipped on most releases. Each job
663+
# names the results it does need instead. `always()` would also do that, and
664+
# would keep them running after somebody cancelled the run.
656665
#
657666
# The `needs:` chain is load-bearing: `eql-docs` attaches to the release
658667
# `eql-sql` creates, and `eql-image` dispatches against the tag it produced.
659668

669+
eql-assets:
670+
name: Does EQL still need its release assets?
671+
needs: [classify, gate, release]
672+
if: >-
673+
!cancelled() &&
674+
needs.classify.outputs.mode == 'production' &&
675+
needs.gate.result == 'success'
676+
runs-on: ubuntu-latest
677+
timeout-minutes: 5
678+
outputs:
679+
needed: ${{ steps.eql.outputs.needed }}
680+
version: ${{ steps.eql.outputs.version }}
681+
prerelease: ${{ steps.eql.outputs.prerelease }}
682+
ref: ${{ steps.eql.outputs.ref }}
683+
steps:
684+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
685+
with:
686+
persist-credentials: false
687+
688+
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
689+
with:
690+
node-version: 22
691+
package-manager-cache: false
692+
693+
# No pnpm install: the script imports node builtins only, as `gate` does.
694+
- name: Ask npm and the tags
695+
id: eql
696+
env:
697+
GH_TOKEN: ${{ github.token }}
698+
REPO: ${{ github.repository }}
699+
PUBLISHED_PACKAGES: ${{ needs.release.outputs.published_packages }}
700+
run: node scripts/eql-release-assets.mjs
701+
660702
eql-sql:
661703
name: Build and attach the EQL SQL release
662-
needs: [classify, gate, eql-armed, release]
704+
needs: [classify, eql-armed, eql-assets]
663705
if: >-
706+
!cancelled() &&
664707
needs.classify.outputs.mode == 'production' &&
665708
needs.eql-armed.outputs.armed == 'true' &&
666-
needs.release.outputs.eql_published == 'true'
709+
needs.eql-assets.outputs.needed == 'true'
667710
permissions:
668711
contents: write # creates the eql-<version> tag and release
669712
uses: ./.github/workflows/_build-eql-sql.yml
670713
with:
671-
ref: ${{ github.sha }}
672-
tag: eql-${{ needs.release.outputs.eql_version }}
714+
# The commit npm's tarball was built from, which is this run's commit
715+
# unless this run is repairing an older release.
716+
ref: ${{ needs.eql-assets.outputs.ref }}
717+
tag: eql-${{ needs.eql-assets.outputs.version }}
673718
attach: true
674-
target_commitish: ${{ github.sha }}
675-
prerelease: ${{ needs.release.outputs.eql_prerelease == 'true' }}
719+
target_commitish: ${{ needs.eql-assets.outputs.ref }}
720+
prerelease: ${{ needs.eql-assets.outputs.prerelease == 'true' }}
676721

677722
eql-docs:
678723
name: Build and attach the EQL docs bundle
679-
needs: [classify, gate, eql-armed, release, eql-sql]
724+
needs: [classify, eql-armed, eql-assets, eql-sql]
680725
if: >-
726+
!cancelled() &&
681727
needs.classify.outputs.mode == 'production' &&
682728
needs.eql-armed.outputs.armed == 'true' &&
683-
needs.release.outputs.eql_published == 'true'
729+
needs.eql-assets.outputs.needed == 'true' &&
730+
needs.eql-sql.result == 'success'
684731
permissions:
685732
contents: write # attaches to the release eql-sql just created
686733
uses: ./.github/workflows/_build-eql-docs.yml
687734
with:
688-
ref: ${{ github.sha }}
689-
tag: eql-${{ needs.release.outputs.eql_version }}
735+
ref: ${{ needs.eql-assets.outputs.ref }}
736+
tag: eql-${{ needs.eql-assets.outputs.version }}
690737

691738
eql-image:
692739
name: Dispatch the Postgres + EQL image build
693-
needs: [classify, gate, eql-armed, release, eql-sql, eql-docs]
740+
needs: [classify, eql-armed, eql-assets, eql-sql, eql-docs]
694741
# Production finals only: the floating :latest / :<version> tags must not
695742
# move for a prerelease. An alpha image is still buildable on demand.
696743
if: >-
744+
!cancelled() &&
697745
needs.classify.outputs.mode == 'production' &&
698746
needs.eql-armed.outputs.armed == 'true' &&
699-
needs.release.outputs.eql_published == 'true' &&
700-
needs.release.outputs.eql_prerelease == 'false'
747+
needs.eql-assets.outputs.needed == 'true' &&
748+
needs.eql-assets.outputs.prerelease == 'false' &&
749+
needs.eql-sql.result == 'success' &&
750+
needs.eql-docs.result == 'success'
701751
runs-on: ubuntu-latest
702752
timeout-minutes: 10
703753
permissions:
@@ -710,7 +760,7 @@ jobs:
710760
# the released source even if main has advanced.
711761
env:
712762
GH_TOKEN: ${{ github.token }}
713-
VERSION: ${{ needs.release.outputs.eql_version }}
763+
VERSION: ${{ needs.eql-assets.outputs.version }}
714764
run: |
715765
set -euo pipefail
716766
# `--repo` is required: no checkout, so gh cannot infer it.
@@ -914,13 +964,14 @@ jobs:
914964
- eql-armed
915965
# `gate` and `release` are not EQL jobs, and they are the two that most
916966
# often decide an EQL run does nothing. `gate` exits non-zero for a frozen
917-
# publisher — which is the NORMAL inert state — and skips `release`, whose
918-
# `eql_published` output then gates the production EQL chain. Without them
919-
# here every job below reads `skipped` and the table cannot separate
920-
# "correctly inert" from "the gate refused this release" from "changesets
921-
# failed", which is the distinction this job exists to draw.
967+
# publisher — which is the NORMAL inert state — and skips `release` and
968+
# `eql-assets`, whose `needed` output gates the production EQL chain.
969+
# Without them here every job below reads `skipped` and the table cannot
970+
# separate "correctly inert" from "the gate refused this release" from
971+
# "changesets failed", which is the distinction this job exists to draw.
922972
- gate
923973
- release
974+
- eql-assets
924975
- eql-sql
925976
- eql-docs
926977
- eql-image
@@ -939,6 +990,8 @@ jobs:
939990
ARMED: ${{ needs.eql-armed.outputs.armed }}
940991
GATE: ${{ needs.gate.result }}
941992
RELEASE: ${{ needs.release.result }}
993+
EQL_ASSETS: ${{ needs.eql-assets.result }}
994+
EQL_NEEDED: ${{ needs.eql-assets.outputs.needed }}
942995
EQL_SQL: ${{ needs.eql-sql.result }}
943996
EQL_DOCS: ${{ needs.eql-docs.result }}
944997
EQL_IMAGE: ${{ needs.eql-image.result }}
@@ -959,6 +1012,7 @@ jobs:
9591012
echo "| --- | --- |"
9601013
echo "| gate | ${GATE} |"
9611014
echo "| release (changesets) | ${RELEASE} |"
1015+
echo "| eql-assets (needed: \`${EQL_NEEDED:-n/a}\`) | ${EQL_ASSETS} |"
9621016
echo "| eql-sql | ${EQL_SQL} |"
9631017
echo "| eql-docs | ${EQL_DOCS} |"
9641018
echo "| eql-image | ${EQL_IMAGE} |"

‎AGENTS.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,13 @@ stays optional for everyone else.
207207
bumps nothing is a no-op for all seven. `ffi-preflight.yml` is the dry run
208208
(`changeset publish` has no `--dry-run`); dispatch it against the Version
209209
Packages branch before merging a release that moves an FFI version.
210+
**The `release` job waits for npm to list all seven** (and the seven
211+
`@cipherstash/auth` packages `publish-auth` publishes the same way) before
212+
`changeset publish`, through `scripts/wait-for-npm-versions.mjs`. npm lists
213+
a publish minutes after accepting it, and `changeset publish` publishes any
214+
version npm does not list yet a second time — with `restricted` access, so
215+
npm refuses it with E402 and the job fails. That happened to protect-ffi
216+
0.33.0 and @cipherstash/auth 0.44.1 before the wait existed.
210217
- **Trusted publishing binds to (repository, workflow filename).** Keep
211218
`release.yml` as the single npm entry point; a rename silently invalidates all
212219
seven publisher configurations. Each one must also list `npm publish` under
@@ -361,6 +368,17 @@ monorepo, which is where the silent failures are.
361368
| `lint-release.yml` | merged into the root file of the same name |
362369
| ~~`rebuild-docs.yml`~~ | **not ported.** It targeted the retired docs site through the deprecated `DOCS_WEBHOOK_URL`; versioned docs artifacts are still built by `_build-eql-docs.yml` |
363370

371+
**The SQL, docs and image jobs key on the registry and the tags, not on the
372+
run that published.** `release.yml`'s `eql-assets` job runs
373+
`scripts/eql-release-assets.mjs`, which reports the assets as owed when npm
374+
carries the tree's EQL version and the `eql-<version>` tag does not exist,
375+
and builds them at the commit the `@cipherstash/eql@<version>` tag names. So
376+
a run whose `changeset publish` failed still builds them, and a later push
377+
repairs a release that never got them — EQL 3.0.6 was the first. The four
378+
jobs are `!cancelled() && …` because the implicit `success()` is false when
379+
any job up the `needs:` chain was skipped, and `publish-ffi` and
380+
`publish-auth` are skipped on most releases.
381+
364382
**Inertness is a derived switch, not a flag somebody flips.** The one piece
365383
of state is `FROZEN_PUBLISHERS` in `scripts/release-gate.mjs` — the existing
366384
map recording "this package lives here but is published elsewhere" — and

0 commit comments

Comments
 (0)