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
39 changes: 39 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ jobs:
# release gates (`pnpm check:release`) so the tree stays publishable.
PUBLISH_ENABLED: ${{ vars.AGENT_BUNDLE_NPM_PUBLISH == 'true' && secrets.NPM_TOKEN != '' }}
AGENT_BUNDLE_PLAYWRIGHT_CHANNEL: chromium
# Packed qualification writes package/digest evidence here when
# `pnpm check:release` / `pnpm release` runs. `github.workspace` is
# valid at job env; `runner.temp` is not (no runner assigned yet).
AGENT_BUNDLE_RELEASE_EVIDENCE: ${{ github.workspace }}/.release-qualification.json
steps:
# The action pushes and opens the PR through the GitHub API with the
# token passed below, so the checkout must not persist GITHUB_TOKEN.
Expand Down Expand Up @@ -78,8 +82,43 @@ jobs:
# changesets, "Version Packages" merge commit), prove the versioned
# tree still passes the pre-publish gates that `pnpm release` would run.
- name: Release gates (publish disabled)
id: qualify
if: >-
env.PUBLISH_ENABLED != 'true' &&
steps.changesets.outputs.has-changesets == 'false' &&
startsWith(github.event.head_commit.message, 'Version Packages')
run: pnpm check:release
# Registry proof is a separate authorized-publication step. Disabled
# publication is a supported outcome and must not call npm view.
- name: Verify published registry artifacts
id: registry
if: env.PUBLISH_ENABLED == 'true' && steps.changesets.outputs.published == 'true'
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
for dir in \
packages/agent-bundle \
packages/rsc-runtime \
packages/rsc-markdown-stream \
packages/create-agent-bundle
do
name=$(node -p "JSON.parse(require('node:fs').readFileSync('$dir/package.json','utf8')).name")
version=$(node -p "JSON.parse(require('node:fs').readFileSync('$dir/package.json','utf8')).version")
published=$(npm view "$name@$version" version)
test "$published" = "$version"
echo "registry $name@$version"
done
- name: Release outcome summary
if: always()
env:
HAS_CHANGESETS: ${{ steps.changesets.outputs.has-changesets }}
PUBLISHED: ${{ steps.changesets.outputs.published }}
QUALIFY_OUTCOME: ${{ steps.qualify.outcome }}
CHANGESETS_OUTCOME: ${{ steps.changesets.outcome }}
REGISTRY_OUTCOME: ${{ steps.registry.outcome }}
JOB_STATUS: ${{ job.status }}
CANDIDATE_SHA: ${{ github.sha }}
EVIDENCE_FILE: ${{ env.AGENT_BUNDLE_RELEASE_EVIDENCE }}
GH_TOKEN: ${{ github.token }}
run: bash scripts/release-outcome-summary.sh >> "$GITHUB_STEP_SUMMARY"
15 changes: 10 additions & 5 deletions docs/preview-packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,16 @@ changes a publishable package carries a `.changeset/*.md`; on each push to
a machine-owned **Version Packages** pull request up to date with the pending
bumps and `CHANGELOG.md` entries. Merging that PR versions the packages but,
by default, publishes nothing: the workflow only runs the release gates
(`pnpm check:release`). Publishing turns on when the repository variable
`AGENT_BUNDLE_NPM_PUBLISH` is `true` *and* the `NPM_TOKEN` secret exists;
the action then runs `pnpm release` (`pnpm check:release && changeset
publish`) with npm provenance. Until then, previews below are the only
installable artifacts.
(`pnpm check:release`) against that exact versioned candidate SHA and
records `qualified-without-publish`. A push that only refreshes the Version
Packages PR — or that neither refreshes it nor qualifies a versioned
candidate — records `version-maintenance-only`. Publishing turns on when the
repository variable `AGENT_BUNDLE_NPM_PUBLISH` is `true` *and* the
`NPM_TOKEN` secret exists; the action then runs `pnpm release`
(`pnpm check:release && changeset publish`) with npm provenance and records
`published` after registry verification. Disabled publication reports
**NOT PUBLISHED**. Until then, previews below are the only installable
artifacts.

## Where previews come from

Expand Down
125 changes: 125 additions & 0 deletions packages/agent-bundle/tests/release-outcome-summary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { execFile as executeFile } from 'node:child_process';
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';

import { expect, it } from '@rstest/core';

const execFile = promisify(executeFile);
const scriptPath = join(dirname(fileURLToPath(import.meta.url)), '../../../scripts/release-outcome-summary.sh');
const candidateSha = 'b435f7b9179271cbff81d3e40d14ee342cbd65dd';

