From 313c0e8ede0972cc83fed1d3957602eac42c8f28 Mon Sep 17 00:00:00 2001 From: treff7es Date: Thu, 13 Aug 2026 17:06:24 +0200 Subject: [PATCH 1/5] fix(ci): grant least-privilege permissions to the Linear release jobs This repo's default workflow permission is "read", which grants only contents/metadata/packages. `gh pr view --json comments` needs pull-requests, so those lookups were failing silently. Declare per-job scopes explicitly. An explicit permissions block is subtractive -- anything not listed becomes none -- so contents: read is re-declared for actions/checkout and for linear-release-action's github_token, which it uses only to download the Linear release CLI. delete-linear-release talks only to api.linear.app and needs no token at all. Also record why INGESTION_LINEAR_KEY must exist in this repo: the workflows that share it live upstream in datahub-project/datahub, where it is configured, and secrets do not cross the fork boundary. When it is missing GitHub substitutes an empty string, so every Linear call 401s silently and releases are created with zero issues attached. --- .github/workflows/linear-release-tracking.yml | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/linear-release-tracking.yml b/.github/workflows/linear-release-tracking.yml index a469ad1f09e9..7b101414fe34 100644 --- a/.github/workflows/linear-release-tracking.yml +++ b/.github/workflows/linear-release-tracking.yml @@ -14,10 +14,16 @@ name: Linear — Ingestion CLI/PyPI Release Tracking # Required secrets: # LINEAR_ACCESS_KEY — pipeline access key (Linear Settings → Releases → CI setup). # Used by linear-release-action. Scoped to release-pipeline ops only. -# INGESTION_LINEAR_KEY — personal API key (already used by pr-to-linear.yml). +# INGESTION_LINEAR_KEY — personal API key (also used by pr-to-linear.yml). # Used for direct GraphQL calls (issueToReleaseCreate, # issueToReleaseDeleteByIssueAndRelease, releaseDelete, issue lookups) # — the pipeline access key cannot authorize these. +# NOTE: pr-to-linear.yml and linear-assignment-notify.yml also live +# upstream in datahub-project/datahub, which is where that secret is +# configured. Secrets do not cross the fork boundary, so this key must +# be added to THIS repo as well; when it is missing, GitHub substitutes +# an empty string and every Linear call silently 401s (releases get +# created but end up with zero issues attached). on: pull_request_target: @@ -114,6 +120,12 @@ jobs: github.event_name == 'pull_request_target' && github.event.pull_request.merged == true runs-on: ubuntu-latest + # This repo's default workflow permission is "read", which grants only + # contents/metadata/packages — `gh pr view --json comments` needs pull-requests. + # Nothing here writes to GitHub; all writes go to Linear over its own API. + permissions: + contents: read + pull-requests: read steps: - name: Find Linear issue from PR comment id: find-issue @@ -164,6 +176,12 @@ jobs: (github.event_name == 'release' && github.event.action == 'published') || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + # contents: read → actions/checkout, and linear-release-action's github_token + # (it uses it only to download the Linear release CLI) + # pull-requests: read → the `gh pr view` lookups in the attach step + permissions: + contents: read + pull-requests: read steps: - uses: actions/checkout@v4 with: @@ -435,6 +453,8 @@ jobs: delete-linear-release: if: github.event_name == 'release' && github.event.action == 'deleted' runs-on: ubuntu-latest + # Talks only to the Linear API — no GitHub token needed at all. + permissions: {} steps: - name: Delete Linear release for ${{ github.event.release.tag_name }} env: From 441d2df0533fc6fa06eb79d8ef76fc7d880dc9f8 Mon Sep 17 00:00:00 2001 From: treff7es Date: Thu, 13 Aug 2026 17:08:06 +0200 Subject: [PATCH 2/5] fix(ci): stop the push sync from overwriting the newest release linear-release-action's push sync is the pattern for CONTINUOUS pipelines, where each push becomes its own release. This pipeline is `scheduled`, so a versionless sync creates nothing -- it targets the most recent release, rewrites its commitSha to master HEAD and attaches commits published after it. That is not hypothetical. On 2026-08-11 a push run rewrote v1.7.0.2's commitSha from f579f9c4 (its tag) to master HEAD and attached 17 PRs that shipped after the release. The next ingestion push would do the same to v1.7.0.3. The behaviour was dormant for three months only because the owner/repo- qualified path filters made every push match zero commits and skip. Fixing those filters reactivated it. Drop the push trigger and the step. Staging work on merge is already the add-to-cli-next job's responsibility, via pull_request_target, which writes to the cli-next release rather than to a versioned one. --- .github/workflows/linear-release-tracking.yml | 23 +++++-------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/.github/workflows/linear-release-tracking.yml b/.github/workflows/linear-release-tracking.yml index 7b101414fe34..cb7b16e69c37 100644 --- a/.github/workflows/linear-release-tracking.yml +++ b/.github/workflows/linear-release-tracking.yml @@ -11,6 +11,12 @@ name: Linear — Ingestion CLI/PyPI Release Tracking # # Manual dispatch supports backfilling past releases and one-off stage overrides. # +# There is deliberately no `push` trigger. linear-release-action's push sync is the pattern for +# CONTINUOUS pipelines, where each push becomes its own release. This pipeline is `scheduled`, so +# a versionless sync does not create anything — it targets the most recent release, rewrites its +# commitSha to master HEAD and attaches commits published after it. Staging work on merge is the +# add-to-cli-next job's responsibility, via pull_request_target. +# # Required secrets: # LINEAR_ACCESS_KEY — pipeline access key (Linear Settings → Releases → CI setup). # Used by linear-release-action. Scoped to release-pipeline ops only. @@ -33,13 +39,6 @@ on: - "metadata-ingestion/**" - "docs/**" - push: - branches: - - master - paths: - - "metadata-ingestion/**" - - "docs/**" - release: types: [published, deleted] @@ -172,7 +171,6 @@ jobs: # ── JOB 2: RELEASE PUBLISHED or MANUAL DISPATCH ───────────────────────────── linear-release: if: > - github.event_name == 'push' || (github.event_name == 'release' && github.event.action == 'published') || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest @@ -187,14 +185,6 @@ jobs: with: fetch-depth: 0 # required — action scans full commit history - # ── 1. MERGE TO MASTER ──────────────────────────────────────────────── - - name: Sync issues on merge to master - if: github.event_name == 'push' - uses: linear/linear-release-action@v0 - with: - access_key: ${{ secrets.LINEAR_ACCESS_KEY }} - include_paths: ${{ env.INCLUDE_PATHS }} - # ── 2. ANY RELEASE PUBLISHED, OR A MANUAL BACKFILL ─────────────────── # Single source of truth for the version every step below operates on: the published @@ -205,7 +195,6 @@ jobs: # The value goes through env rather than being interpolated into the run block — a tag # name is attacker-influenceable text and must not reach the shell as source. - name: Resolve release version - if: github.event_name != 'push' env: VERSION: ${{ github.event.release.tag_name || inputs.version }} run: echo "RELEASE_VERSION=${VERSION}" >> "$GITHUB_ENV" From 6bea477d17301afb21f689d6def095002f21ef12 Mon Sep 17 00:00:00 2001 From: treff7es Date: Thu, 13 Aug 2026 17:36:12 +0200 Subject: [PATCH 3/5] docs(ci): renumber section banners after the push-sync removal --- .github/workflows/linear-release-tracking.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/linear-release-tracking.yml b/.github/workflows/linear-release-tracking.yml index cb7b16e69c37..8bd212c30db4 100644 --- a/.github/workflows/linear-release-tracking.yml +++ b/.github/workflows/linear-release-tracking.yml @@ -185,7 +185,7 @@ jobs: with: fetch-depth: 0 # required — action scans full commit history - # ── 2. ANY RELEASE PUBLISHED, OR A MANUAL BACKFILL ─────────────────── + # ── 1. RELEASE PUBLISHED (RC or stable) ────────────────────────────── # Single source of truth for the version every step below operates on: the published # tag on a release event, the operator-supplied version on a manual dispatch. Steps @@ -234,7 +234,7 @@ jobs: command: complete version: ${{ env.RELEASE_VERSION }} - # ── 3. MANUAL DISPATCH ──────────────────────────────────────────────── + # ── 2. MANUAL DISPATCH (backfill) ───────────────────────────────────── # The action takes the release's commit from HEAD — it has no input for it. On a # `release` event HEAD is already the tagged commit, but a dispatch checks out the ref @@ -266,8 +266,13 @@ jobs: include_paths: ${{ env.INCLUDE_PATHS }} base_ref: ${{ inputs.base_ref }} - # Both RC and stable: attach the issues whose PRs are in this release's commit range, - # and de-stage any of them that were sitting in cli-next + # ── 3. EITHER PATH ──────────────────────────────────────────────────── + + # Attach the issues whose PRs are in this release's commit range, and de-stage any of + # them that were sitting in cli-next. Runs for a published release and for a backfill + # alike — it keys off RELEASE_VERSION, not the event. It must come last: on a release + # event the object already exists from the sync above, but on a dispatch it is created + # by the manual command, so attaching earlier would find no release and skip. - name: Attach release issues and clear them from cli-next if: env.RELEASE_VERSION != '' env: From ee1c695d1ce13f4fa5a23c599eadc4375907d4a9 Mon Sep 17 00:00:00 2001 From: treff7es Date: Mon, 31 Aug 2026 15:20:54 +0200 Subject: [PATCH 4/5] fix(ci): surface Linear API errors instead of reporting "not found" The release lookup piped the response straight into `jq -r '... // empty'`, so an auth or scope failure -- which returns errors[] with data:null -- produced the same "Could not find Linear release" message as a genuinely absent release. A broken credential and a missing release were indistinguishable in the log. Check for errors[] and print them, failing the step. Everything else about the flow is unchanged. --- .github/workflows/linear-release-tracking.yml | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/linear-release-tracking.yml b/.github/workflows/linear-release-tracking.yml index 8bd212c30db4..42b23c45b2e7 100644 --- a/.github/workflows/linear-release-tracking.yml +++ b/.github/workflows/linear-release-tracking.yml @@ -281,11 +281,22 @@ jobs: TAG: ${{ env.RELEASE_VERSION }} run: | # 1. Find the Linear release we just created/synced (match by version + pipeline) - RELEASE_ID=$(curl -s -X POST https://api.linear.app/graphql \ + RESP=$(curl -s -X POST https://api.linear.app/graphql \ -H "Authorization: $LINEAR_API_KEY" \ -H "Content-Type: application/json" \ - -d "{\"query\": \"{ releasePipeline(id: \\\"$LINEAR_PIPELINE_ID\\\") { releases(filter: { version: { eq: \\\"$TAG\\\" } }) { nodes { id } } } }\"}" \ - | jq -r '.data.releasePipeline.releases.nodes[0].id // empty') + -d "{\"query\": \"{ releasePipeline(id: \\\"$LINEAR_PIPELINE_ID\\\") { releases(filter: { version: { eq: \\\"$TAG\\\" } }) { nodes { id } } } }\"}") + + # Surface API errors instead of coercing them to "not found". An auth or scope + # failure returns errors[] with data:null, which the old `// empty` fallback + # rendered identically to a genuinely absent release — so a broken credential and + # a missing release were indistinguishable in the log. + if [ "$(printf '%s' "$RESP" | jq -r 'has("errors")')" = "true" ]; then + echo "::error::Linear API error querying release $TAG:" + printf '%s' "$RESP" | jq -c '.errors[]' | sed 's/^/ /' + exit 1 + fi + + RELEASE_ID=$(printf '%s' "$RESP" | jq -r '.data.releasePipeline.releases.nodes[0].id // empty') if [ -z "$RELEASE_ID" ]; then echo "Could not find Linear release for $TAG — skipping migration" From fabf21b878cf6a24cb5e7eafd266ffab6adeb507 Mon Sep 17 00:00:00 2001 From: treff7es Date: Mon, 31 Aug 2026 15:27:22 +0200 Subject: [PATCH 5/5] fix(ci): correct the release lookup query, which was invalid GraphQL ReleasePipeline.releases takes no `filter` argument. The query returned Unknown argument "filter" on field "ReleasePipeline.releases" GRAPHQL_VALIDATION_FAILED (400) so the lookup has never succeeded -- not once since the workflow was written. Both call sites were affected: the attach step in linear-release, and the lookup in delete-linear-release, meaning release deletion has never worked either. Fetch the pipeline's releases and match the version client-side instead. Verified against the live API: the corrected query returns the expected ids for v1.7.0.8rc1 and v1.7.0.2. This was the third fault stacked on the same step, behind the missing INGESTION_LINEAR_KEY secret and the missing pull-requests scope. All three degraded to the same "Could not find Linear release" line and exit 0, which is why fixing the first two changed nothing visible. --- .github/workflows/linear-release-tracking.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/linear-release-tracking.yml b/.github/workflows/linear-release-tracking.yml index 42b23c45b2e7..1fec9bb41594 100644 --- a/.github/workflows/linear-release-tracking.yml +++ b/.github/workflows/linear-release-tracking.yml @@ -284,7 +284,7 @@ jobs: RESP=$(curl -s -X POST https://api.linear.app/graphql \ -H "Authorization: $LINEAR_API_KEY" \ -H "Content-Type: application/json" \ - -d "{\"query\": \"{ releasePipeline(id: \\\"$LINEAR_PIPELINE_ID\\\") { releases(filter: { version: { eq: \\\"$TAG\\\" } }) { nodes { id } } } }\"}") + -d "{\"query\": \"{ releasePipeline(id: \\\"$LINEAR_PIPELINE_ID\\\") { releases(first: 250) { nodes { id version } } } }\"}") # Surface API errors instead of coercing them to "not found". An auth or scope # failure returns errors[] with data:null, which the old `// empty` fallback @@ -296,7 +296,9 @@ jobs: exit 1 fi - RELEASE_ID=$(printf '%s' "$RESP" | jq -r '.data.releasePipeline.releases.nodes[0].id // empty') + RELEASE_ID=$(printf '%s' "$RESP" \ + | jq -r --arg v "$TAG" '.data.releasePipeline.releases.nodes[] + | select(.version == $v) | .id' | head -1) if [ -z "$RELEASE_ID" ]; then echo "Could not find Linear release for $TAG — skipping migration" @@ -470,8 +472,9 @@ jobs: RELEASE_ID=$(curl -s -X POST https://api.linear.app/graphql \ -H "Authorization: $LINEAR_API_KEY" \ -H "Content-Type: application/json" \ - -d "{\"query\": \"{ releasePipeline(id: \\\"$LINEAR_PIPELINE_ID\\\") { releases(filter: { version: { eq: \\\"$TAG\\\" } }) { nodes { id } } } }\"}" \ - | jq -r '.data.releasePipeline.releases.nodes[0].id // empty') + -d "{\"query\": \"{ releasePipeline(id: \\\"$LINEAR_PIPELINE_ID\\\") { releases(first: 250) { nodes { id version } } } }\"}" \ + | jq -r --arg v "$TAG" '.data.releasePipeline.releases.nodes[] + | select(.version == $v) | .id' | head -1) if [ -z "$RELEASE_ID" ]; then echo "No Linear release found for $TAG — nothing to delete"