Skip to content

Publish Package

Publish Package #37

Workflow file for this run

# 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"