From 1b981b1eb27ad26e4d980dee09eeaab43ff4cab2 Mon Sep 17 00:00:00 2001 From: Miya Date: Sun, 6 Sep 2026 12:46:56 +0200 Subject: [PATCH] ci: publish versioned packages with verified release tarballs Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 Session-Id: c228933d-4f94-4d83-9a9a-daf3c83b94f1 --- .github/workflows/publish.yml | 242 ++++++++++++++++++++++++++++++++++ packages/sdk/package.json | 2 +- packages/surface/package.json | 2 +- scripts/pack-release.mjs | 60 +++++++++ scripts/publish.test.mjs | 102 ++++++++++++++ scripts/version-packages.mjs | 32 +++++ 6 files changed, 438 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/publish.yml create mode 100644 scripts/pack-release.mjs create mode 100644 scripts/publish.test.mjs create mode 100644 scripts/version-packages.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 000000000..903ea473f --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,242 @@ +# 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] + 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 + run: node scripts/pack-release.mjs sdk + - 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 + bun build packages/sdk/src/cli-executable.ts --compile --target=bun-linux-x64 \ + --outfile=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 + + publish-packages: + name: Publish packages in dependency order + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 15 + 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 + - name: Restore built packages + env: + NEW_VERSION: ${{ needs.build.outputs.new_version }} + run: | + for package in surface sdk runtime-linux-x64; do + tar -xzf "dist/build-output/relayflows-${package}-${NEW_VERSION}.tgz" \ + --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. + - 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 + - name: Publish to NPM (surface before SDK) + 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; do + if [[ "$PACKAGE" != all && "$PACKAGE" != "$package" ]]; then continue; fi + args=() + if [[ "$DRY_RUN" == true ]]; then args+=(--dry-run); fi + npm publish "dist/publish/relayflows-${package}-${NEW_VERSION}.tgz" \ + --access public --provenance --ignore-scripts --tag "$NPM_TAG" "${args[@]}" + done + - name: Regenerate release lockfiles + if: ${{ !inputs.dry_run }} + env: + NEW_VERSION: ${{ needs.build.outputs.new_version }} + 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" + node --input-type=module - <<'NODE' + import assert from 'node:assert/strict'; + import { readFileSync } from 'node:fs'; + const lock = JSON.parse(readFileSync('packages/sdk/package-lock.json', 'utf8')); + const surface = lock.packages['node_modules/@relayflows/surface']; + assert.equal(surface.version, process.env.NEW_VERSION); + assert.match(surface.resolved, /^https:\/\/registry\.npmjs\.org\//); + assert(!surface.link, 'release lockfile must resolve the published surface'); + NODE + npm ci --prefix packages/surface --dry-run --ignore-scripts + npm ci --prefix packages/sdk --dry-run --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 + 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" diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 65dcb2037..17300a2cb 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -45,6 +45,6 @@ "repository": { "type": "git", "url": "git+https://github.com/AgentWorkforce/flows.git", - "directory": "sdk" + "directory": "packages/sdk" } } diff --git a/packages/surface/package.json b/packages/surface/package.json index a9a94f64e..a4d1a8094 100644 --- a/packages/surface/package.json +++ b/packages/surface/package.json @@ -34,6 +34,6 @@ "repository": { "type": "git", "url": "git+https://github.com/AgentWorkforce/flows.git", - "directory": "surface" + "directory": "packages/surface" } } diff --git a/scripts/pack-release.mjs b/scripts/pack-release.mjs new file mode 100644 index 000000000..0808748ef --- /dev/null +++ b/scripts/pack-release.mjs @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const [name, output = 'dist/publish'] = process.argv.slice(2); +assert(['surface', 'sdk', 'runtime-linux-x64'].includes(name), 'unknown release package'); +const directory = resolve(`packages/${name}`); +const destination = resolve(output); +mkdirSync(destination, { recursive: true }); +const [packed] = JSON.parse(execFileSync('npm', [ + 'pack', '--ignore-scripts', '--json', '--pack-destination', destination, +], { cwd: directory, encoding: 'utf8' })); +const archive = join(destination, packed.filename); +const unpacked = mkdtempSync(join(tmpdir(), 'flows-release-')); +try { + execFileSync('tar', ['-xzf', archive, '-C', unpacked]); + const root = join(unpacked, 'package'); + const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); + assert.equal(pkg.name, `@relayflows/${name}`); + const expected = JSON.parse(readFileSync('packages/sdk/package.json', 'utf8')).version; + assert.equal(pkg.version, expected, 'package version differs from SDK anchor'); + for (const type of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) { + for (const [dependency, version] of Object.entries(pkg[type] || {})) { + assert(!/^(file:|link:|workspace:)/.test(version), `local dependency ${dependency}`); + if (dependency.startsWith('@relayflows/')) assert.equal(version, expected); + } + } + const required = name === 'runtime-linux-x64' + ? ['bin/relayflowd', 'bin/flows'] + : ['dist/index.js', 'dist/index.d.ts']; + if (name === 'surface') required.push('dist/runtime.js', 'dist/runtime.d.ts'); + if (name === 'sdk') required.push('dist/cli.js'); + for (const file of required) { + assert(packed.files.some((entry) => entry.path === file), `missing package/${file}`); + assert(existsSync(join(root, file)), `missing unpacked package/${file}`); + assert(statSync(join(root, file)).size > 0, `empty package/${file}`); + } + for (const file of Object.values(pkg.bin || {})) { + assert(statSync(join(root, file)).mode & 0o111, `non-executable ${file}`); + } + if (name === 'runtime-linux-x64') { + assert.equal(process.platform, 'linux', 'runtime smoke requires Linux'); + assert.equal(process.arch, 'x64', 'runtime smoke requires x64'); + execFileSync(join(root, 'bin/relayflowd'), ['--help'], { stdio: 'inherit' }); + const report = JSON.parse(execFileSync(join(root, 'bin/flows'), [ + 'check', '--json', 'testdata/hello-deterministic.flow.yaml', + ], { encoding: 'utf8' })); + assert.equal(report.ok, true); + assert.equal(report.path, 'testdata/hello-deterministic.flow.yaml'); + } + console.log(`PACK_OK ${pkg.name}@${pkg.version}: ${required.map((file) => `package/${file}`).join(', ')}`); + if (process.env.GITHUB_OUTPUT) { + const { appendFileSync } = await import('node:fs'); + appendFileSync(process.env.GITHUB_OUTPUT, `tarball=${archive}\n`); + } +} finally { + rmSync(unpacked, { recursive: true, force: true }); +} diff --git a/scripts/publish.test.mjs b/scripts/publish.test.mjs new file mode 100644 index 000000000..e69ffcacd --- /dev/null +++ b/scripts/publish.test.mjs @@ -0,0 +1,102 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const versionScript = resolve('scripts/version-packages.mjs'); +const packScript = resolve('scripts/pack-release.mjs'); +const read = (path) => JSON.parse(readFileSync(path, 'utf8')); +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), 'flows-publish-test-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + for (const name of ['surface', 'sdk', 'runtime-linux-x64']) { + mkdirSync(join(root, 'packages', name), { recursive: true }); + const path = join(root, 'packages', name, 'package.json'); + cpSync(`packages/${name}/package.json`, path); + const pkg = read(path); + pkg.version = '2.0.0'; + writeFileSync(path, JSON.stringify(pkg)); + } + return root; +} +function version(root, env) { + return spawnSync(process.execPath, [versionScript], { + cwd: root, encoding: 'utf8', env: { ...process.env, CUSTOM_VERSION: '', ...env }, + }); +} + +test('one SDK anchor rewrites all internal dependency types and preserves external ranges', (t) => { + const root = fixture(t); + const path = join(root, 'packages/sdk/package.json'); + const pkg = read(path); + pkg.dependencies['@relayflows/surface'] = 'file:../surface'; + pkg.optionalDependencies = { '@relayflows/runtime-linux-x64': '^1.0.0' }; + pkg.peerDependencies = { '@relayflows/surface': '^1.0.0' }; + pkg.devDependencies['@relayflows/surface'] = 'workspace:*'; + writeFileSync(path, JSON.stringify(pkg)); + const result = version(root, { CUSTOM_VERSION: '3.0.0-rc.2' }); + assert.equal(result.status, 0, result.stderr); + for (const name of ['sdk', 'surface', 'runtime-linux-x64']) { + assert.equal(read(join(root, 'packages', name, 'package.json')).version, '3.0.0-rc.2'); + } + const updated = read(path); + for (const type of ['dependencies', 'devDependencies', 'peerDependencies']) { + assert.equal(updated[type]['@relayflows/surface'], '3.0.0-rc.2'); + } + assert.equal(updated.optionalDependencies['@relayflows/runtime-linux-x64'], '3.0.0-rc.2'); + assert.equal(updated.dependencies.yaml, pkg.dependencies.yaml); +}); + +test('prerelease bumps use the SDK anchor and output the resolved version', (t) => { + const root = fixture(t); + const output = join(root, 'output'); + const result = version(root, { VERSION_TYPE: 'preminor', PREID: 'beta', GITHUB_OUTPUT: output }); + assert.equal(result.status, 0, result.stderr); + assert.equal(read(join(root, 'packages/sdk/package.json')).version, '2.1.0-beta.0'); + assert.equal(readFileSync(output, 'utf8'), 'new_version=2.1.0-beta.0\nis_prerelease=true\n'); +}); + +test('invalid custom versions fail before any package changes', (t) => { + const root = fixture(t); + for (const value of ['invalid', '--help', '2.0.1; echo injected']) { + const result = version(root, { CUSTOM_VERSION: value }); + assert.notEqual(result.status, 0); + assert.equal(read(join(root, 'packages/sdk/package.json')).version, '2.0.0'); + } +}); + +test('actual npm tarballs reject missing dist and local dependencies, then accept built surface', (t) => { + const root = fixture(t); + const run = () => spawnSync(process.execPath, [packScript, 'surface'], { cwd: root, encoding: 'utf8' }); + const missing = run(); + assert.notEqual(missing.status, 0); + assert.match(missing.stderr, /missing package\/dist\/index.js/); + const dist = join(root, 'packages/surface/dist'); + mkdirSync(dist); + for (const file of ['index.js', 'index.d.ts', 'runtime.js', 'runtime.d.ts']) { + writeFileSync(join(dist, file), 'export {};\n'); + } + const path = join(root, 'packages/surface/package.json'); + const pkg = read(path); + pkg.dependencies = { external: 'file:../external' }; + writeFileSync(path, JSON.stringify(pkg)); + const local = run(); + assert.notEqual(local.status, 0); + assert.match(local.stderr, /local dependency external/); + delete pkg.dependencies; + writeFileSync(path, JSON.stringify(pkg)); + const built = run(); + assert.equal(built.status, 0, built.stderr); + assert.match(built.stdout, /PACK_OK @relayflows\/surface@2.0.0/); +}); + +test('runtime tarball refuses an unstaged binary package', (t) => { + const root = fixture(t); + const result = spawnSync(process.execPath, [packScript, 'runtime-linux-x64'], { + cwd: root, encoding: 'utf8', + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /missing package\/bin\/relayflowd/); +}); diff --git a/scripts/version-packages.mjs b/scripts/version-packages.mjs new file mode 100644 index 000000000..47a518cec --- /dev/null +++ b/scripts/version-packages.mjs @@ -0,0 +1,32 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; + +// The SDK is the version anchor; no package independently computes a bump. +const paths = ['surface', 'sdk', 'runtime-linux-x64'].map((name) => `packages/${name}/package.json`); +if (process.env.CUSTOM_VERSION && !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(process.env.CUSTOM_VERSION)) { + throw new Error('custom_version must be a semantic version'); +} +const versionArgs = process.env.CUSTOM_VERSION + ? [process.env.CUSTOM_VERSION, '--allow-same-version'] + : [process.env.VERSION_TYPE || 'patch', `--preid=${process.env.PREID || 'beta'}`]; +execFileSync('npm', ['version', ...versionArgs, '--no-git-tag-version', '--ignore-scripts', '--package-lock=false'], { + cwd: 'packages/sdk', stdio: 'inherit', +}); +const read = (path) => JSON.parse(readFileSync(path, 'utf8')); +const version = read('packages/sdk/package.json').version; +const names = new Set(paths.map((path) => read(path).name)); +for (const path of paths) { + const pkg = read(path); + pkg.version = version; + for (const type of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) { + for (const name of Object.keys(pkg[type] || {})) { + if (names.has(name)) pkg[type][name] = version; + } + } + writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n'); + console.log(`${pkg.name} -> ${version}`); +} +if (process.env.GITHUB_OUTPUT) { + writeFileSync(process.env.GITHUB_OUTPUT, + `new_version=${version}\nis_prerelease=${version.includes('-')}\n`, { flag: 'a' }); +}