Skip to content

Commit 7356550

Browse files
committed
feat(sync-snippets): composite action that pulls SDK snippets
New action at actions/sync-snippets/. Resolves the latest launchdarkly/sdk-meta `snippets/*` GitHub Release, downloads the platform's signed binary, cosign-verifies it (keyless / OIDC, identity pinned to sdk-meta's release-please workflow), runs `snippets render` against the consumer checkout, and opens or updates a sync PR via peter-evans/create-pull-request when the render produced any diff. Designed for gonfalon (and future ld-docs-private use); both call this with `@main`. Daily cron + workflow_dispatch on the consumer side keeps the consumer current without a coordinated rollout. Implementation notes: - The `snippets` CLI binary embeds the canonical sdks/ tree at build time (sdk-meta side, separate PR). One artifact, one signature check, atomic content + engine pinning. - `--certificate-identity-regexp` matches sdk-meta's release-please workflow path on main, so a token leaked from any other workflow cannot produce a matching OIDC claim. - Per-platform archive name resolution mirrors goreleaser's default template (snippets_<version>_<os>_<arch>.tar.gz).
1 parent 0a54234 commit 7356550

2 files changed

Lines changed: 280 additions & 0 deletions

File tree

actions/sync-snippets/README.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# sync-snippets
2+
3+
A composite action that pulls the canonical SDK code snippets from the latest
4+
[`launchdarkly/sdk-meta`](https://github.com/launchdarkly/sdk-meta) release and
5+
opens a sync PR against the calling repository. Used by gonfalon (and future
6+
consumers like `ld-docs-private`) to stay current with snippet changes without
7+
hand-editing.
8+
9+
## Usage
10+
11+
```yaml
12+
name: Sync SDK snippets
13+
on:
14+
schedule:
15+
- cron: '0 12 * * *' # daily at 12:00 UTC
16+
workflow_dispatch:
17+
18+
permissions:
19+
contents: write
20+
pull-requests: write
21+
id-token: write # required for cosign keyless verification
22+
23+
jobs:
24+
sync:
25+
runs-on: ubuntu-latest
26+
steps:
27+
- uses: actions/checkout@v4
28+
- uses: launchdarkly/gh-actions/actions/sync-snippets@main
29+
with:
30+
github-token: ${{ secrets.GITHUB_TOKEN }}
31+
```
32+
33+
## What it does
34+
35+
1. Resolves the latest `snippets/vX.Y.Z` GitHub Release on `launchdarkly/sdk-meta` (override with `version:` if you need to pin or roll back).
36+
2. Downloads the platform-specific binary archive plus the cosign signature and certificate.
37+
3. Verifies the signature is keyless-signed by `launchdarkly/sdk-meta`'s `release-please` workflow on `main` via GitHub OIDC.
38+
4. Runs `snippets render --target=<adapter> --out=$GITHUB_WORKSPACE`. The binary embeds the canonical `sdks/` tree at build time — no separate snippet fetch.
39+
5. Opens (or updates) a pull request with the rewritten files. If `render` produced no diff, the action exits 0 without opening a PR.
40+
41+
## Inputs
42+
43+
| Name | Default | Description |
44+
|---|---|---|
45+
| `version` | `latest` | Release tag to install (e.g. `snippets/v0.3.0`). `latest` resolves to the most recent published `snippets/*` release. |
46+
| `target` | `ld-application` | Adapter target. `ld-application` for gonfalon. Future targets (e.g. `ld-docs`) plug in here. |
47+
| `branch` | `chore/sync-sdk-snippets` | Branch the action commits the rendered diff to. |
48+
| `pr-title` | `chore: sync SDK snippets` | Pull-request title. |
49+
| `pr-body` | (auto-generated) | Pull-request body. Defaults to a one-liner pointing at the upstream release notes. |
50+
| `pr-labels` | `sdk-snippets,automated-pr` | Comma-separated labels applied to the sync PR. |
51+
| `github-token` | (required) | Token used to download release assets and open the PR. The repo's default `GITHUB_TOKEN` is sufficient when the workflow has `contents: write` and `pull-requests: write`. |
52+
53+
## Outputs
54+
55+
| Name | Description |
56+
|---|---|
57+
| `version` | The release tag that was installed. |
58+
| `changes` | `true` if `render` produced any diff. |
59+
| `pr-number` | Pull-request number when one was opened or updated; empty otherwise. |
60+
61+
## How the supply chain is locked down
62+
63+
- **Signing identity is pinned to a specific workflow path.** `cosign verify-blob` checks `--certificate-identity-regexp` against `https://github.com/launchdarkly/sdk-meta/.github/workflows/release-please.yml@.+`. A token leaked from any other workflow in any other repo cannot produce a matching OIDC claim.
64+
- **No long-lived signing keys.** Each release signs itself using GitHub's OIDC token, so there is nothing to rotate, store, or accidentally check in.
65+
- **Snippet sources travel with the binary.** The CLI's `--sdks=` flag is optional; when omitted (the default for this action) it reads from `embed.FS`. Pinning a release version pins both the engine and the snippet content atomically.

actions/sync-snippets/action.yml

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
name: 'Sync SDK Snippets'
2+
description: |
3+
Pulls the canonical SDK code snippets from the latest sdk-meta `snippets/` release
4+
into the current consumer checkout (gonfalon, ld-docs-private), then opens or
5+
updates a sync pull request when the rendered output differs from `main`.
6+
7+
The action is a thin wrapper around the `snippets` CLI. The CLI binary is
8+
downloaded from sdk-meta's GitHub Releases as a cosign-signed artifact;
9+
signing is keyless (GitHub OIDC) and verification pins the signing identity
10+
to launchdarkly/sdk-meta's own `release-please` workflow, so a compromised
11+
release token can't substitute a different binary without also faking the
12+
OIDC claim.
13+
14+
Consumer-side wiring:
15+
16+
jobs:
17+
sync:
18+
runs-on: ubuntu-latest
19+
permissions:
20+
contents: write
21+
pull-requests: write
22+
id-token: write # for cosign keyless verification
23+
steps:
24+
- uses: actions/checkout@v4
25+
- uses: launchdarkly/gh-actions/actions/sync-snippets@main
26+
with:
27+
github-token: ${{ secrets.GITHUB_TOKEN }}
28+
29+
inputs:
30+
version:
31+
description: |
32+
Release tag to install, e.g. `snippets/v0.3.0`. The default `latest`
33+
resolves to the most recent published release whose tag begins with
34+
`snippets/`.
35+
required: false
36+
default: 'latest'
37+
target:
38+
description: |
39+
Adapter target. `ld-application` for gonfalon (and any other consumer that
40+
uses TSX render markers); future targets (e.g. `ld-docs`) plug in here.
41+
required: false
42+
default: 'ld-application'
43+
branch:
44+
description: 'Branch the action commits the rendered diff to.'
45+
required: false
46+
default: 'chore/sync-sdk-snippets'
47+
pr-title:
48+
description: 'Pull-request title.'
49+
required: false
50+
default: 'chore: sync SDK snippets'
51+
pr-body:
52+
description: |
53+
Pull-request body. The default points at the upstream release notes for
54+
the version being synced.
55+
required: false
56+
default: ''
57+
pr-labels:
58+
description: 'Comma-separated labels applied to the sync PR.'
59+
required: false
60+
default: 'sdk-snippets,automated-pr'
61+
github-token:
62+
description: |
63+
Token used to download release assets and open the PR. Needs `contents:
64+
write` and `pull-requests: write` on the consumer repo. The repo's default
65+
`GITHUB_TOKEN` is sufficient.
66+
required: true
67+
68+
outputs:
69+
version:
70+
description: 'The sdk-meta snippets release tag that was actually installed.'
71+
value: ${{ steps.resolve.outputs.tag }}
72+
changes:
73+
description: 'true if `snippets render` produced any diff against the working tree.'
74+
value: ${{ steps.diff.outputs.changes }}
75+
pr-number:
76+
description: 'Pull-request number when a sync PR was opened or updated; empty otherwise.'
77+
value: ${{ steps.cpr.outputs.pull-request-number }}
78+
79+
runs:
80+
using: composite
81+
steps:
82+
# 1. Resolve the version. `latest` queries the GitHub API for the most
83+
# recent release whose tag starts with `snippets/`. Any other value is
84+
# used verbatim — caller pins, action obeys.
85+
- id: resolve
86+
name: 'Resolve snippets release tag'
87+
shell: bash
88+
env:
89+
GH_TOKEN: ${{ inputs.github-token }}
90+
run: |
91+
set -euo pipefail
92+
if [ "${{ inputs.version }}" = "latest" ]; then
93+
tag=$(gh release list --repo launchdarkly/sdk-meta --limit 100 \
94+
--json tagName --jq '.[] | .tagName | select(startswith("snippets/"))' \
95+
| head -n 1)
96+
if [ -z "$tag" ]; then
97+
echo "::error::no snippets/* release found in launchdarkly/sdk-meta"
98+
exit 1
99+
fi
100+
else
101+
tag="${{ inputs.version }}"
102+
fi
103+
echo "tag=$tag" >> "$GITHUB_OUTPUT"
104+
echo "Using snippets release: $tag"
105+
106+
# 2. Resolve the platform's archive name. goreleaser names archives
107+
# snippets_<version>_<os>_<arch>.tar.gz, where <version> is the
108+
# bare semver (the `snippets/` tag prefix is stripped).
109+
- id: archive
110+
name: 'Resolve archive filename'
111+
shell: bash
112+
run: |
113+
set -euo pipefail
114+
case "$RUNNER_OS" in
115+
Linux) os=linux ;;
116+
macOS) os=darwin ;;
117+
*) echo "::error::unsupported runner OS: $RUNNER_OS"; exit 1 ;;
118+
esac
119+
case "$RUNNER_ARCH" in
120+
X64) arch=amd64 ;;
121+
ARM64) arch=arm64 ;;
122+
*) echo "::error::unsupported runner arch: $RUNNER_ARCH"; exit 1 ;;
123+
esac
124+
version="${{ steps.resolve.outputs.tag }}"
125+
version="${version#snippets/v}"
126+
archive="snippets_${version}_${os}_${arch}.tar.gz"
127+
echo "archive=$archive" >> "$GITHUB_OUTPUT"
128+
129+
# 3. Pull the archive, its detached signature, and the OIDC certificate
130+
# from the GitHub Release.
131+
- name: 'Download release assets'
132+
shell: bash
133+
working-directory: ${{ runner.temp }}
134+
env:
135+
GH_TOKEN: ${{ inputs.github-token }}
136+
run: |
137+
set -euo pipefail
138+
gh release download "${{ steps.resolve.outputs.tag }}" \
139+
--repo launchdarkly/sdk-meta \
140+
--pattern "${{ steps.archive.outputs.archive }}" \
141+
--pattern "${{ steps.archive.outputs.archive }}.sig" \
142+
--pattern "${{ steps.archive.outputs.archive }}.pem" \
143+
--clobber
144+
145+
# 4. Install cosign and verify the signature. The identity regex pins
146+
# the signer to sdk-meta's release-please workflow on the main branch
147+
# — a token leaked from any other workflow couldn't produce a matching
148+
# OIDC claim.
149+
- uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a # v3.7.0
150+
- name: 'Verify signature'
151+
shell: bash
152+
working-directory: ${{ runner.temp }}
153+
run: |
154+
set -euo pipefail
155+
archive="${{ steps.archive.outputs.archive }}"
156+
cosign verify-blob \
157+
--certificate "${archive}.pem" \
158+
--signature "${archive}.sig" \
159+
--certificate-identity-regexp 'https://github\.com/launchdarkly/sdk-meta/\.github/workflows/release-please\.yml@.+' \
160+
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
161+
"$archive"
162+
163+
# 5. Extract the CLI and stage it on $PATH for the render step.
164+
- name: 'Install CLI'
165+
shell: bash
166+
working-directory: ${{ runner.temp }}
167+
run: |
168+
set -euo pipefail
169+
mkdir -p bin
170+
tar -xzf "${{ steps.archive.outputs.archive }}" -C bin snippets
171+
chmod +x bin/snippets
172+
echo "$PWD/bin" >> "$GITHUB_PATH"
173+
174+
# 6. Render against the consumer checkout. `snippets` is now on $PATH.
175+
# The CLI loads the canonical sdks/ tree from the binary's embedded FS
176+
# — no separate snippet sources to fetch.
177+
- name: 'Render snippets'
178+
shell: bash
179+
run: |
180+
snippets version
181+
snippets render --target='${{ inputs.target }}' --out="$GITHUB_WORKSPACE"
182+
183+
# 7. Detect whether render touched anything. Empty diff means the consumer
184+
# is already current; we exit cleanly with no PR.
185+
- id: diff
186+
name: 'Check for changes'
187+
shell: bash
188+
run: |
189+
set -euo pipefail
190+
if [ -n "$(git status --porcelain)" ]; then
191+
echo "changes=true" >> "$GITHUB_OUTPUT"
192+
else
193+
echo "changes=false" >> "$GITHUB_OUTPUT"
194+
echo "Consumer tree is already in sync with ${{ steps.resolve.outputs.tag }}."
195+
fi
196+
197+
# 8. Open or update a PR with the rendered diff. peter-evans/create-pull-
198+
# request handles both cases: pushes to the same branch, force-updates
199+
# on collisions, and leaves an existing PR alone if the diff is
200+
# identical to what's already proposed.
201+
- id: cpr
202+
name: 'Open or update sync PR'
203+
if: steps.diff.outputs.changes == 'true'
204+
uses: peter-evans/create-pull-request@67ccf781d68cd99b580ae25a5c18a1cc84ffff1f # v7.0.6
205+
with:
206+
token: ${{ inputs.github-token }}
207+
commit-message: 'chore: sync SDK snippets to ${{ steps.resolve.outputs.tag }}'
208+
branch: ${{ inputs.branch }}
209+
delete-branch: true
210+
title: ${{ inputs.pr-title }}
211+
body: |
212+
${{ inputs.pr-body != '' && inputs.pr-body || format('Syncs SDK snippets from upstream release [`{0}`](https://github.com/launchdarkly/sdk-meta/releases/tag/{0}).', steps.resolve.outputs.tag) }}
213+
214+
Generated by `launchdarkly/gh-actions/actions/sync-snippets@main`.
215+
labels: ${{ inputs.pr-labels }}

0 commit comments

Comments
 (0)