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
31 changes: 19 additions & 12 deletions .github/workflows/replay-spec-changes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,20 @@ on:
required: true
type: string
mode:
description: Preview manifests or idempotently upsert them to the SDK bot
description: Preview manifests, or upload them as an artifact the SDK bot pulls into its D1
required: true
type: choice
default: dry-run
options:
- dry-run
- push
- upload
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved

concurrency:
# Share the producer's concurrency group so a replay cannot race a normal
# spec-change delivery. Every requested SHA is processed sequentially.
group: spec-changes
cancel-in-progress: false

env:
SDK_BOT_URL: https://sdk-automation-bot.workos.tools

jobs:
replay:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -72,25 +69,35 @@ jobs:
--repository "$GITHUB_REPOSITORY"

- name: Verify push runs from the default branch
if: inputs.mode == 'push'
if: inputs.mode == 'upload'
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
if [ "$GITHUB_REF" != "refs/heads/$DEFAULT_BRANCH" ]; then
echo "::error::Push replays must run from the default branch ($DEFAULT_BRANCH)."
echo "::error::Upload replays must run from the default branch ($DEFAULT_BRANCH)."
exit 1
fi

- name: Rebuild and idempotently push manifests
if: inputs.mode == 'push'
- name: Rebuild manifests for upload
if: inputs.mode == 'upload'
env:
TARGET_SHAS: ${{ inputs.target_shas }}
SPEC_CHANGES_SECRET: ${{ secrets.SPEC_CHANGES_SECRET }}
run: |
node scripts/replay-spec-changes.mjs \
--shas "$TARGET_SHAS" \
--repository "$GITHUB_REPOSITORY" \
--bot-url "$SDK_BOT_URL" \
--push \
--output-dir spec-changes \
--confirm REPLAY_SPEC_CHANGES

- name: Publish manifest artifact for the SDK bot
# The bot pulls this on the workflow_run webhook for this run and
# upserts each manifest by sha. Targets with no changed services write
# nothing, so an empty directory is not an error.
if: inputs.mode == 'upload'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: spec-changes-replay-${{ github.run_id }}
path: spec-changes/
if-no-files-found: ignore
retention-days: 14
63 changes: 30 additions & 33 deletions .github/workflows/spec-changes.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
name: Record spec changes

# On every spec merge, compute which post-mount services changed (and whether
# any change is breaking) and PUSH the manifest to the SDK automation bot, which
# stores it in its own D1. Nothing is committed back to this repo — the
# substance lives in scripts/build-spec-changes.mjs; this workflow runs
# `oagen diff`/`parse` and POSTs the result (HMAC-signed). The bot owns
# retention, so no artifact files accumulate here.
# any change is breaking) and publish the manifest as a workflow artifact
# (spec-changes-<sha>). The SDK automation bot listens for this workflow's
# workflow_run webhook, downloads the artifact with its App token, and stores
# the manifest in its own D1, so nothing is committed back to this repo and no
# request leaves GitHub. The substance lives in scripts/build-spec-changes.mjs;
# this workflow runs `oagen diff`/`parse` and uploads the result. The bot
# upserts by sha, so re-running this workflow re-publishes a manifest it
# missed.

on:
push:
Expand All @@ -25,9 +28,6 @@ concurrency:
group: spec-changes
cancel-in-progress: false

env:
SDK_BOT_URL: https://sdk-automation-bot.workos.tools

jobs:
record:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -104,12 +104,13 @@ jobs:
npx oagen parse --spec /tmp/previous-open-api-spec.yaml > /tmp/previous-ir.json
npx oagen parse --spec spec/open-api-spec.yaml > /tmp/current-ir.json

- name: Push changed-services manifest to the SDK bot
- name: Build changed-services manifest
id: manifest
if: steps.parent.outputs.available == 'true'
env:
SPEC_CHANGES_SECRET: ${{ secrets.SPEC_CHANGES_SECRET }}
HEAD_COMMIT_MSG: ${{ github.event.head_commit.message }}
REPO: ${{ github.repository }}
MANIFEST: spec-changes/${{ github.sha }}.json
run: |
set -euo pipefail
# Commit subject (first line) + originating PR number, for the
Expand All @@ -125,35 +126,31 @@ jobs:
--sha "${{ github.sha }}"
--parent-sha "${{ steps.parent.outputs.parent }}"
--commit-message "$SUBJECT"
--output "$MANIFEST"
)
if [ -n "$PR_NUM" ]; then
ARGS+=(--pr-number "$PR_NUM" --pr-url "https://github.com/$REPO/pull/$PR_NUM")
fi

