|
| 1 | +name: Check Upstream Config Changes |
| 2 | + |
| 3 | +on: |
| 4 | + pull_request: |
| 5 | + workflow_dispatch: |
| 6 | + |
| 7 | +permissions: |
| 8 | + contents: read |
| 9 | + pull-requests: write |
| 10 | + |
| 11 | +jobs: |
| 12 | + compare-configs: |
| 13 | + runs-on: ubuntu-latest |
| 14 | + steps: |
| 15 | + - name: Checkout Code |
| 16 | + uses: actions/checkout@v4 |
| 17 | + |
| 18 | + - name: Set up Python |
| 19 | + uses: actions/setup-python@v4 |
| 20 | + with: |
| 21 | + python-version: '3.x' |
| 22 | + |
| 23 | + - name: Install Packaging Lib |
| 24 | + run: pip install packaging |
| 25 | + |
| 26 | + - name: Determine Latest OJS Version |
| 27 | + id: get_version |
| 28 | + run: | |
| 29 | + LATEST_TAG=$(curl -sL "https://api.github.com/repos/pkp/ojs/tags?per_page=100" \ |
| 30 | + | python3 -c " |
| 31 | + import sys, json, re |
| 32 | + from packaging.version import parse |
| 33 | + try: |
| 34 | + tags = json.load(sys.stdin) |
| 35 | + valid_tags = [] |
| 36 | + tag_map = {} |
| 37 | + for t in tags: |
| 38 | + name = t['name'] |
| 39 | + m = re.search(r'(?:ojs-)?(3[._]\d+[._]\d+[-_]\d+)', name) |
| 40 | + if m: |
| 41 | + version_str = m.group(1).replace('_', '.').replace('-', '.') |
| 42 | + valid_tags.append(version_str) |
| 43 | + tag_map[version_str] = name |
| 44 | + if not valid_tags: |
| 45 | + print('stable-3_4_0') |
| 46 | + sys.exit(0) |
| 47 | + valid_tags.sort(key=parse, reverse=True) |
| 48 | + print(tag_map[valid_tags[0]]) |
| 49 | + except Exception: |
| 50 | + print('stable-3_4_0') |
| 51 | + ") |
| 52 | + echo "Detected latest tag: $LATEST_TAG" |
| 53 | + echo "tag=$LATEST_TAG" >> $GITHUB_OUTPUT |
| 54 | +
|
| 55 | + - name: Fetch Upstream Config |
| 56 | + env: |
| 57 | + TAG: ${{ steps.get_version.outputs.tag }} |
| 58 | + run: | |
| 59 | + echo "Downloading config template for tag: $TAG" |
| 60 | + curl -sSL "https://raw.githubusercontent.com/pkp/ojs/$TAG/config.TEMPLATE.inc.php" -o upstream.config.php |
| 61 | +
|
| 62 | + - name: Run Comparison Script |
| 63 | + id: check_diff |
| 64 | + shell: python |
| 65 | + env: |
| 66 | + TAG: ${{ steps.get_version.outputs.tag }} |
| 67 | + run: | |
| 68 | + import configparser |
| 69 | + import sys |
| 70 | + import os |
| 71 | + import re |
| 72 | +
|
| 73 | + # --- CONFIGURATION --- |
| 74 | + LOCAL_FILE = 'conf/config.inc.php' |
| 75 | + UPSTREAM_FILE = 'upstream.config.php' |
| 76 | + IGNORED_SECTIONS = [] |
| 77 | + # --------------------- |
| 78 | +
|
| 79 | + def load_ini(filepath): |
| 80 | + config = configparser.ConfigParser(strict=False, allow_no_value=True) |
| 81 | + config.optionxform = str |
| 82 | + try: |
| 83 | + config.read(filepath) |
| 84 | + except Exception as e: |
| 85 | + print(f"Error reading {filepath}: {e}") |
| 86 | + sys.exit(1) |
| 87 | + return config |
| 88 | +
|
| 89 | + if not os.path.exists(LOCAL_FILE): |
| 90 | + print(f"::error::Local file {LOCAL_FILE} not found!") |
| 91 | + sys.exit(1) |
| 92 | +
|
| 93 | + local_cfg = load_ini(LOCAL_FILE) |
| 94 | + upstream_cfg = load_ini(UPSTREAM_FILE) |
| 95 | +
|
| 96 | + # Read raw upstream content for context extraction |
| 97 | + with open(UPSTREAM_FILE, 'r') as f: |
| 98 | + upstream_lines = f.readlines() |
| 99 | + upstream_raw = "".join(upstream_lines) |
| 100 | +
|
| 101 | + # --- HELPER: Extract comments and raw line for a key --- |
| 102 | + def get_key_context(target_section, target_key): |
| 103 | + current_section = None |
| 104 | + for i, line in enumerate(upstream_lines): |
| 105 | + stripped = line.strip() |
| 106 | + # Detect section header |
| 107 | + if stripped.startswith('[') and stripped.endswith(']'): |
| 108 | + current_section = stripped[1:-1] |
| 109 | + continue |
| 110 | + |
| 111 | + # If we are in the right section, look for the key |
| 112 | + if current_section == target_section: |
| 113 | + # Regex: Start of line, optional whitespace, key, whitespace, equals |
| 114 | + if re.match(r'^\s*' + re.escape(target_key) + r'\s*=', line): |
| 115 | + # Found the key line! Now look backwards for comments. |
| 116 | + comments = [] |
| 117 | + j = i - 1 |
| 118 | + while j >= 0: |
| 119 | + prev = upstream_lines[j] |
| 120 | + if prev.strip().startswith(';'): |
| 121 | + comments.insert(0, prev.rstrip()) |
| 122 | + elif not prev.strip(): |
| 123 | + # Stop at blank line (end of comment block) |
| 124 | + break |
| 125 | + else: |
| 126 | + # Stop if we hit another key or section |
| 127 | + break |
| 128 | + j -= 1 |
| 129 | + |
| 130 | + # Return (list of comment lines, the raw key line) |
| 131 | + return comments, line.rstrip() |
| 132 | + return [], f"{target_key} =" |
| 133 | +
|
| 134 | + missing_sections = [] |
| 135 | + # Dictionary to hold missing keys grouped by section: { 'section': [text_block, text_block] } |
| 136 | + missing_keys_grouped = {} |
| 137 | + deprecated_keys = [] |
| 138 | +
|
| 139 | + # 1. Check for NEW items |
| 140 | + for section in upstream_cfg.sections(): |
| 141 | + if section in IGNORED_SECTIONS: continue |
| 142 | + |
| 143 | + if not local_cfg.has_section(section): |
| 144 | + missing_sections.append(section) |
| 145 | + # If whole section is missing, we could list all keys, |
| 146 | + # but usually just flagging the section is enough. |
| 147 | + continue |
| 148 | + |
| 149 | + for key in upstream_cfg.options(section): |
| 150 | + if not local_cfg.has_option(section, key): |
| 151 | + # Extract the raw context |
| 152 | + comments, raw_line = get_key_context(section, key) |
| 153 | + |
| 154 | + # Format the block |
| 155 | + block = "" |
| 156 | + for c in comments: |
| 157 | + block += f"{c}\n" |
| 158 | + block += f"{raw_line}" |
| 159 | + |
| 160 | + if section not in missing_keys_grouped: |
| 161 | + missing_keys_grouped[section] = [] |
| 162 | + missing_keys_grouped[section].append(block) |
| 163 | +
|
| 164 | + # 2. Check for DEPRECATED items |
| 165 | + # Helper for raw check |
| 166 | + def key_exists_in_raw(key_name): |
| 167 | + pattern = r"^\s*;?\s*" + re.escape(key_name) + r"\s*=" |
| 168 | + return re.search(pattern, upstream_raw, re.MULTILINE) is not None |
| 169 | +
|
| 170 | + for section in local_cfg.sections(): |
| 171 | + if section in IGNORED_SECTIONS: continue |
| 172 | +
|
| 173 | + if not upstream_cfg.has_section(section): |
| 174 | + # Check if section header exists in raw even if commented out |
| 175 | + if f"[{section}]" not in upstream_raw: |
| 176 | + for key in local_cfg.options(section): |
| 177 | + deprecated_keys.append(f"[{section}] {key} (Section removed)") |
| 178 | + continue |
| 179 | +
|
| 180 | + for key in local_cfg.options(section): |
| 181 | + if not upstream_cfg.has_option(section, key): |
| 182 | + if not key_exists_in_raw(key): |
| 183 | + deprecated_keys.append(f"[{section}] {key}") |
| 184 | +
|
| 185 | + # 3. Generate Report |
| 186 | + if missing_sections or missing_keys_grouped or deprecated_keys: |
| 187 | + tag_name = os.environ.get('TAG', 'unknown') |
| 188 | + report = f"### ⚠️ Upstream Config Changes Detected ({tag_name})\n\n" |
| 189 | + |
| 190 | + if missing_sections: |
| 191 | + report += "**Missing Sections (New in Upstream):**\n" |
| 192 | + for s in missing_sections: |
| 193 | + report += f"- `[{s}]`\n" |
| 194 | + report += "\n" |
| 195 | +
|
| 196 | + if missing_keys_grouped: |
| 197 | + report += "**Missing Keys (New in Upstream):**\n" |
| 198 | + report += "Below are the missing keys with their original comments and default values.\n\n" |
| 199 | + |
| 200 | + for section, blocks in missing_keys_grouped.items(): |
| 201 | + report += f"**Section `[{section}]`**\n" |
| 202 | + report += "```ini\n" |
| 203 | + for block in blocks: |
| 204 | + report += f"{block}\n\n" |
| 205 | + report += "```\n" |
| 206 | +
|
| 207 | + if deprecated_keys: |
| 208 | + report += "**Deprecated Keys (Removed from Upstream):**\n" |
| 209 | + report += "> *These keys exist in your local file but seem to be completely removed from the upstream template.*\n" |
| 210 | + for k in deprecated_keys: |
| 211 | + report += f"- `{k}`\n" |
| 212 | + |
| 213 | + delimiter = "EOF" |
| 214 | + with open(os.environ['GITHUB_OUTPUT'], 'a') as f: |
| 215 | + f.write(f"comment<<{delimiter}\n{report}\n{delimiter}\n") |
| 216 | + f.write("has_changes=true\n") |
| 217 | + else: |
| 218 | + print("Configs match perfectly.") |
| 219 | + with open(os.environ['GITHUB_OUTPUT'], 'a') as f: |
| 220 | + f.write("has_changes=false\n") |
| 221 | +
|
| 222 | + - name: Post Comment on PR |
| 223 | + if: steps.check_diff.outputs.has_changes == 'true' |
| 224 | + uses: actions/github-script@v6 |
| 225 | + env: |
| 226 | + COMMENT_BODY: ${{ steps.check_diff.outputs.comment }} |
| 227 | + with: |
| 228 | + script: | |
| 229 | + const output = process.env.COMMENT_BODY; |
| 230 | + const { data: comments } = await github.rest.issues.listComments({ |
| 231 | + owner: context.repo.owner, |
| 232 | + repo: context.repo.repo, |
| 233 | + issue_number: context.issue.number, |
| 234 | + }); |
| 235 | + const botComment = comments.find(c => c.body.includes('Upstream Config Changes Detected')); |
| 236 | +
|
| 237 | + if (botComment) { |
| 238 | + await github.rest.issues.updateComment({ |
| 239 | + owner: context.repo.owner, repo: context.repo.repo, comment_id: botComment.id, body: output |
| 240 | + }); |
| 241 | + } else { |
| 242 | + await github.rest.issues.createComment({ |
| 243 | + issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: output |
| 244 | + }); |
| 245 | + } |
0 commit comments