From e4340d1f45b14b1571c5a5a2ac01ea6edf9d6806 Mon Sep 17 00:00:00 2001 From: Matt Peake Date: Thu, 5 Nov 2026 13:36:39 -0500 Subject: [PATCH 1/3] Deliver spec manifests through verified Tailscale exits --- .github/workflows/spec-changes.yml | 100 ++++++++---- .github/workflows/validate-sdks.yml | 3 + docs/spec-changes-delivery.md | 62 +++++++ scripts/__tests__/push-spec-changes.spec.mjs | 161 +++++++++++++++++++ scripts/push-spec-changes.mjs | 107 ++++++++++++ 5 files changed, 404 insertions(+), 29 deletions(-) create mode 100644 docs/spec-changes-delivery.md create mode 100644 scripts/__tests__/push-spec-changes.spec.mjs create mode 100644 scripts/push-spec-changes.mjs diff --git a/.github/workflows/spec-changes.yml b/.github/workflows/spec-changes.yml index ae6ea4f..445bbbe 100644 --- a/.github/workflows/spec-changes.yml +++ b/.github/workflows/spec-changes.yml @@ -25,18 +25,18 @@ concurrency: group: spec-changes cancel-in-progress: false -env: - SDK_BOT_URL: https://sdk-automation-bot.workos.tools - jobs: record: runs-on: ubuntu-latest + outputs: + deliver: ${{ steps.manifest.outputs.deliver }} permissions: contents: read steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + persist-credentials: false - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: @@ -104,10 +104,10 @@ 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 }} run: | @@ -130,30 +130,72 @@ jobs: 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[@]}") - echo "Manifest:" - echo "$MANIFEST" | jq . - - if [ "$(echo "$MANIFEST" | jq '.changedServices | length')" -eq 0 ]; then + node scripts/build-spec-changes.mjs "${ARGS[@]}" --output /tmp/spec-changes/manifest.json + if [ "$(jq '.changedServices | length' /tmp/spec-changes/manifest.json)" -gt 0 ]; then + echo "deliver=true" >> "$GITHUB_OUTPUT" + else echo "No service changes; nothing to push." - 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 + - name: Hand manifest to isolated delivery job + if: steps.manifest.outputs.deliver == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: spec-changes-manifest + path: /tmp/spec-changes/manifest.json + if-no-files-found: error + retention-days: 1 + + deliver: + needs: record + if: >- + needs.record.outputs.deliver == 'true' && + github.repository == 'workos/openapi-spec' && + github.ref == 'refs/heads/main' && + (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + id-token: write + env: + TAILSCALE_EXIT_NODE: ${{ vars.SDK_SPEC_CHANGES_EXIT_NODE }} + TAILSCALE_CLIENT_ID: ${{ vars[github.event_name == 'push' && 'SDK_SPEC_CHANGES_PUSH_CLIENT_ID' || 'SDK_SPEC_CHANGES_DISPATCH_CLIENT_ID'] }} + TAILSCALE_AUDIENCE: ${{ vars[github.event_name == 'push' && 'SDK_SPEC_CHANGES_PUSH_AUDIENCE' || 'SDK_SPEC_CHANGES_DISPATCH_AUDIENCE'] }} + steps: + # The build job has no OIDC or signing secret. Delivery uses only this + # checked-in, dependency-free script; never execute artifact contents. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + sparse-checkout: scripts/push-spec-changes.mjs + sparse-checkout-cone-mode: false + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '24' + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: spec-changes-manifest + path: ${{ runner.temp }}/spec-changes + + - name: Require exact workflow and public identity configuration + run: node scripts/push-spec-changes.mjs --check-config + + - name: Join Tailscale as an ephemeral SDK delivery runner + uses: tailscale/github-action@064b10558093f38325e838ddea35486bdc6b1d5c # v4 + with: + oauth-client-id: ${{ env.TAILSCALE_CLIENT_ID }} + audience: ${{ env.TAILSCALE_AUDIENCE }} + tags: tag:ci-sdk-spec-changes + version: '1.104.4' + use-cache: 'false' + hostname: sdk-spec-${{ github.run_id }}-${{ github.run_attempt }} + tailscaled-args: --tun=userspace-networking --socks5-server=127.0.0.1:1055 + args: --exit-node=${{ env.TAILSCALE_EXIT_NODE }} --exit-node-allow-lan-access=false --accept-routes=false --accept-dns=false + + - name: Verify exit NAT and deliver exact HMAC-signed bytes + env: + SPEC_CHANGES_SECRET: ${{ secrets.SPEC_CHANGES_SECRET }} + run: node scripts/push-spec-changes.mjs "$RUNNER_TEMP/spec-changes/manifest.json" diff --git a/.github/workflows/validate-sdks.yml b/.github/workflows/validate-sdks.yml index cd08b18..8b05190 100644 --- a/.github/workflows/validate-sdks.yml +++ b/.github/workflows/validate-sdks.yml @@ -28,6 +28,9 @@ jobs: - name: Test sync conflict resolution run: node --test scripts/__tests__/resolve-sync-conflicts.spec.mjs + - name: Test signed Tailscale delivery + run: node --test scripts/__tests__/push-spec-changes.spec.mjs + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 id: filter with: diff --git a/docs/spec-changes-delivery.md b/docs/spec-changes-delivery.md new file mode 100644 index 0000000..aa73218 --- /dev/null +++ b/docs/spec-changes-delivery.md @@ -0,0 +1,62 @@ +# Spec-change delivery through Tailscale + +`spec-changes.yml` builds a manifest without a signing secret or OIDC permission. +A separate delivery job downloads that same-run data artifact, obtains GitHub +OIDC identity, joins Tailscale ephemerally as `tag:ci-sdk-spec-changes`, and signs +the exact manifest file bytes with the existing `SPEC_CHANGES_SECRET`. + +The transport is a userspace SOCKS5 listener on `127.0.0.1:1055` through an +explicit internal exit, then TLS to `sdk-automation-bot.workos.tools`. The +Cloudflare Worker remains a public endpoint. It needs no deployment change: +the existing WAF permits the internal exit NAT addresses and the Worker still +authenticates `X-Spec-Changes-Signature` with HMAC. This change does not open a +new public exception or rely on held infra PR #2126. + +Delivery refuses missing identity configuration, unsupported workflow/ref/event, +non-userspace Tailscale, a missing/offline/wrong/untagged exit, an unapproved NAT, +proxy failures and redirects. All retries use the same fixed host/path through +the proxy. curlrc and `NO_PROXY` cannot select a direct route. Local DNS plus +`--ipv4`/`socks5://` sends a numeric IPv4 to SOCKS5 without exit DNS permission. +Only the delivery script gets the signing secret; no npm install runs there. +The signature travels to curl over stdin, not its command line, and response +bodies and diagnostics are kept out of logs. + +Apply the reviewed `workos/infra` trust/policy PR **before** merging this one. +Two event-specific identities require exact repository/owner numeric IDs, +main ref, workflow path and GitHub-hosted runner. Each has `auth_keys` only. +Their grant permits general IPv4 Internet TCP/443 through tagged internal exits; +it is not a hostname/path ACL. Private/tailnet/link-local/loopback destinations, +IPv6 and other ports are denied. Repository main workflow review remains a +trust boundary; trusted main code can change this workflow. + +Set these nonsecret Actions repository variables from the infra output +`sdk_spec_changes_wif` and a reviewed exit: + +| Variable | Source | +| --- | --- | +| `SDK_SPEC_CHANGES_PUSH_CLIENT_ID` | output `["push"].client_id` | +| `SDK_SPEC_CHANGES_PUSH_AUDIENCE` | output `["push"].audience` | +| `SDK_SPEC_CHANGES_DISPATCH_CLIENT_ID` | output `["workflow_dispatch"].client_id` | +| `SDK_SPEC_CHANGES_DISPATCH_AUDIENCE` | output `["workflow_dispatch"].audience` | +| `SDK_SPEC_CHANGES_EXIT_NODE` | approved, online internal exit's Tailscale IPv4 address | + +Keep the existing `SPEC_CHANGES_SECRET` synchronized with the bot. No new static +Tailscale secret is needed. The action's pinned version uses memory-only state, +requests an ephemeral node and logs out after the job. If the configured exit +is replaced, verify its tag/approval/NAT and update the variable; there is no +automatic selection of an arbitrary or external exit. + +The complete human bootstrap, apply, rollback and verification procedure is in +[the infra runbook](https://github.com/workos/infra/blob/codex/github-actions-sdk-tailscale/docs/runbooks/tailscale-control/github-actions-sdk.md). +After landing, manually dispatch on main (workflow-only changes do not match the +spec path filter), then verify the next real spec merge independently. Confirm +the bot's manifest row and ephemeral node removal. Preserve application HMAC +verification; never fall back to direct public delivery on failure. + +`node --test scripts/__tests__/push-spec-changes.spec.mjs` checks byte-exact +signing, identity/config/status denial cases, proxy-only commands, NAT checks, +redirects, retries and real curl failure with a closed loopback proxy. SDK +validation CI runs it even when there is no spec change. Live OIDC, tailnet +policy enforcement, actual exit/NAT/Worker access and cleanup need the human +rollout checks. The existing historical `replay-spec-changes.yml` is separate +and receives no trust or transport change in this PR. diff --git a/scripts/__tests__/push-spec-changes.spec.mjs b/scripts/__tests__/push-spec-changes.spec.mjs new file mode 100644 index 0000000..2cf39b3 --- /dev/null +++ b/scripts/__tests__/push-spec-changes.spec.mjs @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import { createHmac } from 'node:crypto'; +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +const script = resolve('scripts/push-spec-changes.mjs'); +const secret = 'test-signing-secret-do-not-log'; +const manifest = '{"changedServices":[{"name":"SSO"}],"text":"é"}\n'; +const goodStatus = { + BackendState: 'Running', TUN: false, + Self: { Tags: ['tag:ci-sdk-spec-changes'] }, + ExitNodeStatus: { ID: 'test-exit', Online: true, TailscaleIPs: ['100.100.10.1/32'] }, + Peer: { key: { + ID: 'test-exit', Online: true, ExitNode: true, ExitNodeOption: true, + Tags: ['tag:internal-exit-node'], TailscaleIPs: ['100.100.10.1'], + } }, +}; + +function run(t, { env = {}, status = goodStatus, mode = '', configOnly = false, realCurl = false } = {}) { + const dir = mkdtempSync(join(tmpdir(), 'sdk-delivery-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const log = join(dir, 'calls.jsonl'); + const file = join(dir, 'manifest.json'); + writeFileSync(file, manifest); + writeFileSync(join(dir, 'tailscale'), `#!${process.execPath} +import fs from 'node:fs'; +fs.appendFileSync(process.env.CALL_LOG, JSON.stringify({command:'tailscale',args:process.argv.slice(2)})+'\\n'); +console.log(process.env.STATUS); +`, { mode: 0o755 }); + writeFileSync(join(dir, 'curl'), `#!${process.execPath} +import fs from 'node:fs'; +import {spawnSync} from 'node:child_process'; +const args=process.argv.slice(2); +const input=fs.readFileSync(0,'utf8'); +const post=args.includes('POST'); +fs.appendFileSync(process.env.CALL_LOG,JSON.stringify({command:'curl',args,input, + body:post?fs.readFileSync(args[args.indexOf('--data-binary')+1].slice(1),'utf8'):null})+'\\n'); +if(process.env.REAL_CURL==='true') { + // Pin local DNS in this test: real curl must fail at the absent SOCKS5 + // listener even with hostile NO_PROXY and an ambient curlrc to bypass it. + const r=spawnSync('/usr/bin/curl',['--disable','--resolve','sdk-automation-bot.workos.tools:443:203.0.113.8',...args],{encoding:'utf8',input}); + process.stdout.write(r.stdout);process.stderr.write(r.stderr);process.exit(r.status??1); +} +if(process.env.MODE==='proxy-failed') { console.error(process.env.SPEC_CHANGES_SECRET);process.exit(7); } +if(!post) { process.stdout.write('ip='+(process.env.MODE==='wrong-nat'?'203.0.113.1':'3.151.179.114')+'\\n200'); } +else if(process.env.MODE==='redirect') { process.stdout.write('reflected '+input+'\\n302'); } +else if(process.env.MODE==='post-failed') { console.error(input);process.exit(7); } +else { process.stdout.write('ok\\n200'); } +`, { mode: 0o755 }); + // Top-level retry delays are shortened only by a test loader, with no + // production flag or exported implementation seam. + const loader = join(dir, 'fast-timers.mjs'); + writeFileSync(loader, 'globalThis.setTimeout = (fn) => { queueMicrotask(fn); return 0; };'); + const r = spawnSync(process.execPath, ['--import', loader, script, configOnly ? '--check-config' : file], { + cwd: dir, encoding: 'utf8', timeout: 10_000, + env: { + PATH: `${dir}:${process.env.PATH}`, CALL_LOG: log, STATUS: JSON.stringify(status), MODE: mode, + GITHUB_REPOSITORY: 'workos/openapi-spec', GITHUB_REF: 'refs/heads/main', + GITHUB_WORKFLOW_REF: 'workos/openapi-spec/.github/workflows/spec-changes.yml@refs/heads/main', + GITHUB_EVENT_NAME: 'push', TAILSCALE_EXIT_NODE: '100.100.10.1', + TAILSCALE_CLIENT_ID: 'fixture-client', TAILSCALE_AUDIENCE: 'api.tailscale.com/fixture-client', + SPEC_CHANGES_SECRET: secret, NO_PROXY: '*', no_proxy: '*', + REAL_CURL: String(realCurl), CURL_HOME: dir, ...env, + }, + }); + let calls = []; + try { calls = readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); } catch {} + const output = `${r.stdout}${r.stderr}`; + assert.ok(!output.includes(secret), 'secret must never be logged'); + assert.ok(!output.includes(createHmac('sha256', secret).update(manifest).digest('hex')), 'signature must never be logged'); + return { ...r, calls }; +} + +test('both exact supported events can validate config; other repo/ref/workflow/events fail before commands', t => { + for (const event of ['push', 'workflow_dispatch']) { + assert.equal(run(t, { configOnly: true, env: { GITHUB_EVENT_NAME: event } }).status, 0); + } + for (const env of [ + { GITHUB_REPOSITORY: 'attacker/openapi-spec' }, { GITHUB_REF: 'refs/heads/topic' }, + { GITHUB_REF: 'refs/tags/main' }, { GITHUB_WORKFLOW_REF: 'workos/openapi-spec/.github/workflows/replay-spec-changes.yml@refs/heads/main' }, + { GITHUB_EVENT_NAME: 'pull_request' }, { GITHUB_EVENT_NAME: 'pull_request_target' }, + { GITHUB_EVENT_NAME: 'workflow_run' }, { TAILSCALE_EXIT_NODE: '' }, + { TAILSCALE_EXIT_NODE: '100.100.10.1 --exit-node-allow-lan-access' }, + { TAILSCALE_EXIT_NODE: '10.0.0.1' }, { TAILSCALE_EXIT_NODE: 'auto:any' }, + { TAILSCALE_CLIENT_ID: '' }, { TAILSCALE_AUDIENCE: 'api.tailscale.com/*' }, + { SPEC_CHANGES_SECRET: '' }, + ]) { + const r = run(t, { env }); + assert.equal(r.status, 1, JSON.stringify(env)); + assert.deepEqual(r.calls, []); + } +}); + +test('HMAC covers exactly the UTF-8 file bytes, sent through explicit IPv4 SOCKS5 with no bypass', t => { + const r = run(t); + assert.equal(r.status, 0, r.stderr); + const calls = r.calls.filter(c => c.command === 'curl'); + assert.equal(calls.length, 2); + assert.equal(calls[0].args.at(-1), 'https://sdk-automation-bot.workos.tools/cdn-cgi/trace'); + const post = calls[1]; + assert.equal(post.args.at(-1), 'https://sdk-automation-bot.workos.tools/internal/spec-changes'); + assert.equal(post.body, manifest); + assert.equal(post.input, `X-Spec-Changes-Signature: sha256=${createHmac('sha256', secret).update(Buffer.from(manifest)).digest('hex')}\n`); + assert.ok(!post.args.some(a => a.includes(secret) || a.includes('sha256='))); + for (const call of calls) { + assert.equal(call.args[0], '--disable'); + assert.equal(call.args[call.args.indexOf('--proxy') + 1], 'socks5://127.0.0.1:1055'); + assert.equal(call.args[call.args.indexOf('--noproxy') + 1], ''); + assert.equal(call.args[call.args.indexOf('--proto') + 1], '=https'); + assert.ok(call.args.includes('--ipv4')); + assert.ok(!call.args.includes('--location')); + } +}); + +test('missing/offline/wrong/untagged exit and non-userspace status prevent every HTTP request', t => { + const cases = [ + { ...goodStatus, BackendState: 'Stopped' }, { ...goodStatus, TUN: true }, + { ...goodStatus, Self: { Tags: ['tag:internal-k8s-operator'] } }, + { ...goodStatus, ExitNodeStatus: null }, + { ...goodStatus, ExitNodeStatus: { ...goodStatus.ExitNodeStatus, Online: false } }, + { ...goodStatus, ExitNodeStatus: { ...goodStatus.ExitNodeStatus, TailscaleIPs: ['100.100.10.2/32'] } }, + { ...goodStatus, Peer: {} }, + ...['Online', 'ExitNode', 'ExitNodeOption'].map(key => ({ ...goodStatus, Peer: { key: { ...goodStatus.Peer.key, [key]: false } } })), + { ...goodStatus, Peer: { key: { ...goodStatus.Peer.key, Tags: ['tag:other-exit'] } } }, + ]; + for (const status of cases) { + const r = run(t, { status }); + assert.equal(r.status, 1); + assert.ok(r.calls.every(c => c.command !== 'curl')); + } +}); + +test('wrong NAT and proxy failure stop before a signed request, with no direct fallback', t => { + for (const mode of ['wrong-nat', 'proxy-failed']) { + const r = run(t, { mode }); + assert.equal(r.status, 1); + assert.equal(r.calls.filter(c => c.command === 'curl').length, 1); + assert.ok(r.calls.every(c => !c.args.includes('POST'))); + } +}); + +test('redirects and POST errors retry only the same signed endpoint through the verified proxy', t => { + for (const mode of ['redirect', 'post-failed']) { + const r = run(t, { mode }); + assert.equal(r.status, 1); + const calls = r.calls.filter(c => c.command === 'curl'); + assert.equal(calls.length, 6); + assert.equal(calls.filter(c => c.args.includes('POST')).length, 3); + assert.ok(calls.every(c => c.args[c.args.indexOf('--proxy') + 1] === 'socks5://127.0.0.1:1055')); + } +}); + +test('real curl fails closed at an absent local SOCKS5 listener despite NO_PROXY=*', t => { + const r = run(t, { realCurl: true }); + assert.equal(r.status, 1); + assert.equal(r.calls.filter(c => c.command === 'curl').length, 1); + assert.ok(r.calls.every(c => !c.args.includes('POST'))); +}); diff --git a/scripts/push-spec-changes.mjs b/scripts/push-spec-changes.mjs new file mode 100644 index 0000000..006d923 --- /dev/null +++ b/scripts/push-spec-changes.mjs @@ -0,0 +1,107 @@ +// No npm dependencies: this runs in the isolated delivery job with OIDC/HMAC. +import { createHmac } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { isIPv4 } from 'node:net'; +import { spawnSync } from 'node:child_process'; + +const BOT = 'https://sdk-automation-bot.workos.tools'; +const PROXY = 'socks5://127.0.0.1:1055'; +const TAG = 'tag:ci-sdk-spec-changes'; +const NAT_IPS = new Set(['3.151.179.114', '16.58.134.105', '16.58.183.17']); + +function checkConfig() { + const e = process.env; + if (e.GITHUB_REPOSITORY !== 'workos/openapi-spec' || + e.GITHUB_REF !== 'refs/heads/main' || + e.GITHUB_WORKFLOW_REF !== 'workos/openapi-spec/.github/workflows/spec-changes.yml@refs/heads/main' || + !['push', 'workflow_dispatch'].includes(e.GITHUB_EVENT_NAME)) { + throw new Error('Delivery requires the spec-changes workflow on workos/openapi-spec main.'); + } + const ip = e.TAILSCALE_EXIT_NODE ?? ''; + const octets = ip.split('.').map(Number); + if (!isIPv4(ip) || octets[0] !== 100 || octets[1] < 64 || octets[1] > 127) { + throw new Error('TAILSCALE_EXIT_NODE must be an explicitly reviewed Tailscale IPv4 address.'); + } + if (!e.TAILSCALE_CLIENT_ID || !/^[A-Za-z0-9-]+$/.test(e.TAILSCALE_CLIENT_ID) || + e.TAILSCALE_AUDIENCE !== `api.tailscale.com/${e.TAILSCALE_CLIENT_ID}`) { + throw new Error('Missing or invalid public per-event Tailscale identity configuration.'); + } +} + +function checkExit() { + const result = spawnSync('tailscale', ['status', '--json'], { encoding: 'utf8', timeout: 10_000 }); + if (result.status !== 0) throw new Error('Cannot read Tailscale status.'); + let status; + try { status = JSON.parse(result.stdout); } catch { throw new Error('Invalid Tailscale status.'); } + const exit = status.ExitNodeStatus; + const peer = Object.values(status.Peer ?? {}).find(p => p.ID === exit?.ID); + // ExitNodeStatus.TailscaleIPs are prefixes in v1.104.4. PeerStatus uses IPs. + if (status.BackendState !== 'Running' || status.TUN !== false || + status.Self?.Tags?.length !== 1 || status.Self.Tags[0] !== TAG || + !exit?.ID || exit.Online !== true || + !exit.TailscaleIPs?.includes(`${process.env.TAILSCALE_EXIT_NODE}/32`) || + peer?.Online !== true || peer.ExitNode !== true || peer.ExitNodeOption !== true || + !peer.Tags?.includes('tag:internal-exit-node') || + !peer.TailscaleIPs?.includes(process.env.TAILSCALE_EXIT_NODE)) { + throw new Error('The reviewed internal exit must be online and selected in userspace mode.'); + } +} + +function curl(path, extra = [], input) { + // --disable must be first (ignore curlrc). Override NO_PROXY explicitly. + // socks5 (not socks5h) + --ipv4 resolves locally and sends a numeric IPv4 + // destination: no exit PeerAPI DNS grant and no IPv6 destination is needed. + const result = spawnSync('curl', [ + '--disable', '--silent', '--show-error', '--ipv4', + '--proxy', PROXY, '--noproxy', '', '--proto', '=https', + '--connect-timeout', '10', '--max-time', '30', + '--max-redirs', '0', '--write-out', '\n%{http_code}', + ...extra, `${BOT}${path}`, + ], { encoding: 'utf8', input, timeout: 35_000, maxBuffer: 1024 * 1024 }); + // Do not relay curl stderr or response bodies to the log. Neither may be + // trusted to be free of a reflected signature or application data. + if (result.status !== 0) throw new Error('Request through Tailscale SOCKS5 failed.'); + const split = result.stdout.lastIndexOf('\n'); + const codeText = result.stdout.slice(split + 1); + if (split < 0 || !/^\d{3}$/.test(codeText)) throw new Error('Invalid HTTP status from curl.'); + const code = Number(codeText); + if (code < 200 || code >= 300) throw new Error(`SDK bot returned HTTP ${code}; redirects are forbidden.`); + return result.stdout.slice(0, split); +} + +try { + checkConfig(); + if (process.argv.length === 3 && process.argv[2] === '--check-config') { + process.exit(0); + } + if (process.argv.length !== 3) throw new Error('Usage: node scripts/push-spec-changes.mjs '); + const manifest = readFileSync(process.argv[2]); + if (!process.env.SPEC_CHANGES_SECRET) throw new Error('SPEC_CHANGES_SECRET is required.'); + const signature = `sha256=${createHmac('sha256', process.env.SPEC_CHANGES_SECRET).update(manifest).digest('hex')}`; + + for (let attempt = 1; attempt <= 3; attempt++) { + // A verified exit and same-host source-IP probe precede every signed POST. + // Never fall back to a direct request if the proxy/exit/config fails. + checkExit(); + const trace = curl('/cdn-cgi/trace'); + const ips = trace.split('\n').filter(line => line.startsWith('ip=')); + if (ips.length !== 1 || !NAT_IPS.has(ips[0].slice(3))) { + throw new Error('SDK host did not observe an approved exit NAT IPv4 address.'); + } + checkExit(); + try { + curl('/internal/spec-changes', [ + '--request', 'POST', '--header', 'Content-Type: application/json', + '--header', '@-', '--data-binary', `@${process.argv[2]}`, + ], `X-Spec-Changes-Signature: ${signature}\n`); + console.log('Pushed signed spec-changes manifest through the approved Tailscale exit.'); + process.exit(0); + } catch { + if (attempt === 3) throw new Error('Signed delivery failed after three attempts.'); + await new Promise(resolve => setTimeout(resolve, 5000)); + } + } +} catch (error) { + console.error(error.message); + process.exitCode = 1; +} From 2db88fc56cc682b134bf5bd590b2583b614541e0 Mon Sep 17 00:00:00 2001 From: Matt Peake Date: Thu, 5 Nov 2026 13:41:07 -0500 Subject: [PATCH 2/3] Block direct origin fallback on the delivery runner --- docs/spec-changes-delivery.md | 10 +++++++-- scripts/__tests__/push-spec-changes.spec.mjs | 23 +++++++++++++++++++- scripts/push-spec-changes.mjs | 20 +++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/docs/spec-changes-delivery.md b/docs/spec-changes-delivery.md index aa73218..6314dc4 100644 --- a/docs/spec-changes-delivery.md +++ b/docs/spec-changes-delivery.md @@ -15,7 +15,12 @@ new public exception or rely on held infra PR #2126. Delivery refuses missing identity configuration, unsupported workflow/ref/event, non-userspace Tailscale, a missing/offline/wrong/untagged exit, an unapproved NAT, proxy failures and redirects. All retries use the same fixed host/path through -the proxy. curlrc and `NO_PROXY` cannot select a direct route. Local DNS plus +the proxy. Before any HTTP request, delivery pins the host's resolved IPv4 with +curl `--resolve` and installs a runner-local iptables OUTPUT reject for direct +TCP/443 to that exact IP. This also blocks tailscaled's host-network fallback +if its exit route disappears. Missing sudo/iptables permission stops delivery; +the rule disappears with the hosted VM. curlrc and `NO_PROXY` cannot select a +direct route. Local DNS plus `--ipv4`/`socks5://` sends a numeric IPv4 to SOCKS5 without exit DNS permission. Only the delivery script gets the signing secret; no npm install runs there. The signature travels to curl over stdin, not its command line, and response @@ -57,6 +62,7 @@ verification; never fall back to direct public delivery on failure. signing, identity/config/status denial cases, proxy-only commands, NAT checks, redirects, retries and real curl failure with a closed loopback proxy. SDK validation CI runs it even when there is no spec change. Live OIDC, tailnet -policy enforcement, actual exit/NAT/Worker access and cleanup need the human +policy enforcement, actual exit/NAT/Worker access, Linux firewall enforcement +and cleanup need the human rollout checks. The existing historical `replay-spec-changes.yml` is separate and receives no trust or transport change in this PR. diff --git a/scripts/__tests__/push-spec-changes.spec.mjs b/scripts/__tests__/push-spec-changes.spec.mjs index 2cf39b3..aac4831 100644 --- a/scripts/__tests__/push-spec-changes.spec.mjs +++ b/scripts/__tests__/push-spec-changes.spec.mjs @@ -29,6 +29,11 @@ function run(t, { env = {}, status = goodStatus, mode = '', configOnly = false, import fs from 'node:fs'; fs.appendFileSync(process.env.CALL_LOG, JSON.stringify({command:'tailscale',args:process.argv.slice(2)})+'\\n'); console.log(process.env.STATUS); +`, { mode: 0o755 }); + writeFileSync(join(dir, 'sudo'), `#!${process.execPath} +import fs from 'node:fs'; +fs.appendFileSync(process.env.CALL_LOG, JSON.stringify({command:'sudo',args:process.argv.slice(2)})+'\\n'); +process.exit(process.env.MODE==='firewall-failed'?1:0); `, { mode: 0o755 }); writeFileSync(join(dir, 'curl'), `#!${process.execPath} import fs from 'node:fs'; @@ -53,7 +58,9 @@ else { process.stdout.write('ok\\n200'); } // Top-level retry delays are shortened only by a test loader, with no // production flag or exported implementation seam. const loader = join(dir, 'fast-timers.mjs'); - writeFileSync(loader, 'globalThis.setTimeout = (fn) => { queueMicrotask(fn); return 0; };'); + writeFileSync(loader, `import dns from 'node:dns/promises'; +dns.lookup = async () => ({address: '203.0.113.8', family: 4}); +globalThis.setTimeout = (fn) => { queueMicrotask(fn); return 0; };`); const r = spawnSync(process.execPath, ['--import', loader, script, configOnly ? '--check-config' : file], { cwd: dir, encoding: 'utf8', timeout: 10_000, env: { @@ -98,6 +105,12 @@ test('HMAC covers exactly the UTF-8 file bytes, sent through explicit IPv4 SOCKS const r = run(t); assert.equal(r.status, 0, r.stderr); const calls = r.calls.filter(c => c.command === 'curl'); + const firewall = r.calls.find(c => c.command === 'sudo'); + assert.deepEqual(firewall.args, [ + '-n', 'iptables', '-w', '5', '-I', 'OUTPUT', '1', '-p', 'tcp', + '-d', '203.0.113.8', '--dport', '443', '-j', 'REJECT', + ]); + assert.ok(r.calls.indexOf(firewall) < r.calls.findIndex(c => c.command === 'curl')); assert.equal(calls.length, 2); assert.equal(calls[0].args.at(-1), 'https://sdk-automation-bot.workos.tools/cdn-cgi/trace'); const post = calls[1]; @@ -110,6 +123,7 @@ test('HMAC covers exactly the UTF-8 file bytes, sent through explicit IPv4 SOCKS assert.equal(call.args[call.args.indexOf('--proxy') + 1], 'socks5://127.0.0.1:1055'); assert.equal(call.args[call.args.indexOf('--noproxy') + 1], ''); assert.equal(call.args[call.args.indexOf('--proto') + 1], '=https'); + assert.equal(call.args[call.args.indexOf('--resolve') + 1], 'sdk-automation-bot.workos.tools:443:203.0.113.8'); assert.ok(call.args.includes('--ipv4')); assert.ok(!call.args.includes('--location')); } @@ -142,6 +156,13 @@ test('wrong NAT and proxy failure stop before a signed request, with no direct f } }); +test('failure to block direct origin traffic prevents every HTTP request', t => { + const r = run(t, { mode: 'firewall-failed' }); + assert.equal(r.status, 1); + assert.equal(r.calls.filter(c => c.command === 'sudo').length, 1); + assert.ok(r.calls.every(c => c.command !== 'curl')); +}); + test('redirects and POST errors retry only the same signed endpoint through the verified proxy', t => { for (const mode of ['redirect', 'post-failed']) { const r = run(t, { mode }); diff --git a/scripts/push-spec-changes.mjs b/scripts/push-spec-changes.mjs index 006d923..c9c7619 100644 --- a/scripts/push-spec-changes.mjs +++ b/scripts/push-spec-changes.mjs @@ -3,11 +3,28 @@ import { createHmac } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { isIPv4 } from 'node:net'; import { spawnSync } from 'node:child_process'; +import dns from 'node:dns/promises'; const BOT = 'https://sdk-automation-bot.workos.tools'; const PROXY = 'socks5://127.0.0.1:1055'; const TAG = 'tag:ci-sdk-spec-changes'; const NAT_IPS = new Set(['3.151.179.114', '16.58.134.105', '16.58.183.17']); +let destinationIPv4; + +async function blockDirectOrigin() { + const result = await dns.lookup(new URL(BOT).hostname, { family: 4 }); + if (!isIPv4(result.address)) throw new Error('Cannot resolve SDK host to IPv4.'); + destinationIPv4 = result.address; + // Tailscale UserDial can dial with the host network when no matching route + // exists. Pin curl to this IP and reject direct host TCP to it, including + // from root-owned tailscaled. Encrypted exit/DERP transport uses other + // destinations and is unaffected. Rule lives only on this disposable VM. + const rule = spawnSync('sudo', [ + '-n', 'iptables', '-w', '5', '-I', 'OUTPUT', '1', '-p', 'tcp', + '-d', destinationIPv4, '--dport', '443', '-j', 'REJECT', + ], { encoding: 'utf8', timeout: 10_000 }); + if (rule.status !== 0) throw new Error('Cannot install the direct-origin network deny rule.'); +} function checkConfig() { const e = process.env; @@ -53,6 +70,7 @@ function curl(path, extra = [], input) { // destination: no exit PeerAPI DNS grant and no IPv6 destination is needed. const result = spawnSync('curl', [ '--disable', '--silent', '--show-error', '--ipv4', + '--resolve', `${new URL(BOT).hostname}:443:${destinationIPv4}`, '--proxy', PROXY, '--noproxy', '', '--proto', '=https', '--connect-timeout', '10', '--max-time', '30', '--max-redirs', '0', '--write-out', '\n%{http_code}', @@ -78,6 +96,8 @@ try { const manifest = readFileSync(process.argv[2]); if (!process.env.SPEC_CHANGES_SECRET) throw new Error('SPEC_CHANGES_SECRET is required.'); const signature = `sha256=${createHmac('sha256', process.env.SPEC_CHANGES_SECRET).update(manifest).digest('hex')}`; + checkExit(); + await blockDirectOrigin(); for (let attempt = 1; attempt <= 3; attempt++) { // A verified exit and same-host source-IP probe precede every signed POST. From dd61502836beb70f4db089b9c7a50a42447d0d21 Mon Sep 17 00:00:00 2001 From: Matt Peake Date: Thu, 5 Nov 2026 13:57:54 -0500 Subject: [PATCH 3/3] Migrate confirmed historical replays to isolated Tailscale delivery --- .github/workflows/replay-spec-changes.yml | 82 +++++++++++-- .github/workflows/validate-sdks.yml | 2 +- docs/spec-change-replay.md | 20 ++- docs/spec-changes-delivery.md | 16 ++- docs/tailscale-action-review.md | 68 +++++++++++ scripts/__tests__/push-spec-changes.spec.mjs | 49 +++++++- .../__tests__/replay-spec-changes.spec.mjs | 33 +++++ scripts/push-spec-changes.mjs | 87 +++++++++---- scripts/replay-spec-changes.mjs | 115 +++++++----------- 9 files changed, 359 insertions(+), 113 deletions(-) create mode 100644 docs/tailscale-action-review.md diff --git a/.github/workflows/replay-spec-changes.yml b/.github/workflows/replay-spec-changes.yml index bbf8a9e..eb3f16c 100644 --- a/.github/workflows/replay-spec-changes.yml +++ b/.github/workflows/replay-spec-changes.yml @@ -29,18 +29,18 @@ concurrency: group: spec-changes cancel-in-progress: false -env: - SDK_BOT_URL: https://sdk-automation-bot.workos.tools - jobs: replay: runs-on: ubuntu-latest + outputs: + deliver: ${{ steps.prepare.outputs.deliver }} permissions: contents: read steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + persist-credentials: false - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: @@ -82,15 +82,83 @@ jobs: exit 1 fi - - name: Rebuild and idempotently push manifests + - name: Prepare confirmed replay delivery + id: prepare if: inputs.mode == 'push' env: TARGET_SHAS: ${{ inputs.target_shas }} - SPEC_CHANGES_SECRET: ${{ secrets.SPEC_CHANGES_SECRET }} run: | + set -euo pipefail node scripts/replay-spec-changes.mjs \ --shas "$TARGET_SHAS" \ --repository "$GITHUB_REPOSITORY" \ - --bot-url "$SDK_BOT_URL" \ - --push \ + --prepare-delivery "$RUNNER_TEMP/replay-delivery" \ --confirm REPLAY_SPEC_CHANGES + if [ "$(jq 'length' "$RUNNER_TEMP/replay-delivery/index.json")" -gt 0 ]; then + echo "deliver=true" >> "$GITHUB_OUTPUT" + fi + + - name: Hand replay data to isolated delivery job + if: steps.prepare.outputs.deliver == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: replay-spec-changes-manifests + path: ${{ runner.temp }}/replay-delivery/*.json + if-no-files-found: error + retention-days: 1 + + deliver: + needs: replay + if: >- + needs.replay.outputs.deliver == 'true' && inputs.mode == 'push' && + github.repository == 'workos/openapi-spec' && + github.ref == 'refs/heads/main' && + github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + id-token: write + env: + REPLAY_MODE: ${{ inputs.mode }} + REPLAY_CONFIRMATION: REPLAY_SPEC_CHANGES + TAILSCALE_EXIT_NODE: ${{ vars.SDK_SPEC_CHANGES_EXIT_NODE }} + TAILSCALE_CLIENT_ID: ${{ vars.SDK_SPEC_REPLAY_CLIENT_ID }} + TAILSCALE_AUDIENCE: ${{ vars.SDK_SPEC_REPLAY_AUDIENCE }} + steps: + # The build job has no OIDC or signing secret. Delivery uses only this + # checked-in, dependency-free script; never execute artifact contents. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + sparse-checkout: scripts/push-spec-changes.mjs + sparse-checkout-cone-mode: false + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '24' + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: replay-spec-changes-manifests + path: ${{ runner.temp }}/spec-changes + + - name: Require exact workflow and public identity configuration + run: node scripts/push-spec-changes.mjs --check-config + + - name: Join Tailscale as an ephemeral SDK delivery runner + uses: tailscale/github-action@064b10558093f38325e838ddea35486bdc6b1d5c # v4 + with: + oauth-client-id: ${{ env.TAILSCALE_CLIENT_ID }} + audience: ${{ env.TAILSCALE_AUDIENCE }} + tags: tag:ci-sdk-spec-changes + version: '1.104.4' + use-cache: 'false' + hostname: sdk-replay-${{ github.run_id }}-${{ github.run_attempt }} + tailscaled-args: --tun=userspace-networking --socks5-server=127.0.0.1:1055 + args: --exit-node=${{ env.TAILSCALE_EXIT_NODE }} --exit-node-allow-lan-access=false --accept-routes=false --accept-dns=false + + - name: Verify exit NAT and deliver exact HMAC-signed bytes + env: + SPEC_CHANGES_SECRET: ${{ secrets.SPEC_CHANGES_SECRET }} + run: node scripts/push-spec-changes.mjs --replay-dir "$RUNNER_TEMP/spec-changes" diff --git a/.github/workflows/validate-sdks.yml b/.github/workflows/validate-sdks.yml index 8b05190..b5eb833 100644 --- a/.github/workflows/validate-sdks.yml +++ b/.github/workflows/validate-sdks.yml @@ -29,7 +29,7 @@ jobs: run: node --test scripts/__tests__/resolve-sync-conflicts.spec.mjs - name: Test signed Tailscale delivery - run: node --test scripts/__tests__/push-spec-changes.spec.mjs + run: node --test scripts/__tests__/push-spec-changes.spec.mjs scripts/__tests__/replay-spec-changes.spec.mjs - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 id: filter diff --git a/docs/spec-change-replay.md b/docs/spec-change-replay.md index ddcf97b..e646c09 100644 --- a/docs/spec-change-replay.md +++ b/docs/spec-change-replay.md @@ -34,8 +34,11 @@ node scripts/replay-spec-changes.mjs \ 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. +branch (`main` for the configured trust). The build job prepares exact manifest +bytes plus an ordered index as a same-run artifact, without OIDC permission or +the signing secret. A separate delivery job uses the replay-only OIDC identity +and the shared fail-closed Tailscale transport, signs those exact bytes and POSTs +them to the existing SDK bot endpoint. 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. @@ -43,4 +46,15 @@ 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. +`SPEC_CHANGES_SECRET` only to the isolated delivery step. Configure the dedicated +`SDK_SPEC_REPLAY_CLIENT_ID` and `SDK_SPEC_REPLAY_AUDIENCE` public variables, plus +the shared reviewed exit, after the infra trust/policy rollout described in +[delivery setup](spec-changes-delivery.md). + +The script's `--prepare-delivery --confirm REPLAY_SPEC_CHANGES` +mode writes delivery data without posting or reading a signing secret. The +legacy `--push --confirm REPLAY_SPEC_CHANGES` option has no direct `fetch` path: +it invokes the shared transport and requires the configured, confirmed main +replay workflow context and Tailscale runner. Use the Actions workflow to push; +local dry-run needs no such identity. No production replay was run while +preparing this change. diff --git a/docs/spec-changes-delivery.md b/docs/spec-changes-delivery.md index 6314dc4..eaf06ac 100644 --- a/docs/spec-changes-delivery.md +++ b/docs/spec-changes-delivery.md @@ -1,6 +1,7 @@ # Spec-change delivery through Tailscale `spec-changes.yml` builds a manifest without a signing secret or OIDC permission. +Confirmed replay push mode similarly prepares an ordered manifest artifact. A separate delivery job downloads that same-run data artifact, obtains GitHub OIDC identity, joins Tailscale ephemerally as `tag:ci-sdk-spec-changes`, and signs the exact manifest file bytes with the existing `SPEC_CHANGES_SECRET`. @@ -27,7 +28,7 @@ The signature travels to curl over stdin, not its command line, and response bodies and diagnostics are kept out of logs. Apply the reviewed `workos/infra` trust/policy PR **before** merging this one. -Two event-specific identities require exact repository/owner numeric IDs, +Three workflow/event-specific identities require exact repository/owner numeric IDs, main ref, workflow path and GitHub-hosted runner. Each has `auth_keys` only. Their grant permits general IPv4 Internet TCP/443 through tagged internal exits; it is not a hostname/path ACL. Private/tailnet/link-local/loopback destinations, @@ -43,6 +44,8 @@ Set these nonsecret Actions repository variables from the infra output | `SDK_SPEC_CHANGES_PUSH_AUDIENCE` | output `["push"].audience` | | `SDK_SPEC_CHANGES_DISPATCH_CLIENT_ID` | output `["workflow_dispatch"].client_id` | | `SDK_SPEC_CHANGES_DISPATCH_AUDIENCE` | output `["workflow_dispatch"].audience` | +| `SDK_SPEC_REPLAY_CLIENT_ID` | output `["replay"].client_id` | +| `SDK_SPEC_REPLAY_AUDIENCE` | output `["replay"].audience` | | `SDK_SPEC_CHANGES_EXIT_NODE` | approved, online internal exit's Tailscale IPv4 address | Keep the existing `SPEC_CHANGES_SECRET` synchronized with the bot. No new static @@ -58,11 +61,16 @@ spec path filter), then verify the next real spec merge independently. Confirm the bot's manifest row and ephemeral node removal. Preserve application HMAC verification; never fall back to direct public delivery on failure. -`node --test scripts/__tests__/push-spec-changes.spec.mjs` checks byte-exact +`node --test scripts/__tests__/push-spec-changes.spec.mjs scripts/__tests__/replay-spec-changes.spec.mjs` checks byte-exact signing, identity/config/status denial cases, proxy-only commands, NAT checks, redirects, retries and real curl failure with a closed loopback proxy. SDK validation CI runs it even when there is no spec change. Live OIDC, tailnet policy enforcement, actual exit/NAT/Worker access, Linux firewall enforcement and cleanup need the human -rollout checks. The existing historical `replay-spec-changes.yml` is separate -and receives no trust or transport change in this PR. +rollout checks. The historical replay workflow has its own exact manual-dispatch +trust and uses the same isolated transport. It preserves dry-run, main/default-branch and +explicit replay confirmation, validates every target before posting, and sends +changed manifests sequentially in the supplied order. See [replay instructions](spec-change-replay.md). + +The two Socket upstream argument-flow warnings have been traced without +suppression; see the [pinned action review](tailscale-action-review.md). diff --git a/docs/tailscale-action-review.md b/docs/tailscale-action-review.md new file mode 100644 index 0000000..fd852d1 --- /dev/null +++ b/docs/tailscale-action-review.md @@ -0,0 +1,68 @@ +# Pinned Tailscale action argument-flow review + +The two Socket warnings on [PR #264](https://github.com/workos/openapi-spec/pull/264#issuecomment-6666124487) +were reviewed on 2026-11-05 for action commit +`064b10558093f38325e838ddea35486bdc6b1d5c`. Neither warning was ignored or +suppressed. The Socket package alert detail identifies these two sources and +sinks; its source-location UI currently renders the sink objects as +`[object Object]`, so the exact code locations below come from the pinned +upstream source and its executed bundle. + +| Socket instance | Exact source and path | Sink | Our supplied value | +| --- | --- | --- | --- | +| 1 | `core.getInput("version")`, `src/main.ts:261`, through `parseVersion`, `resolvedVersion`, and fixed vendor `downloadUrl` | `tc.downloadTool(downloadUrl, tarDest)` at line 519 (Linux), and `tc.downloadTool(downloadUrl, msiPath)` at line 637 (Windows) | Literal `1.104.4`; hosted Ubuntu selects Linux. No event, PR, dispatch or repository variable controls this input. | +| 2 | `core.getInput("statedir")`, `src/main.ts:272`, through `config.stateDir`, `stateArgs` and daemon `args` | `spawn("sudo", ["-E", cmdTailscaled, ...args], ...)` at line 771 | Omitted/empty, which selects `--state=mem:` at lines 755–757. No writable state directory or user-controlled shell command is supplied. | + +The version parser permits only a numeric version (optionally prefixed `v`) or +the named release tracks. Our workflow uses a fixed numeric version. The +download host is `pkgs.tailscale.com`; the action verifies the downloaded +archive's SHA-256 before installing. `statedir` reaches an argument-array spawn +with no `shell: true`. These are input-to-download/process flows, not proof of +credential disclosure or command injection in this workflow configuration. + +The other action inputs are public client ID/audience, a fixed tag, fixed +userspace/proxy flags, numeric GitHub run identifiers, and the exit IPv4 +validated by `--check-config` before the action runs. We supply no `authkey`, +`oauth-secret`, dynamic `version`, or `statedir`. The normal producer and replay +use distinct exact workflow/event trusts. No PR or dispatch text is interpolated +into the action's command arguments. + +## OIDC and signing-secret exposure + +The action obtains the short-lived JWT using `core.getIDToken(config.audience)` +at `src/main.ts:866`, then passes it to the Tailscale CLI as +`--id-token=${token}` at line 869. In the pinned executed `dist/index.js`, the +bundled `OidcClient.getIDToken` calls `core.setSecret(id_token)` before returning +the token (including the bundled copy at lines 11647–11659). The logging wrapper +normally suppresses command output; debug mode can print commands, but the raw +JWT is already registered with GitHub's runner masking. The two Socket warnings +do not trace this JWT or the HMAC secret to their sinks. + +The JWT **is briefly present in the Tailscale CLI's process arguments**. Log +masking does not conceal argv from privileged or same-user processes on the +runner. This is why these jobs use fresh GitHub-hosted VMs, run no npm install +or artifact code in delivery, and must not be moved onto shared/persistent +self-hosted runners without a separate credential-isolation review. A +compromised action/runner can access a token it is authorized to use; this review +does not establish safety against that compromise. + +`SPEC_CHANGES_SECRET` is scoped only to the later delivery-script step, so it is +not provided to the Tailscale action. The script passes only the derived HMAC +header to curl over stdin and does not log it, the secret, or response bodies. +The data-building jobs receive neither signing secret nor OIDC permission. + +For these pinned inputs, neither Socket flow requires a code change. The +alerts remain visible for review. This is a static source/bundle analysis: +no production OIDC token was minted and no live credential/log exposure was +tested. + +Sources: + +- [Socket's two argument-to-sink instances](https://socket.dev/actions/package/tailscale/github-action/alerts/064b10558093f38325e838ddea35486bdc6b1d5c?alert_name=ghaArgToSink) +- [Pinned input parsing and version validation](https://github.com/tailscale/github-action/blob/064b10558093f38325e838ddea35486bdc6b1d5c/src/main.ts#L242) +- [Pinned Linux download sink](https://github.com/tailscale/github-action/blob/064b10558093f38325e838ddea35486bdc6b1d5c/src/main.ts#L519) +- [Pinned Windows download sink](https://github.com/tailscale/github-action/blob/064b10558093f38325e838ddea35486bdc6b1d5c/src/main.ts#L637) +- [Pinned memory-state branch and daemon spawn](https://github.com/tailscale/github-action/blob/064b10558093f38325e838ddea35486bdc6b1d5c/src/main.ts#L751) +- [Pinned OIDC argument construction](https://github.com/tailscale/github-action/blob/064b10558093f38325e838ddea35486bdc6b1d5c/src/main.ts#L866) +- [Executed bundle token masking](https://github.com/tailscale/github-action/blob/064b10558093f38325e838ddea35486bdc6b1d5c/dist/index.js#L11647) +- [Pinned logging wrapper](https://github.com/tailscale/github-action/blob/064b10558093f38325e838ddea35486bdc6b1d5c/src/logging.ts#L65) diff --git a/scripts/__tests__/push-spec-changes.spec.mjs b/scripts/__tests__/push-spec-changes.spec.mjs index aac4831..87df3e0 100644 --- a/scripts/__tests__/push-spec-changes.spec.mjs +++ b/scripts/__tests__/push-spec-changes.spec.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { createHmac } from 'node:crypto'; -import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; @@ -19,12 +19,22 @@ const goodStatus = { } }, }; -function run(t, { env = {}, status = goodStatus, mode = '', configOnly = false, realCurl = false } = {}) { +const SHA_A = 'a'.repeat(40); +const SHA_B = 'b'.repeat(40); + +function run(t, { env = {}, status = goodStatus, mode = '', configOnly = false, realCurl = false, + replay = false, index = [SHA_B, SHA_A], replayPayloads = {} } = {}) { const dir = mkdtempSync(join(tmpdir(), 'sdk-delivery-')); t.after(() => rmSync(dir, { recursive: true, force: true })); const log = join(dir, 'calls.jsonl'); const file = join(dir, 'manifest.json'); writeFileSync(file, manifest); + const replayDir = join(dir, 'replay'); + mkdirSync(replayDir); + writeFileSync(join(replayDir, 'index.json'), JSON.stringify(index)); + for (const sha of [SHA_A, SHA_B]) { + writeFileSync(join(replayDir, `${sha}.json`), replayPayloads[sha] ?? JSON.stringify({ ...JSON.parse(manifest), sha }) + '\n'); + } writeFileSync(join(dir, 'tailscale'), `#!${process.execPath} import fs from 'node:fs'; fs.appendFileSync(process.env.CALL_LOG, JSON.stringify({command:'tailscale',args:process.argv.slice(2)})+'\\n'); @@ -61,7 +71,8 @@ else { process.stdout.write('ok\\n200'); } writeFileSync(loader, `import dns from 'node:dns/promises'; dns.lookup = async () => ({address: '203.0.113.8', family: 4}); globalThis.setTimeout = (fn) => { queueMicrotask(fn); return 0; };`); - const r = spawnSync(process.execPath, ['--import', loader, script, configOnly ? '--check-config' : file], { + const args = configOnly ? ['--check-config'] : replay ? ['--replay-dir', replayDir] : [file]; + const r = spawnSync(process.execPath, ['--import', loader, script, ...args], { cwd: dir, encoding: 'utf8', timeout: 10_000, env: { PATH: `${dir}:${process.env.PATH}`, CALL_LOG: log, STATUS: JSON.stringify(status), MODE: mode, @@ -71,6 +82,11 @@ globalThis.setTimeout = (fn) => { queueMicrotask(fn); return 0; };`); TAILSCALE_CLIENT_ID: 'fixture-client', TAILSCALE_AUDIENCE: 'api.tailscale.com/fixture-client', SPEC_CHANGES_SECRET: secret, NO_PROXY: '*', no_proxy: '*', REAL_CURL: String(realCurl), CURL_HOME: dir, ...env, + ...(replay ? { + GITHUB_WORKFLOW_REF: 'workos/openapi-spec/.github/workflows/replay-spec-changes.yml@refs/heads/main', + GITHUB_EVENT_NAME: 'workflow_dispatch', REPLAY_MODE: 'push', REPLAY_CONFIRMATION: 'REPLAY_SPEC_CHANGES', + ...env, + } : {}), }, }); let calls = []; @@ -101,6 +117,33 @@ test('both exact supported events can validate config; other repo/ref/workflow/e } }); +test('confirmed replay delivers indexed manifests sequentially using the shared transport and exact HMAC bytes', t => { + const r = run(t, { replay: true }); + assert.equal(r.status, 0, r.stderr); + const posts = r.calls.filter(c => c.command === 'curl' && c.args.includes('POST')); + assert.deepEqual(posts.map(c => JSON.parse(c.body).sha), [SHA_B, SHA_A]); + for (const post of posts) { + assert.equal(post.input, `X-Spec-Changes-Signature: sha256=${createHmac('sha256', secret).update(post.body).digest('hex')}\n`); + assert.equal(post.args.at(-1), 'https://sdk-automation-bot.workos.tools/internal/spec-changes'); + assert.equal(post.args[post.args.indexOf('--proxy') + 1], 'socks5://127.0.0.1:1055'); + } +}); + +test('replay requires manual push confirmation on main and validates every indexed target before networking', t => { + for (const options of [ + { env: { GITHUB_EVENT_NAME: 'push' } }, { env: { GITHUB_EVENT_NAME: 'pull_request_target' } }, + { env: { REPLAY_MODE: 'dry-run' } }, { env: { REPLAY_CONFIRMATION: '' } }, + { env: { GITHUB_REF: 'refs/heads/topic' } }, { index: ['../manifest'] }, + { index: [SHA_A, SHA_A] }, { index: [] }, + { replayPayloads: { [SHA_A]: JSON.stringify({ sha: SHA_B, changedServices: [{}] }) } }, + { replayPayloads: { [SHA_A]: JSON.stringify({ sha: SHA_A, changedServices: [] }) } }, + ]) { + const r = run(t, { replay: true, ...options }); + assert.equal(r.status, 1); + assert.deepEqual(r.calls, [], 'no partial replay before later target validation'); + } +}); + test('HMAC covers exactly the UTF-8 file bytes, sent through explicit IPv4 SOCKS5 with no bypass', t => { const r = run(t); assert.equal(r.status, 0, r.stderr); diff --git a/scripts/__tests__/replay-spec-changes.spec.mjs b/scripts/__tests__/replay-spec-changes.spec.mjs index a383739..0b2de1b 100644 --- a/scripts/__tests__/replay-spec-changes.spec.mjs +++ b/scripts/__tests__/replay-spec-changes.spec.mjs @@ -1,10 +1,14 @@ import assert from "node:assert/strict"; import { test } from "node:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { firstParentFromRevList, parseArgs, parseTargetShas, pullRequestMetadata, + writeDelivery, } from "../replay-spec-changes.mjs"; const SHA_A = "a".repeat(40); @@ -17,6 +21,35 @@ test("parseTargetShas accepts comma and whitespace separated full SHAs", () => { ]); }); +test("artifact preparation requires explicit replay confirmation and cannot combine with push", () => { + const base = ["node", "replay", "--shas", SHA_A, "--repository", "workos/openapi-spec"]; + assert.throws(() => parseArgs([...base, "--prepare-delivery", "/tmp/replay-fixture"]), /Refusing to POST/); + const args = parseArgs([...base, "--prepare-delivery", "/tmp/replay-fixture", "--confirm", "REPLAY_SPEC_CHANGES"]); + assert.equal(args.prepareDelivery, "/tmp/replay-fixture"); + assert.equal(args.push, false); + assert.throws(() => parseArgs([...base, "--prepare-delivery", "/tmp/replay-fixture", "--push"]), /not both/); + assert.throws(() => parseArgs([...base, "--push", "--confirm", "REPLAY_SPEC_CHANGES", "--bot-url", "https://other.example"]), /fixed SDK bot/); +}); + +test("delivery artifact preserves ordered exact bytes, excludes unchanged targets, and preflights all files", t => { + const root = mkdtempSync(join(tmpdir(), "replay-data-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const a = JSON.stringify({ sha: SHA_A, changedServices: [{ name: "SSO" }], text: "é" }) + "\n"; + const b = JSON.stringify({ sha: SHA_B.toLowerCase(), changedServices: [] }) + "\n"; + const out = join(root, "data"); + assert.equal(writeDelivery(out, [SHA_B.toLowerCase(), SHA_A], [{ manifestText: b }, { manifestText: a }]), 1); + assert.deepEqual(JSON.parse(readFileSync(join(out, "index.json"))), [SHA_A]); + assert.equal(readFileSync(join(out, `${SHA_A}.json`), "utf8"), a); + assert.ok(!existsSync(join(out, `${SHA_B.toLowerCase()}.json`))); + const unchanged = join(root, "unchanged"); + assert.equal(writeDelivery(unchanged, [SHA_B.toLowerCase()], [{ manifestText: b }]), 0); + assert.deepEqual(JSON.parse(readFileSync(join(unchanged, "index.json"))), []); + assert.throws(() => writeDelivery(out, [SHA_A], [{ manifestText: a }]), /already exist/); + const invalid = join(root, "invalid"); + assert.throws(() => writeDelivery(invalid, [SHA_A, SHA_B.toLowerCase()], [{ manifestText: a }, { manifestText: a }]), /SHA mismatch/); + assert.ok(!existsSync(invalid), "invalid later target must not leave partial delivery data"); +}); + test("parseTargetShas rejects empty, abbreviated, ref, range, and duplicate input", () => { for (const value of [ "", diff --git a/scripts/push-spec-changes.mjs b/scripts/push-spec-changes.mjs index c9c7619..b408bcc 100644 --- a/scripts/push-spec-changes.mjs +++ b/scripts/push-spec-changes.mjs @@ -4,6 +4,7 @@ import { readFileSync } from 'node:fs'; import { isIPv4 } from 'node:net'; import { spawnSync } from 'node:child_process'; import dns from 'node:dns/promises'; +import { join } from 'node:path'; const BOT = 'https://sdk-automation-bot.workos.tools'; const PROXY = 'socks5://127.0.0.1:1055'; @@ -28,11 +29,15 @@ async function blockDirectOrigin() { function checkConfig() { const e = process.env; + const producer = e.GITHUB_WORKFLOW_REF === 'workos/openapi-spec/.github/workflows/spec-changes.yml@refs/heads/main' && + ['push', 'workflow_dispatch'].includes(e.GITHUB_EVENT_NAME); + const replay = e.GITHUB_WORKFLOW_REF === 'workos/openapi-spec/.github/workflows/replay-spec-changes.yml@refs/heads/main' && + e.GITHUB_EVENT_NAME === 'workflow_dispatch' && e.REPLAY_MODE === 'push' && + e.REPLAY_CONFIRMATION === 'REPLAY_SPEC_CHANGES'; if (e.GITHUB_REPOSITORY !== 'workos/openapi-spec' || e.GITHUB_REF !== 'refs/heads/main' || - e.GITHUB_WORKFLOW_REF !== 'workos/openapi-spec/.github/workflows/spec-changes.yml@refs/heads/main' || - !['push', 'workflow_dispatch'].includes(e.GITHUB_EVENT_NAME)) { - throw new Error('Delivery requires the spec-changes workflow on workos/openapi-spec main.'); + (!producer && !replay)) { + throw new Error('Delivery requires an approved workflow/event on workos/openapi-spec main and explicit replay confirmation.'); } const ip = e.TAILSCALE_EXIT_NODE ?? ''; const octets = ip.split('.').map(Number); @@ -43,6 +48,33 @@ function checkConfig() { e.TAILSCALE_AUDIENCE !== `api.tailscale.com/${e.TAILSCALE_CLIENT_ID}`) { throw new Error('Missing or invalid public per-event Tailscale identity configuration.'); } + return replay; +} + +function readDeliveryFiles(replay) { + if (!replay && process.argv.length === 3 && !process.argv[2].startsWith('--')) { + return [{ file: process.argv[2], bytes: readFileSync(process.argv[2]) }]; + } + if (!replay || process.argv.length !== 4 || process.argv[2] !== '--replay-dir') { + throw new Error('Usage: producer or confirmed replay --replay-dir '); + } + const directory = process.argv[3]; + const shas = JSON.parse(readFileSync(join(directory, 'index.json'), 'utf8')); + if (!Array.isArray(shas) || shas.length === 0 || new Set(shas).size !== shas.length || + !shas.every(sha => typeof sha === 'string' && /^[0-9a-f]{40}$/.test(sha))) { + throw new Error('Replay index must contain distinct full lowercase target SHAs.'); + } + // Load and validate every target before any network activity. Index order is + // the operator's explicit input order; no artifact contents are executed. + return shas.map(sha => { + const file = join(directory, `${sha}.json`); + const bytes = readFileSync(file); + const manifest = JSON.parse(bytes); + if (manifest.sha !== sha || !Array.isArray(manifest.changedServices) || manifest.changedServices.length === 0) { + throw new Error('Replay manifest must match its indexed SHA and contain changed services.'); + } + return { file, bytes }; + }); } function checkExit() { @@ -88,37 +120,38 @@ function curl(path, extra = [], input) { } try { - checkConfig(); + const replay = checkConfig(); if (process.argv.length === 3 && process.argv[2] === '--check-config') { process.exit(0); } - if (process.argv.length !== 3) throw new Error('Usage: node scripts/push-spec-changes.mjs '); - const manifest = readFileSync(process.argv[2]); + const manifests = readDeliveryFiles(replay); if (!process.env.SPEC_CHANGES_SECRET) throw new Error('SPEC_CHANGES_SECRET is required.'); - const signature = `sha256=${createHmac('sha256', process.env.SPEC_CHANGES_SECRET).update(manifest).digest('hex')}`; checkExit(); await blockDirectOrigin(); - for (let attempt = 1; attempt <= 3; attempt++) { - // A verified exit and same-host source-IP probe precede every signed POST. - // Never fall back to a direct request if the proxy/exit/config fails. - checkExit(); - const trace = curl('/cdn-cgi/trace'); - const ips = trace.split('\n').filter(line => line.startsWith('ip=')); - if (ips.length !== 1 || !NAT_IPS.has(ips[0].slice(3))) { - throw new Error('SDK host did not observe an approved exit NAT IPv4 address.'); - } - checkExit(); - try { - curl('/internal/spec-changes', [ - '--request', 'POST', '--header', 'Content-Type: application/json', - '--header', '@-', '--data-binary', `@${process.argv[2]}`, - ], `X-Spec-Changes-Signature: ${signature}\n`); - console.log('Pushed signed spec-changes manifest through the approved Tailscale exit.'); - process.exit(0); - } catch { - if (attempt === 3) throw new Error('Signed delivery failed after three attempts.'); - await new Promise(resolve => setTimeout(resolve, 5000)); + for (const { file, bytes } of manifests) { + const signature = `sha256=${createHmac('sha256', process.env.SPEC_CHANGES_SECRET).update(bytes).digest('hex')}`; + for (let attempt = 1; attempt <= 3; attempt++) { + // A verified exit and same-host source-IP probe precede every signed POST. + // Never fall back to a direct request if the proxy/exit/config fails. + checkExit(); + const trace = curl('/cdn-cgi/trace'); + const ips = trace.split('\n').filter(line => line.startsWith('ip=')); + if (ips.length !== 1 || !NAT_IPS.has(ips[0].slice(3))) { + throw new Error('SDK host did not observe an approved exit NAT IPv4 address.'); + } + checkExit(); + try { + curl('/internal/spec-changes', [ + '--request', 'POST', '--header', 'Content-Type: application/json', + '--header', '@-', '--data-binary', `@${file}`, + ], `X-Spec-Changes-Signature: ${signature}\n`); + console.log('Pushed signed spec-changes manifest through the approved Tailscale exit.'); + break; + } catch { + if (attempt === 3) throw new Error('Signed delivery failed after three attempts.'); + await new Promise(resolve => setTimeout(resolve, 5000)); + } } } } catch (error) { diff --git a/scripts/replay-spec-changes.mjs b/scripts/replay-spec-changes.mjs index 2bf3c37..555b505 100644 --- a/scripts/replay-spec-changes.mjs +++ b/scripts/replay-spec-changes.mjs @@ -9,7 +9,8 @@ // --repository workos/openapi-spec // // Posting requires both an explicit flag and confirmation. The caller must -// provide SPEC_CHANGES_SECRET only for this mode: +// provide SPEC_CHANGES_SECRET and the configured replay Tailscale context only +// for this mode (CI uses --prepare-delivery then an isolated delivery job): // node scripts/replay-spec-changes.mjs \ // --shas "," \ // --repository workos/openapi-spec \ @@ -20,7 +21,6 @@ // 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, @@ -99,6 +99,7 @@ export function parseArgs(argv) { botUrl: process.env.SDK_BOT_URL ?? "", push: false, confirm: "", + prepareDelivery: "", }; for (let i = 2; i < argv.length; i += 1) { @@ -107,6 +108,7 @@ export function parseArgs(argv) { 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 === "--prepare-delivery") args.prepareDelivery = argv[++i] ?? ""; else if (arg === "--confirm") args.confirm = argv[++i] ?? ""; else throw new Error(`Unknown option: ${arg}`); } @@ -117,12 +119,15 @@ export function parseArgs(argv) { throw new Error("--repository in owner/name form is required"); } - if (args.push) { + if (args.push && args.prepareDelivery) throw new Error("Choose --push or --prepare-delivery, not both"); + if (args.push || args.prepareDelivery) { if (args.confirm !== PUSH_CONFIRMATION) { throw new Error( `Refusing to POST without --confirm ${PUSH_CONFIRMATION}`, ); } + } + if (args.push) { if (!args.botUrl) { throw new Error("--bot-url (or SDK_BOT_URL) is required with --push"); } @@ -130,6 +135,9 @@ export function parseArgs(argv) { if (endpoint.protocol !== "https:") { throw new Error("The spec-changes endpoint must use HTTPS"); } + if (endpoint.href !== "https://sdk-automation-bot.workos.tools/internal/spec-changes") { + throw new Error("Confirmed replay delivery supports only the fixed SDK bot endpoint"); + } args.endpoint = endpoint; } @@ -324,54 +332,24 @@ 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); +// Export exact bytes as data for the isolated, dependency-free delivery job. +// This function also serves --push, so replay has no direct fetch fallback. +export function writeDelivery(directory, targetShas, rebuilt) { + if (existsSync(directory)) throw new Error("Delivery directory must not already exist"); + const selected = targetShas.flatMap((sha, index) => { + if (!/^[0-9a-f]{40}$/.test(sha)) throw new Error("Invalid replay delivery target SHA"); + const entry = rebuilt[index]; + const manifest = JSON.parse(entry.manifestText); + if (manifest.sha !== sha) throw new Error("Replay delivery SHA mismatch"); + if (!Array.isArray(manifest.changedServices)) throw new Error("Invalid changed-services manifest"); + return manifest.changedServices.length === 0 ? [] : [{ sha, text: entry.manifestText }]; + }); + mkdirSync(directory, { recursive: true }); + for (const { sha, text } of selected) { + writeFileSync(join(directory, `${sha}.json`), text); } + writeFileSync(join(directory, "index.json"), JSON.stringify(selected.map(entry => entry.sha))); + return selected.length; } async function main() { @@ -403,28 +381,29 @@ 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.push && !args.prepareDelivery) process.stdout.write("Dry run: manifest was not posted.\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.push && !args.prepareDelivery) return; - 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"); - continue; - } - - await postManifest(args.endpoint, manifestText, secret, targetSha); - process.stdout.write( - `Posted manifest for ${targetSha}; the bot upserts this SHA idempotently.\n`, - ); + const deliveryDir = args.prepareDelivery + ? resolve(args.prepareDelivery) : join(tempRoot, "delivery"); + const count = writeDelivery(deliveryDir, args.targetShas, rebuilt); + if (args.prepareDelivery) { + process.stdout.write(`Prepared ${count} manifest(s) for isolated delivery; nothing was posted.\n`); + return; + } + if (count === 0) { + process.stdout.write("No changed services; manifest was not posted.\n"); + return; } + // Legacy --push still requires explicit confirmation, but uses the same + // guarded Tailscale transport and workflow context as isolated CI delivery. + // There is no independent fetch implementation or direct retry path. + const delivered = run(process.execPath, [ + join(SCRIPT_DIR, "push-spec-changes.mjs"), "--replay-dir", deliveryDir, + ]); + process.stdout.write(delivered.stdout); } finally { rmSync(tempRoot, { recursive: true, force: true }); }