Skip to content

Merger pipeline (ACCC register update email) #1992

Merger pipeline (ACCC register update email)

Merger pipeline (ACCC register update email) #1992

Workflow file for this run

name: Merger Pipeline
run-name: >-
${{
github.event_name == 'repository_dispatch' && github.event.client_payload.custom_title || 'Merger pipeline'
}}
on:
repository_dispatch:
types: [new_merger_detected]
workflow_dispatch:
inputs:
all_mergers:
description: 'Process all mergers (not just new/changed ones)'
required: false
default: false
type: boolean
conflict_retry_count:
description: 'Internal: chained-retry counter after a rebase conflict against main. Leave as 0.'
required: false
default: '0'
type: string
push:
branches:
- main
schedule:
- cron: '8 7,11,15,19 * * 1-5'
timezone: 'Australia/Sydney'
- cron: '8 7 * * 0'
timezone: 'Australia/Sydney'
permissions:
contents: write
issues: write
pull-requests: write # needed by the folded-in detect-* PR steps
actions: write # needed to trigger a fresh run after a rebase conflict
# Serialise pipeline runs on main. Two concurrent runs can otherwise race
# on the same files and rely on the post-rebase HTML re-clean / commit-drop
# logic below to recover. Forcing them to queue keeps that recovery code as
# a belt-and-braces guard rather than a hot path.
concurrency:
group: pipeline-main
cancel-in-progress: false
jobs:
pipeline:
runs-on: ubuntu-latest
# Without this, a hung step (e.g. an apt-get fetch that stalls) runs to
# GitHub's default 6-hour job timeout before failing. Because runs are
# serialised via the concurrency group above, that also blocks every
# queued run behind it for the same 6 hours. Normal runs take ~1-3
# minutes; with the apt cache above, even a LibreOffice install run
# should stay well under this, with headroom for a cold-cache run.
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v7
# Go is only ever used to build pup, so the cache is checked first and
# the toolchain is installed only when the binary has to be rebuilt.
- name: Cache pup binary
id: cache-pup
uses: actions/cache@v6
with:
path: ~/go/bin/pup
key: ${{ runner.os }}-pup-v0.4.0
# The floor is set by pup's dependencies, not by pup: it ships no go.mod,
# so `go install` resolves golang.org/x/net, fatih/color et al at their
# latest versions, and those now require Go 1.25. setup-go pins
# GOTOOLCHAIN=local, so too low a floor fails a cold-cache install.
- name: Set up Go
if: steps.cache-pup.outputs.cache-hit != 'true'
uses: actions/setup-go@v6
with:
go-version: '>=1.25'
cache: false
# Pinned rather than @latest, which resolves to this same version today:
# the cache key names v0.4.0, so a future pup release must not be able to
# install itself under a key that claims otherwise.
- name: Install pup HTML parser
if: steps.cache-pup.outputs.cache-hit != 'true'
run: go install github.com/ericchiang/pup@v0.4.0
- name: Add Go bin to PATH
run: echo "${HOME}/go/bin" >> $GITHUB_PATH
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: '3.11'
cache: 'pip'
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install -r scripts/requirements.txt
# --- Scrape ---
- name: Run scraper
env:
SCRAPE_REPORT_DIR: ${{ runner.temp }}/scrape-report
run: |
chmod +x ./scripts/scrape/scrape.sh
if [ "${{ inputs.all_mergers }}" = "true" ]; then
./scripts/scrape/scrape.sh --all
else
./scripts/scrape/scrape.sh
fi
# Record which merger IDs this run actually fetched, which of their
# pages changed, and which matters were skipped past cutoff, so a run
# can be spot checked against an ACCC register notification email.
# Runs even when the scraper failed, to summarise what it got through.
- name: Summarise scraped mergers
if: ${{ !cancelled() }}
env:
SCRAPE_REPORT_DIR: ${{ runner.temp }}/scrape-report
# Passed through env, never interpolated into the script: the
# subject/matter IDs are attacker-influencable content from an
# inbound email (matter_ids is regex-extracted from the body by
# the accc-register-watcher Worker, but treat it the same way).
TRIGGER_EVENT: ${{ github.event_name }}
EMAIL_SUBJECT: ${{ github.event.client_payload.email_subject }}
EMAIL_MATTER_IDS: ${{ github.event.client_payload.matter_ids }}
run: |
{
echo "### Scrape trigger"
echo ""
printf -- '- Event: `%s`\n' "$TRIGGER_EVENT"
if [ -n "$EMAIL_SUBJECT" ]; then
printf -- '- ACCC register email: %s\n' "$EMAIL_SUBJECT"
fi
if [ -n "$EMAIL_MATTER_IDS" ]; then
printf -- '- Matter IDs mentioned in email: `%s`\n' "$EMAIL_MATTER_IDS"
fi
echo ""
} >> "$GITHUB_STEP_SUMMARY"
git status --porcelain -- data/raw/matters/ \
| awk '{print $NF}' > "${RUNNER_TEMP}/scrape-changed.txt"
python -m scripts.scrape.scrape_summary \
--report-dir "$SCRAPE_REPORT_DIR" \
--changed-paths "${RUNNER_TEMP}/scrape-changed.txt" \
| tee -a "$GITHUB_STEP_SUMMARY"
- name: Verify HTML cleaning
run: |
# Report any matter HTML files modified by this scrape run that still
# contain known uncleaned dynamic tokens. This catches cases where
# clean_file did not run, or where a git rebase will later undo its
# substitutions when two concurrent runs touched the same file.
modified_html=()
while IFS= read -r f; do
modified_html+=("$f")
done < <(git status --porcelain data/raw/matters/ 2>/dev/null \
| awk '{print $NF}' | grep '\.html$' || true)
if [ ${#modified_html[@]} -eq 0 ]; then
echo "No matter HTML files were modified by this scrape run."
else
echo "Checking ${#modified_html[@]} modified matter page(s) for uncleaned tokens..."
bad_files=()
for f in "${modified_html[@]}"; do
if grep -qE \
'js-view-dom-id-[a-f0-9]{64}|/(css|js)/[a-zA-Z]+_[A-Za-z0-9_-]{30,}\.(css|js)(\?|")' \
"$f" 2>/dev/null; then
bad_files+=("$f")
fi
done
if [ ${#bad_files[@]} -gt 0 ]; then
# ::warning:: so this surfaces as an annotation on the run rather
# than as a line of plain text nobody scrolls to.
for f in "${bad_files[@]}"; do
echo "::warning file=${f}::Uncleaned dynamic content in ${f} — clean_file may not have run, or a concurrent pipeline run touching the same file will reintroduce raw tokens on rebase."
done
echo "WARNING: The following matter pages appear to have uncleaned dynamic content:"
printf ' %s\n' "${bad_files[@]}"
else
echo "HTML cleaning check passed (${#modified_html[@]} file(s) verified)."
fi
fi
- name: Check for scrape changes
id: scrape-changes
run: |
git add -A
if git diff --staged --quiet; then
echo "changed=false" >> $GITHUB_OUTPUT
else
echo "changed=true" >> $GITHUB_OUTPUT
fi
# --- Extract phase 1: HTML parse + attachment download ---
# extract_mergers.py downloads attachment PDFs / DOCX files as a side
# effect of parsing each matter page. We skip questionnaire/NOCC PDF
# parsing here so we can convert any newly downloaded DOCX files first,
# then do the PDF-parse pass in phase 2 below.
- name: Run extraction (download phase)
if: steps.scrape-changes.outputs.changed == 'true' || github.event_name != 'schedule'
run: |
if [ "${{ inputs.all_mergers }}" = "true" ]; then
python -m scripts.extract_mergers --all --skip-pdf-enrich
else
python -m scripts.extract_mergers --skip-pdf-enrich
fi
# --- Convert DOCX to PDF ---
# Runs whenever there are unconverted files, catching both new downloads and stragglers.
- name: Find unconverted DOCX files
id: find-docx
# Always run so we also catch stragglers from a previous conversion
# failure, even on scheduled runs that found no scrape changes.
run: |
> "${RUNNER_TEMP}/unconverted.txt"
while IFS= read -r -d '' docx; do
pdf="${docx%.docx}.pdf"
if [ ! -f "$pdf" ]; then
echo "$docx" >> "${RUNNER_TEMP}/unconverted.txt"
fi
done < <(find data/raw/matters -name "*.docx" -type f -print0 2>/dev/null)
if [ -s "${RUNNER_TEMP}/unconverted.txt" ]; then
echo "Found unconverted DOCX files:"
cat "${RUNNER_TEMP}/unconverted.txt"
echo "has_unconverted=true" >> $GITHUB_OUTPUT
else
echo "No unconverted DOCX files found"
echo "has_unconverted=false" >> $GITHUB_OUTPUT
fi
# Everything from the enrich phase to the commit shares one condition:
# phase 1 ran (scrape changes, or any trigger other than the schedule),
# or we are about to convert DOCX stragglers whose PDFs the enrich pass
# needs to see. Evaluate it once here rather than repeating the three
# clauses on each of those steps, where they could drift apart.
- name: Decide whether to run the enrich phase
id: enrich-gate
env:
SCRAPE_CHANGED: ${{ steps.scrape-changes.outputs.changed }}
HAS_UNCONVERTED: ${{ steps.find-docx.outputs.has_unconverted }}
IS_SCHEDULE: ${{ github.event_name == 'schedule' }}
run: |
if [ "$SCRAPE_CHANGED" = "true" ] \
|| [ "$HAS_UNCONVERTED" = "true" ] \
|| [ "$IS_SCHEDULE" != "true" ]; then
echo "run=true" >> "$GITHUB_OUTPUT"
echo "Enrich phase will run (scrape_changed=${SCRAPE_CHANGED}, unconverted_docx=${HAS_UNCONVERTED}, scheduled=${IS_SCHEDULE})"
else
echo "run=false" >> "$GITHUB_OUTPUT"
echo "Scheduled run with no scrape changes and no unconverted DOCX — skipping enrich phase"
fi
# The runner's apt mirror has been slow enough on some days to make this
# install take 10+ minutes on its own (run #1690: 96.7 MB at 160 kB/s;
# run #1693 stalled on it entirely and hit the 6-hour job timeout).
# libreoffice-writer's package set rarely changes, so caching the
# downloaded .debs sidesteps the mirror on a cache hit.
#
# The cache is keyed on the candidate version apt would install, so it
# is an exact hit on an unchanged package set (actions/cache skips the
# save on an exact hit) and misses cleanly on an upstream version bump.
# Resolving that needs the package lists, so the `apt-get update` the
# install used to do lives here instead of being run twice.
- name: Resolve LibreOffice package version
id: libreoffice-version
if: steps.find-docx.outputs.has_unconverted == 'true'
run: |
sudo apt-get update
version=$(apt-cache policy libreoffice-writer | awk '/Candidate:/ {print $2}')
# A missing candidate would silently key every run the same; fail
# loudly instead so a broken mirror doesn't poison the cache.
if [ -z "$version" ] || [ "$version" = "(none)" ]; then
echo "Could not resolve a libreoffice-writer candidate version" >&2
exit 1
fi
echo "Candidate libreoffice-writer version: ${version}"
echo "version=${version}" >> "$GITHUB_OUTPUT"
# Cached into a runner-owned directory, NOT /var/cache/apt/archives:
# actions/cache runs unprivileged, so untarring straight into that
# root-owned directory failed with "Cannot open: Permission denied" on
# every .deb and the restore was abandoned ("Failed to restore ... exit
# code 2"). The install then re-downloaded the whole ~96 MB set on every
# run while the post-job step happily re-saved it, so the cache had been
# costing a 96 MB upload per run and never once serving one.
- name: Cache LibreOffice apt packages
if: steps.find-docx.outputs.has_unconverted == 'true'
uses: actions/cache@v6
with:
path: ${{ runner.temp }}/apt-libreoffice
key: ${{ runner.os }}-apt-libreoffice-writer-${{ steps.libreoffice-version.outputs.version }}
restore-keys: |
${{ runner.os }}-apt-libreoffice-writer-
- name: Install LibreOffice
if: steps.find-docx.outputs.has_unconverted == 'true'
env:
APT_CACHE_DIR: ${{ runner.temp }}/apt-libreoffice
run: |
set -euo pipefail
mkdir -p "$APT_CACHE_DIR"
# Seed apt's own archive directory from the cache. apt serves any
# .deb already sitting there and downloads only what is missing or
# superseded, so a partial (restore-keys) hit still helps.
shopt -s nullglob
cached=("$APT_CACHE_DIR"/*.deb)
shopt -u nullglob
if [ ${#cached[@]} -gt 0 ]; then
echo "Seeding apt archives with ${#cached[@]} cached .deb(s)"
sudo cp -f "${cached[@]}" /var/cache/apt/archives/
else
echo "No cached .deb files — installing from the mirror"
fi
sudo apt-get install -y libreoffice-writer --no-install-recommends
# Hand the .debs back to the runner-owned directory so the post-job
# save can read them without sudo.
shopt -s nullglob
fetched=(/var/cache/apt/archives/*.deb)
shopt -u nullglob
if [ ${#fetched[@]} -gt 0 ]; then
sudo cp -f "${fetched[@]}" "$APT_CACHE_DIR"/
fi
sudo chown -R "$(id -u):$(id -g)" "$APT_CACHE_DIR"
echo "Cache directory now holds $(ls -1 "$APT_CACHE_DIR"/*.deb 2>/dev/null | wc -l) .deb file(s)"
- name: Convert DOCX to PDF
if: steps.find-docx.outputs.has_unconverted == 'true'
run: |
while IFS= read -r docx; do
if [ -n "$docx" ]; then
echo "Converting: $docx"
dir=$(dirname "$docx")
libreoffice --headless --convert-to pdf --outdir "$dir" "$docx"
fi
done < "${RUNNER_TEMP}/unconverted.txt"
# --- Install Tesseract OCR, only if a pending Phase 2 Notice needs it ---
# Most notices have a full text layer and never touch the OCR fallback
# in parse_phase2_notice.py; a matter is only pending here the first
# time its notice is seen, so this (and the OCR itself) never re-runs
# for a matter that's already been parsed, e.g. Ampol-EG Australia.
- name: Check if Phase 2 Notice OCR is needed
id: check-ocr
run: |
needs_ocr=$(python -m scripts.check_phase2_notice_ocr_needed)
echo "needs_ocr=$needs_ocr" >> "$GITHUB_OUTPUT"
- name: Install Tesseract OCR
if: steps.check-ocr.outputs.needs_ocr == 'true'
run: sudo apt-get update && sudo apt-get install -y tesseract-ocr --no-install-recommends
# --- Extract phase 2: PDF parsing + downstream generation ---
# Loads the mergers.json written by phase 1, runs questionnaire/NOCC
# parsing (now seeing any newly-converted PDFs), auto-fixes missing
# questionnaire dates, and rewrites mergers.json. Downstream generators
# run exactly once here.
- name: Run extraction (enrich phase) and downstream generation
# Run if phase 1 ran (scrape changes or non-schedule trigger) OR if
# we just converted DOCX stragglers (so questionnaire/NOCC parsing
# picks them up).
if: steps.enrich-gate.outputs.run == 'true'
run: |
python -m scripts.enrich_pdfs
python -m scripts.generate.generate_similar_mergers
python -m scripts.generate.generate_static_data
./scripts/generate/generate-cli-data.sh
python -m scripts.generate.generate_rss_feed
# Cloudflare Pages rejects an entire deployment if any single asset is
# over 25 MiB, so a scanned exhibit that lands over it takes the site
# build down until it's shrunk. Do that before the files are committed.
# Ghostscript is only installed when there's something to compress, which
# is rare — the same pattern as the Tesseract step above.
- name: Compress oversized PDFs
if: steps.enrich-gate.outputs.run == 'true'
run: |
if find data/raw/matters -type f -name '*.pdf' -size +25M | grep -q .; then
sudo apt-get update
sudo apt-get install -y ghostscript --no-install-recommends
python -m scripts.compress_pdfs
else
echo "No PDFs over the 25 MiB Pages asset limit"
fi
- name: Stage extracted data
if: steps.enrich-gate.outputs.run == 'true'
run: |
git add data/processed/mergers.json data/processed/questionnaire_data.json \
data/processed/nocc_data.json data/processed/similar_mergers.json \
data/processed/phase1_estimates.json \
data/raw/matters/ frontend/public/data/ \
frontend/public/feed.xml data/output/ \
data/frozen_events_mergers.json
# --- Single commit ---
- name: Commit all changes
id: commit
env:
GH_TOKEN: ${{ github.token }}
RETRY_COUNT: ${{ inputs.conflict_retry_count || '0' }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
timestamp=$(TZ='Australia/Sydney' date)
if git diff --staged --quiet; then
echo "No changes to commit"
else
git commit -m "Update merger data: ${timestamp}"
# Scoped to main on purpose. A bare `git pull` uses the default
# refspec and fetches *every* branch into this depth-1 clone —
# including cli-dist, whose multi-MB cli.sqlite this same job
# force-pushes fresh each run, so it is never already present.
# Measured at 42s of a 105s run (run #1847) to fetch two branches
# this step does not read, only to report main hadn't moved.
if ! git pull --rebase --autostash origin main; then
git rebase --abort
# A real conflict here means something else pushed to main while
# this run was scraping/extracting - hand-merging the JSON isn't
# safe, so give up on this run's commit and kick off a fresh run
# instead. It'll check out the now-current main and re-scrape/
# re-extract on top of it, so it won't hit the same conflict.
# Capped so a misbehaving concurrent writer can't cause an
# infinite chain of retriggers.
next_retry=$((RETRY_COUNT + 1))
if [ "$next_retry" -le 3 ]; then
echo "Rebase conflict against a concurrent push to main (retry ${next_retry}/3) - triggering a fresh pipeline run"
gh workflow run pipeline.yml --ref main -f "conflict_retry_count=${next_retry}"
else
echo "Rebase conflict against a concurrent push to main - already retried ${RETRY_COUNT} times, giving up and leaving this to the next scheduled run"
fi
exit 1
fi
# After rebase, re-clean any HTML files that changed. A 3-way merge
# during rebase can reintroduce raw dynamic tokens if both the local
# commit and the pulled commits touched the same file.
rebase_html=$(git diff HEAD^..HEAD --name-only 2>/dev/null | grep '\.html$' || true)
if [ -n "$rebase_html" ]; then
echo "HTML files in rebased commit:"
echo "$rebase_html" | sed 's/^/ /'
while IFS= read -r f; do
if [ -f "$f" ]; then
./scripts/scrape/scrape.sh --clean-file "$f"
git add "$f"
echo "Re-cleaned: $f"
fi
done <<< "$rebase_html"
if ! git diff --staged --quiet; then
echo "WARNING: Rebase introduced uncleaned HTML — amending commit to fix"
git commit --amend --no-edit
fi
fi
git push
# Flag downstream cli-sqlite publish if cli-manifest.json is in
# the pushed commit. The manifest is the tracked stand-in for the
# bundle, which is gitignored — generate-cli-data.sh only rewrites
# the manifest when the bundle content actually changed, so it is
# an exact signal. Pushes from GITHUB_TOKEN don't fire other
# workflows, so we publish inline rather than relying on a
# paths-filtered push trigger on publish-cli-sqlite.yml.
if git diff HEAD^..HEAD --name-only \
| grep -qx "data/output/cli/cli-manifest.json"; then
echo "publish_cli=true" >> "$GITHUB_OUTPUT"
fi
fi
# --- Publish cli.sqlite to the cli-dist orphan branch ---
# Inlined here (rather than a separate workflow triggered by push to
# cli-bundle.json) because pushes from the default GITHUB_TOKEN do not
# fire downstream workflow runs.
- name: Build CLI SQLite
if: steps.commit.outputs.publish_cli == 'true' && github.ref == 'refs/heads/main'
run: |
mkdir -p "${RUNNER_TEMP}/cli-dist"
python -m scripts.generate.build_cli_sqlite \
--bundle data/output/cli/cli-bundle.json \
--output-dir "${RUNNER_TEMP}/cli-dist"
echo "--- Manifest ---"
cat "${RUNNER_TEMP}/cli-dist/cli-manifest.json"
- name: Force-push cli.sqlite to cli-dist branch
if: steps.commit.outputs.publish_cli == 'true' && github.ref == 'refs/heads/main'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
work="${RUNNER_TEMP}/cli-dist-publish"
mkdir -p "$work"
cp "${RUNNER_TEMP}/cli-dist/cli.sqlite" "$work/cli.sqlite"
cp "${RUNNER_TEMP}/cli-dist/cli-manifest.json" "$work/cli-manifest.json"
cd "$work"
git init -q -b cli-dist
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
version=$(python -c 'import json; print(json.load(open("cli-manifest.json"))["version"])')
git add cli.sqlite cli-manifest.json
git commit -q -m "Publish cli.sqlite (data version ${version})"
remote="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
for attempt in 1 2 3 4; do
if git push --force "$remote" HEAD:cli-dist; then
echo "Push succeeded on attempt ${attempt}"
break
fi
if [ "$attempt" -eq 4 ]; then
echo "Push failed after 4 attempts" >&2
exit 1
fi
sleep $((2 ** attempt))
done
# --- Open GitHub issues for auto-fixed event dates ---
# The extraction script writes missing_event_dates.json when it auto-sets
# a date on a catchable event (questionnaire, remedy offer, etc.).
# We create one issue per affected merger so the owner can confirm the date.
- name: Create issues for auto-fixed event dates
env:
GH_TOKEN: ${{ github.token }}
run: |
if [ ! -f data/processed/missing_event_dates.json ]; then
echo "No missing event dates to report"
exit 0
fi
COUNT=$(jq '.issues | length' data/processed/missing_event_dates.json)
if [ "$COUNT" -eq 0 ]; then
echo "No issues to create"
exit 0
fi
echo "Creating $COUNT issue(s) for missing event dates..."
gh label create "missing-event-date" \
--description "Event date was missing; auto-set by pipeline" \
--color "e4e669" \
--force
# List the open issues once and match locally. Asking per merger cost
# an API call each to almost always learn nothing, and gh's default
# page size of 30 meant the guard silently stopped seeing older
# issues once more than 30 were open, re-creating them.
OPEN_TITLES="${RUNNER_TEMP}/missing-event-date-titles.txt"
gh issue list \
--label "missing-event-date" \
--state open \
--limit 500 \
--json title \
--jq '.[].title' > "$OPEN_TITLES"
for i in $(seq 0 $((COUNT - 1))); do
MERGER_ID=$(jq -r ".issues[$i].merger_id" data/processed/missing_event_dates.json)
# Skip if an open issue already exists for this merger (idempotent guard)
if grep -qF -- "$MERGER_ID" "$OPEN_TITLES"; then
echo "Issue already open for $MERGER_ID — skipping"
continue
fi
TITLE=$(jq -r ".issues[$i].title" data/processed/missing_event_dates.json)
jq -n \
--argjson idx "$i" \
--slurpfile data data/processed/missing_event_dates.json \
'{"title": $data[0].issues[$idx].title,
"body": $data[0].issues[$idx].body,
"labels": ["missing-event-date"]}' | \
gh api "repos/$GITHUB_REPOSITORY/issues" \
--method POST --input -
# Keep the local guard true for the rest of this run.
echo "$TITLE" >> "$OPEN_TITLES"
echo "Created issue: $TITLE"
done
# --- Manage inferred-Phase-2 tracking issues ---
# detect_inferred_phase_2 writes inferred_phase_2.json when a merger has a
# Phase 2 notice but the ACCC register still shows Phase 1 (the site treats
# it as Phase 2). We open a tracking issue so the owner can confirm, and
# auto-close it once the register's stage catches up to Phase 2.
- name: Manage inferred Phase 2 issues
env:
GH_TOKEN: ${{ github.token }}
run: |
FILE=data/processed/inferred_phase_2.json
if [ ! -f "$FILE" ]; then
echo "No inferred Phase 2 data to process"
exit 0
fi
# List this label's issues once, in both states, and match locally.
# Asking per merger cost an API call each, and gh's default page size
# of 30 meant both guards silently stopped seeing older issues once
# the label had more than 30. One listing covers both guards: the
# open-issue view is filtered out of it locally rather than costing a
# second identical query.
ALL_ISSUES="${RUNNER_TEMP}/inferred-phase-2-all.json"
gh issue list \
--label "inferred-phase-2" \
--state all \
--limit 500 \
--json number,title,state > "$ALL_ISSUES"
ALL_TITLES="${RUNNER_TEMP}/inferred-phase-2-titles.txt"
jq -r '.[].title' "$ALL_ISSUES" > "$ALL_TITLES"
OPEN_ISSUES="${RUNNER_TEMP}/inferred-phase-2-open.json"
jq '[.[] | select(.state | ascii_upcase == "OPEN")]' "$ALL_ISSUES" > "$OPEN_ISSUES"
# Open a tracking issue for each newly inferred Phase 2 merger.
OPEN_COUNT=$(jq '.open | length' "$FILE")
echo "Inferred Phase 2 mergers to open: $OPEN_COUNT"
# Only needed to label an issue we are about to create. `confirmed`
# only ever grows, so this step runs on every pipeline run with
# nothing to open — no reason to spend the call then.
if [ "$OPEN_COUNT" -gt 0 ]; then
gh label create "inferred-phase-2" \
--description "Merger inferred as Phase 2 from a notice; awaiting ACCC stage update" \
--color "d4c5f9" \
--force
fi
for i in $(seq 0 $((OPEN_COUNT - 1))); do
MERGER_ID=$(jq -r ".open[$i].merger_id" "$FILE")
# Skip if an issue already exists in ANY state, so we never reopen one
# the owner deliberately closed (e.g. after parties dropped out).
if grep -qF -- "$MERGER_ID" "$ALL_TITLES"; then
echo "Issue already exists for $MERGER_ID — skipping"
continue
fi
TITLE=$(jq -r ".open[$i].title" "$FILE")
jq -n \
--argjson idx "$i" \
--slurpfile data "$FILE" \
'{"title": $data[0].open[$idx].title,
"body": $data[0].open[$idx].body,
"labels": ["inferred-phase-2"]}' | \
gh api "repos/$GITHUB_REPOSITORY/issues" --method POST --input -
# Keep the local guard true for the rest of this run.
echo "$TITLE" >> "$ALL_TITLES"
echo "Created inferred Phase 2 issue: $TITLE"
done
# Auto-close tracking issues where the ACCC register now shows Phase 2.
# `confirmed` names every merger whose stage has caught up, so it only
# grows: nearly all of them were closed on an earlier run. Match it
# against the open issues already listed above rather than asking
# GitHub about each one, and report what was actually closed.
CLOSE_COUNT=$(jq '.confirmed | length' "$FILE")
CLOSED=0
for i in $(seq 0 $((CLOSE_COUNT - 1))); do
MERGER_ID=$(jq -r ".confirmed[$i]" "$FILE")
NUMBER=$(jq -r --arg id "$MERGER_ID" \
'map(select(.title | contains($id))) | .[0].number // empty' \
"$OPEN_ISSUES")
if [ -n "$NUMBER" ]; then
gh issue comment "$NUMBER" \
--body "The ACCC register now shows this merger at Phase 2 — closing automatically."
gh issue close "$NUMBER"
echo "Closed inferred Phase 2 issue #$NUMBER for $MERGER_ID"
CLOSED=$((CLOSED + 1))
fi
done
echo "Confirmed Phase 2 mergers: ${CLOSE_COUNT}; tracking issues closed: ${CLOSED}"
# --- Duplicate & related detection (create/refresh review PRs) ---
# These blocks mirror the former standalone detect-duplicates.yml,
# detect-related-mergers.yml and detect-related-parties.yml workflows,
# which now only run on manual dispatch. Each one branches off the
# latest main (origin/main, i.e. this run's push), applies its own
# suggestions to its own processed-data file, force-pushes a well-known
# fix branch and opens/updates — or auto-closes — a review PR. They edit
# different files (mergers.json / related_mergers.json /
# related_parties.json) and each commits only its own file, so they never
# collide with each other or with the pipeline's own commit to main above.
# Running last keeps main's working tree intact for every step before this.
- name: Record base for detection
id: detect_base
run: |
# Base the fix branches off the true latest main so the PR diffs are
# clean whether or not this run committed. With the pipeline
# serialised on main, origin/main is stable for the rest of the job.
git fetch origin main
echo "sha=$(git rev-parse origin/main)" >> "$GITHUB_OUTPUT"
# -- Duplicate events → fix/duplicate-events --
- name: Duplicate events review PR
uses: ./.github/actions/detection-pr
with:
base-sha: ${{ steps.detect_base.outputs.sha }}
branch: fix/duplicate-events
heading: Duplicate events detection
command: >-
python -m scripts.detect.detect_duplicates
--apply-fixes
--pr-markdown pr_body.md
data-file: data/processed/mergers.json
commit-message: "fix: remove duplicate events detected on {date}"
pr-title: "fix: remove duplicate events ({date})"
close-comment: >-
All duplicate events resolved as of {date}. Closing automatically.
github-token: ${{ github.token }}
ntfy-topic: ${{ secrets.NTFY_TOPIC }}
ntfy-token: ${{ secrets.NTFY_TOKEN }}
ntfy-server: ${{ vars.NTFY_SERVER || 'https://ntfy.sh' }}
# -- Related mergers → fix/related-mergers --
- name: Related mergers review PR
id: relm
uses: ./.github/actions/detection-pr
with:
base-sha: ${{ steps.detect_base.outputs.sha }}
branch: fix/related-mergers
heading: Related mergers detection
command: >-
python -m scripts.detect.detect_related_mergers
--summary
--apply-suggestions
--pr-markdown pr_body.md
--issue-markdown pr_issue_body.md
--meta-json pr_meta.json
data-file: data/processed/related_mergers.json
commit-message: "suggest: candidate related mergers detected on {date}"
pr-title: "suggest: link related mergers ({date})"
close-comment: >-
All detected related-merger pairs have been recorded as of {date}.
Closing automatically.
github-token: ${{ github.token }}
ntfy-topic: ${{ secrets.NTFY_TOPIC }}
ntfy-token: ${{ secrets.NTFY_TOKEN }}
ntfy-server: ${{ vars.NTFY_SERVER || 'https://ntfy.sh' }}
# An exact (100%) name match on acquirer, target, and merger name for a
# refiled waiver leaves essentially no room for a false positive (see
# detect_related_mergers.is_certain_match), so that case is merged
# immediately instead of waiting on manual review. A follow-up issue
# still asks for a sanity check. Mixed batches (some exact, some not)
# are conservatively left for manual review in their entirety, since a
# PR merge can't be split pair-by-pair.
- name: Auto-merge exact-match waiver-refile PR
id: automerge
if: steps.relm.outputs.found == 'true' && steps.relm.outputs.changed == 'true'
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ steps.relm.outputs.pr-number }}
run: |
if [ -z "$PR_NUMBER" ] || [ "$(jq -r '.all_certain' pr_meta.json)" != "true" ]; then
echo "Not an all-exact-match batch (or PR number missing) — leaving PR #$PR_NUMBER for manual review."
exit 0
fi
gh pr merge "$PR_NUMBER" --squash
gh label create "needs-verification" \
--description "Auto-merged by automation; needs a human to double-check" \
--color "fbca04" \
--force
TITLE="Verify auto-merged waiver refile(s) ($(date -u +%Y-%m-%d))"
jq -n \
--arg title "$TITLE" \
--rawfile body pr_issue_body.md \
'{"title": $title, "body": $body, "labels": ["needs-verification"]}' | \
gh api "repos/$GITHUB_REPOSITORY/issues" --method POST --input -
echo "Merged PR #$PR_NUMBER automatically and opened a verification issue"
echo "merged=true" >> "$GITHUB_OUTPUT"
# This one merged itself into main without review, so it is the detection
# outcome most worth interrupting for — hence priority 4 rather than the
# default the review PRs get.
- name: Notify about the auto-merged waiver refile
if: steps.automerge.outputs.merged == 'true'
uses: ./.github/actions/ntfy
with:
topic: ${{ secrets.NTFY_TOPIC }}
token: ${{ secrets.NTFY_TOKEN }}
server: ${{ vars.NTFY_SERVER || 'https://ntfy.sh' }}
title: Waiver refile auto-merged
message: >-
PR #${{ steps.relm.outputs.pr-number }} was an exact name match and
has been merged into main. A verification issue is open for a
sanity check.
tags: robot,white_check_mark
priority: '4'
click: ${{ github.server_url }}/${{ github.repository }}/issues?q=is%3Aopen+label%3Aneeds-verification
# -- Related parties → fix/related-parties --
- name: Related parties review PR
uses: ./.github/actions/detection-pr
with:
base-sha: ${{ steps.detect_base.outputs.sha }}
branch: fix/related-parties
heading: Related parties detection
command: >-
python -m scripts.detect.detect_related_parties
--summary
--apply-suggestions
--pr-markdown pr_body.md
data-file: data/processed/related_parties.json
commit-message: "suggest: candidate related parties detected on {date}"
pr-title: "suggest: link related parties ({date})"
close-comment: >-
All detected related-party groups have been recorded as of {date}.
Closing automatically.
github-token: ${{ github.token }}
ntfy-topic: ${{ secrets.NTFY_TOPIC }}
ntfy-token: ${{ secrets.NTFY_TOKEN }}
ntfy-server: ${{ vars.NTFY_SERVER || 'https://ntfy.sh' }}