name: Check Image EXIF Data
on:
pull_request_target:
types: [opened, synchronize, reopened]
paths: ['**/*.jpg', '**/*.jpeg', '**/*.png', '**/*.webp', '**/*.tiff', '**/*.tif', '**/*.heic', '**/*.heif', '**/*.avif', '**/*.gif']
permissions:
contents: read
pull-requests: write
issues: write
concurrency:
group: exif-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
check-exif:
runs-on: ubuntu-latest
steps:
- name: Install exiftool
run: |
command -v exiftool && exit 0
sudo apt-get install -y --no-install-recommends libimage-exiftool-perl \
|| (sudo apt-get update && sudo apt-get install -y --no-install-recommends libimage-exiftool-perl)
- name: Check EXIF in changed images
uses: actions/github-script@v8
with:
script: |
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');
const pr = context.payload.pull_request;
const { owner, repo } = context.repo;
const prNumber = pr.number;
const IMAGE_EXT = /\.(jpe?g|png|webp|tiff?|hei[cf]|avif|gif)$/i;
// 1. Collect changed image files
// Use compareCommitsWithBasehead (base.sha...head.sha) instead of pulls.listFiles.
// pulls.listFiles computes the diff against the merge base, which can be a very old ancestor
// when a contributor's fork is far behind main (e.g. after clicking "Update branch" which
// creates a merge commit). This causes hundreds of unrelated files to appear as "changed".
// compareCommitsWithBasehead diffs exactly what this PR's head adds on top of main's current
// HEAD, regardless of the fork's branch history.
const changedImages = [];
let comparePage = 1;
while (true) {
const { data: cmp } = await github.rest.repos.compareCommitsWithBasehead({
owner, repo,
basehead: `${pr.base.sha}...${pr.head.sha}`,
per_page: 100, page: comparePage,
});
for (const f of (cmp.files || [])) {
if (['added', 'modified', 'renamed', 'copied'].includes(f.status) && IMAGE_EXT.test(f.filename))
changedImages.push(f.filename);
}
if (!cmp.files || cmp.files.length < 100) break;
comparePage++;
}
if (!changedImages.length) { core.info('No changed images.'); return; }
core.info(`Checking ${changedImages.length} image(s) for EXIF data.`);
// 2. Download & check each image
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'exif-'));
const exifFiles = [];
for (const file of changedImages) {
const tmpFile = path.join(tmp, file.replace(/[\\/]/g, '__'));
try {
// '#' must be percent-encoded in the API path; otherwise GitHub treats it as a URL fragment.
const apiPath = file.replace(/#/g, '%23');
const { data } = await github.rest.repos.getContent({
owner: pr.head.repo.owner.login, repo: pr.head.repo.name,
path: apiPath, ref: pr.head.sha
});
let raw;
if (!Array.isArray(data) && data.content) raw = Buffer.from(data.content, 'base64');
else if (!Array.isArray(data) && data.download_url) {
const resp = await fetch(data.download_url);
if (!resp.ok) throw new Error(`Download ${resp.status}`);
raw = Buffer.from(await resp.arrayBuffer());
} else throw new Error('Cannot fetch file content');
fs.writeFileSync(tmpFile, raw);
// Check only high-sensitivity fields:
// GPS:* — precise location coordinates
// IPTC address fields — textual shooting address
// XMP location/contact fields — address & contact info written by editing software
const HIGH_SENS_ARGS = [
'-GPS:GPSLatitude', '-GPS:GPSLongitude', '-GPS:GPSAltitude',
'-GPS:GPSDateStamp', '-GPS:GPSTimeStamp',
'-GPS:GPSSpeed', '-GPS:GPSTrack', '-GPS:GPSImgDirection',
'-IPTC:City', '-IPTC:Country-PrimaryLocationName',
'-IPTC:Sub-location', '-IPTC:Province-State',
'-XMP-photoshop:City', '-XMP-photoshop:Country', '-XMP-photoshop:State',
'-XMP-iptcCore:CreatorWorkEmail', '-XMP-iptcCore:CreatorWorkTelephone',
'-XMP-iptcCore:CreatorCity', '-XMP-iptcCore:CreatorCountry',
'-XMP-iptcCore:CreatorPostalCode',
];
const safeFile = tmpFile.replace(/'/g, "'\\\\'");
// Filter out empty/undef/all-zero placeholder values (e.g. GPS IFD container with no real coords).
// `: *(undef|00:00:00)?$` matches lines like "GPSLatitude: ", "GPSAltitude: undef", "GPSTimeStamp: 00:00:00"
const count = parseInt(execSync(
`exiftool ${HIGH_SENS_ARGS.join(' ')} -S -q -- '${safeFile}' 2>/dev/null | grep -Ev ': *(undef|00:00:00)?$' | wc -l`,
{ encoding: 'utf-8', timeout: 10000 }
).trim()) || 0;
if (count > 0) { exifFiles.push(file); core.info(`Sensitive EXIF found: ${file} (${count} tags)`); }
} catch (e) {
// Download failures (e.g. diverged fork, encoding issues) are non-fatal.
// Skip the file with a warning rather than failing the entire check.
core.warning(`Skipping ${file}: ${e.message}`);
}
finally { try { fs.unlinkSync(tmpFile); } catch {} }
}
try { fs.rmdirSync(tmp); } catch {}
// 3. Report results
if (exifFiles.length) {
const fileList = exifFiles.map(f => `- ${f}`).join('\n');
const body = [
'我们检测到以下文件包含**高敏感 EXIF 信息**(如 GPS 坐标、地址或联系方式),请移除后再次提交:',
'We detected **high-sensitivity EXIF data** (e.g. GPS coordinates, address, or contact fields) in the following files. Please remove them and resubmit:',
'',
fileList,
'',
'**仅高敏感字段(GPS / 地址 / 联系方式)需要移除,普通摄影参数(光圈、快门等)无需处理。**',
'**Only high-sensitivity fields (GPS / address / contact info) need to be removed. Regular photography parameters (aperture, shutter speed, etc.) are fine to keep.**',
'',
`📖 [EXIF 说明 / EXIF Guide](https://github.com/${owner}/${repo}/blob/master/EXIF.md) · [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/master/CONTRIBUTING.md)`,
'---',
'',
'温馨提示: 您可以直接使用 `/auto-fix` 命令来自动修复这些文件。',
'Warm reminder: You can use the `/auto-fix` command to automatically fix these files.'
].join('\n');
try { await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body }); }
catch (e) { core.warning(`Cannot comment: ${e.message}. Check repo Settings > Actions > Workflow permissions.`); }
core.setFailed('EXIF data detected. Please remove EXIF data for privacy protection.');
} else {
core.info('No EXIF data found. ✅');
}