const summarize = async (extra: NodeJS.ProcessEnv) => {
const { stdout } = await execFile('bash', [scriptPath], {
env: { PATH: process.env['PATH'], CANDIDATE_SHA: candidateSha, ...extra },
});
return stdout;
};

it('reports version-maintenance-only as NOT PUBLISHED with skipped qualification stages', async () => {
expect(await summarize({
PUBLISH_ENABLED: 'false',
HAS_CHANGESETS: 'true',
PUBLISHED: 'false',
QUALIFY_OUTCOME: 'skipped',
CHANGESETS_OUTCOME: 'success',
REGISTRY_OUTCOME: 'skipped',
JOB_STATUS: 'success',
})).toContain([
'outcome: version-maintenance-only',
`workflow_sha: ${candidateSha}`,
'candidate_sha: (not qualified)',
'publication: NOT PUBLISHED',
'',
'stages:',
'- version-maintenance: executed',
'- qualification: skipped',
'- publication: skipped',
'- registry-verification: skipped',
].join('\n'));
});

it('reports qualified-without-publish with pack evidence and pending follow-ups', async () => {
const evidenceFile = join(await mkdtemp(join(tmpdir(), 'release-outcome-')), 'evidence.json');
await writeFile(evidenceFile, `${JSON.stringify({
executedBins: ['agent-bundle'],
interPackageRanges: [{
field: 'dependencies',
name: 'rsc-markdown-stream',
package: '@agent-bundle/runtime',
specifier: '^0.1.0',
}],
packages: [{
digest: 'deadbeef',
name: 'agent-bundle',
tarball: 'agent-bundle-0.1.0.tgz',
version: '0.1.0',
}],
testGroups: ['packed', 'packed-release'],
workspaceRefs: [],
})}\n`);
const stdout = await summarize({
PUBLISH_ENABLED: 'false',
HAS_CHANGESETS: 'false',
PUBLISHED: 'false',
QUALIFY_OUTCOME: 'success',
CHANGESETS_OUTCOME: 'success',
REGISTRY_OUTCOME: 'skipped',
JOB_STATUS: 'success',
EVIDENCE_FILE: evidenceFile,
});
expect(stdout).toContain([
'outcome: qualified-without-publish',
`workflow_sha: ${candidateSha}`,
`candidate_sha: ${candidateSha}`,
'publication: NOT PUBLISHED',
'',
'stages:',
'- version-maintenance: skipped',
'- qualification: executed',
'- publication: skipped',
'- registry-verification: skipped',
].join('\n'));
expect(stdout).toContain('- agent-bundle@0.1.0 agent-bundle-0.1.0.tgz sha256:deadbeef');
expect(stdout).toContain('- @agent-bundle/runtime dependencies rsc-markdown-stream: ^0.1.0');
expect(stdout).toContain('workspace-only refs:\n- none');
expect(stdout).toContain('- packed-release: executed');
expect(stdout).toContain('- #688 schema-label provenance: pending');
});

it('reports cancelled qualification as failed', async () => {
expect(await summarize({
PUBLISH_ENABLED: 'false',
HAS_CHANGESETS: 'false',
PUBLISHED: 'false',
QUALIFY_OUTCOME: 'cancelled',
CHANGESETS_OUTCOME: 'success',
REGISTRY_OUTCOME: 'skipped',
JOB_STATUS: 'cancelled',
})).toContain('outcome: failed\n');
});

it('reports published only when publish is enabled and registry succeeded', async () => {
expect(await summarize({
PUBLISH_ENABLED: 'true',
HAS_CHANGESETS: 'false',
PUBLISHED: 'true',
QUALIFY_OUTCOME: 'skipped',
CHANGESETS_OUTCOME: 'success',
REGISTRY_OUTCOME: 'success',
JOB_STATUS: 'success',
})).toContain([
'outcome: published',
`workflow_sha: ${candidateSha}`,
`candidate_sha: ${candidateSha}`,
'publication: published',
'',
'stages:',
'- version-maintenance: skipped',
'- qualification: executed',
'- publication: executed',
'- registry-verification: executed',
].join('\n'));
});
132 changes: 132 additions & 0 deletions scripts/release-outcome-summary.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Release packages workflow summary: outcome, stages, and optional pack evidence.
set -euo pipefail

