Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 103 additions & 32 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,18 @@ permissions:
contents: read

jobs:
verify:
release-source:
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
outputs:
release-version: ${{ steps.release.outputs.version }}
package-sha256: ${{ steps.package.outputs.sha256 }}
sha: ${{ steps.release.outputs.sha }}
version: ${{ steps.release.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref }}
persist-credentials: false

- name: Read exact first-party cohort
id: cohort
run: node scripts/release-cohort.mjs --github-output "$GITHUB_OUTPUT"

- name: Verify release source and version
id: release
env:
Expand All @@ -56,25 +52,10 @@ jobs:
echo "::error::Tag/version mismatch: tag=$TAG_VERSION package=$NPM_VERSION."
exit 1
fi
echo "sha=$CHECKOUT_SHA" >> "$GITHUB_OUTPUT"
echo "version=$NPM_VERSION" >> "$GITHUB_OUTPUT"
echo "Release source locked: $RELEASE_TAG at $TAG_SHA"

- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: pnpm
registry-url: https://registry.npmjs.org

- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'

- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: '0.11.32'

# A release tag must sit on the tip of the line it releases. Without this, a
# tag cut from an older commit publishes while newer merged commits are
# silently excluded: 0.106.0 was tagged at the #624 merge and published 45
Expand Down Expand Up @@ -117,6 +98,38 @@ jobs:
echo "Re-cut the tag at the tip of main, or push a $LINE branch for a backport."
exit 1

verify:
needs: release-source
runs-on: ubuntu-latest
outputs:
release-version: ${{ needs.release-source.outputs.version }}
package-sha256: ${{ steps.package.outputs.sha256 }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release-source.outputs.sha }}
persist-credentials: false

- name: Read exact first-party cohort
id: cohort
run: node scripts/release-cohort.mjs --github-output "$GITHUB_OUTPUT"

- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: pnpm
registry-url: https://registry.npmjs.org

- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'

- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: '0.11.32'

- name: Install deps
run: pnpm install --frozen-lockfile && pnpm peers check

Expand Down Expand Up @@ -168,14 +181,6 @@ jobs:
--cohort-manifest release/cohort.json
--report "$RUNNER_TEMP/cohort-report.json"

- name: Verify packed Runtime with official GEPA and SkillOpt
run: pnpm run verify:official-optimizers

- name: Verify agent-bench against this release
env:
AGENT_BENCH_PACKAGE_TEST_TIMEOUT_MS: 900000
run: pnpm run verify:bench

