From be6fe1d7ecae2275187746d8c7eaff762616df6d Mon Sep 17 00:00:00 2001 From: Raffi1202 Date: Fri, 11 Sep 2026 17:07:15 +0200 Subject: [PATCH 1/4] ci: only consume artifacts from the real firmware workflow --- .github/workflows/ci-size-report.yml | 1 + .github/workflows/pr-test-builds.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/ci-size-report.yml b/.github/workflows/ci-size-report.yml index f095124a0b9..4f172f18afd 100644 --- a/.github/workflows/ci-size-report.yml +++ b/.github/workflows/ci-size-report.yml @@ -137,6 +137,7 @@ jobs: runs-on: ubuntu-latest if: > github.event.workflow_run.name == 'Build firmware' && + github.event.workflow_run.path == '.github/workflows/ci.yml' && github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' concurrency: diff --git a/.github/workflows/pr-test-builds.yml b/.github/workflows/pr-test-builds.yml index 19867077aa5..ab47c0e092a 100644 --- a/.github/workflows/pr-test-builds.yml +++ b/.github/workflows/pr-test-builds.yml @@ -16,6 +16,7 @@ jobs: # Only act on pull_request-triggered runs that succeeded. if: > github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.path == '.github/workflows/ci.yml' && github.event.workflow_run.conclusion == 'success' # Prevent concurrent runs for the same PR branch racing on the # release delete/create cycle. From 4305a1a097a15a3941bf240194530029acf638ad Mon Sep 17 00:00:00 2001 From: Raffi1202 Date: Wed, 23 Sep 2026 00:34:06 +0200 Subject: [PATCH 2/4] Document exercised firmware workflow path filter --- .github/workflows/README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 7e28f7cad01..7dc1a849940 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -113,6 +113,33 @@ be invoked from a job that has not checked out a known branch first. **Uses the same `PR_BUILDS_TOKEN` secret and `workflow_run` trigger pattern as `pr-test-builds.yml`** (secrets available even for fork PRs). +### Distinguishing firmware runs from the non-code stub + +Both `ci.yml` and `non-code-change.yaml` are named `Build firmware`. The artifact +consumers therefore compare `github.event.workflow_run.path` with +`.github/workflows/ci.yml`, in addition to checking the pull-request event and +successful conclusion. A matching workflow name alone is not sufficient. + +The path condition was exercised by the size-report consumer on the fork's +default branch after merge `ac6fbcdece18170a79e415bcfc92a8d79b3598aa`: + +- [Run 34614556238](https://github.com/Raffi1202/inav/actions/runs/34614556238), + job `pr-comment` / `103313115327`: successful artifact downloads and PR comment. +- [Run 34616048856](https://github.com/Raffi1202/inav/actions/runs/34616048856), + job `pr-comment` / `103318129472`: successful artifact downloads and PR comment. + +These jobs could only execute after the path expression evaluated true. Thus the +field was present and matched for actual firmware-triggered `workflow_run` +events; an absent field would have skipped the whole job. The PR-test-builds +consumer uses the same path expression; its publishing job was checked by +inspection only, so these runs do not validate the release-publishing steps. + +The filter fails closed for missing or unexpected paths. Do not replace it with a +hard-coded workflow ID: IDs differ between the upstream repository and forks. +When changing these consumers, test both a real firmware run and the same-name +non-code stub using the consumer on the repository's default branch, because a +PR's consumer revision is not the one GitHub executes for `workflow_run`. + #### `pr-branch-suggestion.yml` - Branch Targeting Suggestion **Triggers:** PRs targeting master branch **Purpose:** Suggests using maintenance-9.x or maintenance-10.x instead From 4e713cae87fb11dac6d015ec7c38d314393031cc Mon Sep 17 00:00:00 2001 From: Raffi1202 Date: Wed, 23 Sep 2026 00:40:28 +0200 Subject: [PATCH 3/4] Identify artifact producer through workflow event metadata --- .../scripts/artifact-workflow-filter.test.js | 54 +++++++++++++++++++ .github/workflows/README.md | 37 +++++-------- .github/workflows/ci-size-report.yml | 2 +- .github/workflows/pr-test-builds.yml | 2 +- 4 files changed, 69 insertions(+), 26 deletions(-) create mode 100644 .github/scripts/artifact-workflow-filter.test.js diff --git a/.github/scripts/artifact-workflow-filter.test.js b/.github/scripts/artifact-workflow-filter.test.js new file mode 100644 index 00000000000..2056c49239e --- /dev/null +++ b/.github/scripts/artifact-workflow-filter.test.js @@ -0,0 +1,54 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +function conditionFor(file, job) { + const source = fs.readFileSync(path.join(__dirname, '../workflows', file), 'utf8'); + const jobBody = source.split(`\n ${job}:\n`)[1]?.split(/\n [\w-]+:\n/)[0]; + const expression = jobBody?.match(/\n if: >\n((?: .*\n)+)/)?.[1].trim(); + assert.ok(expression, `Missing condition for ${file}:${job}`); + // These checked-in conditions use only member access, == and &&, shared + // by GitHub expressions and JavaScript. Read them directly to catch drift. + return new Function('github', `return Boolean(${expression});`); +} + +function event(overrides = {}) { + return { + workflow: { path: '.github/workflows/ci.yml' }, + workflow_run: { name: 'Build firmware', event: 'pull_request', conclusion: 'success' }, + ...overrides, + }; +} + +for (const [file, job] of [['pr-test-builds.yml', 'publish'], ['ci-size-report.yml', 'pr-comment']]) { + const accepts = conditionFor(file, job); + test(`${file}: accepts firmware without workflow_run.path`, () => { + assert.equal(accepts({ event: event() }), true); + }); + test(`${file}: rejects same-name non-code workflow`, () => { + assert.equal(accepts({ event: event({ workflow: { path: '.github/workflows/non-code-change.yaml' } }) }), false); + }); + test(`${file}: ignores a misleading run path`, () => { + const payload = event({ workflow: { path: '.github/workflows/non-code-change.yaml' } }); + payload.workflow_run.path = '.github/workflows/ci.yml'; + assert.equal(accepts({ event: payload }), false); + }); + test(`${file}: rejects unsuccessful and non-PR runs`, () => { + for (const conclusion of ['failure', 'cancelled', 'skipped', 'action_required', null]) { + const payload = event(); + payload.workflow_run.conclusion = conclusion; + assert.equal(accepts({ event: payload }), false); + } + const payload = event(); + payload.workflow_run.event = 'push'; + assert.equal(accepts({ event: payload }), false); + }); + test(`${file}: rejects missing or unknown workflow paths`, () => { + for (const workflow of [{}, { path: '.github/workflows/other.yml' }]) { + assert.equal(accepts({ event: event({ workflow }) }), false); + } + }); +} diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 7dc1a849940..1cdd7aadd82 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -115,30 +115,19 @@ as `pr-test-builds.yml`** (secrets available even for fork PRs). ### Distinguishing firmware runs from the non-code stub -Both `ci.yml` and `non-code-change.yaml` are named `Build firmware`. The artifact -consumers therefore compare `github.event.workflow_run.path` with -`.github/workflows/ci.yml`, in addition to checking the pull-request event and -successful conclusion. A matching workflow name alone is not sufficient. - -The path condition was exercised by the size-report consumer on the fork's -default branch after merge `ac6fbcdece18170a79e415bcfc92a8d79b3598aa`: - -- [Run 34614556238](https://github.com/Raffi1202/inav/actions/runs/34614556238), - job `pr-comment` / `103313115327`: successful artifact downloads and PR comment. -- [Run 34616048856](https://github.com/Raffi1202/inav/actions/runs/34616048856), - job `pr-comment` / `103318129472`: successful artifact downloads and PR comment. - -These jobs could only execute after the path expression evaluated true. Thus the -field was present and matched for actual firmware-triggered `workflow_run` -events; an absent field would have skipped the whole job. The PR-test-builds -consumer uses the same path expression; its publishing job was checked by -inspection only, so these runs do not validate the release-publishing steps. - -The filter fails closed for missing or unexpected paths. Do not replace it with a -hard-coded workflow ID: IDs differ between the upstream repository and forks. -When changing these consumers, test both a real firmware run and the same-name -non-code stub using the consumer on the repository's default branch, because a -PR's consumer revision is not the one GitHub executes for `workflow_run`. +Both `ci.yml` and `non-code-change.yaml` are named `Build firmware`. Artifact +consumers identify the source through `github.event.workflow.path`, in addition +to checking the pull-request event and successful conclusion. This is the +workflow object in the completed event, not an optional field on the run. +GitHub's [completed-event schema](https://github.com/octokit/webhooks/blob/main/payload-schemas/api.github.com/workflow_run/completed.schema.json) +includes `workflow`, whose [schema](https://github.com/octokit/webhooks/blob/main/payload-schemas/api.github.com/common/workflow.schema.json) +requires `path`. Missing or unexpected workflow paths fail closed. + +Run `node --test .github/scripts/artifact-workflow-filter.test.js` to check both +consumer conditions against firmware, same-name non-code, failed, push and +incomplete event payloads. The tests omit `workflow_run.path` deliberately. +They validate the job filters, not artifact download or release publication. +Workflow IDs are not hard-coded because they differ between repositories. #### `pr-branch-suggestion.yml` - Branch Targeting Suggestion **Triggers:** PRs targeting master branch diff --git a/.github/workflows/ci-size-report.yml b/.github/workflows/ci-size-report.yml index 4f172f18afd..2b8160fa008 100644 --- a/.github/workflows/ci-size-report.yml +++ b/.github/workflows/ci-size-report.yml @@ -137,7 +137,7 @@ jobs: runs-on: ubuntu-latest if: > github.event.workflow_run.name == 'Build firmware' && - github.event.workflow_run.path == '.github/workflows/ci.yml' && + github.event.workflow.path == '.github/workflows/ci.yml' && github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' concurrency: diff --git a/.github/workflows/pr-test-builds.yml b/.github/workflows/pr-test-builds.yml index ab47c0e092a..cb81c088b23 100644 --- a/.github/workflows/pr-test-builds.yml +++ b/.github/workflows/pr-test-builds.yml @@ -16,7 +16,7 @@ jobs: # Only act on pull_request-triggered runs that succeeded. if: > github.event.workflow_run.event == 'pull_request' && - github.event.workflow_run.path == '.github/workflows/ci.yml' && + github.event.workflow.path == '.github/workflows/ci.yml' && github.event.workflow_run.conclusion == 'success' # Prevent concurrent runs for the same PR branch racing on the # release delete/create cycle. From 5a9e7970e953069de5ce9c9550c3342945db466c Mon Sep 17 00:00:00 2001 From: Raffi1202 <250872901+Raffi1202@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:17:21 +0200 Subject: [PATCH 4/4] Keep artifact filter comments, docs and test in line with the path guard --- .../scripts/artifact-workflow-filter.test.js | 10 +++++--- .github/workflows/README.md | 23 ++++++------------- .github/workflows/ci-size-report.yml | 15 ++++++------ .github/workflows/pr-test-builds.yml | 3 ++- 4 files changed, 23 insertions(+), 28 deletions(-) diff --git a/.github/scripts/artifact-workflow-filter.test.js b/.github/scripts/artifact-workflow-filter.test.js index 2056c49239e..0214907fa24 100644 --- a/.github/scripts/artifact-workflow-filter.test.js +++ b/.github/scripts/artifact-workflow-filter.test.js @@ -6,12 +6,12 @@ const assert = require('node:assert/strict'); const { test } = require('node:test'); function conditionFor(file, job) { - const source = fs.readFileSync(path.join(__dirname, '../workflows', file), 'utf8'); + const source = fs.readFileSync(path.join(__dirname, '../workflows', file), 'utf8').replace(/\r\n/g, '\n'); const jobBody = source.split(`\n ${job}:\n`)[1]?.split(/\n [\w-]+:\n/)[0]; const expression = jobBody?.match(/\n if: >\n((?: .*\n)+)/)?.[1].trim(); assert.ok(expression, `Missing condition for ${file}:${job}`); - // These checked-in conditions use only member access, == and &&, shared - // by GitHub expressions and JavaScript. Read them directly to catch drift. + // The conditions use only member access, == and &&, so they run as JavaScript. + // GitHub's == ignores case and a missing object yields null; JS models neither. return new Function('github', `return Boolean(${expression});`); } @@ -52,3 +52,7 @@ for (const [file, job] of [['pr-test-builds.yml', 'publish'], ['ci-size-report.y } }); } + +test('firmware workflow path exists', () => { + assert.ok(fs.existsSync(path.join(__dirname, '../..', event().workflow.path))); +}); diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 1cdd7aadd82..8b469ad949c 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -113,22 +113,6 @@ be invoked from a job that has not checked out a known branch first. **Uses the same `PR_BUILDS_TOKEN` secret and `workflow_run` trigger pattern as `pr-test-builds.yml`** (secrets available even for fork PRs). -### Distinguishing firmware runs from the non-code stub - -Both `ci.yml` and `non-code-change.yaml` are named `Build firmware`. Artifact -consumers identify the source through `github.event.workflow.path`, in addition -to checking the pull-request event and successful conclusion. This is the -workflow object in the completed event, not an optional field on the run. -GitHub's [completed-event schema](https://github.com/octokit/webhooks/blob/main/payload-schemas/api.github.com/workflow_run/completed.schema.json) -includes `workflow`, whose [schema](https://github.com/octokit/webhooks/blob/main/payload-schemas/api.github.com/common/workflow.schema.json) -requires `path`. Missing or unexpected workflow paths fail closed. - -Run `node --test .github/scripts/artifact-workflow-filter.test.js` to check both -consumer conditions against firmware, same-name non-code, failed, push and -incomplete event payloads. The tests omit `workflow_run.path` deliberately. -They validate the job filters, not artifact download or release publication. -Workflow IDs are not hard-coded because they differ between repositories. - #### `pr-branch-suggestion.yml` - Branch Targeting Suggestion **Triggers:** PRs targeting master branch **Purpose:** Suggests using maintenance-9.x or maintenance-10.x instead @@ -137,6 +121,13 @@ Workflow IDs are not hard-coded because they differ between repositories. **Triggers:** Pull requests **Purpose:** Detects PRs with only documentation/formatting changes +It shares the name `Build firmware` with `ci.yml` but produces none of its +artifacts. The `workflow_run` consumers `pr-test-builds.yml` and +`ci-size-report.yml` therefore also require +`github.event.workflow.path == '.github/workflows/ci.yml'` (a path, not a +workflow ID, so forks behave the same). Check both conditions with +`node --test .github/scripts/artifact-workflow-filter.test.js`. + ## Configuration Files - `../.github/stale.yml` - Stale issue/PR management diff --git a/.github/workflows/ci-size-report.yml b/.github/workflows/ci-size-report.yml index 2b8160fa008..d5d6e7a3772 100644 --- a/.github/workflows/ci-size-report.yml +++ b/.github/workflows/ci-size-report.yml @@ -34,8 +34,9 @@ name: CI Size Report # of the two fires per PR and either satisfies the same required-status- # check name. `workflow_run` can't tell the two apart by name (see # github-actions-workflows.md), and the stub's only job is `test` — no -# `upload-artifacts`, none of the artifacts this job needs — so a guard -# step checks for that job directly before downloading anything. +# `upload-artifacts`, none of the artifacts this job needs — so pr-comment +# filters on the workflow path, and a guard step still checks that +# `upload-artifacts` succeeded before downloading anything. # # Requires the same repository secret PR_BUILDS_TOKEN as pr-test-builds.yml # (Contents: write access to iNavFlight/pr-test-builds). @@ -155,12 +156,10 @@ jobs: # untrusted code is never checked out in this privileged context. - uses: actions/checkout@v4 - # ci.yml's "Build firmware" name is shared with the non-code-change.yaml - # stub (see comment at the top of this file), whose only job is `test` - # — no upload-artifacts, none of the artifacts downloaded below. Check - # for that job directly rather than trusting the aggregate - # workflow_run.conclusion, which the job-level `if:` above already - # confirmed is 'success' for either workflow. + # The job-level `if:` already excludes the non-code-change.yaml stub by + # path (see comment at the top of this file). Still check + # upload-artifacts directly, so a renamed or skipped job skips the + # comment instead of failing on the downloads below. - name: Check upload-artifacts job succeeded id: check env: diff --git a/.github/workflows/pr-test-builds.yml b/.github/workflows/pr-test-builds.yml index cb81c088b23..8ead14bd46c 100644 --- a/.github/workflows/pr-test-builds.yml +++ b/.github/workflows/pr-test-builds.yml @@ -13,7 +13,8 @@ on: jobs: publish: runs-on: ubuntu-latest - # Only act on pull_request-triggered runs that succeeded. + # Only act on successful pull_request runs of ci.yml. non-code-change.yaml + # shares the name "Build firmware" but uploads no artifacts. if: > github.event.workflow_run.event == 'pull_request' && github.event.workflow.path == '.github/workflows/ci.yml' &&