publish_enabled=${PUBLISH_ENABLED:-false}
has_changesets=${HAS_CHANGESETS:-false}
published=${PUBLISHED:-false}
qualify_outcome=${QUALIFY_OUTCOME:-skipped}
changesets_outcome=${CHANGESETS_OUTCOME:-skipped}
registry_outcome=${REGISTRY_OUTCOME:-skipped}
job_status=${JOB_STATUS:-success}
workflow_sha=${CANDIDATE_SHA:-}

if [ "$changesets_outcome" = failure ] || [ "$changesets_outcome" = cancelled ] \
|| [ "$qualify_outcome" = failure ] || [ "$qualify_outcome" = cancelled ] \
|| [ "$registry_outcome" = failure ] || [ "$registry_outcome" = cancelled ] \
|| [ "$job_status" = failure ] || [ "$job_status" = cancelled ]; then
outcome=failed
elif [ "$published" = true ] && [ "$publish_enabled" = true ]; then
outcome=published
elif [ "$qualify_outcome" = success ]; then
outcome=qualified-without-publish
else
outcome=version-maintenance-only
fi

if [ "$has_changesets" = true ]; then
version_maintenance=executed
else
version_maintenance=skipped
fi

if [ "$qualify_outcome" = success ] || [ "$published" = true ]; then
qualification=executed
elif [ "$qualify_outcome" = failure ] || [ "$qualify_outcome" = cancelled ]; then
qualification=failed
else
qualification=skipped
fi

if [ "$published" = true ]; then
publication=executed
else
publication=skipped
fi

if [ "$registry_outcome" = success ]; then
registry=executed
elif [ "$registry_outcome" = failure ]; then
registry=failed
else
registry=skipped
fi

if [ "$outcome" = published ]; then
publication_line='publication: published'
candidate_sha=$workflow_sha
else
publication_line='publication: NOT PUBLISHED'
if [ "$qualification" = executed ]; then
candidate_sha=$workflow_sha
else
candidate_sha='(not qualified)'
fi
fi

cat <<EOF
outcome: $outcome
workflow_sha: $workflow_sha
candidate_sha: $candidate_sha
$publication_line

stages:
- version-maintenance: $version_maintenance
- qualification: $qualification
- publication: $publication
- registry-verification: $registry
EOF

if [ "$qualification" = executed ] && [ -n "${EVIDENCE_FILE:-}" ] && [ -f "$EVIDENCE_FILE" ]; then
echo
echo "packages:"
node --input-type=module -e '
import { readFileSync } from "node:fs";
const evidence = JSON.parse(readFileSync(process.env.EVIDENCE_FILE, "utf8"));
for (const pkg of evidence.packages ?? []) {
console.log(`- ${pkg.name}@${pkg.version} ${pkg.tarball} sha256:${pkg.digest}`);
}
console.log("");
console.log("inter-package ranges:");
const ranges = evidence.interPackageRanges ?? [];
if (ranges.length === 0) console.log("- (none)");
for (const range of ranges) {
console.log(`- ${range.package} ${range.field} ${range.name}: ${range.specifier}`);
}
console.log("");
console.log("workspace-only refs:");
const refs = evidence.workspaceRefs ?? [];
console.log(refs.length === 0 ? "- none" : refs.map((ref) => `- ${ref}`).join("\n"));
console.log("");
console.log("test groups:");
for (const group of evidence.testGroups ?? []) console.log(`- ${group}: executed`);
console.log("");
console.log("executed bins:");
const bins = evidence.executedBins ?? [];
if (bins.length === 0) console.log("- (none)");
for (const bin of bins) console.log(`- ${bin}`);
'
fi

if [ "$qualification" = executed ]; then
echo
echo "follow-ups vs ${candidate_sha}:"
while IFS=$'\t' read -r issue_number issue_label || [ -n "${issue_number:-}" ]; do
[ -z "${issue_number:-}" ] && continue
state=pending
if command -v gh >/dev/null 2>&1 && { [ -n "${GITHUB_TOKEN:-}" ] || [ -n "${GH_TOKEN:-}" ]; }; then
state=$(gh issue view "$issue_number" --repo "${GITHUB_REPOSITORY:-ScriptedAlchemy/agent-bundle}" \
--json state --jq .state 2>/dev/null || echo pending)
state=$(printf '%s' "$state" | tr '[:upper:]' '[:lower:]')
fi
echo "- #$issue_number $issue_label: $state"
issue_number=
done <<'EOF'
680 executable/preflight selection
681 retention
683 native acceptance
685 legacy purge
686 production Flight streaming
688 schema-label provenance
EOF
fi
Loading
Loading