Skip to content

Merge pull request #37 from cometapi-dev/agent/recover-publish-runtim… #16

Merge pull request #37 from cometapi-dev/agent/recover-publish-runtim…

Merge pull request #37 from cometapi-dev/agent/recover-publish-runtim… #16

Workflow file for this run

name: Publish
on:
push:
branches:
- main
paths:
- .github/workflows/publish.yml
workflow_run:
workflows:
- Release Please
types:
- completed
permissions:
actions: read
contents: read
concurrency:
group: npm-publish
cancel-in-progress: false
jobs:
verify:
name: Verify the immutable release artifact
if: >-
vars.RELEASE_PLEASE_ENABLED == 'true' &&
((github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == 'main') ||
(github.event_name == 'push' && github.ref == 'refs/heads/main'))
runs-on: ubuntu-latest
timeout-minutes: 30
env:
SOURCE_RELEASE_COMMIT: ${{ github.event_name == 'push' && 'c98b514227858cd183c781270a7f78f65b577e82' || github.event.workflow_run.head_sha }}
SOURCE_RELEASE_RUN_ATTEMPT: ${{ github.event_name == 'push' && '1' || github.event.workflow_run.run_attempt }}
SOURCE_RELEASE_RUN_ID: ${{ github.event_name == 'push' && '30469181724' || github.event.workflow_run.id }}
outputs:
artifact-name: ${{ steps.artifact-name.outputs.name }}
dist-tag: ${{ steps.version.outputs.dist-tag }}
release-commit: ${{ steps.trust.outputs.release-commit }}
release-tag: ${{ steps.trust.outputs.release-tag }}
version: ${{ steps.version.outputs.version }}
steps:
- name: Check out the workflow control commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ github.sha }}
- name: Validate the one-cycle exact release recovery
if: github.event_name == 'push'
env:
ACTOR: ${{ github.actor }}
CHANGED_FILES: ${{ runner.temp }}/publish-recovery-files
EVENT_AFTER: ${{ github.event.after }}
EVENT_BEFORE: ${{ github.event.before }}
EVENT_NAME: ${{ github.event_name }}
EVENT_REF: ${{ github.ref }}
MAIN_COMMIT: ${{ github.sha }}
WORKFLOW_RUN_ATTEMPT: ${{ github.run_attempt }}
shell: bash
run: |
set -euo pipefail
if [[ "$(git rev-parse HEAD)" != "$MAIN_COMMIT" ]]; then
echo "The recovery control checkout does not match the triggering SHA." >&2
exit 1
fi
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
if [[ "$(git rev-parse refs/remotes/origin/main)" != "$MAIN_COMMIT" ]]; then
echo "main moved after the publish recovery was triggered." >&2
exit 1
fi
git diff --name-only "$EVENT_BEFORE" "$MAIN_COMMIT" > "$CHANGED_FILES"
MAIN_FIRST_PARENT="$(git rev-parse "${MAIN_COMMIT}^1")" \
node --input-type=module <<'EOF'
import { readFileSync } from "node:fs";
import { validatePublishRecoveryTrigger } from "./scripts/release-workflow-validation.mjs";
validatePublishRecoveryTrigger({
actor: process.env.ACTOR,
changedFiles: readFileSync(process.env.CHANGED_FILES, "utf8")
.split("\n")
.filter((file) => file !== ""),
eventAfter: process.env.EVENT_AFTER,
eventBefore: process.env.EVENT_BEFORE,
eventName: process.env.EVENT_NAME,
eventRef: process.env.EVENT_REF,
mainCommit: process.env.MAIN_COMMIT,
mainFirstParent: process.env.MAIN_FIRST_PARENT,
sourceReleaseCommit: process.env.SOURCE_RELEASE_COMMIT,
sourceRunAttempt: Number(process.env.SOURCE_RELEASE_RUN_ATTEMPT),
sourceRunId: Number(process.env.SOURCE_RELEASE_RUN_ID),
workflowRunAttempt: Number(process.env.WORKFLOW_RUN_ATTEMPT),
});
EOF
- name: Check out the exact release commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ env.SOURCE_RELEASE_COMMIT }}
- name: Read the exact Release Please source run
env:
GH_TOKEN: ${{ github.token }}
SOURCE_RUN_FILE: ${{ runner.temp }}/release-please-source-run.json
shell: bash
run: |
set -euo pipefail
gh api \
"repos/${GITHUB_REPOSITORY}/actions/runs/${SOURCE_RELEASE_RUN_ID}" \
> "$SOURCE_RUN_FILE"
- name: Download the exact Release Please result
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-please-result-${{ env.SOURCE_RELEASE_RUN_ID }}-${{ env.SOURCE_RELEASE_RUN_ATTEMPT }}
path: ${{ runner.temp }}/release-please-result
github-token: ${{ github.token }}
repository: ${{ github.repository }}
run-id: ${{ env.SOURCE_RELEASE_RUN_ID }}
- name: Reject an untrusted Release Please workflow run
id: trust
env:
EXPECTED_BUGS_URL: https://github.com/cometapi-dev/cometapi-node/issues
EXPECTED_REPOSITORY: cometapi-dev/cometapi-node
EXPECTED_REPOSITORY_URL: git+https://github.com/cometapi-dev/cometapi-node.git
EXPECTED_WORKFLOW: Release Please
EXPECTED_WORKFLOW_PATH: .github/workflows/release-please.yml
CONTROL_SHA: ${{ github.sha }}
EVENT_NAME: ${{ github.event_name }}
RELEASE_RESULT: ${{ runner.temp }}/release-please-result/result.json
SOURCE_RUN_FILE: ${{ runner.temp }}/release-please-source-run.json
SOURCE_RUN_ATTEMPT: ${{ env.SOURCE_RELEASE_RUN_ATTEMPT }}
SOURCE_RUN_ID: ${{ env.SOURCE_RELEASE_RUN_ID }}
WORKFLOW_SHA: ${{ env.SOURCE_RELEASE_COMMIT }}
shell: bash
run: |
set -euo pipefail
head_commit="$(git rev-parse HEAD)"
if [[ "$head_commit" != "$WORKFLOW_SHA" ]]; then
echo "The successful Release Please SHA is no longer the exact main tip." >&2
exit 1
fi
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
case "$EVENT_NAME" in
workflow_run) expected_main="$WORKFLOW_SHA" ;;
push) expected_main="$CONTROL_SHA" ;;
*) echo "Publish received an unsupported event." >&2; exit 1 ;;
esac
if [[ "$(git rev-parse refs/remotes/origin/main)" != "$expected_main" ]]; then
echo "origin/main moved after the trusted publish event." >&2
exit 1
fi
node --input-type=module <<'EOF'
import { appendFileSync, readFileSync } from "node:fs";
import {
validateReleasePleaseActionResult,
validateReleaseWorkflowRun,
} from "./scripts/release-workflow-validation.mjs";
const sourceRun = JSON.parse(
readFileSync(process.env.SOURCE_RUN_FILE, "utf8"),
);
const sourceEvent = {
action: "completed",
repository: { full_name: sourceRun.repository?.full_name },
workflow_run: {
conclusion: sourceRun.conclusion,
event: sourceRun.event,
head_branch: sourceRun.head_branch,
head_repository: sourceRun.head_repository,
head_sha: sourceRun.head_sha,
id: sourceRun.id,
name: sourceRun.name,
path: sourceRun.path,
run_attempt: sourceRun.run_attempt,
},
};
const run = validateReleaseWorkflowRun(sourceEvent, {
checkedOutSha: process.env.WORKFLOW_SHA,
repository: process.env.EXPECTED_REPOSITORY,
workflowName: process.env.EXPECTED_WORKFLOW,
workflowPath: process.env.EXPECTED_WORKFLOW_PATH,
});
if (
run.runId !== Number(process.env.SOURCE_RUN_ID) ||
run.runAttempt !== Number(process.env.SOURCE_RUN_ATTEMPT)
) {
throw new Error(
"Release workflow source run ID or attempt changed before publication.",
);
}
if (process.env.EVENT_NAME === "workflow_run") {
const eventRun = validateReleaseWorkflowRun(
JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")),
{
checkedOutSha: process.env.WORKFLOW_SHA,
repository: process.env.EXPECTED_REPOSITORY,
workflowName: process.env.EXPECTED_WORKFLOW,
workflowPath: process.env.EXPECTED_WORKFLOW_PATH,
},
);
if (JSON.stringify(eventRun) !== JSON.stringify(run)) {
throw new Error(
"Release workflow source run differs from the workflow_run event.",
);
}
}
const manifest = JSON.parse(readFileSync("package.json", "utf8"));
if (
manifest.repository?.type !== "git" ||
manifest.repository?.url !== process.env.EXPECTED_REPOSITORY_URL
) {
throw new Error(
`package.json repository must equal ${process.env.EXPECTED_REPOSITORY_URL}.`,
);
}
if (manifest.bugs?.url !== process.env.EXPECTED_BUGS_URL) {
throw new Error(`package.json bugs.url must equal ${process.env.EXPECTED_BUGS_URL}.`);
}
const actionResult = JSON.parse(
readFileSync(process.env.RELEASE_RESULT, "utf8"),
);
const release = validateReleasePleaseActionResult(actionResult, {
releaseCommit: run.releaseCommit,
repository: process.env.EXPECTED_REPOSITORY,
runAttempt: run.runAttempt,
runId: run.runId,
version: manifest.version,
workflowName: process.env.EXPECTED_WORKFLOW,
workflowPath: process.env.EXPECTED_WORKFLOW_PATH,
});
appendFileSync(
process.env.GITHUB_OUTPUT,
[
`release-commit=${release.releaseCommit}`,
`release-tag=${release.tag}`,
`release-url=${release.htmlUrl}`,
`release-version=${release.version}`,
"",
].join("\n"),
);
EOF
- name: Set up Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24.x
cache: npm
- name: Install validation dependencies without lifecycle scripts
run: npm ci --ignore-scripts
- name: Verify release metadata and derive the npm dist-tag
id: version
env:
RELEASE_TAG: ${{ steps.trust.outputs.release-tag }}
shell: bash
run: |
set -euo pipefail
node scripts/validate-release.mjs \
--tag "$RELEASE_TAG" \
--require-final \
--require-releasable-docs >> "$GITHUB_OUTPUT"
- name: Verify the exact immutable GitHub release and tag
id: release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_COMMIT: ${{ steps.trust.outputs.release-commit }}
RELEASE_HTML_URL: ${{ steps.trust.outputs.release-url }}
RELEASE_TAG: ${{ steps.trust.outputs.release-tag }}
shell: bash
run: |
set -euo pipefail
release_json="$RUNNER_TEMP/github-release.json"
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" > "$release_json"
git fetch --no-tags origin \
"+refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}"
tag_commit="$(git rev-parse --verify "refs/tags/${RELEASE_TAG}^{commit}")"
RELEASE_JSON="$release_json" TAG_COMMIT="$tag_commit" \
node --input-type=module <<'EOF'
import { readFileSync } from "node:fs";
import {
extractReleaseNotesFromChangelog,
validateGitHubRelease,
} from "./scripts/release-workflow-validation.mjs";
const release = JSON.parse(readFileSync(process.env.RELEASE_JSON, "utf8"));
const version = JSON.parse(readFileSync("package.json", "utf8")).version;
validateGitHubRelease(release, {
expectedBody: extractReleaseNotesFromChangelog(
readFileSync("CHANGELOG.md", "utf8"),
version,
),
htmlUrl: process.env.RELEASE_HTML_URL,
releaseCommit: process.env.RELEASE_COMMIT,
tag: process.env.RELEASE_TAG,
tagCommit: process.env.TAG_COMMIT,
});
EOF
- name: Use a Trusted Publishing-capable npm CLI
run: npm install --global npm@11.12.1
- name: Install locked dependencies
run: npm ci
- name: Run release checks
run: |
npm run format:check
npm run lint
npm run typecheck
npm test
npm run test:secrets
npm run build
npm run test:package
npm run test:live-contract
npm run test:compat -- --lane locked
npm run check:self-contained
- name: Pack the exact release artifact
id: pack
shell: bash
run: |
set -euo pipefail
mkdir -p release-artifacts
npm pack --pack-destination release-artifacts
mapfile -t tarballs < <(find release-artifacts -maxdepth 1 -type f -name '*.tgz' -print)
if [[ "${#tarballs[@]}" -ne 1 ]]; then
echo "Expected exactly one packed artifact, found ${#tarballs[@]}." >&2
exit 1
fi
echo "tarball=${tarballs[0]}" >> "$GITHUB_OUTPUT"
- name: Test consumers against the exact release artifact
run: |
npm run test:package -- \
--tarball "${{ steps.pack.outputs.tarball }}" \
--tag "${{ steps.trust.outputs.release-tag }}"
npm run test:examples -- --tarball "${{ steps.pack.outputs.tarball }}"
npm run test:fixtures -- --tarball "${{ steps.pack.outputs.tarball }}"
- name: Name the attempt-qualified release artifact
id: artifact-name
shell: bash
run: |
set -euo pipefail
echo "name=npm-package-${{ steps.version.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT"
- name: Upload the verified release artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ steps.artifact-name.outputs.name }}
path: ${{ steps.pack.outputs.tarball }}
if-no-files-found: error
retention-days: 30
live-smoke:
name: Verify the release tag against CometAPI
needs:
- verify
concurrency:
group: live-smoke
cancel-in-progress: false
runs-on: ubuntu-latest
timeout-minutes: 5
# Required repository configuration: configure the protected live-smoke environment
# without required reviewers and add COMETAPI_KEY before publishing a release.
environment: live-smoke
steps:
- name: Check out the verified release tag
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ needs.verify.outputs.release-commit }}
- name: Set up Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24.x
cache: npm
- name: Install locked dependencies
run: npm ci
- name: Build the release tag
run: npm run build
- name: Run the bounded live smoke
env:
COMETAPI_KEY: ${{ secrets.COMETAPI_KEY }}
COMETAPI_LIVE_SMOKE: "1"
COMETAPI_SMOKE_MODEL: ${{ vars.COMETAPI_SMOKE_MODEL || 'gpt-5.4' }}
COMETAPI_LIVE_REQUEST_LIMIT: "3"
COMETAPI_LIVE_MAX_OUTPUT_TOKENS: "16"
COMETAPI_LIVE_REQUEST_TIMEOUT_MS: "60000"
COMETAPI_LIVE_CONCURRENCY: "1"
run: npm run test:live
publish:
name: Publish with npm Trusted Publishing
needs:
- live-smoke
- verify
runs-on: ubuntu-latest
timeout-minutes: 15
# Required repository configuration: configure the protected npm environment with
# approval by the current release approver and self-review allowed, plus package ownership.
environment:
name: npm
url: https://www.npmjs.com/package/cometapi/v/${{ needs.verify.outputs.version }}
permissions:
contents: read
id-token: write
steps:
- name: Check out the verified release commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ needs.verify.outputs.release-commit }}
- name: Set up Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 24.x
package-manager-cache: false
registry-url: https://registry.npmjs.org
- name: Use a Trusted Publishing-capable npm CLI
run: npm install --global npm@11.12.1
- name: Download the verified release artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ needs.verify.outputs.artifact-name }}
path: release-artifacts
- name: Publish the exact artifact with provenance
env:
DIST_TAG: ${{ needs.verify.outputs.dist-tag }}
VERSION: ${{ needs.verify.outputs.version }}
run: bash scripts/publish-artifact.sh
- name: Verify the public registry artifact
env:
DIST_TAG: ${{ needs.verify.outputs.dist-tag }}
VERSION: ${{ needs.verify.outputs.version }}
shell: bash
run: |
set -euo pipefail
mapfile -t tarballs < <(find "$GITHUB_WORKSPACE/release-artifacts" -maxdepth 1 -type f -name '*.tgz' -print)
if [[ "${#tarballs[@]}" -ne 1 ]]; then
echo "Expected exactly one downloaded artifact for registry verification." >&2
exit 1
fi
local_integrity="$(node -e 'const {createHash}=require("node:crypto");const {readFileSync}=require("node:fs");process.stdout.write("sha512-"+createHash("sha512").update(readFileSync(process.argv[1])).digest("base64"))' "${tarballs[0]}")"
registry_ready="false"
for attempt in {1..12}; do
resolved="$(npm view "cometapi@${VERSION}" version 2>/dev/null || true)"
tagged="$(npm view "cometapi@${DIST_TAG}" version 2>/dev/null || true)"
registry_dist="$(npm view "cometapi@${VERSION}" dist --json 2>/dev/null || true)"
if [[ "$resolved" == "$VERSION" && "$tagged" == "$VERSION" && -n "$registry_dist" ]] && \
REGISTRY_DIST="$registry_dist" LOCAL_INTEGRITY="$local_integrity" node <<'EOF'
let ready = false;
try {
const dist = JSON.parse(process.env.REGISTRY_DIST);
ready =
dist.integrity === process.env.LOCAL_INTEGRITY &&
Boolean(dist.attestations?.url) &&
dist.attestations?.provenance?.predicateType ===
"https://slsa.dev/provenance/v1";
} catch {}
process.exitCode = ready ? 0 : 1;
EOF
then
registry_ready="true"
break
fi
if [[ "$attempt" -lt 12 ]]; then
sleep 10
fi
done
if [[ "$registry_ready" != "true" ]]; then
echo "Registry state did not converge for cometapi@${VERSION}, ${DIST_TAG}, integrity, and provenance." >&2
exit 1
fi
verify_dir="$(mktemp -d)"
cd "$verify_dir"
npm init --yes >/dev/null
npm install --ignore-scripts --no-audit --no-fund \
"openai@6.47.0" "cometapi@${VERSION}"
signatures_verified="false"
for attempt in {1..3}; do
if npm audit signatures; then
signatures_verified="true"
break
fi
if [[ "$attempt" -lt 3 ]]; then
sleep 10
fi
done
if [[ "$signatures_verified" != "true" ]]; then
echo "Registry signature and provenance verification did not converge." >&2
exit 1
fi
npm ls openai --all
if [[ -d node_modules/cometapi/node_modules/openai ]]; then
echo "The registry fixture contains a nested OpenAI installation." >&2
exit 1
fi
node --input-type=module <<'EOF'
import assert from "node:assert/strict";
import { CometAPI } from "cometapi";
const client = new CometAPI({
apiKey: "mock-registry-key",
maxRetries: 0,
fetch: async () =>
new Response(JSON.stringify({ object: "list", data: [] }), {
status: 200,
headers: { "content-type": "application/json" },
}),
});
const models = await client.models.list();
assert.deepEqual(models.data, []);
EOF
node <<'EOF'
const assert = require("node:assert/strict");
const { CometAPI } = require("cometapi");
const { APIError } = require("openai");
const client = new CometAPI({
apiKey: "mock-registry-key",
maxRetries: 0,
fetch: async () =>
new Response(
JSON.stringify({
error: {
message: "mock registry failure",
type: "invalid_request_error",
},
}),
{
status: 400,
headers: { "content-type": "application/json" },
},
),
});
(async () => {
let caught;
try {
await client.models.list();
} catch (error) {
caught = error;
}
assert.ok(caught instanceof APIError);
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
EOF