diff --git a/.github/workflows/devto-publish.yml b/.github/workflows/devto-publish.yml index 6b22b19..ec1a289 100644 --- a/.github/workflows/devto-publish.yml +++ b/.github/workflows/devto-publish.yml @@ -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: @@ -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) @@ -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 diff --git a/scripts/devto-crosspost.py b/scripts/devto-crosspost.py index 9396194..ff0a3e1 100644 --- a/scripts/devto-crosspost.py +++ b/scripts/devto-crosspost.py @@ -5,6 +5,7 @@ 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 [--dry-run] Options: --draft Post new articles as drafts (default: publish immediately) @@ -12,11 +13,14 @@ --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. """ @@ -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 # --------------------------------------------------------------------------- @@ -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 @@ -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() @@ -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" @@ -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 diff --git a/scripts/devto-ids.json b/scripts/devto-ids.json index 75af975..0fca1d2 100644 --- a/scripts/devto-ids.json +++ b/scripts/devto-ids.json @@ -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 }