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
27 changes: 24 additions & 3 deletions .github/workflows/devto-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ name: Publish new posts to dev.to
paths:
- 'content/posts/**'
workflow_dispatch:
inputs:
post:
description: >-
Filename of one already-published post to re-push, e.g.
2026-06-29-fastest-windows-on-xcp-ng.adoc. Leave empty for the
normal run, which only publishes posts missing from dev.to.
required: false
default: ''

jobs:
publish:
Expand Down Expand Up @@ -44,10 +52,21 @@ jobs:
env:
DEVTO_API_KEY: ${{ secrets.DEVTO_API_KEY }}
DEVTO_POSTS_DIR: ${{ github.workspace }}/content/posts
run: python3 scripts/devto-crosspost.py
POST_INPUT: ${{ inputs.post }}
run: |
# POST_INPUT goes through the environment rather than being
# interpolated into this script, so a crafted dispatch input cannot
# run as shell.
if [ -n "$POST_INPUT" ]; then
python3 scripts/devto-crosspost.py --update "$POST_INPUT"
else
python3 scripts/devto-crosspost.py
fi

- name: Preserve IDs from any open ID-map PR
if: ${{ !cancelled() && github.event_name == 'push' }}
if: >-
${{ !cancelled()
&& (github.event_name == 'push' || inputs.post != '') }}
run: |
# A prior run may have proposed IDs on chore/devto-ids-update that are
# not on main yet. Merge them in so this run (checked out from main)
Expand All @@ -61,7 +80,9 @@ jobs:
fi

- name: Open PR for updated ID map
if: ${{ !cancelled() && github.event_name == 'push' }}
if: >-
${{ !cancelled()
&& (github.event_name == 'push' || inputs.post != '') }}
uses: peter-evans/create-pull-request@v8
with:
add-paths: scripts/devto-ids.json
Expand Down
80 changes: 79 additions & 1 deletion scripts/devto-crosspost.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,22 @@
python3 scripts/devto-crosspost.py [--dry-run] [--limit N] [--draft]
python3 scripts/devto-crosspost.py --publish-drafts [--dry-run]
python3 scripts/devto-crosspost.py --init-ids [--dry-run]
python3 scripts/devto-crosspost.py --update <post.adoc> [--dry-run]

Options:
--draft Post new articles as drafts (default: publish immediately)
--publish-drafts Flip all existing unpublished articles to published
--init-ids Populate devto-ids.json from existing dev.to articles (one-time setup)
--dry-run Show what would happen without making changes
--limit N Process at most N missing articles (non-CI mode only)
--update FILE Re-push one already-published post, ignoring the git diff

CI behaviour (push to main):
When GITHUB_ACTIONS=true and GITHUB_EVENT_NAME=push the script uses
git diff HEAD^ HEAD to discover changed .adoc files, then publishes new
ones (POST) or updates existing ones (PUT) based on devto-ids.json.
A PUT that 404s means the stored ID is stale: the article is looked up
again by title and devto-ids.json is corrected in place.

Reads API key from DEVTO_API_KEY env var or ~/dev.to.key.
"""
Expand Down Expand Up @@ -136,6 +140,20 @@ def fetch_existing_titles(api_key: str) -> set[str]:
return {a["title"].strip().lower() for a in fetch_existing_articles(api_key)}


def find_article_id_by_title(title: str, api_key: str) -> int | None:
"""Locate an article by exact title, case-insensitively.