# build-spec-changes.mjs writes the manifest to stdout when --output is
# omitted. Capture it, then HMAC-sign the exact bytes we POST.
MANIFEST=$(node scripts/build-spec-changes.mjs "${ARGS[@]}")
node scripts/build-spec-changes.mjs "${ARGS[@]}"
echo "Manifest:"
echo "$MANIFEST" | jq .
jq . "$MANIFEST"

if [ "$(echo "$MANIFEST" | jq '.changedServices | length')" -eq 0 ]; then
echo "No service changes; nothing to push."
if [ "$(jq '.changedServices | length' "$MANIFEST")" -eq 0 ]; then
echo "No service changes; nothing to publish."
echo "publish=false" >> "$GITHUB_OUTPUT"
exit 0
fi

SIG="sha256=$(printf '%s' "$MANIFEST" | openssl dgst -sha256 -hmac "$SPEC_CHANGES_SECRET" | sed 's/^.*= //')"
for attempt in 1 2 3; do
if curl -sS -X POST "$SDK_BOT_URL/internal/spec-changes" \
-H "Content-Type: application/json" \
-H "X-Spec-Changes-Signature: $SIG" \
--data-raw "$MANIFEST" \
--fail-with-body; then
echo ""
echo "Pushed manifest for ${{ github.sha }}."
exit 0
fi
echo "push attempt ${attempt} failed; retrying in 5s..."
sleep 5
done
echo "::error::could not push spec-changes manifest after 3 attempts"
exit 1
echo "publish=true" >> "$GITHUB_OUTPUT"

- name: Publish manifest artifact for the SDK bot
# The bot pulls this on the workflow_run webhook for this run. Retention
# only has to outlive a missed delivery long enough for a redeliver or
# a re-run to pick it up.
if: steps.manifest.outputs.publish == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: spec-changes-${{ github.sha }}
path: spec-changes/${{ github.sha }}.json
if-no-files-found: error
retention-days: 14
28 changes: 16 additions & 12 deletions docs/spec-change-replay.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ the target commit subject, PR number/URL, commit timestamp, SHA, and parent SHA.
not change the spec are rejected.
4. Keep **mode** set to **dry-run**.

The workflow prints every rebuilt manifest but does not receive the signing
secret or make a network request.
The workflow prints every rebuilt manifest and writes nothing.

For a local preview after `npm ci && npm run build:policy`:

