Skip to content

Skip redundant tests when staging a release - #121

Merged
cjfravel-dev merged 12 commits into
mainfrom
ci/publish-skip-tests
Sep 4, 2026
Merged

cjfravel-dev merged 12 commits into
mainfrom
ci/publish-skip-tests

Conversation

@cjfravel-dev

@cjfravel-dev cjfravel-dev commented Sep 3, 2026 •

Copy link
Copy Markdown
Owner

Problem

publish.yml stages both Spark profiles with ./mvnw clean deploy, which re-runs the full test suite. That is roughly 45 of the ~60 minutes a release takes, and it cannot discover anything new: a release tag points at a commit already built and tested by CI on main. For 0.1.10-beta the tested tree and the released tree were byte-identical (405dc48).

Change

Skip tests during staging. Both staging steps now pass -DskipTests.

Gate on CI provenance instead. Skipping tests is only sound if the tagged commit really was tested, so a new step runs before staging and fails the release unless the tagged commit has successful Build & Test (Spark 3.5) and Build & Test (Spark 4.1) check runs. ci.yml triggers on push: [main], so the merge commit a tag points at has its own run. The gate also reads the conclusion of the build step inside each job: CI guards its build steps with if: needs.changes.outputs.source == 'true', so a documentation-only commit reports both jobs green within seconds having compiled nothing, and a job conclusion alone would not show the artifacts were built. If a tag points at a commit that was never built or tested, the release stops before anything is uploaded — which matters because Maven Central coordinates are immutable. The gate reads every page of check runs, so a required run that lands beyond the first page cannot read as missing and stop a release that should have gone ahead.

Run the governance checks explicitly. -DskipTests does not only suppress the Scala suites. The scripts in dev/scripts are bound to the test phase via exec-maven-plugin, which carries a plugin-level <skip>${skipTests}</skip>; execution-level configuration merges with that block rather than replacing it, so all fourteen bindings inherit the skip — including readme-has-version.sh, the version-consistency check that exists specifically to catch a mismatched release. The workflow now runs dev/scripts/run-governance-checks.sh before staging. That script derives its list from pom.xml, so a newly registered check cannot silently go unrun.

Two executions opt out with <skip>false</skip> and still run during staging: clean-api-docs-output and test-package-contents, the latter validating the shaded jar's contents. Scalafix and scalastyle are separate plugins and are unaffected.

Verification

  • The skip was real. Replaced readme-has-version.sh with a script printing a sentinel and exiting 1. mvn test -Pspark35 -DskipTests succeeded and never printed the sentinel — the script did not run. Maven prints the execution header regardless, which is what made this easy to miss.

  • The fix is real. The same sentinel makes run-governance-checks.sh fail. Clean tree: all 15 checks pass.

  • The conclusion lookup handles re-runs. commits/{sha}/check-runs does not deduplicate by name — commit 7f0176ce carries two Analyze (actions) runs, and total_count is 7 with and without filter=all. Reading a single attempt is therefore not enough: an unfinished attempt reports completed_at and conclusion as null (confirmed on a live re-run), so ordering by completion time sorted it to the front and selected the previous, already finished attempt — a release started while CI was re-running could be matched against a stale success.

    The gate now refuses outright if any attempt for a required check has not reached status == "completed", and only then reads the conclusion of the newest attempt by started_at. Failing closed means the safety property does not depend on how a pending run populates its timestamps.

    Exercised across eleven cases: a single success, no runs at all, only a differently-named check, a re-run that fixed a failure, a re-run that broke a success, an in-progress attempt alone, an in-progress re-run following a green run, a queued attempt with started_at set, a queued attempt with started_at null, a cancelled attempt, and two greens where the newest must win. Confirmed against live API data too — with a re-run in flight both profiles resolved to incomplete, while the 0.1.10-beta release commit still resolved to success.

  • The step check distinguishes a real build from a skipped one. Extracted the gate from the workflow YAML and ran it unmodified against real commits: fdecbb2 (the 0.1.10-beta release commit) passes, while the documentation-only 1474a03 — both jobs green in 23s and 7s — is refused with its "Build and test with coverage" step was skipped. The job is located via the check run's details_url, and its name is checked before its steps are read, so a mis-identified job stops the release instead of being judged in place of the right one.

  • Pagination does not change any verdict. This repository is far from the page limit (total_count is 7 on the commits checked), so the paging was forced rather than waited for: running the same gate with per_page=2 splits those runs across four pages and produces identical results, with fdecbb2 passing and 1474a03 refused either way. Merging pages defaults to an empty list, so a commit with no check runs reports no run rather than failing on a null — confirmed against cfea07e, which genuinely has none.

  • dev/scripts/release-pipeline-tests.sh ties the pieces together so they cannot drift apart: -DskipTests, the gate step, both check names, the paginated fetch, the fail-closed condition, the job lookup and job-name check, the governance step, and both checks: read and actions: read. Because the gate names CI steps literally, it also verifies each name still exists in ci.yml and fails on a rename.

