Skip to content

feat(cicd): 36605 changelog auto-publish to dev site #36606

Open
sfreudenthaler wants to merge 24 commits into
mainfrom
issue-36605-changelog-site-publish
Open

feat(cicd): 36605 changelog auto-publish to dev site #36606
sfreudenthaler wants to merge 24 commits into
mainfrom
issue-36605-changelog-site-publish

Conversation

@sfreudenthaler

@sfreudenthaler sfreudenthaler commented Jul 16, 2026

Copy link
Copy Markdown
Member

What

End-to-end implementation of automated release changelog publishing to dev.dotcms.com, spec-first (speckit flow: spec → plan → tasks → TDD implementation → adversarial verification), plus two CI-hygiene cleanups. All pieces are independently mergeable — no dependency on #36416 or any other open PR.

1. Spec (specs/36605-changelog-site-publish/)

Spec (13 FRs incl. human-edit protection, no-auto-backfill, older-version patch semantics; LTS/CLI descoped v1) plus data-model.md (live-verified Dotcmsbuilds field contract). The working artifacts (plan/tasks/research/checklists — incl. Constitution Check and ADR-gate consultation, 3 relevant ADRs, none conflicting; 44/44 TDD tasks complete) were used during development and deliberately not committed.

2. Implementation

  • .github/actions/core-cicd/changelog-publisher/ — new Python CLI (mirrors the evergreen-tracks package pattern): searches Dotcmsbuilds by version (minor_dotraw), upserts create/update-in-place, fires the System Workflow Publish action with disabledWYSIWYG:["releaseNotes"] (markdown-preservation guard). Dry-run by default; --apply to fire; --force (manual only) to override human-edit protection, which skips any entry last modified by a non-service-account user (immutable modUser id comparison, display-name fallback). Exit contract: 0 = success/protective-skip (::changelog-skip:: marker), 2 = validation (out-of-scope version rejected before any network call), 1 = runtime failure. 27 pytest (responses-mocked) green.
  • Single-inference design: there is no separate site-notes generation. The existing gather-release-data/prompt-template.md becomes the one shared editorial template (site style: prose intro, no emoji, site section set, [[#N](issues url)] links), the GitHub release body is the single shared artifact, and the publisher injects the site's per-version heading anchors mechanically at publish time. One inference per release; the site path has zero Claude dependency at publish time; a hand-edited GitHub release re-syncs the site on re-run. Note: this restyles the GitHub releases page to the site's editorial format (deliberate).
  • .github/workflows/cicd_comp_changelog-site-publish-phase.yml + additive job in cicd_6-release.yml — runs after release-notes (fetches the published release body via gh release view), gated on is_latest (current-track GA only: excludes CLI/LTS), non-blocking (continue-on-error; a publish failure can never fail a release), serialized per version via a concurrency group. Slack notices to #dot-releases (CE1TBQU00) on failure, protective skip, and pre-publisher step failure (catch-all if: failure()).

3. CI hygiene (independent commits)

  • Moved the evergreen-tracks uv package from top-level cicd/ into .github/actions/core-cicd/ (64 tests pass from new home).
  • Deleted orphaned cicd/resources/update_osgi_plugins.py (caller removed Feb 2024 in #27655: release fixes for lts #27667; no consumer repo- or org-wide) — the top-level cicd/ folder is gone.

Operational prerequisites before first live run (not code)

  1. Provision a dedicated service account on corpsites-headless + JWT as release secret DOTCMS_DEVSITE_TOKEN.
  2. Set repo var DOTCMS_DEVSITE_SERVICE_ACCOUNT to that account's modUser id (unset = all updates skip protectively).
  3. SLACK_BOT_TOKEN must be available to the release workflow (existing secret, new consumer).
  4. First release after merge: watch #dot-releases and the changelog page — there is no non-prod corpsites, so the maiden --apply is the real end-to-end test (manual dry-run checklist in the package README).

Verification

Adversarially reviewed by an independent pass: FR conformance in code, token-leak grep, workflow needs/if semantics vs the real release pipeline, docker-tag emit-order trace, and date-format acceptance traced through ContentletAPI.DEFAULT_DATE_FORMATS. All findings fixed (5bd97cac13).

Closes: #36605 (feature issue, under epic #35693)

🤖 Generated with Claude Code

https://claude.ai/code/session_012ogYXVeZsBVUHkwzdStYPJ

sfreudenthaler and others added 2 commits July 16, 2026 14:29
Speckit spec + quality checklist for auto-publishing release changelogs
to the dev site on release publish. Spec follows the dotCMS spec template
shape proposed in #36416 (plain markdown; no scaffolding dependency).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ogYXVeZsBVUHkwzdStYPJ
The promotion engine landed in a new top-level cicd/ folder (#36161);
CI support code belongs with the rest of the CI machinery. Pure move +
path updates (3 workflows, README); 64 tests pass from the new home.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ogYXVeZsBVUHkwzdStYPJ
@github-actions github-actions Bot added Area : CI/CD PR changes GitHub Actions/workflows Area : Documentation PR changes documentation files labels Jul 16, 2026
@claude

claude Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Claude finished @sfreudenthaler's task in 6m 7s —— View job


Claude finished @sfreudenthaler's task in 5m 39s —— View job


PR Review — changelog auto-publish to dev site

This is a well-structured, well-tested addition. The design is sound: dry-run default, non-blocking in the release pipeline, per-version concurrency serialization, human-edit protection via immutable modUser, and the single-artifact (GitHub-body-is-site-content) approach. Findings below are all non-blocking.

New Issues

  • 🟡 Medium: .github/actions/core-cicd/changelog-publisher/src/changelog_publisher/publisher.py:33-38 (used at :127) — a transient HTTP error during the post-create read-back poll turns a successful create into a reported failure. _wait_until_searchable calls client._search, which calls resp.raise_for_status(). If any poll gets a 5xx/timeout after the create's fire() already returned 200, the exception propagates out of publish(), the CLI catches it (cli.py:58) and returns 1, and Slack posts "Changelog site publish FAILED" even though the entry was written. The code comment states "Timeout is a warning, not a failure — the write itself succeeded", but only loop-exhaustion (returns False → warning) is handled gracefully; an exception mid-poll is not. No data corruption (a re-run idempotently updates in place), but it produces a misleading failure alarm. Wrap the _search call in _wait_until_searchable in a try/except requests.RequestException and treat it like the timeout branch (warn, don't fail). Fix this → (matching test gap: test_publisher.py covers the poll-sees-row and timeout cases, but not poll-raises-after-successful-create.)

  • 🟡 Medium: .github/actions/core-cicd/changelog-publisher/src/changelog_publisher/client.py:88-93fire() only checks the HTTP status (raise_for_status()). Assumption: the dotCMS System Workflow fire endpoint can return HTTP 200 with a per-contentlet error inside the response entity (a common dotCMS pattern for validation/workflow errors). If so, a rejected publish would be reported as success, and no Slack alert fires. What to verify: confirm whether PUT /api/v1/workflow/actions/{id}/fire returns non-2xx on a rejected contentlet, or 200 with an error payload. If the latter, inspect resp.json() for an error/errors field before treating the fire as successful.

  • 🟡 Medium: .github/actions/core-cicd/changelog-publisher/src/changelog_publisher/publisher.py:46,54 — the injected heading anchor uses only the heading's first word (m.group(1)), so any two sections whose first word matches collide to the same {#Word-<version>} anchor. Safe for the current fixed template (Features / Enhancements / Fixes / Deprecations / Infrastructure — all distinct first words), but the notes are the hand-editable GitHub release body, so an edited re-run (e.g. adding a second heading starting "Security…") would produce duplicate in-page anchors. Low likelihood given the controlled template; worth a comment noting the invariant, or slugging the full heading text.

Notes (non-blocking, no action needed)

  • version.py:13 counter [0-9]{1,2} — confirmed intended by author (upstream releaser caps the index at two digits).
  • ES index-lag guard (post-create read-back poll) — added per author follow-up; correctly addresses the sequential double-create window, and the workflow concurrency group covers the concurrent one.
  • Token handling is clean: read once, sent only as the Authorization header, never logged; the test_cli.py leak-grep tests (stdout/stderr/caplog) are a good guard.
  • Workflow error branching (set +e / capture RC / if: failure() catch-all) correctly keeps the release green regardless of publisher outcome (FR-008).

No blocking issues.
• branch issue-36605-changelog-site-publish

Its only caller (maven-release-process.yml) dropped the invocation in
#27667 and the workflow itself is gone; no references in repo or org.
Removes the now-empty top-level cicd/ folder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ogYXVeZsBVUHkwzdStYPJ
Comment thread specs/36605-changelog-site-publish/checklists/requirements.md Outdated
…human-edit protection, Slack alerts, descope LTS/CLI

Per review: patch-of-older-version gets own entry with actual date (FR-013);
runs never auto-backfill (FR-012); entries last modified by a human are
never overwritten without explicit override (FR-011, SC-006); failures and
skips notify #dot-releases (FR-008, US3); LTS and CLI releases descoped v1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ogYXVeZsBVUHkwzdStYPJ
Adds the speckit PLAN artifacts (plan.md, research.md, data-model.md) for the
automated release-changelog publishing feature. Architecture: generation reuses
the existing deterministic gather-release-data source via a new site-format
prompt template; delivery is a new changelog-publisher Python CLI (mirroring the
evergreen-tracks house pattern) doing idempotent upsert-by-version + System
Workflow Publish with disabledWYSIWYG, human-edit protection, and Slack-on-failure
to #dot-releases. Wired as a non-blocking phase gated on release-prepare is_latest
(reuses the existing CLI/LTS exclusion). Constitution Check passes; ADR gate ran
(no accepted-ADR conflicts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ogYXVeZsBVUHkwzdStYPJ
Dependency-ordered tasks.md generated from the approved plan: Setup →
Foundational (resolves the 3 open questions + exit-code contract) → US1
auto-publish (MVP) → US2 idempotency/human-edit protection → US3 Slack →
Polish. Every user story encodes the Constitution V TDD gates
(tests → approval GATE → Red GATE → implementation). The
JUnit/Postman/Karate/e2e "cannot implement" declaration is recorded as
signed off (CI-only tooling), not re-asked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ogYXVeZsBVUHkwzdStYPJ
…n questions (#36605)

Phase 1 (setup) + Phase 2 (foundational) for automated changelog site publishing:

- T001/T003: scaffold changelog-publisher Python package mirroring evergreen-tracks
  (pyproject with console_script, src/ layout, tests/fixtures, local .gitignore).
- T002: uv env + pytest + responses install and collect (empty suite → pytest's
  exit-5 "no tests collected" sentinel; goes green once US1 tests land).
- T004: verified live corpsites Dotcmsbuilds field values (26.06.30-01) into
  data-model.md — current-track lts=3, released=true, download=1,
  showInChangeLog=true; modUserName = authenticating user's display name.
- T005: exit-code/stdout contract in README (0=success/skip, skip marker
  ::changelog-skip::, 2=usage, 1=runtime failure).
- T006: golden-file lives in gather-release-data jest suite (recorded in README + research.md D2).
- T007: client.py skeleton — token read-once from DOTCMS_DEVSITE_TOKEN, Bearer
  session, PUBLISH_ACTION_ID, _search/fire seams (never logs the token).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ogYXVeZsBVUHkwzdStYPJ
TDD Red for User Story 1 (auto-publish happy path + generation format):

- T010 test_client.py: _search 0-hit/1-hit parsing (query string, limit 2,
  entity.jsonObjectView.contentlets), Bearer header.
- T011 test_publisher.py: create-path fire payload shape + disabledWYSIWYG
  regression guard (FR-005), no identifier on create.
- T012 test_cli.py: version.py current-track validation; _lts_/CLI forms rejected
  before any network call (FR-007).
- T013 test_cli.py: dry-run-by-default vs --apply (no fire in dry-run).
- T014 gather-release-data jest: site editorial format golden guard + asserts
  prompt-template-site.md exists/encodes the format.

Importable NotImplementedError skeletons for version/publisher/cli so Red is
behavioral (14 pytest failures, all NotImplementedError; jest fails only on the
missing prompt-template-site.md) rather than import/collection errors. No US1
implementation yet — awaiting approval + Red gate (T015/T016).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ogYXVeZsBVUHkwzdStYPJ
US2 (P2) implementation — all 19 pytest tests green (14 US1 + 5 US2):

- T031 publisher decision table: 0 hits -> create; 1 hit owned by service account ->
  update in place with the found identifier; 1 hit + other modifier + no --force -> skip
  with a reason naming the human; 1 hit + --force -> update; >1 hits -> AmbiguousMatchError.
  Ownership keys on the immutable modUser id, falling back to modUserName only when the
  entry carries no id (per lead directive; documented in data-model.md).
- T032 cli.py: --force (manual operator override; never passed by the release workflow) and
  --service-account (or env DOTCMS_DEVSITE_SERVICE_ACCOUNT); AmbiguousMatchError -> exit 1;
  skip -> ::changelog-skip:: marker + exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ogYXVeZsBVUHkwzdStYPJ
sfreudenthaler and others added 3 commits July 16, 2026 16:24
TDD tests for User Story 3 (failures/skips announced, never silent):

- T033 failure exit contract: 401 (auth) and 4xx (payload rejected) each -> non-zero exit;
  the bearer token never appears in stdout/stderr/logs (Constitution III); no skip marker.
- T034 skip-vs-failure distinction: protective skip -> exit 0 + ::changelog-skip:: marker;
  real failure (incl. >1-hit AmbiguousMatchError) -> non-zero exit, no marker.

Note: these pass on arrival — the exit/stdout contract they assert was necessarily authored
in cli.py under T020 (US1) and T032 (US2), per the T005 contract. There is no separate
Red to stage for the unit-testable slice; the genuinely new US3 implementation is the
workflow Slack branch (T038/T039, YAML). Flagged to the lead at the US3 gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ogYXVeZsBVUHkwzdStYPJ
)

US3 (P3) workflow wiring — failures/skips announced in #dot-releases, never silent,
never blocking the release (FR-008, SC-005):

- T038 channel id: #dot-releases = CE1TBQU00 (looked up by lead), as the default on a new
  slack_channel_id phase input (override-able; no repo variable / unset-var failure mode).
- T039 Slack branch in the phase: the Publish step captures the tool's exit code + stdout
  and sets result=success|skip|failure per the T005 contract; failure -> 🚨
  wording, protective skip -> ⚠️ wording (distinct), success -> no post. The step
  always exits 0 and the job is allow_failure, so a changelog hiccup never fails the release.
  SLACK_BOT_TOKEN threaded through cicd_6-release.yml; service-account id via
  vars.DOTCMS_DEVSITE_SERVICE_ACCOUNT (ops-provisioned, non-secret).
- T037/T040: CLI exit/stdout contract confirmed; release stays green (finalize/report do
  not depend on this job). T036 Red ruled satisfied-by-declaration (rationale in tasks.md).

Suites green: 23 pytest + 6 jest; both workflow YAMLs valid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ogYXVeZsBVUHkwzdStYPJ
- T041 README: argument table, --force operator override, exit/skip contract, explicit
  per-version manual-backfill procedure (no auto-backfill, FR-012), and the one-time manual
  end-to-end validation checklist.
- T042 edge cases (test_edge_cases.py): internal-maintenance-only note still publishes an
  entry; same-day hotfix -02/-03 each create their own row; older-line -04 patch carries
  today's date and searches/writes only its own version (FR-013).
- T043: full suites green — 26 pytest + 38 jest (whole gather-release-data suite).
- T044 security/hygiene: token never logged/echoed; GITHUB_OUTPUT carries only
  result/reason (never the token); version validated before any network call; .venv and
  caches gitignored and untracked.

All 44 tasks complete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ogYXVeZsBVUHkwzdStYPJ
…guard, token error handling (#36605)

Verifier fixes (same branch, core behavior already PASS):

1. (MUST/SC-005) Add a trailing `if: failure()` catch-all Slack step so a failure in any
   pre-publisher step (npm ci, tsc, gather-release-data, Claude generation, verify-notes) —
   which would otherwise skip the result-gated Slack steps while allow_failure keeps the run
   green — still posts a failure notice to #dot-releases. The two specific steps are unchanged;
   no double-post (the publisher step exits 0, so success/skip aren't a job-failure state).
2. (SHOULD) verify-notes uses `[ -s ]` not `[ -f ]` — an empty notes file no longer publishes
   an empty releaseNotes.
3. (SHOULD) cli.py catches RuntimeError (missing DOTCMS_DEVSITE_TOKEN) -> clean one-line error,
   rc 1, no traceback; new pytest asserts rc=1 / no traceback / no network call.
4. (CHEAP) Docker-tag match tightened to `dotcms/dotcms:*_*` (sha-tagged version_sha form) so
   it can't match `:latest`; sha-less fallback kept; emit-order assumption noted inline.

Suites green: 27 pytest + 38 jest; both workflow YAMLs valid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ogYXVeZsBVUHkwzdStYPJ
…cal working artifacts

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ogYXVeZsBVUHkwzdStYPJ
sfreudenthaler and others added 3 commits July 16, 2026 17:00
…ADME

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ogYXVeZsBVUHkwzdStYPJ
…e duplicate test helpers (#36605)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ogYXVeZsBVUHkwzdStYPJ
One step, one channel: the if covers publisher failure, protective skip,
and pre-publisher failure(); wording switches inline and the reason falls
back for pre-publisher errors. Behavior per FR-008/SC-005 unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ogYXVeZsBVUHkwzdStYPJ
@sfreudenthaler
sfreudenthaler marked this pull request as ready for review July 16, 2026 22:23
@sfreudenthaler
sfreudenthaler requested a review from a team as a code owner July 16, 2026 22:23
sfreudenthaler and others added 2 commits July 16, 2026 18:28
…ersion concurrency group (#36605)

Claude review finding: dotCMS _search is ES-backed/async-indexed, so a fast
re-run after a create could miss the new row and double-create (then every
run hits AmbiguousMatchError). The tool now polls until the created row is
searchable before exiting (sequential window), and the workflow job gets a
per-version concurrency group (concurrent window) — spec edge case satisfied.
Counter-regex finding rejected: mirrors release-prepare's own is_latest gate,
so an out-of-range version never reaches this job.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ogYXVeZsBVUHkwzdStYPJ
@sfreudenthaler sfreudenthaler changed the title spec(#36605): changelog auto-publish to dev site + move evergreen-tracks under core-cicd feat(cicd): 36605 changelog auto-publish to dev site Jul 17, 2026
@sfreudenthaler

Copy link
Copy Markdown
Member Author

RE: the two medium findings

  1. Search-then-create has no guard against Elasticsearch index lag on a retry: fixed with subsequent commit. Makes sure a read can happen before complete
  2. The counter is restricted to 1–2 digits (-[0-9]{1,2}): As intended. there's an upstream limitation on the releaser for maximum of two character index as well.

… site content (#36605)

Retires the second Claude call and the site prompt template entirely. The
ai-release-notes template becomes the one shared editorial format (site
style: prose intro, no emoji, site section set, double-bracket issue links,
no anchors); the site phase now just fetches the published release body via
gh release view and hands it to the publisher, which injects per-version
heading anchors and strips GitHub's compare footer mechanically (tested,
idempotent). Removes node/tsc/gather/claude-code-action/verify steps, the
ANTHROPIC_API_KEY secret, and the OIDC permission from the phase — the site
path no longer depends on inference at publish time, and a hand-edited
release re-syncs the site on re-run. Suites: 30 pytest + 32 jest green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ogYXVeZsBVUHkwzdStYPJ
import requests

# corpsites-headless is the site's backend; overridable for local testing only.
BASE_URL = os.environ.get("DOTCMS_DEVSITE_URL", "https://corpsites-headless.dotcms.cloud")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

do we want to expose the corpsites URL as a default value in our public repo?

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

Labels

AI: Safe To Rollback Area : CI/CD PR changes GitHub Actions/workflows Area : Documentation PR changes documentation files

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Automate publishing release changelogs to dev.dotcms.com

2 participants