Publish Package #37
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Model: AgentWorkforce/relayfile/.github/workflows/publish.yml. | |
| # Configure each npm trusted publisher for AgentWorkforce/flows, publish.yml. | |
| name: Publish Package | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| package: | |
| description: Package to publish (single-package selections are dry-run only) | |
| required: true | |
| type: choice | |
| options: [all, surface, sdk, runtime-linux-x64, runtime-darwin-arm64, relayflows] | |
| default: all | |
| version: | |
| description: Version bump type | |
| required: true | |
| type: choice | |
| options: [patch, minor, major, prepatch, preminor, premajor, prerelease] | |
| default: patch | |
| custom_version: | |
| description: Custom version (overrides bump type) | |
| required: false | |
| type: string | |
| preid: | |
| description: Prerelease identifier | |
| type: choice | |
| options: [beta, alpha, rc] | |
| default: beta | |
| dry_run: | |
| description: Dry run (build, pack and verify without publishing) | |
| type: boolean | |
| default: true | |
| tag: | |
| description: NPM dist-tag | |
| type: choice | |
| options: [latest, next, beta, alpha] | |
| default: latest | |
| concurrency: | |
| group: publish-package | |
| cancel-in-progress: false | |
| permissions: | |
| contents: write | |
| id-token: write | |
| env: | |
| NPM_CONFIG_FUND: 'false' | |
| jobs: | |
| build: | |
| name: Build & Version | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 30 | |
| outputs: | |
| new_version: ${{ steps.bump.outputs.new_version }} | |
| is_prerelease: ${{ steps.bump.outputs.is_prerelease }} | |
| steps: | |
| - name: Validate release mode | |
| env: | |
| PACKAGE: ${{ inputs.package }} | |
| DRY_RUN: ${{ inputs.dry_run }} | |
| REF_TYPE: ${{ github.ref_type }} | |
| run: | | |
| if [[ "$DRY_RUN" != true && ( "$PACKAGE" != all || "$REF_TYPE" != branch ) ]]; then | |
| echo 'Real releases require package=all and a branch: all versions and internal dependencies advance together.' >&2 | |
| exit 1 | |
| fi | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: '22' | |
| cache: npm | |
| cache-dependency-path: packages/sdk/package-lock.json | |
| registry-url: https://registry.npmjs.org | |
| - uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: '1.4.0' | |
| - uses: dtolnay/rust-toolchain@stable | |
| - name: Install build dependencies | |
| run: | | |
| npm install --prefix packages/surface --ignore-scripts | |
| npm ci --prefix packages/sdk --ignore-scripts | |
| - name: Test release tooling | |
| run: node --test scripts/publish.test.mjs | |
| - name: Version all packages | |
| id: bump | |
| env: | |
| CUSTOM_VERSION: ${{ inputs.custom_version }} | |
| VERSION_TYPE: ${{ inputs.version }} | |
| PREID: ${{ inputs.preid }} | |
| run: node scripts/version-packages.mjs | |
| - name: Build surface | |
| working-directory: packages/surface | |
| run: ./node_modules/.bin/tsc | |
| - name: Pack and assert surface | |
| id: surface | |
| run: node scripts/pack-release.mjs surface | |
| # Install the actual packed surface, without saving a file: dependency. | |
| # npm ci's development link must not be the SDK's build-time dependency. | |
| - name: Build SDK against packed surface | |
| env: | |
| SURFACE_TARBALL: ${{ steps.surface.outputs.tarball }} | |
| working-directory: packages/sdk | |
| run: | | |
| npm install --no-save --package-lock=false --ignore-scripts "$SURFACE_TARBALL" | |
| test ! -L node_modules/@relayflows/surface | |
| ./node_modules/.bin/tsc | |
| node scripts/make-cli-executable.mjs | |
| - name: Pack and assert SDK | |
| id: sdk | |
| run: node scripts/pack-release.mjs sdk | |
| # relayflows only re-exposes the SDK's CLI under the unscoped name, so it | |
| # builds against the packed SDK the same way the SDK builds against the | |
| # packed surface — and it's cheap enough to smoke-test for real here | |
| # rather than only asserting the tarball's shape in pack-release.mjs. | |
| # | |
| # Both tarballs, not just the SDK's: relayflows doesn't depend on | |
| # @relayflows/surface directly, but the SDK tarball does (transitively), | |
| # at this same freshly-bumped, never-published version. Installing only | |
| # the SDK tarball leaves npm to resolve that transitive dependency from | |
| # the real registry — which 404s on every version bump, since nothing | |
| # is published yet at build time. Feeding the surface tarball too | |
| # satisfies it locally, the same reason the SDK step above installs the | |
| # surface tarball rather than letting its own dependency resolve remotely. | |
| - name: Build relayflows CLI wrapper against packed SDK | |
| env: | |
| SDK_TARBALL: ${{ steps.sdk.outputs.tarball }} | |
| SURFACE_TARBALL: ${{ steps.surface.outputs.tarball }} | |
| working-directory: packages/relayflows | |
| run: | | |
| npm install --no-save --package-lock=false --ignore-scripts "$SDK_TARBALL" "$SURFACE_TARBALL" | |
| test ! -L node_modules/@relayflows/sdk | |
| report=$(node bin/flows.js check --json ../../testdata/hello-deterministic.flow.yaml) | |
| echo "$report" | node -e ' | |
| const report = JSON.parse(require("fs").readFileSync(0, "utf8")); | |
| if (report.ok !== true) { console.error(report); process.exit(1); } | |
| ' | |
| - name: Pack and assert relayflows CLI wrapper | |
| run: node scripts/pack-release.mjs relayflows | |
| - name: Build relayflowd | |
| working-directory: kernel | |
| run: cargo build --locked --release -p relayflowd | |
| - name: Build and execute runtime binaries | |
| run: | | |
| mkdir -p packages/runtime-linux-x64/bin | |
| cp kernel/target/release/relayflowd packages/runtime-linux-x64/bin/relayflowd | |
| node scripts/build-standalone-cli.mjs bun-linux-x64 packages/runtime-linux-x64/bin/flows | |
| chmod +x packages/runtime-linux-x64/bin/relayflowd packages/runtime-linux-x64/bin/flows | |
| packages/runtime-linux-x64/bin/relayflowd --help | |
| packages/runtime-linux-x64/bin/flows check --json testdata/hello-deterministic.flow.yaml | |
| - name: Pack and assert runtime (executes both unpacked binaries) | |
| run: node scripts/pack-release.mjs runtime-linux-x64 | |
| - name: Upload build artifacts | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: build-output | |
| path: dist/publish/*.tgz | |
| if-no-files-found: error | |
| retention-days: 7 | |
| # A native macOS job, not a cross-compile from the linux `build` job above. | |
| # relayflowd's release build has no cross-compile setup (no osxcross, no | |
| # macOS SDK on the linux runner), and macos-14 runners are Apple Silicon, so | |
| # `cargo build --release` already targets aarch64-apple-darwin natively — | |
| # the same reason no `--target` flag is needed below. | |
| # | |
| # This job independently re-derives `new_version` by re-running | |
| # version-packages.mjs with the same workflow inputs against the same | |
| # commit as the `build` job. `npm version <bump>` is a pure function of the | |
| # committed version and the bump type, so both jobs compute the identical | |
| # version without either depending on the other's output — the alternative | |
| # (pass new_version as a job output) would force this job to wait on `build` | |
| # for no reason; they can run in parallel instead. | |
| build-darwin-arm64: | |
| name: Build & pack darwin-arm64 runtime | |
| runs-on: macos-14 | |
| timeout-minutes: 30 | |
| steps: | |
| - name: Validate release mode | |
| env: | |
| PACKAGE: ${{ inputs.package }} | |
| DRY_RUN: ${{ inputs.dry_run }} | |
| REF_TYPE: ${{ github.ref_type }} | |
| run: | | |
| if [[ "$DRY_RUN" != true && ( "$PACKAGE" != all || "$REF_TYPE" != branch ) ]]; then | |
| echo 'Real releases require package=all and a branch: all versions and internal dependencies advance together.' >&2 | |
| exit 1 | |
| fi | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: '22' | |
| - uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: '1.4.0' | |
| - uses: dtolnay/rust-toolchain@stable | |
| - name: Install build dependencies | |
| run: | | |
| npm install --prefix packages/surface --ignore-scripts | |
| npm ci --prefix packages/sdk --ignore-scripts | |
| - name: Version all packages | |
| env: | |
| CUSTOM_VERSION: ${{ inputs.custom_version }} | |
| VERSION_TYPE: ${{ inputs.version }} | |
| PREID: ${{ inputs.preid }} | |
| run: node scripts/version-packages.mjs | |
| - name: Build surface | |
| working-directory: packages/surface | |
| run: ./node_modules/.bin/tsc | |
| - name: Pack and assert surface | |
| id: surface | |
| run: node scripts/pack-release.mjs surface | |
| # Only the SDK's module graph is needed here (bun bundles cli-executable.ts | |
| # straight from source) — not a tsc build, which is the SDK's own | |
| # publishable dist and unrelated to the standalone bun binary below. | |
| - name: Install SDK against packed surface | |
| env: | |
| SURFACE_TARBALL: ${{ steps.surface.outputs.tarball }} | |
| working-directory: packages/sdk | |
| run: | | |
| npm install --no-save --package-lock=false --ignore-scripts "$SURFACE_TARBALL" | |
| test ! -L node_modules/@relayflows/surface | |
| - name: Build relayflowd | |
| working-directory: kernel | |
| run: cargo build --locked --release -p relayflowd | |
| - name: Build and execute runtime binaries | |
| run: | | |
| mkdir -p packages/runtime-darwin-arm64/bin | |
| cp kernel/target/release/relayflowd packages/runtime-darwin-arm64/bin/relayflowd | |
| node scripts/build-standalone-cli.mjs bun-darwin-arm64 packages/runtime-darwin-arm64/bin/flows | |
| chmod +x packages/runtime-darwin-arm64/bin/relayflowd packages/runtime-darwin-arm64/bin/flows | |
| packages/runtime-darwin-arm64/bin/relayflowd --help | |
| packages/runtime-darwin-arm64/bin/flows check --json testdata/hello-deterministic.flow.yaml | |
| - name: Pack and assert runtime (executes both unpacked binaries) | |
| run: node scripts/pack-release.mjs runtime-darwin-arm64 | |
| - name: Upload build artifacts | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: build-output-darwin-arm64 | |
| path: dist/publish/*.tgz | |
| if-no-files-found: error | |
| retention-days: 7 | |
| publish-packages: | |
| name: Publish packages in dependency order | |
| needs: [build, build-darwin-arm64] | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 30 # publish + up to 10 minutes of registry propagation wait | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| ref: ${{ github.sha }} | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: '22' | |
| registry-url: https://registry.npmjs.org | |
| # OIDC requires npm >=11.5.1. Keep Node 22 compatible with npm's major. | |
| - name: Update npm for OIDC support | |
| run: npm install -g npm@11 | |
| - uses: actions/download-artifact@v4 | |
| with: | |
| name: build-output | |
| path: dist/build-output | |
| - uses: actions/download-artifact@v4 | |
| with: | |
| name: build-output-darwin-arm64 | |
| path: dist/build-output | |
| # relayflows is the one unscoped package, so `npm pack` names its tarball | |
| # `relayflows-<version>.tgz`, not `relayflows-relayflows-<version>.tgz`. | |
| - name: Restore built packages | |
| env: | |
| NEW_VERSION: ${{ needs.build.outputs.new_version }} | |
| run: | | |
| for package in surface sdk runtime-linux-x64 runtime-darwin-arm64 relayflows; do | |
| if [[ "$package" == relayflows ]]; then | |
| tarball="relayflows-${NEW_VERSION}.tgz" | |
| else | |
| tarball="relayflows-${package}-${NEW_VERSION}.tgz" | |
| fi | |
| tar -xzf "dist/build-output/$tarball" --strip-components=1 -C "packages/$package" | |
| done | |
| # Repack and check EVERYTHING before the first publish. Tar archives | |
| # preserve executable bits across Actions artifact upload/download. | |
| # runtime-darwin-arm64's own execution smoke does not re-run here — this | |
| # runner is linux, so pack-release.mjs asserts its shape only, per the | |
| # foreign-host skip described where that check lives. It already ran for | |
| # real on the macos-14 runner that built it. | |
| - name: Pack and assert all release tarballs | |
| run: | | |
| node scripts/pack-release.mjs surface | |
| node scripts/pack-release.mjs sdk | |
| node scripts/pack-release.mjs runtime-linux-x64 | |
| node scripts/pack-release.mjs runtime-darwin-arm64 | |
| node scripts/pack-release.mjs relayflows | |
| - name: Publish to NPM (surface before SDK, relayflows last) | |
| env: | |
| PACKAGE: ${{ inputs.package }} | |
| NEW_VERSION: ${{ needs.build.outputs.new_version }} | |
| DRY_RUN: ${{ inputs.dry_run }} | |
| NPM_TAG: ${{ inputs.tag }} | |
| run: | | |
| for package in surface sdk runtime-linux-x64 runtime-darwin-arm64 relayflows; do | |
| if [[ "$PACKAGE" != all && "$PACKAGE" != "$package" ]]; then continue; fi | |
| if [[ "$package" == relayflows ]]; then | |
| tarball="relayflows-${NEW_VERSION}.tgz" | |
| else | |
| tarball="relayflows-${package}-${NEW_VERSION}.tgz" | |
| fi | |
| args=() | |
| if [[ "$DRY_RUN" == true ]]; then args+=(--dry-run); fi | |
| npm publish "dist/publish/$tarball" \ | |
| --access public --provenance --ignore-scripts --tag "$NPM_TAG" "${args[@]}" | |
| done | |
| - name: Wait for the registry to serve every published version | |
| if: ${{ !inputs.dry_run }} | |
| env: | |
| PACKAGE: ${{ inputs.package }} | |
| NEW_VERSION: ${{ needs.build.outputs.new_version }} | |
| # `npm publish` above already fetched and cached each packument from | |
| # before the new version existed, and npm honors the registry's | |
| # 5-minute max-age on that cache. Bypass it or this loop can spin on | |
| # a stale copy after the registry has caught up. | |
| NPM_CONFIG_PREFER_ONLINE: 'true' | |
| run: | | |
| # `npm publish` returns before the registry serves the new version. | |
| # The lockfile regeneration below resolves the just-published | |
| # packages from the registry, and on the v2.0.10 release it hit | |
| # ETARGET for @relayflows/surface@2.0.10 seconds after that publish | |
| # succeeded, which skipped the release commit and tag. Poll the | |
| # packument for each version and probe its tarball (the CDN edge can | |
| # still 404 after the packument lists it) before touching lockfiles. | |
| for package in surface sdk runtime-linux-x64 runtime-darwin-arm64 relayflows; do | |
| if [[ "$PACKAGE" != all && "$PACKAGE" != "$package" ]]; then continue; fi | |
| if [[ "$package" == relayflows ]]; then name=relayflows; else name="@relayflows/$package"; fi | |
| spec="$name@$NEW_VERSION" | |
| ready=false | |
| for attempt in $(seq 1 20); do | |
| tarball=$(npm view "$spec" dist.tarball 2>/dev/null || true) | |
| if [[ -n "$tarball" ]]; then | |
| status=$(curl -sS -o /dev/null -w '%{http_code}' -I \ | |
| --connect-timeout 5 --max-time 15 "$tarball" || echo 000) | |
| if [[ "$status" == 200 ]]; then | |
| echo "$spec is served (attempt $attempt)" | |
| ready=true | |
| break | |
| fi | |
| echo "$spec listed but tarball returned HTTP $status (attempt $attempt/20)" | |
| else | |
| echo "$spec not yet in the registry (attempt $attempt/20)" | |
| fi | |
| sleep 30 | |
| done | |
| if [[ "$ready" != true ]]; then | |
| echo "::error::$spec was published but is still not served after 10 minutes; finish the release by rerunning this job once it propagates." | |
| exit 1 | |
| fi | |
| done | |
| - name: Regenerate release lockfiles | |
| if: ${{ !inputs.dry_run }} | |
| env: | |
| NEW_VERSION: ${{ needs.build.outputs.new_version }} | |
| NPM_CONFIG_PREFER_ONLINE: 'true' # same stale-packument cache as the wait step above | |
| run: | | |
| npm install --prefix packages/surface --package-lock-only --ignore-scripts | |
| npm install --prefix packages/sdk --package-lock-only --ignore-scripts --save-exact "@relayflows/surface@$NEW_VERSION" | |
| # No explicit --save-exact target here, unlike the SDK line above. | |
| # package.json (restored from the just-published tarball) already | |
| # carries the exact new-version pin for every dependency, including | |
| # both runtime-* optionalDependencies — so a bare install is enough | |
| # to record them, and it has to be bare: a targeted `install pkg@v` | |
| # only reconciles that one named package and, found the hard way | |
| # against this exact command on a real release run, silently drops | |
| # OTHER already-resolved entries rather than refreshing them — | |
| # emptying node_modules/@relayflows/runtime-linux-x64 out of the | |
| # lockfile entirely and leaving `npm ci` downstream refusing with | |
| # "Missing ... from lock file" for both runtime packages at once. | |
| npm install --prefix packages/relayflows --package-lock-only --ignore-scripts | |
| node --input-type=module - <<'NODE' | |
| import assert from 'node:assert/strict'; | |
| import { readFileSync } from 'node:fs'; | |
| const version = process.env.NEW_VERSION; | |
| const check = (lockPath, depNodeModulesPath) => { | |
| const lock = JSON.parse(readFileSync(lockPath, 'utf8')); | |
| const dep = lock.packages[depNodeModulesPath]; | |
| assert.equal(dep.version, version); | |
| assert.match(dep.resolved, /^https:\/\/registry\.npmjs\.org\//); | |
| assert(!dep.link, `release lockfile must resolve the published dependency (${lockPath})`); | |
| }; | |
| check('packages/sdk/package-lock.json', 'node_modules/@relayflows/surface'); | |
| check('packages/relayflows/package-lock.json', 'node_modules/@relayflows/sdk'); | |
| // Deliberately not asserting the two runtime-* optionalDependencies | |
| // resolve here. Empirically (found the hard way: a real release run | |
| // against a linux runner), npm's package-lock v3 writer only fully | |
| // resolves the optional-dependency variant matching the CURRENT | |
| // machine's os/cpu — it fetches the others' packuments but omits | |
| // their node_modules/ entries entirely, and can even drop an | |
| // existing entry for the *matching* platform when the install is a | |
| // targeted/incremental one rather than a from-scratch resolve. This | |
| // isn't a correctness problem: `npm install -g relayflows` (the | |
| // thing that actually matters) never consults this lockfile — a | |
| // global install always resolves fresh from package.json against | |
| // the installing machine's own os/cpu. This lockfile is monorepo | |
| // tooling hygiene (`npm ci` here), not an end-user install path. | |
| NODE | |
| npm ci --prefix packages/surface --dry-run --ignore-scripts | |
| npm ci --prefix packages/sdk --dry-run --ignore-scripts | |
| # `npm ci` (unlike plain `npm install`) refuses outright — before | |
| # even looking at --omit — if the lockfile is missing an entry for | |
| # ANY package.json dependency, optional included. Confirmed on two | |
| # separate real release runs that which of the two runtime-* | |
| # optionalDependencies ends up resolved by the installs above is | |
| # not reliably both from a single linux runner (one run dropped | |
| # both, the next dropped only the one matching this runner's own | |
| # platform) — an npm quirk with platform-gated optional deps in | |
| # --package-lock-only mode, not something worth chasing further. | |
| # `npm install --dry-run` verifies the same "is this installable" | |
| # question without that all-or-nothing gate. | |
| npm install --prefix packages/relayflows --dry-run --package-lock-only --ignore-scripts | |
| - name: Commit version bump and create tag | |
| if: ${{ !inputs.dry_run }} | |
| env: | |
| NEW_VERSION: ${{ needs.build.outputs.new_version }} | |
| RELEASE_BRANCH: ${{ github.ref_name }} | |
| run: | | |
| git config user.name 'GitHub Actions' | |
| git config user.email 'actions@github.com' | |
| git add packages/surface/package.json packages/surface/package-lock.json \ | |
| packages/sdk/package.json packages/sdk/package-lock.json \ | |
| packages/runtime-linux-x64/package.json \ | |
| packages/runtime-darwin-arm64/package.json \ | |
| packages/relayflows/package.json packages/relayflows/package-lock.json | |
| if ! git diff --staged --quiet; then | |
| git commit -m "chore(release): v${NEW_VERSION}" | |
| fi | |
| git tag -a "v${NEW_VERSION}" -m "Release v${NEW_VERSION}" | |
| git push --atomic origin "HEAD:refs/heads/${RELEASE_BRANCH}" "refs/tags/v${NEW_VERSION}" | |
| - name: Create GitHub Release | |
| if: ${{ !inputs.dry_run }} | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: v${{ needs.build.outputs.new_version }} | |
| name: v${{ needs.build.outputs.new_version }} | |
| prerelease: ${{ needs.build.outputs.is_prerelease == 'true' }} | |
| generate_release_notes: true | |
| files: dist/publish/*.tgz | |
| - name: Summary | |
| if: always() | |
| env: | |
| NEW_VERSION: ${{ needs.build.outputs.new_version }} | |
| PACKAGE: ${{ inputs.package }} | |
| DRY_RUN: ${{ inputs.dry_run }} | |
| NPM_TAG: ${{ inputs.tag }} | |
| RESULT: ${{ job.status }} | |
| run: | | |
| { | |
| echo "Package: $PACKAGE" | |
| echo "Version: $NEW_VERSION" | |
| echo "NPM tag: $NPM_TAG" | |
| echo "Dry run: $DRY_RUN" | |
| echo "Publish job: $RESULT" | |
| } >> "$GITHUB_STEP_SUMMARY" |