Notes

checks: read and actions: read were added to the workflow permissions: the gate reads check runs and then the job behind each one, and declaring permissions explicitly sets every unlisted scope to none. docs/contributors/releasing.html documents the tradeoff and the skip semantics.

The publish job ran the full suite for both profiles, adding roughly 45
minutes to every release to re-test a tree CI had already tested. A release
tag points at a merge commit on main, and CI runs on every push to main, so
that commit already has its own green run for both profiles.

Staging now builds with -DskipTests. To keep the guarantee that the released
tree was actually tested, a new step first asserts the tagged commit has
successful Build & Test runs for both Spark 3.5 and Spark 4.1, and fails
before anything is uploaded if it does not. This also closes a gap the old
design left open: a tag placed on a commit that never went through CI used
to be released as long as the tests happened to pass on the runner.

-DskipTests suppresses only Scala test execution. All thirteen governance
exec bindings, including the version-consistency and packaged-jar contents
checks, still run during staging — verified by running the test phase with
the flag set.

The two changes are load-bearing together, so release-pipeline-tests.sh now
asserts both the flag and the provenance gate are present.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI lite review requested due to automatic review settings September 3, 2026 19:03

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The new CI gate can incorrectly fail when multiple check runs with the same name exist for a commit (e.g., after reruns), because the conclusion parsing may become multi-line and not equal to success.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR speeds up the release publishing workflow by avoiding redundant re-execution of the full test suite during staging, while preserving safety by gating releases on already-successful CI runs for the tagged commit.

Changes:

  • Add a “Verify the release commit passed CI” gate in publish.yml (requires checks: read) to ensure the tagged commit has green CI for both Spark profiles before staging.
  • Stage both Spark profiles with Maven -DskipTests to skip redundant test execution.
  • Update release pipeline contract tests and contributor docs to document/enforce the paired gate + skip-tests design.
File summaries
File Description
docs/contributors/releasing.html Documents the new CI-provenance gate and the rationale for skipping tests during staging.
dev/scripts/release-pipeline-tests.sh Adds contract assertions to ensure both the CI gate and -DskipTests remain present together.
.github/workflows/publish.yml Adds check-runs verification for the release commit and applies -DskipTests to both staging deploy steps.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +84 to +96
sha=$(git rev-parse HEAD)
checks=$(
gh api "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?per_page=100" \
--jq '.check_runs[] | "\(.name)\t\(.conclusion)"'
)
for name in "Build & Test (Spark 3.5)" "Build & Test (Spark 4.1)"; do
conclusion=$(printf '%s\n' "$checks" | awk -F'\t' -v n="$name" '$1 == n { print $2 }')
if [[ "$conclusion" != "success" ]]; then
echo "$name did not succeed for $sha (got: ${conclusion:-no run})." >&2
echo "Staging skips tests, so the tagged commit must already have a green CI run." >&2
exit 1
fi
done

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Good catch — this was a real bug, and my assumption behind the original code was wrong.

I had believed the check-runs API's default filter=latest collapsed duplicates by name. It does not. Commit
7f0176ce on main already carries two Analyze (actions) runs, and total_count is 7 both with and without
filter=all.

Running the original logic against that commit returns $'success\nsuccess' — two lines — so it would have rejected
the commit. A re-run of either Build & Test job would therefore have blocked an otherwise valid release.

Fixed in 6182bff by sorting the matching runs by completed_at and taking the last, so exactly one conclusion is
compared. I used sort-and-take-last rather than "any run succeeded" deliberately, so the gate stays correct in the
other direction too: if a re-run fails after an earlier success, the latest conclusion is the failure and the release
is rejected.

Verified against the live API plus synthetic cases: duplicate names now yield a single success; a re-run whose
latest attempt failed yields failure; a still-running check yields incomplete; a commit with no runs yields
no run. Only the first case passes the gate.