- name: Package and verify the exact Runtime archive
id: package
run: |
Expand Down Expand Up @@ -204,6 +209,7 @@ jobs:
echo "::error::Expected one Runtime archive, found ${#packages[@]}."
exit 1
fi
cp "$RUNNER_TEMP/cohort-report.json" "$package_dir/cohort-report.json"
manifest="$package_dir/agent-runtime-conformance-manifest.json"
node scripts/emit-conformance-manifest.mjs \
--tarball "${packages[0]}" \
Expand All @@ -221,12 +227,77 @@ jobs:
path: |
${{ runner.temp }}/agent-runtime-package/*.tgz
${{ runner.temp }}/agent-runtime-package/agent-runtime-conformance-manifest.json
${{ runner.temp }}/agent-runtime-package/cohort-report.json
if-no-files-found: error
compression-level: 0
retention-days: 1

verify-official-optimizers:
needs: release-source
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release-source.outputs.sha }}
persist-credentials: false

- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: pnpm

- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'

- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: '0.11.32'

- name: Install deps
run: pnpm install --frozen-lockfile && pnpm peers check

- name: Verify official optimizers
run: pnpm run verify:official-optimizers

verify-runtime-bench:
needs: release-source
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.release-source.outputs.sha }}
persist-credentials: false

- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: pnpm

- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'

- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: '0.11.32'

- name: Install deps
run: pnpm install --frozen-lockfile && pnpm peers check

- name: Verify agent-bench against this release
env:
AGENT_BENCH_PACKAGE_TEST_TIMEOUT_MS: 900000
run: pnpm run verify:bench

publish-npm:
needs: verify
needs: [verify, verify-official-optimizers, verify-runtime-bench]
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ The same boundary applies to trace analysts, Prime Agent RLM, and DSPy RLM: Runt
### The conformance manifest

Each release candidate carries one manifest beside its packed archive: `agent-runtime-conformance-manifest.json`, uploaded with the `.tgz` by `publish.yml`.
The same artifact retains the raw `cohort-report.json` used to verify that manifest.
The release workflow validates the tag once, then checks the archive, official optimizers, and Bench concurrently against that commit.
Publication requires all three checks and verifies the uploaded archive digest before publishing.


`release/cohort.json` is the single source for the exact Interface, Eval, and Knowledge source releases used by CI and publication.
Update each version and full source commit together when the dependency cohort changes.
Expand Down
103 changes: 101 additions & 2 deletions scripts/check-publish-workflow.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ for (const [jobName, requiredJob] of Object.entries(publishJobs)) {
const steps = requireSteps(jobName, job)
const actionNames = steps.flatMap((step) => (typeof step.uses === 'string' ? [step.uses] : []))
assertCount(actionNames, /^actions\/setup-node@[a-f0-9]{40}$/, 1, `${jobName} setup-node`)
assertCount(actionNames, /^actions\/download-artifact@[a-f0-9]{40}$/, 1, `${jobName} artifact download`)
assertCount(
actionNames,
/^actions\/download-artifact@[a-f0-9]{40}$/,
1,
`${jobName} artifact download`,
)

for (const step of steps) {
if (typeof step.uses === 'string') {
Expand Down Expand Up @@ -90,6 +95,98 @@ for (const jobName of ['verify', 'verify-agent-bench']) {
}
}

// All release checks consume the same validated commit, including manual tag retries.
const source = requireJob('release-source')
assertEqual(source.outputs?.sha, '${{ steps.release.outputs.sha }}', 'release source SHA output')
assertEqual(
source.outputs?.version,
'${{ steps.release.outputs.version }}',
'release source version output',
)
assertEqual(
source.if,
"startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'",
'release source event selection',
)
assertEqual(source['continue-on-error'], undefined, 'release source cannot ignore failure')
const sourceSteps = requireSteps('release-source', source)
const sourceCheckout = sourceSteps.find((step) => step.uses?.startsWith('actions/checkout@'))
assertEqual(
sourceCheckout?.with?.ref,
"${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref }}",
'release source tag selection',
)
const sourceGuard = sourceSteps.find((step) => step.id === 'release')
assertEqual(
sourceGuard?.env?.RELEASE_TAG,
"${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }}",
'release source tag validation',
)
for (const step of sourceSteps.filter((step) => typeof step.run === 'string')) {
assertEqual(step.if, undefined, 'release source guard cannot be conditional')
assertEqual(step['continue-on-error'], undefined, 'release source guard cannot ignore failure')
}

const sourceCommands = sourceSteps.map((step) => step.run ?? '').join('\n')
for (const required of [
'sha=$CHECKOUT_SHA',
'"$CHECKOUT_SHA" != "$TAG_SHA"',
'"$TAG_VERSION" != "$NPM_VERSION"',
'refs/remotes/origin/main',
'release/${VERSION%.*}.x',
]) {
if (!sourceCommands.includes(required))
throw new Error(`release source validation is missing ${required}`)
}
const releaseChecks = {
verify: 'pnpm run verify:package:static',
'verify-official-optimizers': 'pnpm run verify:official-optimizers',
'verify-runtime-bench': 'pnpm run verify:bench',
}
for (const [name, command] of Object.entries(releaseChecks)) {
const job = requireJob(name)
assertEqual(job.needs, 'release-source', `${name} parallel source dependency`)
assertEqual(job.if, undefined, `${name} must require successful source validation`)
assertEqual(job['continue-on-error'], undefined, `${name} cannot ignore failure`)
assertNeeds(requireJob('publish-npm'), name)
const steps = requireSteps(name, job)
const checkout = steps.find(
(step) => step.uses?.startsWith('actions/checkout@') && !step.with?.repository,
)
assertEqual(
checkout?.with?.ref,
'${{ needs.release-source.outputs.sha }}',
`${name} immutable checkout`,
)
const checks = steps.filter((step) => step.run === command)
assertEqual(checks.length, 1, `${name} required check count`)
assertEqual(checks[0].if, undefined, `${name} required check condition`)
assertEqual(checks[0]['continue-on-error'], undefined, `${name} required check failure`)
}
assertEqual(
requireJob('publish-npm').if,
"startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'",
'publish must retain default success gating',
)
for (const name of ['release-source', ...Object.keys(releaseChecks)]) {
for (const step of requireSteps(name, requireJob(name))) {
if (step.uses && !/@[a-f0-9]{40}$/.test(step.uses))
throw new Error(`${name} action is not pinned`)
if (step.uses?.startsWith('actions/checkout@'))
assertEqual(step.with?.['persist-credentials'], false, `${name} checkout credentials`)
if (step.env?.NPM_TOKEN || step.env?.NODE_AUTH_TOKEN)
throw new Error(`${name} must not receive publishing credentials`)
}
}

const archiveUpload = requireSteps('verify', requireJob('verify')).find((step) =>
step.uses?.startsWith('actions/upload-artifact@'),
)
for (const file of ['*.tgz', 'agent-runtime-conformance-manifest.json', 'cohort-report.json']) {
if (!archiveUpload?.with?.path?.includes(`agent-runtime-package/${file}`))
throw new Error(`verified release artifact is missing ${file}`)
}

assertCohortJob(workflow, 'verify', releaseCohort)
assertCohortJob(
readWorkflow(resolve(repoRoot, '.github/workflows/ci.yml'), 'CI'),
Expand Down Expand Up @@ -163,6 +260,8 @@ function assertCount(values, pattern, expected, label) {

function assertEqual(actual, expected, label) {
if (actual !== expected) {
throw new Error(`${label}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`)
throw new Error(
`${label}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`,
)
}
}
Loading