Track releases and correlate them with deploy/release workflows
projects-monitor will eventually need a repository-level overview: the latest release per repo,
and whether the workflows around it (deploy, production, …) succeeded. webhooks is the only
thing currently ingesting GitHub webhooks, so this issue asks for the persistence layer to be built
here first, independently of projects-monitor’s eventual GitHub App and UI.
Why this bypasses the existing GitHub pipeline entirely
The obvious approach would be adding release/workflow_run handling to GitHubEventHandler and
extending github_workflow_runs. That would have silently reintroduced exactly the coupling this
feature is required to avoid:
validateEventAndAction()(Src/lib/github.functions.php, itself marked// TODO: Move this to gstraccini-bot repository) — called by nearly every existingisGitHubXToBot()matcher that feedsGitHubEventHandler— rejects any webhook whoseX-GitHub-Hook-Installation-Target-IDisn’t GStraccini-bot’s own App ID (BOT_APP_ID = 480132). The entire existing OOP pipeline (branches, discussions, issues, pull requests, …) only ever processes events delivered to that one App.isGitHubWorkflowRunToBot()goes further still and hardcodes an allowlist of 14 specific installation IDs.workflow_runevents for any other installation are discarded before persistence today — via a legacy function,saveWorkflowRunGitHubToDatabase()(Src/lib/bot.database.php); the OOPWorkflowRunRepository::create()/update()andGitHubEventHandler::handleWorkflowRunEvent()are dead code, sincedatabase.php’s dispatcher never routes a nativeworkflow_rundelivery intoprocessGitHubWebhook().
Release/workflow tracking needs to see every repository this service receives webhooks for, not just GStraccini-bot’s. So it’s a new, parallel, App-agnostic path instead:
Src/Library/ReleaseTrackingHandler.phpis the only entry point. It readsX-GitHub-Eventdirectly — novalidateEventAndAction(), no installation allowlist, no dependency onGitHubEventHandlerat all.saveGithubValidatedToDatabase()(Src/lib/database.php) calls it unconditionally, before the existingisGitHubXToBot()if/elseif chain — purely additive, so existing dispatch behavior for every other event type is untouched.- New tables (
github_releases,github_release_workflow_runs) rather than reusinggithub_workflow_runs, since that table’s only live writer is the allowlist-gated legacy path, and repurposing it for this feature would mean either inheriting that gate or fragmenting its semantics.
Data model
github_releases (Sql/0033) — one row per repository (UNIQUE (RepositoryOwner,
RepositoryName)), always the latest. ReleaseRepository::createOrUpdate() looks up the existing
row’s ReleaseCreatedAt and no-ops if the incoming release isn’t newer, so a delayed or
out-of-order redelivery can’t clobber a newer release already stored. Only action IN ('published',
'released') writes — edited/deleted/unpublished are out of scope for this first pass.
github_release_workflow_runs (Sql/0034) — one row per relevant workflow_run, across all
repos and all time (not just “latest”). “Relevant” is a case-insensitive substring match of
workflow_run.name/.path against ConfigConstants::RELEASE_WORKFLOW_KEYWORDS (deploy,
release, publish, production) — a heuristic, not exhaustive; GitHub’s workflow_run payload
has no direct release reference to key off instead. Upserted by WorkflowRunId, same
create-then-update shape as the existing WorkflowRunRepository.
Correlation is a read-time join, not a stored foreign key. Because github_releases already
collapses to one row per repository, github_release_status_view (SQL SECURITY INVOKER, matching
the convention Sql/0027 established for every view in this schema) joins it to
github_release_workflow_runs rows for the same repository within a symmetric
ReleaseCreatedAt ± 6 HOUR window, keeping only the single closest-in-time run per distinct
relevant workflow (Sql/0036, refining Sql/0035 — see the corrections below), and aggregates:
CASE
WHEN SUM(failure-ish conclusions) > 0 THEN 'Failed'
WHEN COUNT(*) = 0 OR SUM(not completed / conclusion IS NULL) > 0 THEN 'Pending'
ELSE 'Successful'
END AS WorkflowStatus
WorkflowStatus = 'Failed' is the “this release requires attention” signal — matching the issue’s
own worked example (a Deploy workflow can succeed while a Production workflow fails, and the
release still requires attention overall). No explicit FK/snapshot was needed: recomputing this at
read time is naturally robust to reprocessing and out-of-order webhook arrival, and keeps the write
path simple.
Consequences
projects-monitor(or anything else) can querygithub_release_status_viewdirectly for the table the issue describes (repository, last release, tag, date, workflow status) — no aggregation logic needs to live outside this database.- Because there’s no App/installation scoping at all yet, this table currently reflects every
repository this service receives release/workflow_run webhooks for, which will be broader than
projects-monitor’s eventual monitoring scope. Restricting that is explicitly deferred toprojects-monitor’s own future GitHub App — this schema doesn’t need to change for that; a future consumer just filters byRepositoryOwner/RepositoryNameagainst its own installation list. - The
RELEASE_WORKFLOW_KEYWORDSkeyword match is a heuristic. A workflow named e.g.ship-it.ymlwon’t be picked up; a workflow nameddeploy-docs.ymlunrelated to app releases will be. Expected to be refined once real-world workflow naming is observed.
Correction: every release showed as Pending (Sql/0035)
The original join (RunStartedAt >= ReleaseCreatedAt, no look-back) assumed the deploy/release
workflow run always starts after the release it’s being correlated with already exists. That’s
backwards for this repo’s own deploy.yml: the “Deploy via SSH” workflow (name matches the
deploy keyword) starts, deploys, runs migrations, runs API tests, and only as its last job
(create_release) cuts the GitHub Release — so RunStartedAt for that run is always earlier than
ReleaseCreatedAt. The join predicate therefore never matched, COUNT(w.Sequence) was always 0,
and WorkflowStatus was 'Pending' for every repository, not just this one — any repo whose
release is the last step of its own deploy workflow hits the same case.
Sql/0035 widens the lower bound to ReleaseCreatedAt - INTERVAL 6 HOUR instead of a strict
>=, so a workflow run that started shortly before the release it produced still correlates. The
window stays bounded (rather than removing the lower bound entirely) so a stale run left over from
a much earlier release cycle for the same repository doesn’t get attributed to the current one.
Correction: unrelated runs in the window could still flip a release to Failed (Sql/0036)
Sql/0035’s window fixed the “always Pending” bug, but it aggregates every row in
github_release_workflow_runs that falls inside the 6-hour window — not just the run that
actually produced (or followed) the release it’s attached to. That’s a real problem for this
repo specifically: deploy.yml’s “Deploy via SSH” workflow (the only one matching
RELEASE_WORKFLOW_KEYWORDS here) runs on every push to main, and on an active day multiple
runs land inside the same 6-hour window — e.g. four runs between 15:48 and 18:06 on one day,
including one failure sandwiched between two successes. A release cut by the successful 18:06
run would still show WorkflowStatus = 'Failed', purely because of the unrelated earlier
failure, with nothing wrong with the deploy that actually produced it.
Sql/0036 replaces the flat aggregate with ROW_NUMBER() OVER (PARTITION BY release, Name, Path
ORDER BY ABS(TIMESTAMPDIFF(SECOND, RunStartedAt, ReleaseCreatedAt)) ASC), keeping only the
single closest-in-time run per distinct relevant workflow per release, and widens the window to
be symmetric (ReleaseCreatedAt ± 6 HOUR) instead of only bounded on one side — a workflow
triggered by the release itself (on: release: published) necessarily starts after
ReleaseCreatedAt, the mirror image of this repo’s deploy-then-release pattern. Partitioning by
workflow Name/Path (not just by release) is what keeps ADR-0006’s original multi-workflow
scenario working — a Deploy workflow succeeding while a separate Production workflow fails still
surfaces both, since each distinct workflow is matched to the release independently — while a
stale run of the same workflow from a different release cycle can no longer leak in.