The gate matched check runs by name and compared the result directly to
"success". A commit can carry several check runs sharing one name — a re-run
adds another rather than replacing the first, and the check-runs API does not
collapse them. Commit 7f0176c already carries two "Analyze (actions)" runs,
where the previous logic yields $'success\nsuccess' and rejects the commit.

That would have blocked a release whose only irregularity was a re-run job.

The lookup now sorts the matching runs by completed_at and takes the last, so
exactly one conclusion is compared. Sorting rather than searching for any
success also keeps the gate honest in the other direction: when a re-run
fails, the latest conclusion is the failure.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 3, 2026 19:10

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

-DskipTests also skips Maven-bound governance scripts via exec-maven-plugin’s <skip>${skipTests}</skip> wiring, which weakens the release guards unless the workflow runs those checks explicitly.

Review details

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

.github/workflows/publish.yml:110

  • -DskipTests will also cause Maven-bound governance scripts to be skipped here because exec-maven-plugin is configured with <skip>${skipTests}</skip> in pom.xml. That undermines the release guards described in the PR (e.g., version/reference checks) even though Scala tests are skipped. Consider running the governance scripts explicitly before staging (the repo already has dev/scripts/run-governance-checks.sh, which CI uses for this exact purpose).
    dev/scripts/release-pipeline-tests.sh:65
  • This contract test asserts -DskipTests and the CI-provenance gate are present, but it should also assert that governance checks still run somewhere in the workflow. With the current pom.xml wiring (exec-maven-plugin skips when skipTests is set), omitting an explicit governance step would silently drop several release guards.
  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

Code Coverage — Spark 3.5

Code Coverage

Package Line Rate Branch Rate Health
dev.cjfravel.ariadne 85% 77% ✔
dev.cjfravel.ariadne.catalog 81% 73% ✔
dev.cjfravel.ariadne.exceptions 93% 100% ✔
org.apache.spark.sql 100% 100% ✔
Summary 84% (5253 / 6218) 77% (658 / 859) ✔

-DskipTests does not only suppress the Scala suites. The governance
scripts in dev/scripts are bound to the test phase through
exec-maven-plugin, which carries a plugin-level
<skip>${skipTests}</skip>. Execution-level configuration merges with
that block rather than replacing it, so all fourteen test-phase
bindings inherit the skip. Only clean-api-docs-output and
test-package-contents opt out with <skip>false</skip>.

Adding -DskipTests to the staging steps therefore disabled
readme-has-version.sh, the version-consistency check that exists
specifically to catch a mismatched release, along with the rest of the
governance suite. Verified by replacing readme-has-version.sh with a
script that prints a sentinel and exits 1: `mvn test -DskipTests`
succeeded and never printed the sentinel.

Run dev/scripts/run-governance-checks.sh as an explicit step before
staging. The script derives its list from pom.xml, so a newly
registered check cannot silently go unrun. The same sentinel makes the
runner fail, confirming the guards are genuinely restored.

Correct the releasing guide, which claimed the guards still ran.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 3, 2026 19:42
@cjfravel-dev

Copy link
Copy Markdown
Owner Author

You're right, and I'd claimed the opposite in the PR description without checking. Fixed in a20a07f.

Verified it properly this time: I replaced readme-has-version.sh with a script that prints a sentinel and exits 1, then ran ./mvnw test -Pspark35 -B -DskipTests. The build succeeded and the sentinel never printed, so the script genuinely did not run. My earlier check was worthless because Maven prints the --- exec:3.6.3:exec (run-custom-script) --- header whether or not the mojo skips, and I'd taken the header as evidence of execution.

The cause is the plugin-level <configuration><skip>${skipTests}</skip></configuration> at pom.xml:281-288. Execution-level configuration merges with it rather than replacing it, so all fourteen test-phase bindings inherit the skip. Only clean-api-docs-output and test-package-contents opt out via <skip>false</skip> — which also explains why I'd seen the packaged-jar check run during a real staged deploy and wrongly generalised from it.

The fix runs dev/scripts/run-governance-checks.sh as an explicit step before staging. It builds its list by grepping pom.xml, so a newly registered check is picked up automatically instead of needing to be mirrored into the workflow. The same sentinel makes the runner fail, and the clean tree passes all 15 — so the guards are demonstrably back rather than assumed back. release-pipeline-tests.sh now asserts the step exists, and I've corrected both the PR description and releasing.html, which had repeated the same false claim.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The new CI provenance gate can incorrectly pass when a job is re-run and the latest attempt is still in progress because it sorts only by completed_at (ignoring started_at), potentially allowing staging to proceed without a final CI result.

