Repository navigation
feat(workflow_audit): WF028 — reusable call into a moved repository #965
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a3e6b3f
3d2cbf7
10ab3da
e7db2dc
9e889c3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -95,6 +95,7 @@ defmodule Hypatia.Rules.WorkflowAudit do | |
| secret_history = check_secret_history_coverage(workflow_contents) | ||
| toolchain_input_missing = check_sha_pinned_toolchain_without_input(workflow_contents) | ||
| broken_reusable_revisions = check_known_broken_reusable_revision(workflow_contents) | ||
| moved_reusable_calls = check_reusable_call_to_moved_repository(workflow_contents) | ||
|
|
||
| %{ | ||
| # `flawed_regexes` was computed at the top of this function and | ||
|
|
@@ -133,7 +134,8 @@ defmodule Hypatia.Rules.WorkflowAudit do | |
| flawed_regexes ++ | ||
| secret_history ++ | ||
| toolchain_input_missing ++ | ||
| broken_reusable_revisions, | ||
| broken_reusable_revisions ++ | ||
| moved_reusable_calls, | ||
| missing_count: length(missing), | ||
| secret_history_count: length(secret_history), | ||
| unpinned_count: length(unpinned), | ||
|
|
@@ -159,6 +161,7 @@ defmodule Hypatia.Rules.WorkflowAudit do | |
| d_burn_issues_count: length(d_burn_issues), | ||
| toolchain_input_missing_count: length(toolchain_input_missing), | ||
| broken_reusable_revision_count: length(broken_reusable_revisions), | ||
| moved_reusable_call_count: length(moved_reusable_calls), | ||
| workflow_count: length(workflow_files), | ||
| standard_coverage: coverage_percentage(workflow_files) | ||
| } | ||
|
|
@@ -3021,6 +3024,157 @@ defmodule Hypatia.Rules.WorkflowAudit do | |
| "`jobs:`; the repair script rewrites the keys of `jobs:` only, so this one " <> | ||
| "routes to review." | ||
|
|
||
| # ─── WF028: Reusable-workflow call into a moved repository ──────────── | ||
|
|
||
| # Repositories that moved to another owner (transfer or rename). GitHub | ||
| # redirects git and the REST API for the old name, but does not follow the | ||
| # redirect for a reusable workflow's `uses:`: the caller ends in | ||
| # `startup_failure` ("workflow was not found") before any job is created. | ||
| # Measured for standards on 2026-10-26 (hypatia governance runs | ||
| # 40424594888, 40424740139, before and after #958 re-pointed it). | ||
| # | ||
| # One row per move. A transfer into the org (standards D97) is a row, not | ||
| # a rule: append it when the move lands, never before. | ||
| @moved_repositories Enum.map(Hypatia.Rules.StandardsRepo.former(), fn former -> | ||
| %{ | ||
| former: former, | ||
| current: Hypatia.Rules.StandardsRepo.current(), | ||
| moved: "2026-10-25" | ||
| } | ||
| end) | ||
|
|
||
| @doc "The (former, current) table WF028 fires on." | ||
| def moved_repositories, do: @moved_repositories | ||
|
|
||
| @doc """ | ||
| WF028: a job calls a reusable workflow by a repository name that has | ||
| moved to another owner. | ||
|
|
||
| Every such call is a certain `startup_failure`: the redirect GitHub keeps | ||
| for the old name is not followed when a reusable is resolved. Re-pointing | ||
| the owner keeps the pinned revision, since a move keeps the history. | ||
|
|
||
| The call is found where WF027 finds it — a `uses:` key of a job under | ||
| `jobs:`, in block or flow style, continued below its key, or made through | ||
| a job alias — so a path inside a `run:` script, an `env:` value or a | ||
| comment never fires. A step's `uses:` (an action, not a reusable) is out | ||
| of scope: the redirect is unmeasured there. | ||
|
|
||
| No fleet script rewrites the owner yet, so the finding carries | ||
| `auto_fixable: false`. It is `advisory: true` with severity low, as WF026 | ||
| and WF027: reported and routed, never a blocking gate (AGENTIC | ||
| `must-not-promote-to-blocking`). | ||
| """ | ||
| def check_reusable_call_to_moved_repository(workflow_contents) do | ||
| Enum.flat_map(workflow_contents, fn {filename, content} -> | ||
| lines = String.split(content, "\n") | ||
| continued = wf_continued_uses(lines) | ||
| flow = wf_flow_lines(content) | ||
|
|
||
| # Only an alias given as a job's value (`rust: *job`, `jobs: { a: *j }`) | ||
| # makes a reusable call; `- *step` and `steps: [ *step ]` are steps. | ||
| # PinSites classifies each alias where it stands, not by its line. | ||
| aliased = | ||
| for %{key_line: n, kind: :job} <- Hypatia.Rules.PinSites.node_alias_sites(content), | ||
| into: MapSet.new(), | ||
| do: n | ||
|
|
||
| Enum.flat_map(@moved_repositories, fn move -> | ||
| # The value of a `uses:` key, as YAML reads the scalar: a quoted one | ||
| # runs to its closing quote, whatever it holds; an unquoted one ends | ||
| # at whitespace in block style (a branch or tag may hold `,` `}` `]` | ||
| # `#`) and, inside a flow collection, at the delimiters too. | ||
| key = "[\"']?uses[\"']?\\s*:\\s*(?:[&!]\\S*\\s+)*" | ||
| quoted = "\"([^\"]*)\"|'([^']*)'" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a valid double-quoted Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Leaving this as is. The path traced is evasion: someone would have to write Generated by Claude Code |
||
| block_value = "(?:" <> quoted <> "|([^\\s\"']+))" | ||
| flow_value = "(?:" <> quoted <> "|([^\\s\"'#,}\\]]+))" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In an unquoted flow scalar, Useful? React with 👍 / 👎. |
||
|
|
||
| block_re = Regex.compile!("^\\s*(?:-\\s+)?" <> key <> block_value) | ||
| leading_flow_re = Regex.compile!("^\\s*(?:-\\s+)?" <> key <> flow_value) | ||
| inline_re = Regex.compile!("[{,]\\s*" <> key <> flow_value) | ||
|
|
||
| # The path ends at the workflow's extension: a file name may hold | ||
| # `@` (`build@nightly.yml@release`), and so may a branch. | ||
| call_re = | ||
| Regex.compile!( | ||
| "^(?i:#{Regex.escape(move.former)})/(\\.github/workflows/.+?\\.ya?ml)@(.+)$" | ||
| ) | ||
|
|
||
| lines | ||
| |> Enum.with_index(1) | ||
| |> Enum.flat_map(fn {line, number} -> | ||
| line = | ||
| case Map.fetch(continued, number) do | ||
| {:ok, {joined, _how}} -> joined | ||
| :error -> line | ||
|
Comment on lines
+3107
to
+3109
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Inside a flow mapping YAML does not use indentation to determine the value, so Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Leaving this as is. The layout is valid YAML, but I traced who writes it: nobody does. Estate workflows are block-style, with a few KYAML (flow) jobs that keep the value on its key's line or indent it. The resolver is Generated by Claude Code |
||
| end | ||
|
|
||
| live? = number in aliased | ||
| leading_re = if wf_in_flow?(flow, number), do: leading_flow_re, else: block_re | ||
|
|
||
| # The `uses:` the line opens with, and every one a later flow entry | ||
| # on the same line opens (`uses: a@x }, b: { uses: a@y`). | ||
| leading = | ||
| case Regex.run(leading_re, line, return: :index) do | ||
| [_full | groups] -> | ||
| if wf027_job_key(lines, flow, number) != nil or live?, | ||
| do: [wf028_scalar(line, groups)], | ||
| else: [] | ||
|
hyperpolymath marked this conversation as resolved.
|
||
|
|
||
| nil -> | ||
| [] | ||
| end | ||
|
|
||
| inline = | ||
| inline_re | ||
| |> Regex.scan(line, return: :index) | ||
| |> Enum.flat_map(fn [{at, _} | groups] -> | ||
| if wf027_inline_job?(lines, flow, line, number, at + 1) or live?, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When multiple anchored mappings are defined on one physical line, Useful? React with 👍 / 👎. |
||
| do: [wf028_scalar(line, groups)], | ||
| else: [] | ||
| end) | ||
|
|
||
| for value <- leading ++ inline, | ||
| [_, path, ref] <- [Regex.run(call_re, value)] do | ||
| wf028_finding(filename, number, path, ref, move) | ||
| end | ||
| end) | ||
| end) | ||
| end) | ||
| end | ||
|
|
||
| # The scalar a value match captured: whichever of the double-quoted, | ||
| # single-quoted and plain groups took part. | ||
| defp wf028_scalar(line, groups) do | ||
| {at, len} = Enum.find(groups, {0, 0}, fn {at, _} -> at >= 0 end) | ||
| binary_part(line, at, len) | ||
| end | ||
|
|
||
| defp wf028_finding(filename, number, path, ref, move) do | ||
| %{ | ||
| rule: "WF028", | ||
| type: :reusable_call_to_moved_repository, | ||
| file: filename, | ||
| line: number, | ||
| reusable: move.former <> "/" <> path, | ||
| current_sha: ref, | ||
| replacement: move.current <> "/" <> path <> "@" <> ref, | ||
| # Advisory, as WF026/WF027: reported and routed, never a blocking finding. | ||
| severity: :low, | ||
| advisory: true, | ||
| auto_fixable: false, | ||
| fix_script: nil, | ||
| reason: | ||
| "`#{move.former}` moved to `#{move.current}` on #{move.moved}. GitHub does not " <> | ||
| "follow the redirect for a reusable workflow's `uses:`, so this call ends in " <> | ||
| "`startup_failure` (\"workflow was not found\") before any job runs. Re-point " <> | ||
| "it to `#{move.current}/#{path}@#{ref}` (same revision), regenerate " <> | ||
| "`actions.lock` with its tool, and make sure the repository's Actions " <> | ||
| "allow-list admits the new owner.", | ||
| action: :repoint_moved_reusable | ||
| } | ||
| end | ||
|
|
||
| # ─── WF017: Secret-consuming action without secret-presence gate ────── | ||
|
|
||
| # Actions whose primary input is a secret and that fail outright when | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a job's alias value is placed on the following line, as in
jobs:\n g:\n *job, valid YAML resolvesgto the anchored mapping, butnode_alias_sites/1returns no job entry because it recognizes only the same-lineg: *jobform. If that external mapping calls the moved repository,aliasedremains empty and WF028 omits the failing reusable call; include aliases used as continued job values when collecting live anchor lines.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Leaving this as is.
g:with*jobon the line below is valid YAML, but the classifier isPinSites.node_aliases/4, which WF027, PinIntegrity and the merge policy also rely on. Teaching it a new layout changes all of them, which is out of proportion for a rare form in an advisory rule. If it's worth doing, it's a PinSites change with its own tests across those callers, not something to ride along on WF028.Generated by Claude Code