Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 51 additions & 28 deletions actions/update_actions/scanner.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from __future__ import annotations

import sys
from io import StringIO
from pathlib import Path

from ruamel.yaml import YAML
Expand Down Expand Up @@ -84,36 +83,60 @@ def collect_workflow_files(root: Path, file_glob: str) -> list[Path]:

def apply_updates(text: str, upgrades: dict[tuple[str, str], str]) -> str:
"""
Apply updates to a YAML workflow file using ruamel.yaml.
This preserves formatting and comments.
Apply updates to a YAML workflow file by doing targeted text replacements.
This preserves all original formatting and comments, only modifying the 'uses:' lines.
"""
yaml = YAML()
yaml.preserve_quotes = True
yaml.default_flow_style = False
yaml.map_indent = 2
yaml.sequence_indent = 4
yaml.sequence_dash_offset = 2
lines = text.split("\n")

try:
docs = list(yaml.load_all(text))
except Exception:
# If parsing fails, return original text unchanged
return text
for i, line in enumerate(lines):
# Look for lines that contain 'uses:' with a value
# Handle both plain keys and list items with dashes
stripped = line.lstrip()

# Check if any updates are needed
any_updates = False
for doc in docs:
if doc is not None and update_uses_in_structure(doc, upgrades):
any_updates = True
# Check if line has 'uses:' (either "uses:" or "- uses:")
if "uses:" not in stripped:
continue

if not any_updates:
return text
# Find the position of 'uses:' in the line
uses_idx = stripped.find("uses:")
if uses_idx == -1:
continue

# Write back with preserved formatting
output = StringIO()
if len(docs) == 1:
yaml.dump(docs[0], output)
else:
yaml.dump_all(docs, output)
# Check if everything before 'uses:' is valid YAML (dash followed by spaces, or nothing)
prefix = stripped[:uses_idx].strip()
if prefix and prefix != "-":
continue

return output.getvalue()
# Extract the indentation from the original line
indent = line[: len(line) - len(stripped)]

# Get the part after 'uses:'
rest = stripped[uses_idx + 5 :].strip() # Remove 'uses:' and leading whitespace

# Handle comments - extract value and any trailing comment
comment = ""
value_part = rest
if "#" in rest:
parts = rest.split("#", 1)
value_part = parts[0].strip()
comment = "#" + parts[1]

# Check if this value matches any upgrade
for (repo, current_tag), new_tag in upgrades.items():
old_value = f"{repo}@{current_tag}"
new_value = f"{repo}@{new_tag}"
if value_part == old_value:
# Reconstruct the line, preserving list item syntax if present
if stripped.startswith("- "):
if comment:
lines[i] = f"{indent}- uses: {new_value} {comment}"
else:
lines[i] = f"{indent}- uses: {new_value}"
else:
if comment:
lines[i] = f"{indent}uses: {new_value} {comment}"
else:
lines[i] = f"{indent}uses: {new_value}"
break

return "\n".join(lines)
80 changes: 80 additions & 0 deletions actions/update_actions/tests/test_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,86 @@ def test_collect_workflow_files(self):
files = scanner.collect_workflow_files(root, ".github/**/*.yml")
self.assertEqual(files, [target])

def test_apply_updates_preserves_non_uses_variables(self):
"""
Regression test: Ensure that non-'uses' variables and multi-line env vars
are not modified when updating action versions.

This tests the issue where LOCAL_VERSION and LATEST_VERSION environment
variables were being incorrectly split across multiple lines.
"""
text = """name: Automatic Version Synchronization

on:
workflow_dispatch:
schedule:
- cron: "0 0 * * *"

env:
PYTHON_VERSION: "3.14"

jobs:
update-version:
runs-on: ubuntu-latest
env:
LOCAL_VERSION: ${{ needs.get-current-local-version.outputs.local_version }}
LATEST_VERSION: ${{ needs.get-newest-version.outputs.latest_version }}
needs:
- get-newest-version
- get-current-local-version

steps:
- name: Create temporary GitHub App Token
id: app
uses: actions/create-github-app-token@v1
with:
owner: ${{ github.repository_owner }}
app-id: ${{ vars.BOT_APP_ID }}
private-key: ${{ secrets.BOT_PRIVATE_KEY }}

- name: Setup Python
uses: actions/setup-python@v5
with:
cache: "pip"
python-version: "${{ env.PYTHON_VERSION }}"

- name: Print and verify versions
id: print-versions
run: |
echo "Local version: $LOCAL_VERSION"
echo "Latest version: $LATEST_VERSION"
"""

# Upgrade specific actions to new versions
upgrades = {
("actions/create-github-app-token", "v1"): "v2.2.1",
("actions/setup-python", "v5"): "v6.2.0",
}

updated = scanner.apply_updates(text, upgrades)

# Verify that the uses entries were updated
self.assertIn("actions/create-github-app-token@v2.2.1", updated)
self.assertIn("actions/setup-python@v6.2.0", updated)

# Verify that non-uses variables are preserved exactly as-is
self.assertIn('PYTHON_VERSION: "3.14"', updated)
self.assertIn(
"LOCAL_VERSION: ${{ needs.get-current-local-version.outputs.local_version }}",
updated,
)
self.assertIn(
"LATEST_VERSION: ${{ needs.get-newest-version.outputs.latest_version }}",
updated,
)

# Verify that the run command is not split across lines
self.assertIn('run: |\n echo "Local version: $LOCAL_VERSION"', updated)
self.assertIn('echo "Latest version: $LATEST_VERSION"', updated)

# Verify that other comments and structure are preserved
self.assertIn('cron: "0 0 * * *"', updated)


if __name__ == "__main__":
unittest.main()
Loading