Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 62 additions & 18 deletions .github/workflows/sync-fern-docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@
# request. Layer 3 of the sync: a thin shell around `pnpm docs:fern` and `pnpm docs:fern:apply`, both
# of which run locally without credentials (see documentation/AGENTS.md).
#
# Write access to contentful-docs comes from the `cross-repository-write` Vault preset, which mints a
# short-lived `contentful-cross-repository` GitHub App token scoped to that one repository
# (`contents: write`, `pull_requests: write`). The grant is target-owned: contentful-docs lists this
# repository under `cross-repository-write.sources` in its own `.contentful/vault-secrets.yaml`, so
# nothing here can widen it and no long-lived token is stored here.
# Write access to contentful-docs is a short-lived `contentful-cross-repository` GitHub App token
# minted by Vault, scoped to that one repository by immutable ID (`contents: write`,
# `pull_requests: write`). The grant is target-owned: contentful-docs lists this repository under
# `cross-repository-write.sources` in its own `.contentful/vault-secrets.yaml`, so nothing here can
# widen it and no long-lived token is stored here.
#
# The preset picks a Vault role from the triggering event. Both triggers deliver a pull request:
# This talks to Vault directly rather than through the `cross-repository-write` preset in
# contentful/vault-github-actions. That action lives in an internal repository and this repository is
# public, so the runner cannot resolve it — the failure is `Unable to resolve action …, not found`.
# The role names, secret path, and bot identity below are the preset's, so the trust model is
# identical; only the caller differs. Keep them in step with that repository.
#
# The role depends on the triggering event, and both triggers deliver a pull request:
# * release published — role `…-release`, bound to a tag
# * workflow_dispatch — role `…-workflow-dispatch`, bound to the default branch
#
Expand Down Expand Up @@ -104,20 +110,59 @@ jobs:
EXPORT_REF: ${{ steps.resolve.outputs.ref }}
run: pnpm docs:fern -- --ref "$EXPORT_REF"

# Mints a token scoped to contentful-docs alone and configures git as
# `contentful-cross-repository[bot]`, so the commit below is attributable to the App rather
# than to a shared machine user.
#
# Pinned to a commit rather than a release tag. v1.5.0 was cut a minute before the
# `workflow_dispatch` mapping merged, so it rejects a manual run, and the floating `@v1` still
# points at v1.2.1, which has no preset at all. Bump to the first tag that contains both.
# One Vault role per event, named exactly as cf-vault creates them. Failing here rather than
# letting Vault reject the login keeps the reason legible: a dispatch from a feature branch is
# outside the role's bound subject, which is otherwise an opaque permission-denied.
- name: Select the Vault role for this event
id: vault-role
env:
EVENT_NAME: ${{ github.event_name }}
EVENT_ACTION: ${{ github.event.action }}
DEFAULT_BRANCH_REF: refs/heads/${{ github.event.repository.default_branch }}
TARGET_REPO: ${{ env.DOCS_REPO }}
run: |
set -euo pipefail

source_name="${GITHUB_REPOSITORY#*/}"
target_name="${TARGET_REPO#contentful/}"

if [ "$EVENT_NAME" = 'release' ] && [ "$GITHUB_REF_TYPE" = 'tag' ] && [ "$EVENT_ACTION" = 'published' ]; then
suffix='release'
elif [ "$EVENT_NAME" = 'workflow_dispatch' ] && [ "$GITHUB_REF" = "$DEFAULT_BRANCH_REF" ]; then
suffix='workflow-dispatch'
else
echo "No cross-repository write role covers this run ($EVENT_NAME on $GITHUB_REF)." >&2
echo "Publish a release, or dispatch from $DEFAULT_BRANCH_REF and pass 'ref' to export another tree." >&2
exit 1
fi

printf 'role=%s-github-action-cross-repository-write-%s-%s\n' \
"$source_name" "$target_name" "$suffix" >>"$GITHUB_OUTPUT"
printf 'secret=github-cross-repository/token/%s-cross-repository-write-%s\n' \
"$source_name" "$target_name" >>"$GITHUB_OUTPUT"

