Release Workflow Machinery
This describes how the modernized release workflow is built: the custom Concourse resources, the generated pipeline, the app registry, and the Slack bot. For how to use it as an engineer releasing an app, see Concourse release workflow. For migrating an app onto it, see Migrating an app to the release workflow.
Background: RFC mitodl/hq#10465 and epic mitodl/hq#7185.
Why it exists
Doof (mitodl/release-script, the Heroku app odl-release-bot) polled
/static/hash.txt to tell what was deployed and drove release-candidate and
release branches. Neither survives the move to Kubernetes: the same image is
promoted from RC to production, so a separate release branch describes nothing,
and the hash endpoint is not a deployment record.
The replacement keeps the part of the old process people relied on, a checklist that the authors of a release tick off, and moves the rest onto git refs, GitHub Deployments, and Concourse resources.
The pieces
flowchart TD
registry["bridge/settings/apps.py<br/>APPS registry"]
gen["k8s_apps/pipeline.py<br/>pipeline generator"]
bot["release bot<br/>(Slack Bolt, Socket Mode)"]
pipeline["<app>-pipeline<br/>(Concourse team infrastructure)"]
relres["release resource"]
issues["github-issues resource"]
deploys["github-deployments resource"]
repo["app repo on GitHub"]
registry --> gen
registry --> bot
gen --> pipeline
pipeline --> relres
pipeline --> issues
pipeline --> deploys
relres --> repo
issues --> repo
deploys --> repo
bot -->|trigger job, force check| pipeline
bot -->|close release issue, push hotfix tag| repo
Custom Concourse resources
All three live in mitodl/ol-concourse under resources/, are written with
concoursetools, and authenticate as
the same GitHub App.
release (resources/release, image mitodl/concourse-release-resource) owns
the whole git side of a release:
checkcompares the tracked branch's HEAD to the newestYYYY.M.D.Ntag and emits the next version plushead_sha,since,commit_count,authors,in_flightandhotfix.head_shabinds the laterinandoutsteps to the commit that was evaluated, so commits landing mid-build do not change what is released.inwritesversion,in_flight,hotfix,commits.json,checklist.md(the issue body, grouped by author) andchangelog_entry.md.out action: createcutsreleases/<version>, commits the version bump, and tags the pre-bump HEAD.action: finishmerges that branch back into the default branch and deletes it.action: abandontears a cut release down.
github-issues opens the release issue from body_file and, configured with
issue_state: closed and skip_if_labeled: [abandoned], acts as the production
gate. github-deployments records the RC and Production GitHub Deployments.
The resource's own reference documentation, covering the source fields and the
hotfix contract, is in
resources/release/README.md.
The app registry
src/bridge/settings/apps.py in ol-infrastructure is the single registry of
repo, default branch, Slack channel and workflow opt-in. The pipeline generator
and the bot both read it, so an app cannot be migrated in one control surface and
not the other. release_workflow_repos() also decides which repos get the
release bot's bypass on their required-status-checks ruleset, in
src/ol_infrastructure/saas/github/repositories/rulesets.py. The org-level
bypass is separate: it is granted to the App for every tier-1 default branch in
saas/github/organization/org_rulesets.py, regardless of the registry.
The generated pipeline
_build_release_resource_app_pipeline in
src/ol_concourse/pipelines/infrastructure/k8s_apps/pipeline.py emits the
pipeline; build_app_pipeline dispatches to it or to the legacy builder based on
the registry. Pipelines are named <app>-pipeline and live in the Concourse team
infrastructure.
Jobs:
| Job | Trigger | What it does |
|---|---|---|
build-<app>-image-from-<branch> |
Push to the default branch | Builds and pushes the CI image (latest plus git short ref). |
deploy-ol-application-<app>-ci |
New CI image | Pulumi deploy to CI. |
build-<app>-release-image |
The <app>-release resource |
Bumps the version, put action: create, checks out the cut release, builds the image tagged with the version. |
deploy-ol-application-<app>-qa |
New release image | Pulumi deploy to QA, then opens the release issue from checklist.md and marks the RC Deployment successful. |
deploy-ol-application-<app>-production |
<app>-release-gate (a closed release issue) |
Pulumi deploy to production, then marks the Production Deployment successful and runs put action: finish. |
abandon-<app>-release |
Manual, or /doof abandon |
put action: abandon: deletes the release branch and tag. |
The image is built from the cut releases/<version> branch rather than from the
main-branch checkout, because a hotfix is production plus one commit and the
main-branch checkout does not hold that tree.
The Slack bot
src/ol_infrastructure/applications/release_bot/ is an async Slack Bolt app on
Socket Mode, deployed by its own Pulumi project into the operations namespace
of the applications.Production EKS cluster. It is a thin adapter over the
machinery above: /doof release forces a resource check then triggers a job,
/doof promote closes an issue then forces the gate resource to check.
Its background loops:
- ready-to-promote, every 120s: an open release issue whose checklist is fully
checked gets a Slack message with a promote button, deduped with the
promote-readylabel. An app with neither aslack_channelin the registry nor theRELEASE_ANNOUNCE_CHANNELfallback is skipped entirely. - release progress, every 120s: announces RC and Production deployments as they
reach success, and nags every 24h about a release that has been cut but not
finished for longer than
RELEASE_STUCK_AFTER_HOURS(default 24).
It reads its app list from REPOS_CONFIG, a JSON blob rendered from the registry
at Pulumi apply time, and talks to Concourse's REST API with the same
resource-owner password grant fly login uses, scoped to the team named in
CONCOURSE_TEAM (infrastructure).
Invariants and traps
These are the things that have actually broken, and the reasons the code looks the way it does.
The <app>-release resource is check_every: never with no webhook.
Triggering build-<app>-release-image without forcing a check first makes the
job bind whatever version Concourse last recorded, so the new release reuses the
previous release's version number and its commit list. Anything that triggers
that job programmatically must force the check and refuse to proceed if it fails.
Never wrap the action: finish put in a try. It used to be, to tolerate a
re-triggered production job finding the release branch already gone. The resource
now no-ops in that case, so the try bought nothing and swallowed genuine
failures: an app sat for weeks with an unmerged release branch, and therefore a
frozen release version, with no red build anywhere to show for it.
Writes to a mitodl default branch are governed by org-level GitHub rulesets,
not classic branch protection, and each ruleset carries its own bypass actor
list. ol-release-bot (App id 4437866) holds an always bypass on the org rulesets
covering tier-1 default branches, and on the required-status-checks ruleset of
each repo in the app registry.
Without that, action: finish fails with GH013: Repository rule violations
found. Doof was unaffected only because its odlbot identity inherits the
odl-engineering-owners team bypass.
The shared credential is named github_app, not github. Every Concourse team
already has a team-scoped github secret, and Concourse's Vault credential
manager tries team-scoped paths before the shared fallback, so a credential named
github silently resolves to the wrong secret. Pipelines reference it as
((github_app.release_bot_app_id)) and friends.
The bot runs a single replica on purpose. Socket Mode holds one persistent WebSocket per app-level token; a second replica opens a competing connection and Slack distributes events non-deterministically between them, silently dropping interactions. Its module-level state (the in-process checklist watchers, the per-app locks, the map of who requested each release) is only safe because of that. Scaling out needs leader election or a move to the HTTP Events API.
ol_concourse is a namespace package. The meta job puts the ol-infrastructure
checkout's src on PYTHONPATH, so ol_concourse.pipelines resolves to the
checkout while ol_concourse.lib.tasks (where bump_version_task lives)
resolves to the package installed in the mitodl/ol-infrastructure image. A
change to a task in ol-concourse only reaches generated pipelines after that
image is rebuilt, which happens in an image-build pipeline in Concourse team
main (ol-infrastructure-docker-container at the time of writing), not in the
app pipelines.
Concourse is split across two teams. <app>-pipeline and the infrastructure meta
pipelines live in infrastructure; the image builds and the library publish meta
pipelines live in main. A fly command aimed at the wrong team 404s silently
rather than erroring usefully.
Supported by the resource, not wired up in pipelines
Two things the machinery can do are not enabled anywhere, so do not read their presence as working behavior.
/doof publish triggers a Concourse job named publish. No generated pipeline
defines one, and no library is registered in the app registry, so library
releases (RFC phase 3) do not run through this workflow yet.
Changelog management is implemented in the release resource (changelog_style,
either a cumulative CHANGELOG.md or a file per release) but no pipeline sets
it, so no changelog is written or committed as part of a release.