diff --git a/test/fixtures/actions_lock/closure/closed-case-variant.lock b/test/fixtures/actions_lock/closure/closed-case-variant.lock new file mode 100644 index 00000000..6eee99b4 --- /dev/null +++ b/test/fixtures/actions_lock/closure/closed-case-variant.lock @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# Closure corpus fixture: closed: owner/repo spelled in another case than its record (GitHub canonicalises owner/repo). Contract: expected.tsv in this directory. +version: 'v0.0.3' +workflows: + '.github/workflows/rust.yml': + - 'Example/Rust-Cache@5555555555555555555555555555555555555555' +dependencies: + 'example/rust-cache@5555555555555555555555555555555555555555': + ref: 'v2.9.2' + commit: 'sha1-5555555555555555555555555555555555555555' + owner_id: 101 + repo_id: 205 diff --git a/test/fixtures/actions_lock/closure/closed-v0.0.2.lock b/test/fixtures/actions_lock/closure/closed-v0.0.2.lock new file mode 100644 index 00000000..5a2d04c0 --- /dev/null +++ b/test/fixtures/actions_lock/closure/closed-v0.0.2.lock @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# Closure corpus fixture: closed, format v0.0.2. Contract: expected.tsv in this directory. +version: 'v0.0.2' +workflows: + '.github/workflows/ci.yml': + - 'example/build@1111111111111111111111111111111111111111' + '.github/workflows/empty.yml': [] +dependencies: + 'example/build@1111111111111111111111111111111111111111': + ref: 'v1.0.0' + commit: 'sha1-1111111111111111111111111111111111111111' + owner_id: 101 + repo_id: 201 + uses: + - 'example/setup@2222222222222222222222222222222222222222' + 'example/setup@2222222222222222222222222222222222222222': + ref: 'v2.0.0' + commit: 'sha1-2222222222222222222222222222222222222222' + owner_id: 101 + repo_id: 202 diff --git a/test/fixtures/actions_lock/closure/closed-v0.0.3.lock b/test/fixtures/actions_lock/closure/closed-v0.0.3.lock new file mode 100644 index 00000000..381077f1 --- /dev/null +++ b/test/fixtures/actions_lock/closure/closed-v0.0.3.lock @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# Closure corpus fixture: closed, format v0.0.3 with the optional hostname field. Contract: expected.tsv in this directory. +version: 'v0.0.3' +workflows: + '.github/workflows/ci.yml': + - 'example/build@1111111111111111111111111111111111111111' +dependencies: + 'example/build@1111111111111111111111111111111111111111': + ref: 'v1.0.0' + commit: 'sha1-1111111111111111111111111111111111111111' + owner_id: 101 + repo_id: 201 + hostname: 'github.com' + uses: + - 'example/setup@2222222222222222222222222222222222222222' + 'example/setup@2222222222222222222222222222222222222222': + ref: 'v2.0.0' + commit: 'sha1-2222222222222222222222222222222222222222' + owner_id: 101 + repo_id: 202 diff --git a/test/fixtures/actions_lock/closure/dangling-nested.lock b/test/fixtures/actions_lock/closure/dangling-nested.lock new file mode 100644 index 00000000..1b4fb80d --- /dev/null +++ b/test/fixtures/actions_lock/closure/dangling-nested.lock @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# Closure corpus fixture: dangling: a record's nested uses: names a ref with no record (the standards#963 shape). Contract: expected.tsv in this directory. +version: 'v0.0.2' +workflows: + '.github/workflows/push.yml': + - 'example/push-signed@3333333333333333333333333333333333333333' +dependencies: + 'example/push-signed@3333333333333333333333333333333333333333': + ref: 'v1.3' + commit: 'sha1-3333333333333333333333333333333333333333' + owner_id: 101 + repo_id: 203 + uses: + - 'example/setup-python@v2' diff --git a/test/fixtures/actions_lock/closure/dangling-workflow-ref.lock b/test/fixtures/actions_lock/closure/dangling-workflow-ref.lock new file mode 100644 index 00000000..3bae2099 --- /dev/null +++ b/test/fixtures/actions_lock/closure/dangling-workflow-ref.lock @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# Closure corpus fixture: dangling: a ref under workflows: with no record. Contract: expected.tsv in this directory. +version: 'v0.0.3' +workflows: + '.github/workflows/release.yml': + - 'example/build@1111111111111111111111111111111111111111' + - 'example/generator@4444444444444444444444444444444444444444' +dependencies: + 'example/build@1111111111111111111111111111111111111111': + ref: 'v1.0.0' + commit: 'sha1-1111111111111111111111111111111111111111' + owner_id: 101 + repo_id: 201 diff --git a/test/fixtures/actions_lock/closure/expected.tsv b/test/fixtures/actions_lock/closure/expected.tsv new file mode 100644 index 00000000..9e07a443 --- /dev/null +++ b/test/fixtures/actions_lock/closure/expected.tsv @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Closure corpus for actions.lock (standards#963, criterion 5). Each lock is +# CLOSED when every ref under workflows: and every ref in a record's nested +# uses: has a top-level dependency record, comparing owner/repo +# case-insensitively and the ref exactly (GitHub's lockfile parser +# canonicalises owner/repo case; see #1294 and #1314). Otherwise it is +# DANGLING, and the third column lists the dangling refs as the lock spells +# them, space-separated. +# +# Two implementations must agree with this table: +# * scripts/close-lock.sh --check (scripts/tests/close-lock-test.sh) +# * Hypatia.Rules.ActionsLock.parse/1 in hyperpolymath/hypatia, which +# vendors a byte-identical copy of this directory. +# A third, GitHub's runner, is the reason the table exists. +# +# file verdict dangling +closed-v0.0.2.lock closed - +closed-v0.0.3.lock closed - +closed-case-variant.lock closed - +dangling-nested.lock dangling example/setup-python@v2 +dangling-workflow-ref.lock dangling example/generator@4444444444444444444444444444444444444444 diff --git a/test/rules/actions_lock_closure_corpus_test.exs b/test/rules/actions_lock_closure_corpus_test.exs new file mode 100644 index 00000000..9f5bf0e6 --- /dev/null +++ b/test/rules/actions_lock_closure_corpus_test.exs @@ -0,0 +1,67 @@ +# SPDX-License-Identifier: MPL-2.0 + +defmodule Hypatia.Rules.ActionsLockClosureCorpusTest do + @moduledoc """ + Contract test for actions.lock closure, shared with hyperpolymath/standards + (standards#963, criterion 5). + + A lock is closed when every ref under `workflows:` and in a record's nested + `uses:` has a top-level record of its own, with owner/repo compared + case-insensitively and the ref exactly. `test/fixtures/actions_lock/closure/` + is a byte-identical copy of standards' + `scripts/tests/fixtures/actions-lock-closure/`. Its `expected.tsv` gives each + lock's verdict and, for a dangling lock, the refs that dangle. + + standards' `close-lock.sh --check` runs against the same table, so the two + implementations cannot drift apart without one of these suites going red. + Change both copies together. + """ + use ExUnit.Case, async: true + + alias Hypatia.Rules.ActionsLock + + @dir "test/fixtures/actions_lock/closure" + @table Path.join(@dir, "expected.tsv") + @external_resource @table + + @rows (for line <- String.split(File.read!(@table), "\n"), + line != "", + not String.starts_with?(line, "#") do + [file, verdict, dangling] = String.split(line, "\t") + refs = if dangling == "-", do: [], else: String.split(dangling, " ") + {file, verdict, refs} + end) + + @dangling_kinds [:workflow_dependencies_missing, :transitive_dependencies_missing] + + test "expected.tsv has exactly one row for each lock in the corpus" do + locks = + @dir + |> Path.join("*.lock") + |> Path.wildcard() + |> Enum.map(&Path.basename/1) + |> Enum.sort() + + assert locks == @rows |> Enum.map(&elem(&1, 0)) |> Enum.sort() + assert length(locks) >= 5 + end + + for {file, verdict, refs} <- @rows do + test "#{file} is #{verdict}" do + result = ActionsLock.parse(File.read!(Path.join(@dir, unquote(file)))) + assert_verdict(result, unquote(verdict), unquote(refs)) + end + end + + defp assert_verdict(result, "closed", []) do + assert {:ok, %ActionsLock{}} = result + end + + defp assert_verdict(result, "dangling", refs) do + assert {:error, {kind, entries}} = result + assert kind in @dangling_kinds + + got = entries |> Enum.map(&elem(&1, 1)) |> Enum.map(&ActionsLock.normalise_ref/1) + assert Enum.sort(got) == refs |> Enum.map(&ActionsLock.normalise_ref/1) |> Enum.sort() + end +end