|
| 1 | +name: publish-postman |
| 2 | + |
| 3 | +# Pushes generated OpenAPI specs to their Postman public collections via |
| 4 | +# the Postman API (PUT /collections/:id). One matrix entry per collection. |
| 5 | +# |
| 6 | +# Triggered only when a watched openapi.yaml actually changes on main. |
| 7 | +# Each matrix job double-checks via `git diff` so unrelated specs are skipped. |
| 8 | +# |
| 9 | +# Required repo configuration: |
| 10 | +# secrets.POSTMAN_API_KEY - Postman API key |
| 11 | +# vars.POSTMAN_SIGNALWIRE_REST_COLLECTION_ID - public collection UID |
| 12 | +# vars.POSTMAN_COMPATIBILITY_COLLECTION_ID - public collection UID |
| 13 | +# |
| 14 | +# The public collections must already exist in Postman; PUT replaces contents |
| 15 | +# but cannot create a new collection. |
| 16 | + |
| 17 | +on: |
| 18 | + push: |
| 19 | + branches: |
| 20 | + - main |
| 21 | + paths: |
| 22 | + - 'fern/apis/signalwire-rest/openapi.yaml' |
| 23 | + - 'fern/apis/compatibility/openapi.yaml' |
| 24 | + workflow_dispatch: |
| 25 | + inputs: |
| 26 | + force_all: |
| 27 | + description: 'Push every collection regardless of change detection' |
| 28 | + type: boolean |
| 29 | + default: false |
| 30 | + |
| 31 | +# Restrict the default GITHUB_TOKEN: this workflow only reads source files |
| 32 | +# (for git diff) and never writes back to the repo. Lock it down so a |
| 33 | +# compromised dependency cannot use the token to push commits. |
| 34 | +permissions: |
| 35 | + contents: read |
| 36 | + |
| 37 | +jobs: |
| 38 | + publish: |
| 39 | + runs-on: ubuntu-latest |
| 40 | + strategy: |
| 41 | + fail-fast: false |
| 42 | + matrix: |
| 43 | + # collection_id values are looked up from repo Variables at matrix |
| 44 | + # expansion time (static `vars.X` references, not dynamic indexing). |
| 45 | + # UIDs are not sensitive — they appear in public Postman URLs — so |
| 46 | + # they live in Variables, not Secrets. |
| 47 | + include: |
| 48 | + - name: signalwire-rest |
| 49 | + spec: fern/apis/signalwire-rest/openapi.yaml |
| 50 | + collection_id: ${{ vars.POSTMAN_SIGNALWIRE_REST_COLLECTION_ID }} |
| 51 | + - name: compatibility |
| 52 | + spec: fern/apis/compatibility/openapi.yaml |
| 53 | + collection_id: ${{ vars.POSTMAN_COMPATIBILITY_COLLECTION_ID }} |
| 54 | + steps: |
| 55 | + - name: Checkout |
| 56 | + # Pinned to commit SHA (v4.2.2) — tags are mutable and a tag |
| 57 | + # repoint by a compromised upstream would run with our secrets. |
| 58 | + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 |
| 59 | + with: |
| 60 | + fetch-depth: 2 # need HEAD^ for the per-spec diff |
| 61 | + |
| 62 | + - name: Detect whether ${{ matrix.spec }} changed |
| 63 | + id: changed |
| 64 | + env: |
| 65 | + SPEC: ${{ matrix.spec }} |
| 66 | + FORCE_ALL: ${{ github.event_name == 'workflow_dispatch' && inputs.force_all || 'false' }} |
| 67 | + run: | |
| 68 | + if [[ "$FORCE_ALL" == "true" ]]; then |
| 69 | + echo "Manual dispatch with force_all=true; running." |
| 70 | + echo "changed=true" >> "$GITHUB_OUTPUT" |
| 71 | + elif git diff --name-only HEAD^ HEAD | grep -qx "$SPEC"; then |
| 72 | + echo "$SPEC changed in this push." |
| 73 | + echo "changed=true" >> "$GITHUB_OUTPUT" |
| 74 | + else |
| 75 | + echo "$SPEC did not change; skipping." |
| 76 | + echo "changed=false" >> "$GITHUB_OUTPUT" |
| 77 | + fi |
| 78 | +
|
| 79 | + - name: Setup Node.js |
| 80 | + if: steps.changed.outputs.changed == 'true' |
| 81 | + # Pinned to commit SHA for the same reason as actions/checkout above. |
| 82 | + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 |
| 83 | + with: |
| 84 | + node-version: '22' |
| 85 | + |
| 86 | + - name: Install openapi-to-postmanv2 |
| 87 | + if: steps.changed.outputs.changed == 'true' |
| 88 | + # Exact version pin so an upstream npm compromise can't ship new |
| 89 | + # code under the same major. Bump deliberately; verify CLI flags |
| 90 | + # below still validate against any new major. |
| 91 | + run: npm install -g openapi-to-postmanv2@6.0.1 |
| 92 | + |
| 93 | + - name: Rewrite relative docs links for Postman |
| 94 | + if: steps.changed.outputs.changed == 'true' |
| 95 | + # Rewrites markdown links like [foo](/docs/...) to absolute |
| 96 | + # [foo](https://signalwire.com/docs/...) so the published collection |
| 97 | + # links to the live docs site. The OpenAPI source-of-truth stays |
| 98 | + # relative (correct for Fern); only the workflow's checkout copy is |
| 99 | + # rewritten. Absolute URLs already in the spec (externalDocs.url, |
| 100 | + # /company/contact, my.signalwire.com, etc.) are untouched because |
| 101 | + # the rewrite is anchored on `](/` — see the script for details. |
| 102 | + run: node scripts/postman/rewrite-links.js "${{ matrix.spec }}" |
| 103 | + |
| 104 | + - name: Convert OpenAPI to Postman Collection |
| 105 | + if: steps.changed.outputs.changed == 'true' |
| 106 | + # Conversion options live in scripts/postman/convert-options.json so |
| 107 | + # they're identical here and in any local invocation. |
| 108 | + # |
| 109 | + # We pass them via `-c` (config file) rather than `-O key=value` because |
| 110 | + # the CLI's `-O` parser filters its accepted-option list against an |
| 111 | + # OpenAPI version that defaults to 3.0, dropping any 3.1-only option |
| 112 | + # like `includeWebhooks` with a stderr warning and continuing. The |
| 113 | + # `-c` path reads the JSON as-is and bypasses the filter, so 3.1 |
| 114 | + # webhooks (3 in signalwire-rest, 5 in compatibility) come through. |
| 115 | + # Tracked as a quirk of openapi-to-postmanv2 v6.0.1. |
| 116 | + run: | |
| 117 | + openapi2postmanv2 \ |
| 118 | + -s "${{ matrix.spec }}" \ |
| 119 | + -o collection.json \ |
| 120 | + -p \ |
| 121 | + -c scripts/postman/convert-options.json |
| 122 | +
|
| 123 | + - name: Group APIs under a wrapper folder, strip IDs, wrap for PUT |
| 124 | + if: steps.changed.outputs.changed == 'true' |
| 125 | + # Postman API requires {"collection": {...}} and rejects payloads |
| 126 | + # carrying foreign _postman_id / id / uid fields. |
| 127 | + # |
| 128 | + # Layout produced by openapi-to-postmanv2 with `folderStrategy: "Tags"` |
| 129 | + # and `includeWebhooks: true`: top-level `.item[]` is a flat list of |
| 130 | + # tag-derived folders plus one converter-generated "Webhooks" folder |
| 131 | + # that holds the OpenAPI 3.1 `webhooks:` entries. We restructure so |
| 132 | + # webhooks stay peer-level at the root while every tag folder moves |
| 133 | + # into a single wrapper folder named "APIs", alphabetized |
| 134 | + # case-insensitively. "cXML *" then slots near "Call Flows" instead |
| 135 | + # of at the bottom under ASCII byte order. |
| 136 | + # |
| 137 | + # Partitioning is by name ("Webhooks" vs the rest) because the |
| 138 | + # converter doesn't tag the synthetic webhooks folder with any |
| 139 | + # distinguishing metadata. If a future spec adds a tag literally |
| 140 | + # named "Webhooks", that tag would land at the root with the |
| 141 | + # webhooks folder instead of inside "APIs" — revisit then. |
| 142 | + # openapi-to-postmanv2 has no built-in sort or wrap option, so we |
| 143 | + # do it here. Items inside each tag folder keep their existing |
| 144 | + # path/method order; items inside the Webhooks folder are unchanged. |
| 145 | + run: | |
| 146 | + jq ' |
| 147 | + (.item | map(select(.name != "Webhooks")) | sort_by(.name | ascii_downcase)) as $apis |
| 148 | + | (.item | map(select(.name == "Webhooks"))) as $webhooks |
| 149 | + | .item = ([{name: "APIs", item: $apis}] + $webhooks) |
| 150 | + | walk(if type == "object" then del(._postman_id, .id, .uid) else . end) |
| 151 | + | {collection: .} |
| 152 | + ' collection.json > payload.json |
| 153 | +
|
| 154 | + - name: PUT collection to Postman |
| 155 | + if: steps.changed.outputs.changed == 'true' |
| 156 | + # Both env vars are required. The matrix supplies COLLECTION_ID per |
| 157 | + # entry (static `vars.X` resolved at matrix-expansion time); the |
| 158 | + # secret supplies the API key. Both are validated below before any |
| 159 | + # network call. |
| 160 | + # |
| 161 | + # We capture the HTTP status with curl's `-w "%{http_code}"` and |
| 162 | + # branch on it explicitly rather than relying on `--fail-with-body`, |
| 163 | + # so the workflow logs report a clear ✓/✗ line for the PUT and dump |
| 164 | + # the response body on failure. The PUT response shape is |
| 165 | + # {"collection": {"id", "name", "uid"}} — name/uid live directly on |
| 166 | + # `.collection`, not under `.collection.info` (that nested path is |
| 167 | + # the *collection schema* shape, not the PUT envelope). |
| 168 | + env: |
| 169 | + POSTMAN_API_KEY: ${{ secrets.POSTMAN_API_KEY }} |
| 170 | + COLLECTION_ID: ${{ matrix.collection_id }} |
| 171 | + run: | |
| 172 | + : "${POSTMAN_API_KEY:?missing repo secret POSTMAN_API_KEY}" |
| 173 | + : "${COLLECTION_ID:?missing repo variable for ${{ matrix.name }} collection ID — check Settings → Variables}" |
| 174 | +
|
| 175 | + http_status=$(curl -sS -X PUT \ |
| 176 | + "https://api.getpostman.com/collections/$COLLECTION_ID" \ |
| 177 | + -H "X-Api-Key: $POSTMAN_API_KEY" \ |
| 178 | + -H "Content-Type: application/json" \ |
| 179 | + --data-binary @payload.json \ |
| 180 | + -o response.json \ |
| 181 | + -w "%{http_code}") |
| 182 | +
|
| 183 | + if [[ "$http_status" =~ ^2[0-9][0-9]$ ]]; then |
| 184 | + echo "✓ Postman PUT succeeded (HTTP $http_status) for ${{ matrix.name }}" |
| 185 | + jq -r '.collection | " name: \(.name)\n uid: \(.uid)"' response.json \ |
| 186 | + || cat response.json |
| 187 | + else |
| 188 | + echo "✗ Postman PUT failed (HTTP $http_status) for ${{ matrix.name }}" |
| 189 | + echo "Response body:" |
| 190 | + cat response.json |
| 191 | + exit 1 |
| 192 | + fi |
0 commit comments