From c09bacb261231297842fff180de6ad27600e06e9 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 5 Nov 2026 14:41:39 -0500 Subject: [PATCH 1/2] feat(ci): publish spec-change manifests as workflow artifacts spec-changes.yml and replay-spec-changes.yml used to POST each changed-services manifest to the SDK automation bot at /internal/spec-changes, HMAC-signed with SPEC_CHANGES_SECRET. That was the bot's only inbound surface besides the GitHub webhook, and traffic from Actions runners cannot be IP-scoped, so it needed its own WAF exception. Both producers now upload the manifest as a workflow artifact (spec-changes-, spec-changes-replay-). The bot pulls it on the run's workflow_run webhook with its App token and upserts by sha, so nothing leaves GitHub and re-running a producer is the recovery path for a missed delivery. The replay script's --push/--bot-url become --output-dir, still gated on --confirm REPLAY_SPEC_CHANGES. --- .github/workflows/replay-spec-changes.yml | 31 +++--- .github/workflows/spec-changes.yml | 63 ++++++----- .../__tests__/replay-spec-changes.spec.mjs | 32 +++--- scripts/replay-spec-changes.mjs | 102 ++++-------------- 4 files changed, 81 insertions(+), 147 deletions(-) diff --git a/.github/workflows/replay-spec-changes.yml b/.github/workflows/replay-spec-changes.yml index bbf8a9e..bffb4d1 100644 --- a/.github/workflows/replay-spec-changes.yml +++ b/.github/workflows/replay-spec-changes.yml @@ -15,13 +15,13 @@ 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 concurrency: # Share the producer's concurrency group so a replay cannot race a normal @@ -29,9 +29,6 @@ concurrency: group: spec-changes cancel-in-progress: false -env: - SDK_BOT_URL: https://sdk-automation-bot.workos.tools - jobs: replay: runs-on: ubuntu-latest @@ -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 diff --git a/.github/workflows/spec-changes.yml b/.github/workflows/spec-changes.yml index ae6ea4f..6d4b4a4 100644 --- a/.github/workflows/spec-changes.yml +++ b/.github/workflows/spec-changes.yml @@ -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-). 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: @@ -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 @@ -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 @@ -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 diff --git a/scripts/__tests__/replay-spec-changes.spec.mjs b/scripts/__tests__/replay-spec-changes.spec.mjs index a383739..f550b0d 100644 --- a/scripts/__tests__/replay-spec-changes.spec.mjs +++ b/scripts/__tests__/replay-spec-changes.spec.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { resolve } from "node:path"; import { test } from "node:test"; import { firstParentFromRevList, @@ -65,7 +66,7 @@ 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", @@ -73,39 +74,30 @@ test("parseArgs makes POST mode explicit, confirmed, and HTTPS-only", () => { 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")); }); diff --git a/scripts/replay-spec-changes.mjs b/scripts/replay-spec-changes.mjs index 2bf3c37..be95ae2 100644 --- a/scripts/replay-spec-changes.mjs +++ b/scripts/replay-spec-changes.mjs @@ -3,24 +3,23 @@ // Rebuild one or more historical spec-change manifests with the CURRENT // checkout's oagen version, policy bundle, and build-spec-changes.mjs. // -// Safe default (prints manifests, does not POST): +// Safe default (prints manifests, writes nothing): // node scripts/replay-spec-changes.mjs \ // --shas "," \ // --repository workos/openapi-spec // -// Posting requires both an explicit flag and confirmation. The caller must -// provide SPEC_CHANGES_SECRET only for this mode: +// Writing manifests for the SDK bot requires an explicit output directory and +// confirmation. The replay workflow uploads that directory as a spec-changes-* +// artifact, which the bot pulls on the run's workflow_run webhook: // node scripts/replay-spec-changes.mjs \ // --shas "," \ // --repository workos/openapi-spec \ -// --bot-url https://sdk-automation-bot.workos.tools \ -// --push --confirm REPLAY_SPEC_CHANGES +// --output-dir spec-changes --confirm REPLAY_SPEC_CHANGES // // The SDK bot upserts manifests by sha, so replaying the same target is // idempotent. No historical scripts or policy files are checked out: only the // old/new spec documents come from the target commit and its first parent. -import { createHmac } from "node:crypto"; import { existsSync, mkdirSync, @@ -35,7 +34,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; const FULL_SHA = /^[0-9a-f]{40}$/i; const GITHUB_REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; -const PUSH_CONFIRMATION = "REPLAY_SPEC_CHANGES"; +const WRITE_CONFIRMATION = "REPLAY_SPEC_CHANGES"; const SPEC_PATH = "spec/open-api-spec.yaml"; const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(SCRIPT_DIR, ".."); @@ -96,8 +95,7 @@ export function parseArgs(argv) { const args = { shas: "", repository: process.env.GITHUB_REPOSITORY ?? "", - botUrl: process.env.SDK_BOT_URL ?? "", - push: false, + outputDir: "", confirm: "", }; @@ -105,8 +103,7 @@ export function parseArgs(argv) { const arg = argv[i]; if (arg === "--shas") args.shas = argv[++i] ?? ""; else if (arg === "--repository") args.repository = argv[++i] ?? ""; - else if (arg === "--bot-url") args.botUrl = argv[++i] ?? ""; - else if (arg === "--push") args.push = true; + else if (arg === "--output-dir") args.outputDir = argv[++i] ?? ""; else if (arg === "--confirm") args.confirm = argv[++i] ?? ""; else throw new Error(`Unknown option: ${arg}`); } @@ -117,20 +114,13 @@ export function parseArgs(argv) { throw new Error("--repository in owner/name form is required"); } - if (args.push) { - if (args.confirm !== PUSH_CONFIRMATION) { + if (args.outputDir) { + if (args.confirm !== WRITE_CONFIRMATION) { throw new Error( - `Refusing to POST without --confirm ${PUSH_CONFIRMATION}`, + `Refusing to write manifests without --confirm ${WRITE_CONFIRMATION}`, ); } - if (!args.botUrl) { - throw new Error("--bot-url (or SDK_BOT_URL) is required with --push"); - } - const endpoint = new URL("/internal/spec-changes", args.botUrl); - if (endpoint.protocol !== "https:") { - throw new Error("The spec-changes endpoint must use HTTPS"); - } - args.endpoint = endpoint; + args.outputDir = resolve(args.outputDir); } return args; @@ -324,56 +314,6 @@ function buildManifest(targetSha, tempRoot, repository) { return { manifest, manifestText, parentSha, subject }; } -function wait(milliseconds) { - return new Promise((resolveWait) => setTimeout(resolveWait, milliseconds)); -} - -async function postManifest(endpoint, manifestText, secret, targetSha) { - const signature = `sha256=${createHmac("sha256", secret) - .update(manifestText) - .digest("hex")}`; - - for (let attempt = 1; attempt <= 3; attempt += 1) { - let response; - try { - response = await fetch(endpoint, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Spec-Changes-Signature": signature, - }, - body: manifestText, - signal: AbortSignal.timeout(30_000), - }); - } catch (error) { - if (attempt === 3) { - throw new Error( - `POST failed for ${targetSha} after 3 attempts: ${error.message}`, - ); - } - process.stderr.write( - `POST attempt ${attempt} failed for ${targetSha}; retrying\n`, - ); - await wait(5000); - continue; - } - - if (response.ok) return; - - const body = (await response.text()).slice(0, 1000); - if (attempt === 3) { - throw new Error( - `POST failed for ${targetSha} after 3 attempts (${response.status})` + - (body ? `: ${body}` : ""), - ); - } - process.stderr.write( - `POST attempt ${attempt} failed for ${targetSha} (${response.status}); retrying\n`, - ); - await wait(5000); - } -} - async function main() { const args = parseArgs(process.argv); @@ -386,7 +326,7 @@ async function main() { const tempRoot = mkdtempSync(join(tmpdir(), "spec-change-replay-")); try { - // Preflight every target before the first POST. A malformed later target + // Preflight every target before the first write. A malformed later target // must not leave an otherwise avoidable partial replay. const rebuilt = args.targetShas.map((targetSha) => buildManifest( @@ -403,26 +343,24 @@ async function main() { `\nManifest for ${targetSha} (${subject})\n` + `First parent: ${parentSha}\n${manifestText}`, ); - if (!args.push) process.stdout.write("Dry run: manifest was not posted.\n"); + if (!args.outputDir) process.stdout.write("Dry run: manifest was not written.\n"); } - if (!args.push) return; - - // Read the signing secret only after push confirmation and target preflight. - const secret = process.env.SPEC_CHANGES_SECRET; - if (!secret) throw new Error("SPEC_CHANGES_SECRET is required with --push"); + if (!args.outputDir) return; + mkdirSync(args.outputDir, { recursive: true }); for (let index = 0; index < args.targetShas.length; index += 1) { const targetSha = args.targetShas[index]; const { manifest, manifestText } = rebuilt[index]; if ((manifest.changedServices ?? []).length === 0) { - process.stdout.write("No changed services; manifest was not posted.\n"); + process.stdout.write("No changed services; manifest was not written.\n"); continue; } - await postManifest(args.endpoint, manifestText, secret, targetSha); + const outputPath = join(args.outputDir, `${targetSha}.json`); + writeFileSync(outputPath, manifestText, "utf8"); process.stdout.write( - `Posted manifest for ${targetSha}; the bot upserts this SHA idempotently.\n`, + `Wrote manifest for ${targetSha} to ${outputPath}; uploaded as a spec-changes artifact, the bot upserts this SHA idempotently.\n`, ); } } finally { From 615bf5a907f28a0600be661123db5fea88fe36d5 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 5 Nov 2026 15:13:08 -0500 Subject: [PATCH 2/2] fix(ci): cover replay manifest writes and update the replay guide Review follow-ups on #265. The replay guide still described push mode, signed POSTs, and SPEC_CHANGES_SECRET; it now documents upload mode and artifact delivery. The file-writing step moves into an exported writeManifests that validates every target before the first write, with tests for the written contents, skipped empty manifests, and a bad later target leaving no files behind. --- docs/spec-change-replay.md | 28 +++++---- .../__tests__/replay-spec-changes.spec.mjs | 57 ++++++++++++++++++- scripts/replay-spec-changes.mjs | 53 +++++++++++++---- 3 files changed, 113 insertions(+), 25 deletions(-) diff --git a/docs/spec-change-replay.md b/docs/spec-change-replay.md index ddcf97b..81cc6e7 100644 --- a/docs/spec-change-replay.md +++ b/docs/spec-change-replay.md @@ -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`: @@ -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 `.json` per target and uploads the +directory as a `spec-changes-replay-` 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-` 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. diff --git a/scripts/__tests__/replay-spec-changes.spec.mjs b/scripts/__tests__/replay-spec-changes.spec.mjs index f550b0d..e15f811 100644 --- a/scripts/__tests__/replay-spec-changes.spec.mjs +++ b/scripts/__tests__/replay-spec-changes.spec.mjs @@ -1,11 +1,14 @@ import assert from "node:assert/strict"; -import { resolve } from "node:path"; +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); @@ -101,3 +104,55 @@ test("parseArgs makes writing explicit and confirmed, and rejects the removed pu ]); assert.equal(parsed.outputDir, resolve("spec-changes")); }); + +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 .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); +}); diff --git a/scripts/replay-spec-changes.mjs b/scripts/replay-spec-changes.mjs index be95ae2..a603caf 100644 --- a/scripts/replay-spec-changes.mjs +++ b/scripts/replay-spec-changes.mjs @@ -314,6 +314,39 @@ function buildManifest(targetSha, tempRoot, repository) { return { manifest, manifestText, parentSha, subject }; } +// Write one .json per rebuilt target into outputDir for the replay +// workflow to upload as a spec-changes artifact. Every entry is validated +// before the first write so a malformed later target cannot leave a partial +// set behind. Targets with no changed services are reported, not written. +// Returns [{ targetSha, outputPath }] in input order, outputPath null when +// nothing was written. +export function writeManifests(outputDir, rebuilt) { + if (!outputDir) throw new Error("An output directory is required"); + for (const entry of rebuilt) { + const { targetSha, manifest, manifestText } = entry ?? {}; + if (!FULL_SHA.test(targetSha ?? "")) { + throw new Error(`Invalid target SHA "${targetSha}"`); + } + if (typeof manifestText !== "string" || !manifestText.trim()) { + throw new Error(`Empty manifest for ${targetSha}`); + } + if (manifest?.sha?.toLowerCase() !== targetSha.toLowerCase()) { + throw new Error(`Manifest sha does not match target ${targetSha}`); + } + if (!Array.isArray(manifest.changedServices)) { + throw new Error(`Manifest for ${targetSha} has no changedServices list`); + } + } + + mkdirSync(outputDir, { recursive: true }); + return rebuilt.map(({ targetSha, manifest, manifestText }) => { + if (manifest.changedServices.length === 0) return { targetSha, outputPath: null }; + const outputPath = join(outputDir, `${targetSha.toLowerCase()}.json`); + writeFileSync(outputPath, manifestText, "utf8"); + return { targetSha, outputPath }; + }); +} + async function main() { const args = parseArgs(process.argv); @@ -348,19 +381,15 @@ async function main() { if (!args.outputDir) return; - mkdirSync(args.outputDir, { recursive: true }); - for (let index = 0; index < args.targetShas.length; index += 1) { - const targetSha = args.targetShas[index]; - const { manifest, manifestText } = rebuilt[index]; - if ((manifest.changedServices ?? []).length === 0) { - process.stdout.write("No changed services; manifest was not written.\n"); - continue; - } - - const outputPath = join(args.outputDir, `${targetSha}.json`); - writeFileSync(outputPath, manifestText, "utf8"); + const written = writeManifests( + args.outputDir, + args.targetShas.map((targetSha, index) => ({ targetSha, ...rebuilt[index] })), + ); + for (const { targetSha, outputPath } of written) { process.stdout.write( - `Wrote manifest for ${targetSha} to ${outputPath}; uploaded as a spec-changes artifact, the bot upserts this SHA idempotently.\n`, + outputPath + ? `Wrote manifest for ${targetSha} to ${outputPath}; uploaded as a spec-changes artifact, the bot upserts this SHA idempotently.\n` + : `No changed services for ${targetSha}; manifest was not written.\n`, ); } } finally {