Review details

Suppressed comments (1)

.github/workflows/publish.yml:91

  • The provenance gate can incorrectly pass when a check was re-run and the newest attempt is still in progress: in-progress runs typically have completed_at=null, so sorting only by completed_at selects the most recently completed run (possibly a prior success) and ignores the in-progress attempt. That allows staging to proceed while CI is still running (or before the latest attempt’s conclusion is known). Sort by started_at as a fallback so an in-progress re-run is treated as the latest attempt and yields "incomplete".
                  | sort_by(.completed_at // "")
  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

A re-run adds a second check run under the same name instead of
replacing the first, and an unfinished attempt reports completed_at and
conclusion as null. Ordering by completed_at therefore sorted the
running attempt to the front and selected the previous, already
finished one, so a release triggered while CI was re-running could be
matched against a stale success.

Order by started_at, which is always populated and increases with each
attempt, and treat any attempt that has not reached status completed as
incomplete rather than reading its empty conclusion.

Verified against live data: while a re-run was in flight both profiles
resolved to incomplete, and the 0.1.10-beta release commit still
resolved to success.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 3, 2026 20:14
@cjfravel-dev

Copy link
Copy Markdown
Owner Author

Right again — fixed in e607496.

My earlier "in-progress" test case only covered a commit where the sole attempt was unfinished, which resolves correctly by accident. It never covered a finished attempt sitting alongside an unfinished re-run, which is the case you describe.

Confirmed the premise on live data rather than assuming it: I re-ran this PR's CI and queried commits/{sha}/check-runs mid-flight, and the unfinished attempt reports status=in_progress, completed_at=null, conclusion=null. So sort_by(.completed_at // "") sorts it to the front (empty string precedes any timestamp) and last returns the older finished attempt. With a green run already on the commit, the gate would have passed while CI was still running.

The gate now sorts by started_at, which is always populated and increases with each attempt, and treats anything not at status == "completed" as incomplete instead of reading its empty conclusion. That second part matters independently — it also covers queued, where started_at is set but no verdict exists yet.

Re-tested across single success, no runs, only a differently-named check, a re-run that fixed a failure, a re-run that broke a success, in progress, queued, cancelled, and an in-progress re-run following a fix. Also validated against real payloads: with the re-run in flight both profiles resolved to incomplete, and the 0.1.10-beta release commit still resolved to success. release-pipeline-tests.sh now asserts both the started_at ordering and the status check so this cannot quietly regress.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The provenance gate can incorrectly pass when the newest CI attempt is queued due to sorting only by started_at.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread .github/workflows/publish.yml Outdated
printf '%s' "$checks" |
jq -r --arg name "$name" '
[.check_runs[] | select(.name == $name)]
| sort_by(.started_at // "")
Comment thread docs/contributors/releasing.html Outdated

<p>Before staging, the workflow checks that the tagged commit already has successful <code>Build &amp; Test (Spark 3.5)</code> and <code>Build &amp; Test (Spark 4.1)</code> runs. Because CI runs on every push to <code>main</code>, the merge commit a release tag points at has its own green run. Staging then builds with <code>-DskipTests</code>, since re-running the same suite on the same tree adds roughly 45 minutes and cannot discover anything new. If the tag points at a commit CI never tested, the release stops before anything is uploaded.</p>

<p>Re-running a job adds another check run under the same name rather than replacing the old one, so the gate takes the attempt with the newest <code>started_at</code> and requires it to have finished. An unfinished attempt reports both <code>completed_at</code> and <code>conclusion</code> as <code>null</code>, so a release started while CI is still re-running is refused rather than being matched against an earlier green attempt.</p>
Reading a single attempt makes the gate depend on how a pending check
run fills in its timestamps: if an unfinished attempt sorted ahead of a
finished one, the finished attempt would be read instead and a stale
success could admit a release while CI was still running.

Refuse outright when any attempt for a required check has not reached
status completed, and only then read the conclusion of the newest
attempt by started_at. The safety property no longer rests on any
timestamp being populated on a pending run.

Exercised across eleven cases including a queued attempt with a null
started_at, an in-progress re-run following a green run, a re-run that
fixed a failure and one that broke a success. Against live data both
profiles resolve to incomplete while a re-run is in flight, and the
0.1.10-beta release commit still resolves to success.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 3, 2026 20:46
@cjfravel-dev

Copy link
Copy Markdown
Owner Author

I checked the premise this time rather than assuming, and on GitHub Actions it doesn't hold: queued check runs do carry a started_at. I re-ran this PR's CI and polled commits/{sha}/check-runs during the queue window:

Classify changes        | status=queued | started_at=2026-09-03T20:41:40Z | completed_at=null
Build & Test (Spark 4.1)| status=queued | started_at=2026-09-03T20:41:49Z | completed_at=null

So a queued attempt sorts last, not first, and the previous logic already returned incomplete for it.

That said, I've made the change anyway (371d24f), because your underlying point stands even though the specific mechanism doesn't: the gate shouldn't depend on a pending run populating a particular timestamp at all. It now refuses the release if any attempt for a required check hasn't reached status == "completed", and only reads the newest attempt's conclusion once they're all settled. That fails closed, and it stays correct whether or not started_at is set on a pending run.

Retested across eleven cases, including a queued attempt with started_at: null explicitly — the case your comment describes — which now resolves to incomplete regardless of ordering. Also: single success, no runs, only a differently-named check, a re-run that fixed a failure, a re-run that broke a success, in-progress alone, in-progress after a green run, queued with started_at set, cancelled, and two greens where the newest must win. Against live data both profiles resolve to incomplete while a re-run is in flight, and fdecbb2 still resolves to success.

I've corrected the docs paragraph to describe the actual rule rather than the selection key, and release-pipeline-tests.sh now asserts the fail-closed condition so it can't regress.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The new release provenance gate can accept commits where CI “Build & Test” jobs succeeded even though the actual build/test steps were skipped, weakening the safety guarantee behind skipping tests during staging.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment on lines +84 to +95
sha=$(git rev-parse HEAD)
checks=$(gh api "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?per_page=100")
for name in "Build & Test (Spark 3.5)" "Build & Test (Spark 4.1)"; do
conclusion=$(
printf '%s' "$checks" |
jq -r --arg name "$name" '
[.check_runs[] | select(.name == $name)]
| if length == 0 then "no run"
elif any(.[]; .status != "completed") then "incomplete"
else (sort_by(.started_at // "") | last | .conclusion // "incomplete")
end
'
CI classifies each change and guards its build steps with
`if: needs.changes.outputs.source == 'true'`, so a documentation-only
commit reports Build & Test as successful within seconds having
compiled nothing. Commit 1474a03 is such a case: both jobs are green,
in 23 and 7 seconds, and neither built anything.

Checking the job conclusion alone therefore let the gate accept a
commit whose artifacts were never built or tested, which is precisely
what the gate exists to prevent, and Maven Central coordinates cannot
be replaced once published.

Read the conclusion of the build step inside each job and require it to
have run. Verified by extracting the gate from the workflow and running
it unmodified: fdecbb2, the 0.1.10-beta release commit, passes, while
the documentation-only 1474a03 is refused.

The step names are matched literally, so release-pipeline-tests.sh now
checks that each one still exists in ci.yml and fails on a rename.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 3, 2026 21:16
@cjfravel-dev

Copy link
Copy Markdown
Owner Author

Confirmed and fixed in 26175cf — this was a real hole, and a serious one given Central coordinates can't be replaced.

1474a03 (the docs-only PR #120 merge) demonstrates it exactly:

Build & Test (Spark 3.5) conclusion=success  started=18:41:18Z completed=18:41:41Z   (23s)
Build & Test (Spark 4.1) conclusion=success  started=18:41:16Z completed=18:41:23Z   (7s)

Both green, neither compiled anything. Tagging that commit would have passed the gate and published artifacts no test ever touched — the exact scenario the gate was added to prevent.

I went with your option (b). Option (a) would make the check conclusion skipped, but Build & Test (Spark 3.5) is a required status check, so job-level skipping would block every docs-only PR from merging — the step-level if is deliberate.

A check run's id is the Actions job id, so the gate now fetches actions/jobs/{id} and requires the build step's own conclusion to be success. Real data separates cleanly:

commit job Build and test with coverage
1474a03 (docs-only) success skipped
fdecbb2 (release) success success

Verified by extracting the gate step out of the workflow YAML and running it unmodified, so there's no risk I tested a paraphrase: fdecbb2 passes, 1474a03 is refused with its "Build and test with coverage" step was skipped. A missing step name resolves to missing, so renames fail closed too.

Since the gate now names CI steps literally, release-pipeline-tests.sh extracts those names from publish.yml and asserts each still exists in ci.yml; I confirmed it fails when I simulate a rename. Docs and PR description updated.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The new step calls the Actions Jobs API but the workflow permissions don’t currently grant actions: read, which can cause the provenance gate to fail even for valid releases.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread .github/workflows/publish.yml
The gate reads the job behind each check run to confirm the build step
ran. That endpoint sits under /actions/, and declaring permissions
explicitly sets every unlisted scope to none, so the workflow needs
actions: read alongside checks: read.

Without it the gate would fail on releases that should have been
allowed. Local verification did not catch this because it ran with a
personal token rather than the workflow token.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@cjfravel-dev

Copy link
Copy Markdown
Owner Author

Good catch — fixed in 8743242 by adding actions: read.

You're right that the explicit permissions: block is what makes this bite: declaring any permissions sets every unlisted scope to none, so contents: read + checks: read left the /actions/jobs/{id} call unauthorised. The gate fails closed, so the symptom would have been every release stopping at the gate rather than anything unsafe getting published — but it would have broken the next release.

Worth noting why my verification missed it: I tested the gate by extracting it from the workflow and running it locally, which used my personal token and therefore had scopes the workflow token won't have. Testing the logic locally can't validate the permission surface.

release-pipeline-tests.sh now asserts both scopes, with a comment explaining that the two API calls are what require them, so neither gets dropped later.

Incidentally, the workflow-jobs API reference also documents started_at as a required field on the job object and steps[].conclusion as including skipped, which lines up with the behaviour the previous two fixes rely on.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The provenance gate appears to use a Checks API check-run ID as if it were an Actions job ID when calling the Actions Jobs API, which can break the gate and block valid releases.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread .github/workflows/publish.yml Outdated
Comment on lines +114 to +118
[.check_runs[] | select(.name == $name)] | sort_by(.started_at // "") | last | .id
'
)
step_conclusion=$(
gh api "repos/$GITHUB_REPOSITORY/actions/jobs/$job" |
The gate looks up the job behind a check run using the check run's own
id. That holds for check runs produced by Actions, and GitHub asserts
the link in both directions, but the gate should not depend on it
silently.

Check the job's name matches the check run's before reading its steps,
so a mismatch stops the release instead of judging some other job.
Verified by injecting a valid id for the other profile's job, which the
gate now rejects.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 3, 2026 22:10
@cjfravel-dev

Copy link
Copy Markdown
Owner Author

I checked this one rather than taking it on trust, and the two ids are in fact the same for check runs produced by Actions — GitHub links them in both directions:

commit check_run.id details_url job.id job.check_run_url
fdecbb2 100748496142 .../actions/runs/33785277294/job/100748496142 100748496142 .../check-runs/100748496142
8743242 100827004204 .../actions/runs/33809243415/job/100827004204 100827004204 .../check-runs/100827004204

The check run's details_url ends in that same id, and the job's check_run_url points back at the check run it came from, so the round trip closes. The extracted gate has also been run unmodified against three real commits and resolved the correct job every time.

A 404 would also have been the safe direction here — the step runs under set -euo pipefail, so a failed lookup stops the release rather than letting it through.

That said, your second concern is the one worth acting on, so I've done that in 3265636: reading the wrong job's steps would be a silent wrong answer rather than a loud failure. The gate now confirms the job's name matches the check run's name before it reads any steps. Verified by injecting the Spark 4.1 job id where Spark 3.5 was expected:

Expected job 100748496142 to be "Build & Test (Spark 3.5)" but found "Build & Test (Spark 4.1)".
The gate could not identify the job that produced the check run, so the release cannot be verified.

So the gate no longer depends on the id relationship holding — if it ever stops holding, the release stops rather than being judged against the wrong job. I'd rather have that than the extra request needed to list a run's jobs and match by name, which would depend on the same name matching anyway. release-pipeline-tests.sh asserts the check.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The new provenance gate treats a check-run id as a GitHub Actions job id when calling actions/jobs/{id}, which can break job-step verification and incorrectly fail (or mis-evaluate) releases.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

.github/workflows/publish.yml:115

  • The gate assumes the check-run .id from commits/{sha}/check-runs is a GitHub Actions job id and calls actions/jobs/{id} with it. In the REST API these are different identifiers, so this can 404 or fetch the wrong job, breaking releases or mis-evaluating step conclusions. Derive the job id from the check run’s details_url (or via check_suite → workflow run → jobs) before calling actions/jobs/{job_id}.
            job=$(
              printf '%s' "$checks" |
                jq -r --arg name "$name" '
                  [.check_runs[] | select(.name == $name)] | sort_by(.started_at // "") | last | .id
                '
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment on lines +91 to +94
assert_contains .github/workflows/publish.yml "actions/jobs/"
# The job behind a check run is looked up by the check run's own id. Confirm the job's name before trusting its
# steps, so a mismatch fails the release instead of judging some other job.
assert_contains .github/workflows/publish.yml 'if [[ "$job_name" != "$name" ]]'
The check run's details_url names the job that produced it, so parse
the id from there rather than relying on the check run's own id
doubling as a job id. The two do match for check runs produced by
Actions, but reading it from the URL says so directly instead of
leaving a reader to wonder.

Fall back to the check run id if the URL is not in the expected shape.
Either route is validated by the job name check, so a wrong id stops
the release rather than being judged against another job.

Verified against real commits and against a details_url that is null,
malformed and well formed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 3, 2026 22:39
@cjfravel-dev

Copy link
Copy Markdown
Owner Author

Done in 54d42d3 — the gate now takes the job id from details_url as you suggested.

To be clear about what was actually broken: the two ids do match for check runs produced by Actions, and I verified that in both directions before changing anything — details_url ends in the same id, and the job's check_run_url points back at the originating check run. The extracted gate resolved the correct job on every real commit I ran it against. So this wasn't producing a wrong result.

It's still the better way to write it. Reading the id out of details_url states the relationship instead of leaving a reader to rediscover it, which is roughly what this thread has been.

(try (.details_url | capture("/job/(?<id>[0-9]+)") | .id) catch null) // (.id | tostring)

Exercised against a details_url that is null, malformed, and well formed — the first two fall back to the check run id, the third parses 987654 out of the URL. Both routes still pass through the job-name check from 3265636, so a wrong id stops the release rather than being evaluated against a different job.

Re-ran the gate end to end from the workflow YAML: fdecbb2 and 3265636 pass, docs-only 1474a03 is refused.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The new CI-provenance gate is central to release safety and needs a couple of reliability/error-reporting fixes (pagination and clearer failure handling) before it can be trusted for releases.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

.github/workflows/publish.yml:86

  • The check-runs fetch is limited to the first 100 results. If a commit ever accumulates >100 check runs (e.g., additional tooling over time), the required "Build & Test" check runs might not be present in this first page and the release would be rejected with "no run" even though CI succeeded. Fetch all pages (and optionally set filter=all explicitly) before running jq.
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Lite

# A check run produced by Actions carries the id of the job behind it, and the job points back at the same
# check run. Confirm the job's name matches before reading its steps, so the gate fails rather than
# judging the wrong job if that ever stops holding.
job_json=$(gh api "repos/$GITHUB_REPOSITORY/actions/jobs/$job")
The gate read only the first page of check runs. A commit carrying
more than fit on one page could have a required Build & Test run on a
later page, where the gate would read it as missing and stop a release
that should have gone ahead.

Request every page and merge them before matching. Verified by forcing
four pages with per_page=2: the verdicts are unchanged, with fdecbb2
passing and the documentation-only 1474a03 refused either way.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 3, 2026 23:07
@cjfravel-dev

Copy link
Copy Markdown
Owner Author

Fixed in 12efb9f — the gate now requests every page and merges them before matching.

You're right about the direction of the failure: a required run on a later page would read as no run and stop a release that should have gone ahead. Nothing unsafe, but a confusing way to lose a release.

This repo is nowhere near the limit today (total_count is 7 on the commits I checked), so I verified the fix by forcing the pages instead of waiting for the condition — running the same gate with per_page=2, which splits those 7 runs across four pages. Verdicts are unchanged: fdecbb2 passes and the documentation-only 1474a03 is refused, both on one page and across four.

release-pipeline-tests.sh asserts the paginated fetch so it can't quietly regress to a single page.

I left filter at its default. It's worth being explicit that the default isn't a deduplicating filter — filter=latest returns the same 7 runs as filter=all on 7f0176ce, including both of its Analyze (actions) runs — which is why the gate reasons over every attempt for a name rather than assuming one per name.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The new jq aggregation for check runs can yield check_runs: null on empty paginated results, causing the gate to error out unexpectedly instead of cleanly reporting “no run.”

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread .github/workflows/publish.yml Outdated
# read as missing and stop a release that should have gone ahead.
checks=$(
gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?per_page=100" |
jq '{check_runs: (map(.check_runs) | add)}'
Merging pages with add returns null if there are no pages at all, and
iterating that would abort the step with a jq error rather than
reporting that the commit has no run. Default to an empty list.

The API returns a page even when a commit has no check runs, so this
is a guard rather than a fix for observed behaviour. Confirmed the
guarded expression is unchanged for a single empty page and for
several populated pages, and that the gate reports "no run" cleanly
for a commit with no check runs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 3, 2026 23:31
@cjfravel-dev

Copy link
Copy Markdown
Owner Author

Added the guard in 9e16ccc.

Worth separating the two halves of this, though. The jq semantics are exactly as you say — [] | add is null, and iterating that aborts with Cannot iterate over null rather than reporting no run:

$ echo '[]' | jq -c '{check_runs:(map(.check_runs)|add)}'
{"check_runs":null}

But it isn't reachable through this endpoint. add only returns null when there are no pages, and the API returns a page even when the commit has no check runs at all — so the merge yields [], not null:

$ gh api --paginate --slurp ".../commits/cfea07e/check-runs?per_page=100" | jq -c '{pages:length, ...}'
{"pages":1,"merged":{"check_runs":[]}}

cfea07e genuinely has no check runs, and the gate already reported no run for it correctly.

I've added // [] anyway — it costs one operator and turns a hypothetical unhandled jq error into the same clean message the rest of the gate produces. Verified the guarded expression is unchanged for a single empty page and for multiple populated pages, that cfea07e still yields Build & Test (Spark 3.5) did not succeed for cfea07e (got: no run)., and that fdecbb2 and 12efb9f still pass while docs-only 1474a03 is still refused.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

It modifies the release/publishing pipeline with non-trivial GitHub API gating logic, so it warrants final human verification despite only minor review notes.

Review details

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

.github/workflows/publish.yml:129

  • The comment claims the Actions check run “carries the id of the job behind it”, but this step explicitly avoids assuming the check run id is a job id (it parses the job id from details_url and only falls back to the check run id). This inconsistency is misleading for future maintenance.

dev/scripts/release-pipeline-tests.sh:96

  • This comment says the workflow looks up the Actions job “by the check run's own id”, but publish.yml now prefers parsing the job id from the check run’s details_url (only falling back to the check run id). Keeping the comment aligned with the implementation will prevent confusion if the gate needs changes later.
assert_contains .github/workflows/publish.yml "actions/jobs/"
# The job behind a check run is looked up by the check run's own id. Confirm the job's name before trusting its
# steps, so a mismatch fails the release instead of judging some other job.
assert_contains .github/workflows/publish.yml 'if [[ "$job_name" != "$name" ]]'
  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Both comments still said the job is looked up by the check run's own
id, which stopped being true when the id moved to details_url. Describe
the lookup that is there, so the fallback and the name check read as
deliberate rather than redundant.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 3, 2026 23:59
@cjfravel-dev

Copy link
Copy Markdown
Owner Author

Both correct — fixed in 2d18388.

They were accurate when written and went stale when the lookup moved to details_url in 54d42d3; I updated the code and left the comments describing the old behaviour. That's the worst kind of comment, since it argues for an assumption the code deliberately stopped making.

publish.yml now reads:

The id above comes from details_url, or from the check run itself if that was not usable. Confirm the job's name matches either way, so a wrong id stops the release rather than being judged against another job.

and release-pipeline-tests.sh says the job is located from details_url, falling back to the check run's own id. Both now explain the fallback and the name check as the two halves of one idea, rather than the name check looking redundant.

I also grepped the three changed files for the other phrasings of that claim to be sure none survived elsewhere.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟢 Approval recommended

The workflow changes maintain release safety via an explicit CI provenance gate and governance checks while achieving the intended reduction in staging time.

Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@cjfravel-dev
cjfravel-dev merged commit d9bc990 into main Sep 4, 2026
6 checks passed
@cjfravel-dev
cjfravel-dev deleted the ci/publish-skip-tests branch September 4, 2026 00:25
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.

2 participants