Skip to content

feat: EPv2 reconcile-content-branches reusable workflow - #23

Draft
kriscoleman wants to merge 4 commits into
mainfrom
feat/epv2-reconcile-content-branches
Draft

feat: EPv2 reconcile-content-branches reusable workflow#23
kriscoleman wants to merge 4 commits into
mainfrom
feat/epv2-reconcile-content-branches

Conversation

@kriscoleman

@kriscoleman kriscoleman commented Aug 26, 2026

Copy link
Copy Markdown
Member

What this is

A reusable GitHub Actions workflow (workflow_call) that auto-creates the per-release
docs content branches Enterprise Portal v2 needs. A vendor drops one small caller into
their docs repo, hands it an app slug plus a vendor API token, and it keeps a content
branch alive for every release across their configured channels. Generalized from
FirstResponse's implementation so any Replicated vendor can adopt it.

Why it's worth adopting

EPv2 with "Require matching content for release versions" turned on resolves a release's
docs by looking up a git branch in the docs repo named exactly the bare version label:
0.3.312, not v0.3.312, not release/v0.3.312. A release with no matching branch is a
customer-visible 404 in the portal. That rule comes straight from vandoor
(handlers/vendor-api/replv3/enterprise_portal/content_version_pin.go ->
GetVersionByRepoAndBranch).

Today that branch is a manual step, one per release, and forgetting it breaks the portal
quietly. This workflow removes that whole class of failure. It's about as cheap as a
dependency gets: stdlib-only Python, a ~90-line workflow, 33 unit tests over the pure
decision logic, and no pip install. The branch-name rule is the single load-bearing
contract, and it's unit-tested, so the branches we create always match what the portal
looks for.

How it works

Coverage, not mirroring. main is the vendor's canonical working docs branch and the
base new release branches are cut from. It does not track or represent any one release or
channel. The job is coverage: make sure a content branch exists for every release across
the configured channels, because in EPv2 each customer sees the docs for the release their
license is pinned to. The workflow only ever fills in missing branches. It never
force-updates and it never deletes.

Honest timing. Each release branch is cut from main as it exists when reconcile
runs, not as a point-in-time snapshot of what shipped. If main moved on before reconcile
ran, the new branch captures the newer main. So the closer reconcile runs to the
release, the closer the branch tracks what actually shipped. Because of that, the README
recommends firing on the GitHub release: [published] event, with a cron fallback so
nothing slips through. Event-driven keeps branches aligned with what shipped; the timer is
the safety net.

Script delivery. The caller won't have the Python file, so the workflow does a second
actions/checkout of replicatedhq/reusable-workflows at a pinned ref and runs the
script from there. Callers only ever reference the workflow. A required workflow_ref
input (no default) lets a caller pinning uses: to a SHA fetch the script from the same
SHA. The alternative (a composite action, or heredoc-ing the script inline) would fragment
the interface or make the script unreadable and untestable, so checkout-of-self won.

Design calls worth a look

  • app_slug is an input, not a secret. Secrets aren't forwarded to reusable workflows
    unless the caller redeclares them, and the app slug isn't sensitive (it's in the vendor
    portal URL). The vendor API token stays a required secret.
  • Least privilege. The workflow uses the caller's built-in GITHUB_TOKEN for branch
    creation, so the caller grants permissions: contents: write and supplies no GitHub
    token. The script checkout uses persist-credentials: false.
  • The workflow_ref guard is honest about what it can prove. It rejects an empty value
    and prints both the workflow_ref and the caller's trigger ref as a ::notice:: for a
    human to eyeball. It does not infer skew from github.workflow_ref: that's the caller's
    top-level trigger ref, orthogonal to how they pinned uses:, so a heuristic on it
    produces false positives (a release: [published] run from a tag looks "pinned") and
    false negatives (uses:@shaA with workflow_ref: shaB slips through). GitHub can't
    expose a reusable workflow's own ref to itself, so keeping the two in sync stays on the
    caller, and the README says so plainly.
  • Injection-safe. All inputs pass through env: and are quoted in the script; the
    token is never echoed.
  • Actions are pinned to major versions matching the rest of the repo; runs-on is
    ubuntu-22.04 per the repo's guidance to avoid ubuntu-latest.

Files

  • .github/workflows/epv2-reconcile-content-branches.yaml: the reusable workflow_call workflow
  • scripts/epv2_reconcile_content_branches.py: generalized, stdlib-only reconcile script
  • scripts/test_epv2_reconcile_content_branches.py: 33 unit tests over the pure decision logic
  • .github/workflows/epv2-reconcile-content-branches/README.md: vendor-facing docs
  • README.md: new row in the Available Workflows table

Verification

python3 -m unittest discover -s scripts -p 'test_*.py' -> 33 tests, all green.
actionlint passes on the workflow. YAML parses.

Draft on purpose. Not for merge yet.

🤖 Generated with Claude Code

kriscoleman and others added 2 commits August 26, 2026 13:59
Automates Enterprise Portal v2 content-branch creation for any Replicated
vendor, generalized from FirstResponse's implementation.

EPv2 links docs content to a release only when a git branch in the docs repo
is named exactly the bare release version label (e.g. 0.3.312, not v0.3.312).
With "Require matching content for release versions" on, a release with no
matching branch 404s. This workflow reconciles those branches on a schedule.

Rule: main tracks the latest release; each new release gets its own branch off
main; branches are created once and never force-updated (vendors own them after
creation and can edit/sync themselves). Idempotent and bounded.

- workflow_call reusable workflow with inputs + replicated_api_token secret
- generalized stdlib-only Python script + unit tests (19 tests, green)
- workflow fetches the script from this repo at the pinned ref so callers only
  reference the workflow