Expand All @@ -30,17 +29,22 @@ node scripts/replay-spec-changes.mjs \
--repository workos/openapi-spec
```

## Push
## Upload

After reviewing a dry run, launch the workflow again with the same explicit
SHAs and set **mode** to **push**. Push mode is accepted only from the default
branch. The workflow signs the exact manifest bytes and POSTs them to the
existing SDK bot endpoint.
SHAs and set **mode** to **upload**. Upload mode is accepted only from the
default branch. The workflow writes one `<sha>.json` per target and uploads the
directory as a `spec-changes-replay-<run_id>` workflow artifact. The SDK bot
pulls that artifact when the run's `workflow_run` webhook lands, exactly as it
does for the regular `spec-changes-<sha>` artifact the producer publishes on
every merge; no request leaves GitHub and no shared secret is involved.

The SDK bot upserts on the target SHA, so repeating the same replay is
idempotent. A target that produces no changed services is shown but not POSTed.
Every target is validated and rebuilt before the first POST, and the workflow
serializes with the normal spec-change producer to avoid delivery races.

Do not put the signing secret in the SHA input or logs. The workflow exposes
`SPEC_CHANGES_SECRET` only to the confirmed push step.
idempotent. A target that produces no changed services is shown but not
written. Every target is validated and rebuilt before the first file is
written, and the workflow serializes with the normal spec-change producer to
avoid delivery races.

If the bot missed the webhook, re-run the workflow (or use **Redeliver** on
the delivery in the GitHub App's settings); the artifact stays available for
14 days.
85 changes: 66 additions & 19 deletions scripts/__tests__/replay-spec-changes.spec.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { test } from "node:test";
import {
firstParentFromRevList,
parseArgs,
parseTargetShas,
pullRequestMetadata,
writeManifests,
} from "../replay-spec-changes.mjs";

const SHA_A = "a".repeat(40);
Expand Down Expand Up @@ -65,47 +69,90 @@ test("pullRequestMetadata omits absent PR metadata", () => {
);
});

test("parseArgs makes POST mode explicit, confirmed, and HTTPS-only", () => {
test("parseArgs makes writing explicit and confirmed, and rejects the removed push options", () => {
const base = [
"node",
"replay",
"--shas",
SHA_A,
"--repository",
"workos/openapi-spec",
"--push",
];
assert.throws(
() =>
parseArgs([
...base,
"--bot-url",
"https://sdk-automation-bot.workos.tools",
]),
/Refusing to POST/,
() => parseArgs([...base, "--output-dir", "spec-changes"]),
/Refusing to write/,
);
assert.throws(
() =>
parseArgs([
...base,
"--bot-url",
"http://sdk-automation-bot.workos.tools",
"--confirm",
"REPLAY_SPEC_CHANGES",
"https://sdk-automation-bot.workos.tools",
]),
/must use HTTPS/,
/Unknown option/,
);
assert.throws(() => parseArgs([...base, "--push"]), /Unknown option/);

assert.equal(parseArgs(base).outputDir, "");

const parsed = parseArgs([
...base,
"--bot-url",
"https://sdk-automation-bot.workos.tools",
"--output-dir",
"spec-changes",
"--confirm",
"REPLAY_SPEC_CHANGES",
]);
assert.equal(parsed.push, true);
assert.equal(
parsed.endpoint.href,
"https://sdk-automation-bot.workos.tools/internal/spec-changes",
assert.equal(parsed.outputDir, resolve("spec-changes"));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 File writes lack test coverage

The updated tests check --output-dir parsing but never exercise the new file-writing branch. Add tests using a temporary directory to check <sha>.json contents, skipping empty manifests, dry runs writing nothing, and a bad later target leaving no earlier files. These are the guarantees the artifact upload now depends on.

Prompt To Fix With AI
This is a comment left during a code review.
Path: scripts/__tests__/replay-spec-changes.spec.mjs
Line: 102

Comment:
**File writes lack test coverage**

The updated tests check `--output-dir` parsing but never exercise the new file-writing branch. Add tests using a temporary directory to check `<sha>.json` contents, skipping empty manifests, dry runs writing nothing, and a bad later target leaving no earlier files. These are the guarantees the artifact upload now depends on.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

});

function rebuilt(targetSha, changedServices) {
const manifest = {
sha: targetSha,
parentSha: "b".repeat(40),
timestamp: "2026-06-22T00:00:00Z",
changedServices,
};
return { targetSha, manifest, manifestText: `${JSON.stringify(manifest)}\n` };
}

function scratchDir(t) {
const root = mkdtempSync(join(tmpdir(), "replay-write-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
return join(root, "spec-changes");
}

test("writeManifests writes one <sha>.json per target with changes and skips empty ones", (t) => {
const out = scratchDir(t);
const withChanges = rebuilt(SHA_A, [{ service: "SSO", hasBreaking: false }]);
const empty = rebuilt("c".repeat(40), []);

const written = writeManifests(out, [withChanges, empty]);

assert.deepEqual(written, [
{ targetSha: SHA_A, outputPath: join(out, `${SHA_A}.json`) },
{ targetSha: "c".repeat(40), outputPath: null },
]);
assert.deepEqual(readdirSync(out), [`${SHA_A}.json`]);
assert.equal(readFileSync(join(out, `${SHA_A}.json`), "utf8"), withChanges.manifestText);
});

test("writeManifests validates every target before writing, so a bad later target leaves no files", (t) => {
const out = scratchDir(t);
const good = rebuilt(SHA_A, [{ service: "SSO", hasBreaking: false }]);

assert.throws(
() => writeManifests(out, [good, { targetSha: "main", manifest: {}, manifestText: "{}" }]),
/Invalid target SHA/,
);
assert.throws(
() => writeManifests(out, [good, { ...good, targetSha: "d".repeat(40) }]),
/does not match target/,
);
assert.throws(() => writeManifests(out, [good, { ...good, manifestText: "" }]), /Empty manifest/);
assert.throws(
() => writeManifests(out, [good, { ...good, manifest: { sha: good.targetSha } }]),
/no changedServices list/,
);
assert.throws(() => writeManifests("", [good]), /output directory is required/);
assert.equal(existsSync(out), false);
});
Loading
Loading