- name: Retrieve a contentful-docs token from Vault
id: vault
uses: contentful/vault-github-actions/action@3b4e3c6ee2ec24d1dce34061ecfa1539149035b0 # cross-repository-write, incl. workflow_dispatch
uses: hashicorp/vault-action@892a26828f195e65540a40b4768ae4571f51ebfc # v4.0.0
with:
url: ${{ secrets.VAULT_URL }}
template-preset: cross-repository-write
target-repository: ${{ env.DOCS_REPO }}
role: ${{ steps.vault-role.outputs.role }}
method: jwt
path: github-actions
secrets: |
${{ steps.vault-role.outputs.secret }} token | GITHUB_TOKEN ;

# Attributes the sync commit to the App rather than to a shared machine user. The address form
# is the one GitHub links back to the bot account, so the numeric ID has to be looked up.
- name: Configure the bot git identity
env:
GH_TOKEN: ${{ steps.vault.outputs.GITHUB_TOKEN }}
BOT_NAME: contentful-cross-repository[bot]
run: |
set -euo pipefail
user_id="$(gh api "/users/$BOT_NAME" --jq .id)"
git config --global user.name "$BOT_NAME"
git config --global user.email "$user_id+$BOT_NAME@users.noreply.github.com"

- name: Checkout contentful-docs
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
Expand Down Expand Up @@ -151,8 +196,7 @@ jobs:
exit 0
fi

# Git identity is configured globally by the Vault action as
# `contentful-cross-repository[bot]`; do not override it here.
# Git identity was configured globally as `contentful-cross-repository[bot]` above.
git checkout -b "$BRANCH"
git add --all
git commit --message "docs: sync Optimization SDK documentation from ${EXPORT_REF}" \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,16 @@ markdown as the single source of truth for wording.
without a recorded redirect.
- Publication is release-gated. The sync workflow is ref-parameterized, idempotent against one branch
and one pull request, and concurrency-guarded.
- Write access is a short-lived GitHub App token from the `cross-repository-write` Vault preset,
- Write access is a short-lived `contentful-cross-repository` GitHub App token minted by Vault,
scoped to `contentful-docs` alone. The grant is target-owned: `contentful-docs` names this
repository under `cross-repository-write.sources` in its own `.contentful/vault-secrets.yaml`, so
this repository cannot widen its own access and no long-lived token is stored here.
- The preset selects a Vault role from the triggering event, and both triggers publish: a published
release uses the tag-bound role, a manual dispatch uses a role bound to this repository's default
- The workflow authenticates to Vault directly rather than through the shared
`cross-repository-write` preset action. That action is in an internal repository and this one is
public, so the runner cannot resolve it at all. The role names, secret path, and bot identity are
copied from the preset, so the trust boundary is unchanged and only the caller differs.
- The Vault role depends on the triggering event, and both triggers publish: a published release uses
the tag-bound role, a manual dispatch uses a role bound to this repository's default
branch. Manual publication was a requirement, so the Vault grant was extended to cover it rather
than the workflow being narrowed to fit the grant.
- Authoring instructions own the conventions the transform depends on. `STYLE_GUIDE.md` holds the
Expand Down Expand Up @@ -88,6 +92,12 @@ markdown as the single source of truth for wording.
ships. Release-gating means a documentation-only fix waits for the next release, for which manual
dispatch is the escape hatch. Keeping that escape hatch cost a role in the shared Vault grant, since
the preset originally issued no token for `workflow_dispatch`.
- **Consume the shared `cross-repository-write` preset action.** Preferred, and not available: GitHub
will not resolve an action from an internal repository for a public one, which this repository is.
Making that repository public would have fixed it and is not ours to decide, so the workflow calls
`hashicorp/vault-action` directly, as this repository already does when publishing packages. The
cost is that the event-to-role mapping is duplicated here and has to follow that repository if the
naming changes.
- **A long-lived token for `contentful-docs`.** Discarded: it would sit in this repository's secrets
with no expiry and, in practice, wider scope than the one repository it needs. The preset issues a
token good for an hour and scoped to one repository by immutable ID, and the target repository
Expand Down
Loading