Used to recover when devto-ids.json points at an article that no longer
exists, which happens when an article is deleted and re-created: dev.to
issues a new ID and nothing tells us about it.
"""
wanted = title.strip().lower()
for a in fetch_existing_articles(api_key):
if str(a.get("title", "")).strip().lower() == wanted:
return a["id"]
return None


# ---------------------------------------------------------------------------
# Content helpers
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -316,7 +334,24 @@ def process_post(
if dry_run:
print(f" [DRY-RUN] Would update: {title}")
return article_id
devto_put(f"/articles/{article_id}", payload, api_key)
try:
devto_put(f"/articles/{article_id}", payload, api_key)
except urllib.error.HTTPError as e:
if e.code != 404:
raise
# The stored ID is gone. Find the article again by title and heal
# the map, rather than failing every future run on the same entry.
found = find_article_id_by_title(title, api_key)
if found is None:
raise RuntimeError(
f"stored id {article_id} returns 404 and no article titled "
f"{title!r} is on the account. Not posting a new one, because "
f"a title lookup that misses is indistinguishable from a "
f"deleted article and the wrong guess leaves a duplicate."
) from e
print(f" stale id {article_id} -> {found}, matched by title")
devto_put(f"/articles/{found}", payload, api_key)
article_id = found
print(f" UPDATED: {title}")
time.sleep(REQUEST_DELAY)
return article_id
Expand Down Expand Up @@ -361,6 +396,42 @@ def get_changed_adoc_files(posts_dir: Path) -> list[Path]:
return changed


def run_update(target: str, api_key: str, dry_run: bool) -> None:
"""Force one already-published post through the update path.

run_ci only touches posts whose .adoc changed in the push, so a correction
that lands in the same commit as an unrelated failure has no second chance:
re-running the workflow re-runs the same empty diff. This is that second
chance, and it is the only way to re-push an update without a content edit
made purely to trigger one.
"""
path = Path(target)
if not path.is_absolute() and not path.exists():
path = POSTS_DIR / path
if not path.exists():
print(f"No such post: {target}", file=sys.stderr)
sys.exit(1)

ids = load_ids()
article_id = ids.get(path.stem)
if article_id is None:
print(f"{path.stem} is not in {DEVTO_IDS_FILE.name}; it has never been "
f"published, so there is nothing to update.", file=sys.stderr)
sys.exit(1)

print(f"-> {path.name} (forced update)")
try:
new_id = process_post(path, api_key, dry_run, False, article_id)
except Exception as e:
print(f" FAILED: {e}")
sys.exit(1)
if new_id is not None and new_id != article_id:
ids[path.stem] = new_id
if not dry_run:
save_ids(ids)
print(f"ID map saved to {DEVTO_IDS_FILE.name}")


def run_ci(api_key: str, dry_run: bool, draft_mode: bool) -> None:
"""CI push mode: update changed posts, publish new ones, persist ID map."""
ids = load_ids()
Expand Down Expand Up @@ -494,9 +565,12 @@ def main():
publish_drafts = "--publish-drafts" in sys.argv
do_init_ids = "--init-ids" in sys.argv
limit = None
update_target = None
for i, arg in enumerate(sys.argv):
if arg == "--limit" and i + 1 < len(sys.argv):
limit = int(sys.argv[i + 1])
if arg == "--update" and i + 1 < len(sys.argv):
update_target = sys.argv[i + 1]

# Auto-detect CI push context
ci_mode = os.environ.get("GITHUB_ACTIONS") == "true" and os.environ.get("GITHUB_EVENT_NAME") == "push"
Expand All @@ -511,6 +585,10 @@ def main():
publish_all_drafts(api_key, dry_run)
return

if update_target:
run_update(update_target, api_key, dry_run)
return

if ci_mode:
run_ci(api_key, dry_run, draft_mode)
return
Expand Down
2 changes: 1 addition & 1 deletion scripts/devto-ids.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,6 @@
"2026-03-14-nanoclaw-riscv-banana-pi-f3": 3535142,
"2026-03-15-nanoclaw-riscv-automated-builds": 3535143,
"2026-05-12-power-progress-community": 3658730,
"2026-06-29-fastest-windows-on-xcp-ng": 4089982,
"2026-06-29-fastest-windows-on-xcp-ng": 4091331,
"2026-07-29-yum-said-301-the-machine-said-2024": 4264447
}