- vendor-facing README + root README table row

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Round-2 review revisions to the EPv2 reconcile-content-branches workflow.

Model redesign: drop the wrong "main tracks the latest release" framing.
main is the vendor's canonical working docs branch that release branches are
cut from; the job is coverage (a branch for every release across configured
channels), not mirroring a single "latest". Rewritten README + docstrings.

Base-branch drift: reframe branches as cut from main when reconcile runs (not
a point-in-time snapshot of what shipped). Add a timing section recommending a
release-published trigger, modeled on the sibling notify-release workflow, with
cron as the fallback.

Security: add least-privilege `permissions: contents: write` to the reconcile
job. Eliminate workflow_ref version skew by making workflow_ref required (no
default) and hard-failing when the caller pins `uses:` to a SHA/tag while
leaving workflow_ref at main.

Observability / fail-loudly: write a per-channel run summary (seen/selected/
non-bare/created/existed, dry-run plan folded in) to $GITHUB_STEP_SUMMARY;
emit a ::warning:: when a channel has releases but zero bare matches; SystemExit
when no configured channel resolves at all.

Polish: default channels Stable,Beta (Unstable opt-in); document no-cleanup
story; guard RELEASE_LIMIT with a clean SystemExit; correct the
GetVersionByRepoAndBranch(ctx, repoID, branch) signature; add unit tests for the
new pure logic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kriscoleman

Copy link
Copy Markdown
Member Author

A thought: github release triggers are a fairly decent way to trigger this workflow, but not perfect.
A CRON job is another, but also not perfect.
The best trigger would actually be to get a webhook notification from Replicated whenever a release in any channel happens.

B1: the skew guard branched on github.workflow_ref (the caller's trigger
ref), which is orthogonal to how the caller pinned uses:. That produced a
false positive that hard-failed the README's own release:[published] usage
(a release from a tag makes workflow_ref end in @refs/tags/vX.Y.Z) and a
false negative (uses:@shaa with workflow_ref:shaB slipped through). Drop
the trigger-ref heuristic: GitHub cannot expose a reusable workflow's own
uses: ref to itself, so an in-workflow guard cannot prove skew. Keep
workflow_ref required + the empty-string error, and replace the hard
::error:: with an informational ::notice:: echoing both refs for human
review. Update README pinning/how-it-works sections accordingly.

LOW-2: README caveat that release_limit must exceed live releases per
channel, else older-but-still-live releases silently miss coverage.

LOW-3: render_summary footnote when the per-channel "To create" column
sums above the deduped branch total (a version live on two channels is
counted per-channel but created once). Adds two unit tests.

Tests: 33 green. actionlint clean.

(rw-3zz)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kriscoleman

Copy link
Copy Markdown
Member Author

[reusable_workflows/reviewer — product-owner]

Round 3 — VERDICT: SHIP. Worth it. Round-1/2 product blockers stay resolved and this round lands clean.

  • Coverage model reads honestly. The PR body and both READMEs now describe this as a coverage job, not mirroring, and explicitly reject the two framings that tripped earlier rounds — the branch "is not a point-in-time snapshot of what shipped," and main "does not track, mirror, or represent any single release." No "snapshot" / "main tracks latest" language survives except where it's being corrected.
  • The ::notice:: is the right call, not a footgun. The prior hard-fail branched on github.workflow_ref (the caller's trigger ref), which is orthogonal to how they pinned uses: — so it false-positive-failed your own recommended release: [published] usage and false-negatived real @shaA/workflow_ref: shaB skew. Since GitHub can't expose a reusable workflow's own ref to itself, an advisory notice + empty-string rejection is the honest ceiling of what's detectable here. Firing it unconditionally (vs a warning) is also correct — a warning on every run would be alarm fatigue.
  • release_limit caveat is clear. It names the real failure mode (an older-but-still-live release falling outside the window → portal keeps 404-ing for that customer) and tells the vendor to size it above their longest live-release count.
  • Release-triggered recommendation stands. Event-driven-first with a cron fallback is the right guidance, and the Timing section gives the honest why.

LOW / non-blocking: the residual "can't detect ref skew" gap is inherent to GitHub, and the README is upfront about it — no action. Future: your own note about a Replicated release webhook being the ideal trigger is a good follow-up (worth its own issue), not a blocker for this PR.

Tests: 33 green at afa3d17, verified locally.

@kriscoleman

Copy link
Copy Markdown
Member Author

[replicated_docs/obsidian — implementor]

Docs half is up: replicatedhq/replicated-docs#4431 (draft). New "Automating content branch creation" section on the EPv2 versioned-docs page, with the branch-name contract, a caller snippet mirroring this PR's README, timing/drift, the bare-version requirement, and cleanup.

Links in it point at main, so it's set to merge in lockstep with this one. Happy to re-sync the snippet if anything in the README shifts before merge.

Restructure the reconcile-content-branches README so a vendor adopting
cold hits what/why, then the copy-paste quick start, then the inputs and
secrets tables, then the pinning note, then caveats. Consolidate the
overlapping intro/what-it-does/model prose and group timing, version-label,
release_limit, unstable, and cleanup caveats under one section. No workflow
or script changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kriscoleman

Copy link
Copy Markdown
Member Author

In the future I might suggest we work the python logic here into replicated cli instead, then instead of that python script we can just pin a version of replicated cli.


# The branch-name rule is the EP v2 contract; prove it holds before we touch
# any refs. Standard-library only, so no pip install.
- name: Run unit tests

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Im iffy about running tests during a reusable workflow, unless this becomes a prelight that does a dry run with users values/secrets to make sure everything is ready before we actually run it.

But even then it should be optional, it might not make sense to do this everytime.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant